diff --git a/bin/setup b/bin/setup index 573121e..752dd1e 100755 --- a/bin/setup +++ b/bin/setup @@ -19,7 +19,7 @@ Options: --check Run dependency checks only; do not install gems or copy config files. --skip-bundle Skip bundle install. --profile NAME Escalate optional dependencies for a feature profile. - Supported: app, codex, claude, all. May be repeated. + Supported: app, codex, claude, opencode, all. May be repeated. --remote Check Remote UI readiness hints. --push Check browser push notification readiness hints. -h, --help Show this help. @@ -46,7 +46,7 @@ while (($#)); do exit 2 fi case "$1" in - app|codex|claude|all) + app|codex|claude|opencode|all) PROFILES+=("$1") ;; *) @@ -540,15 +540,17 @@ main() { info "Checking optional runtime integrations" check_optional_tool "Git CLI" "git" "" "Project git metadata will render as n/a." - local mise_bin codex_bin claude_bin + local mise_bin codex_bin claude_bin opencode_bin mise_bin="$(resolve_tool MISE_BIN mise "$HOME/.local/bin/mise" /opt/homebrew/bin/mise /usr/local/bin/mise)" codex_bin="$(resolve_tool CODEX_BIN codex "$HOME/.local/bin/codex" /opt/homebrew/bin/codex /usr/local/bin/codex)" claude_bin="$(resolve_tool CLAUDE_BIN claude "$HOME/.local/bin/claude" /opt/homebrew/bin/claude /usr/local/bin/claude)" + opencode_bin="$(resolve_tool OPENCODE_BIN opencode "$HOME/.local/bin/opencode" /opt/homebrew/bin/opencode /usr/local/bin/opencode)" check_optional_tool "mise" "$mise_bin" "app" "Deploy, maintenance, and live actions require mise." check_kamal_readiness check_optional_tool "Codex CLI" "$codex_bin" "codex" "Set TYCHO_CODEX_BIN or install codex." check_optional_tool "Claude CLI" "$claude_bin" "claude" "Set TYCHO_CLAUDE_BIN or install claude." + check_optional_tool "OpenCode CLI" "$opencode_bin" "opencode" "Set TYCHO_OPENCODE_BIN or install opencode." check_custom_harnesses check_tailscale check_macos_terminal_tools diff --git a/docs/HARNESS_INVENTORY.md b/docs/HARNESS_INVENTORY.md new file mode 100644 index 0000000..76872a8 --- /dev/null +++ b/docs/HARNESS_INVENTORY.md @@ -0,0 +1,229 @@ +# Tycho Harness Functionality Inventory + +Date: 2026-06-26 + +Scope: inventory Tycho's current Codex and Claude managed-agent harness behavior, then compare what additional CLI adapters can reuse, adapt, or must leave unsupported until verified. Cursor notes capture the earlier adapter study; OpenCode notes capture the next planned integration target. + +## Executive Summary + +Tycho's managed-agent system is not just a command launcher. A harness participates in configuration, executable discovery, command construction, detached process management, native session resume, stream parsing, structured result extraction, memory capture, skill discovery, TUI and Remote UI forms, schedules, hooks, setup readiness, and interactive terminal open flows. + +Cursor can fit the same high-level lifecycle, but it should be implemented as a third built-in adapter, not as a Claude-compatible custom harness. It has a Claude-like `stream-json` run mode, a Codex-like captured native session id, and its own command, sandbox, model, auth, skill, and parser contracts. + +OpenCode should also be a built-in adapter. It has a direct non-interactive `opencode run` surface, `--format json` raw event output, explicit session continuation flags, provider/model/agent catalogs, first-class permissions, MCP support, and local skills/commands concepts. The main unknown is not command construction; it is the exact JSON event schema Tycho should parse and how reliably OpenCode emits a session id/result summary in headless runs. + +## Current Harness Architecture + +| Area | Codex | Claude | Cursor fit | OpenCode fit | +| --- | --- | --- | --- | --- | +| Built-in registration | `codex` in `HQ::BUILTIN_HARNESSES` | `claude` in `HQ::BUILTIN_HARNESSES` | Add `cursor`; update every UI and setup fallback that assumes only Codex/Claude | Add `opencode`; update resolver/setup/UI fallback exactly like a built-in, not as a custom Claude-compatible harness | +| Custom harness adapter | Not supported | Supported as `adapter: claude` with `execution_command` | Do not expose custom `adapter: cursor` in v1 unless parser and command contracts are stable | Do not expose custom `adapter: opencode` in v1; OpenCode has its own run/session/parser/permission contracts | +| Config validation | Project/template `agent` must be supported | Same | Add `cursor` to supported harnesses and reject custom key collision automatically via built-in list | Add `opencode` to supported harnesses and rely on built-in collision rejection | +| Default project/template harness | `codex` fallback when empty | Selected explicitly | Cursor can be selected wherever harness keys are listed | OpenCode can be selected wherever harness keys are listed; OpenCode-specific `--agent` should remain separate from Tycho's harness field | +| CLI create | `tycho agent create --harness codex` | `--harness claude` or custom Claude key | Works once `HQ.supported_harness?("cursor")` is true | Works once `HQ.supported_harness?("opencode")` is true | +| AgentStore create/clone/scheduled | Persists `agent`, `model`, `reasoning_effort` | Same | Reuses generic paths; scheduled runs will work after command builder support | Reuses generic paths; `reasoning_effort` can map to `opencode run --variant` | + +## Command Execution + +| Function | Codex implementation | Claude implementation | Cursor adaptation | OpenCode adaptation | +| --- | --- | --- | --- | --- | +| Executable lookup | `TYCHO_CODEX_BIN`, fallback paths, then `codex` | `TYCHO_CLAUDE_BIN`, fallback paths, then `claude` | Add `TYCHO_CURSOR_BIN`; default command should be verified as `agent`, with `cursor-agent` considered as a fallback or documented alias | Add `TYCHO_OPENCODE_BIN`, fallback paths, then `opencode`; version command is `opencode --version` | +| Detached process | Spawned through Tycho's Ruby runner; stdout/stderr appended to raw log | Same | Reuse unchanged | Reuse unchanged | +| Headless command | `codex exec` | `claude --print --output-format stream-json --verbose` | Likely `agent -p --output-format stream-json --stream-partial-output`; verify exact flag spelling from installed CLI | `opencode run --format json --dir [message...]` | +| Prompt delivery | Prompt is argv after `--` | Prompt is argv | Prefer stdin or prompt file for Cursor to avoid argv limits on large composed HQ prompts | Prompt is argv `message`; no documented prompt-file flag, so keep prompts bounded or consider server API later | +| Workspace | `-C ` on cold run | Process `chdir`, no explicit workspace flag | Cursor should pass `--workspace ` and also keep process `chdir` | Pass `--dir ` and keep process `chdir` | +| Model | `--model ` | `--model ` | Cursor appears to support `--model`; pass when set | Pass `--model ` when set | +| Reasoning effort | `-c model_reasoning_effort="..."` | `--effort ` | No Tycho-equivalent Cursor flag found; hide or ignore effort for Cursor until CLI advertises one | Pass `--variant `; OpenCode describes this as provider-specific reasoning effort | +| Sandbox full access | `--dangerously-bypass-approvals-and-sandbox` | `--dangerously-skip-permissions` | Likely needs `--force`, `--trust`, `--approve-mcps`; decide whether `--sandbox disabled` is required for true full access | Use `--dangerously-skip-permissions`; explicit OpenCode `deny` permissions still win | +| Sandbox restricted | `--full-auto --sandbox ` on cold run | No non-danger mapping | Cursor has `--sandbox enabled`, but real restrictions depend on `.cursor/sandbox.json`; treat as partially supported | No direct Tycho sandbox-mode mapping; rely on OpenCode config/agent permissions (`allow`, `ask`, `deny`) | +| Output files | `-o ` | Structured output is in stream | Cursor has no Tycho output file equivalent; structured result must come from stream text/result events | No output-file flag; structured result must come from `--format json` events or final assistant text | +| Missing binary failure | Start records failed run with clear log entry | Same | Reuse once executable resolver knows Cursor | Reuse once executable resolver knows OpenCode | +| Stop running agent | Process group TERM/KILL via existing `stop!` | Same | Reuse unchanged | Reuse unchanged | +| Hooks | Generic lifecycle hooks fire before/after runs and memory capture | Same | Reuse unchanged | Reuse unchanged | + +## Native Session And Prompt Policy + +| Function | Codex | Claude | Cursor adaptation | OpenCode adaptation | +| --- | --- | --- | --- | --- | +| First run session id | Captured from stream (`thread_id`, `session_id`, `id`) | Tycho pre-generates UUID and passes `--session-id`; later confirms stream session | Cursor should not pre-generate; capture emitted `session_id` or equivalent from stream | Do not pre-generate; capture emitted session id from raw JSON events, or use `session list --format json` / `export` only as fallback | +| Resume flag | `codex exec resume ` and prompt after `--` | `--resume ` | Cursor appears to support `--resume `; verify if it is a flag or subcommand in current CLI | `opencode run --session ...`; avoid `--continue` because it resumes last global session, not Tycho's selected agent | +| Native resume prompt | If native session exists and prior runs exist, send latest user message only | If bootstrapped session exists, send latest user message only | Cursor should follow Codex-style eligibility: resume only after an emitted or created native session is known | Same native resume policy once session id is captured | +| Bootstrap fallback | None needed beyond stream capture | Not applicable because Tycho supplied id | PRD proposes `agent create-chat`; verify command and output before relying on it | Use initial `opencode run` to create a session; no separate bootstrap command needed | +| Restart self-heal | Codex capture is simple | Claude has log-based reconciliation for unfinalized first run | Cursor needs log-based reconciliation if session id was captured but HQ restarted before finalization | Add log-based reconciliation for captured session id if HQ restarts before finalization | + +## Parsing And Chat Rendering + +| Function | Codex parser | Claude parser | Cursor adaptation | OpenCode adaptation | +| --- | --- | --- | --- | --- | +| Parser registration | `HQ::Parser::Codex` selected for adapter `codex` | `HQ::Parser::Claude` selected for adapter `claude` | Add `HQ::Parser::Cursor` and select by adapter `cursor` | Add `HQ::Parser::OpenCode` and select by adapter `opencode` | +| Assistant messages | `item.completed` with `item.type=agent_message` | `assistant` content text or `item.completed agent_message` | Cursor assistant events can be deltas plus final/cumulative messages; parser must dedupe partial output | Capture OpenCode `--format json` fixtures first; parser should emit final assistant text and dedupe any deltas/thinking blocks | +| Tool calls | `command_execution`, `file_change`, `todo_list` as system tool calls | `tool_use` as call, `tool_result` as result | Cursor needs native event mapping for `tool_call` started/completed and read/write/search/shell variants | Map OpenCode tool/permission events into Tycho system/tool entries after fixture capture | +| Usage/run result | `turn.completed` usage | `result` usage/cost/duration | Cursor `result` should become run summary and structured-result input when present | Prefer run/session events if present; otherwise use `opencode export ` or `stats` only for diagnostics/backfill | +| Unknown events | Mostly ignored unless known error/failure | Mostly ignored unless known types | Cursor should log unknown, connection, retry, and thinking events as system metadata or summaries, not fail | Log unknown event names as metadata during early support; do not fail the run for new OpenCode event types | +| Live chat | `raw.log` tail is parsed into conversation/system blocks | Same | Reuse after parser emits common `ConversationEntry` and `SystemEntry` | Reuse after parser emits common `ConversationEntry` and `SystemEntry` | +| Memory capture | Assistant messages, token usage, tool summaries, structured inquiry, attachments, run summary | Same | Reuse after parser and structured result are implemented | Reuse after parser and structured result are implemented | + +## Structured Results, Inquiry, And Attachments + +| Function | Codex | Claude | Cursor adaptation | OpenCode adaptation | +| --- | --- | --- | --- | --- | +| Schema enforcement | `--output-schema ` on cold runs | `--json-schema ` | Cursor has no known schema flag; use prompt-only best effort | No schema flag found in OpenCode CLI/config docs; use prompt-only best effort | +| Structured result source | Last message output file and JSON agent messages | `StructuredOutput` tool or `result.structured_output` | Parse JSON object from result/assistant text, then fallback to prose summary | Parse a final JSON object from result/assistant events, then fallback to prose summary | +| Required normalized shape | `status`, `summary`, optional `inquiry`, optional `attachments` | Same | Reuse `AgentResultNormalizer` after `AgentStructuredResult` learns Cursor payload shapes | Reuse `AgentResultNormalizer` after `AgentStructuredResult` learns OpenCode payload shapes | +| Inquiry form | Generic from normalized structured result | Generic | Reuse unchanged | Reuse unchanged | +| Attachments | Generic normalized links/files persisted into memory and attachments file | Generic | Reuse unchanged | Reuse unchanged; later pass initial local files with `opencode run --file` | +| Final-output checklist | Appended to every execution prompt | Same | Reuse, but make Cursor prompt explicitly ask for a single JSON object because there is no schema enforcement | Reuse, but make OpenCode prompt explicitly ask for a single JSON object because there is no schema enforcement | + +## Catalog, Readiness, And Model UX + +| Function | Codex | Claude | Cursor adaptation | OpenCode adaptation | +| --- | --- | --- | --- | --- | +| Setup readiness | Resolver checks executable and returns catalog | Resolver checks executable and returns catalog | Add Cursor readiness payload with binary, version/auth if available | Add OpenCode readiness payload with binary, version, auth providers, and model-catalog availability | +| Model suggestions | `codex debug models` JSON | Static defaults plus `claude --help` effort parsing | Cursor likely has `agent models`; verify output format and timeout behavior | Use `opencode models [provider]` with a timeout; avoid `--refresh` in normal readiness because it updates cache from models.dev | +| Auth status | Not explicitly probed | Not explicitly probed | Cursor should probe `agent status` or `agent about`; support `CURSOR_API_KEY` and login guidance | Probe `opencode auth list`; report provider names/count and guide operators to `opencode auth login` | +| Reasoning effort suggestions | From Codex model catalog | Claude defaults or help | Empty for Cursor unless real values exist | Offer provider-specific `--variant` values only when model metadata or explicit defaults are known; otherwise keep free-form/empty | +| Remote UI harness list | Built from setup payload; fallback hardcodes Codex/Claude | Same | Update fallback and adapter helper so Cursor is not treated as Codex | Update fallback and adapter helper so OpenCode is not treated as Codex | + +## Skills + +| Function | Codex | Claude | Cursor adaptation | OpenCode adaptation | +| --- | --- | --- | --- | --- | +| Discovery roots | `~/.codex/skills`, `~/.agents/skills`, workspace `.agents/skills` | `~/.claude/skills`, workspace `.claude/skills` | Cursor skill roots are unverified; likely `.cursor` conventions, but do not assume `SKILL.md` support without local proof | Official docs list `.opencode/skills`, `~/.config/opencode/skills`, `.claude/skills`, `~/.claude/skills`, `.agents/skills`, and `~/.agents/skills` | +| Trigger character | `$` | `/` | Cursor UI uses `/` commands, but Tycho skill trigger needs verification against Cursor-compatible skill mechanism | No manual trigger character; OpenCode exposes skills to agents through the native `skill` tool, so Tycho autocomplete should use a harness-specific insertion/prompt strategy | +| Remote skills endpoint | Generic `GET /projects/:key/skills/:harness` | Same | Reuse after `SkillDiscovery.roots_for` and trigger rules support Cursor | Reuse after `SkillDiscovery.roots_for` adds OpenCode roots and permissions/duplicates are handled | + +## UI And Operator Surfaces + +| Surface | Current behavior | Cursor adaptation | OpenCode adaptation | +| --- | --- | --- | --- | +| TUI project editor | Harness choices from `HQ.harness_keys` | Mostly automatic after registration | Mostly automatic after registration | +| TUI agent editor | Harness choices from `HQ.harness_keys`; free-form model/effort inputs | Register Cursor; decide whether to suppress effort for Cursor | Register OpenCode; label effort as OpenCode `variant` or leave it free-form/optional | +| TUI chat/detail | Uses generic parser entries, memory, summary, attachments, session id | Works after parser/session/structured result support | Works after parser/session/structured result support | +| Interactive terminal open | Codex and Claude command builders each have an interactive branch | Add Cursor interactive branch using native `agent` plus resume/workspace/model | Add OpenCode interactive branch using plain `opencode` or `opencode run -i`, plus `--dir`, `--session`, `--model`, and optional `--agent` | +| Remote UI project/agent forms | Harness options come from setup payload; frontend adapter fallback maps unknown to Codex | Add Cursor setup item and JS adapter fallback for `cursor` | Add OpenCode setup item and JS adapter fallback for `opencode` | +| Remote UI setup | Shows built-in Codex/Claude plus custom harnesses | Add Cursor readiness and auth messages | Add OpenCode readiness, version, auth-provider, model-catalog, and config-path messages | +| CLI `tycho agent create` | Generic supported-harness validation | Works after registration | Works after registration | +| Schedules | Use project default harness through `AgentStore.create_scheduled` | Works after command builder/parser support | Works after command builder/parser support | +| Clone/archive | Generic agent state/log handling | Works unchanged | Works unchanged | + +## Cursor Capability Classification + +| Tycho capability | Cursor status | Notes | +| --- | --- | --- | +| Built-in selection in config/TUI/Remote UI/CLI | Adaptable | Low risk after registration and frontend fallback changes | +| Detached run lifecycle, logs, stop, hooks | Adaptable | Existing process wrapper works | +| Headless live streaming | Adaptable with parser work | Cursor supports `stream-json` and partial streaming, but event forms are unstable enough to require fixtures | +| Native session resume | Adaptable with verification | `--resume` exists in examples; exact bootstrap and emitted id fields need real capture | +| Structured result parity | Partial | Prompt-only, best-effort; no schema guarantee like Codex/Claude | +| Inquiry and attachments | Adaptable after structured normalization | Generic Tycho flows can consume normalized hashes | +| Model selection | Adaptable | `--model` is visible in docs/examples | +| Reasoning effort | Unsupported for now | Do not present a control unless Cursor exposes a stable equivalent | +| Full access sandbox | Partial/needs decision | `--force`, `--trust`, `--approve-mcps`; possibly `--sandbox disabled` | +| Restricted sandbox | Partial/needs real tests | `--sandbox enabled` exists, but `.cursor/sandbox.json` behavior has had contradictory reports | +| Skill autocomplete | Unknown | Need proof of Cursor-compatible skill roots and invocation syntax | +| Catalog/readiness | Adaptable with probing | `agent status/about/models` appear available in references; output shape must be captured | +| Interactive terminal mode | Adaptable | Native `agent` interactive exists; resume behavior needs verification | + +## OpenCode Capability Classification + +| Tycho capability | OpenCode status | Notes | +| --- | --- | --- | +| Built-in selection in config/TUI/Remote UI/CLI | Adaptable | Low risk after registration and frontend fallback changes | +| Detached run lifecycle, logs, stop, hooks | Adaptable | Existing process wrapper works with `opencode run` | +| Headless live streaming | Adaptable with parser work | `opencode run --format json` provides raw JSON events, but Tycho needs captured fixtures for event names and final-message behavior | +| Native session resume | Adaptable and verified | Use `opencode run --session ` after capturing the emitted session id; avoid global `--continue` | +| Structured result parity | Partial | Prompt-only, best-effort; no schema flag found in OpenCode CLI/config docs | +| Inquiry and attachments | Adaptable after structured normalization | Generic Tycho flows can consume normalized hashes; local file inputs can later map to `--file` | +| Model selection | Adaptable | `--model ` is supported by `opencode run` | +| Reasoning effort | Adaptable | `--variant` maps reasonably to Tycho's effort field, but values are provider-specific | +| Full access sandbox | Partial | `--dangerously-skip-permissions` auto-approves non-denied permissions; OpenCode `deny` rules still take precedence | +| Restricted sandbox | Partial | Use OpenCode permission config (`allow`, `ask`, `deny`) rather than trying to emulate Codex sandbox modes | +| Skill autocomplete | Adaptable with UX decision | Skill roots are documented, but OpenCode uses a native skill tool rather than a slash/dollar trigger | +| Catalog/readiness | Adaptable with probing | `auth list`, `models`, `agent list`, and `debug paths` provide enough readiness data with timeouts/caching | +| Interactive terminal mode | Adaptable | Plain `opencode` and `opencode run -i` exist; resume behavior needs real interactive verification | + +## OpenCode Capability Inventory + +Local versions inspected: `opencode 1.15.13` at `/opt/homebrew/bin/opencode` during initial research, then `opencode 1.17.10` during parser fixture capture. + +Primary docs inspected: OpenCode CLI, config, agents, permissions, MCP servers, commands, and skills docs at `https://opencode.ai/docs/`. + +### OpenCode CLI Surface + +| Capability | Local/official fact | Tycho implication | +| --- | --- | --- | +| Executable | `opencode --version` prints `1.15.13`; command is available at `/opt/homebrew/bin/opencode` | Add built-in harness `opencode`, resolver env `TYCHO_OPENCODE_BIN`, fallback paths, and version command `opencode --version` | +| Non-interactive run | `opencode run [message..]` sends a prompt from argv | Implement headless command around `opencode run`; consider prompt length limits because there is no documented prompt-file flag | +| Raw output | `opencode run --format json` is documented locally as raw JSON events | Add `HQ::Parser::OpenCode`; first implementation needs real NDJSON fixtures before treating parser mapping as stable | +| Workspace | `opencode run --dir ` runs in a directory; top-level default command accepts a project path | Use `--dir ` plus process `chdir` for parity with Tycho's detached runner | +| Model | `-m, --model` accepts `provider/model` | Map Tycho model directly to `--model` | +| Reasoning effort | `--variant` is described as provider-specific reasoning effort | Map Tycho `reasoning_effort` to OpenCode `--variant`, but label it as provider-specific and allow empty | +| Agent selection | `--agent` selects an OpenCode agent; `opencode agent list` lists primary/subagents | Tycho can expose model separately from harness; agent selection is probably an OpenCode-specific advanced option, not Tycho's `agent` field | +| Session resume | `-s, --session` continues a session id; `-c, --continue` continues the last session; `--fork` can fork before continuing | Persist emitted OpenCode session id and resume with `--session `; verified through a two-turn `tycho agent create --harness opencode --run` / `tycho agent send` smoke test | +| Attachments | `-f, --file` attaches one or more files | Tycho already has attachment normalization; later integration can pass local file attachments through `--file` for initial prompts | +| Dangerous mode | `--dangerously-skip-permissions` auto-approves permissions not explicitly denied | Map `danger-full-access` to this flag only when operator intent is clear; explicit deny rules still matter | +| Interactive mode | `opencode run -i` runs direct interactive split-footer mode; plain `opencode [project]` starts TUI | Add an interactive command branch using either `opencode run -i` or plain `opencode`, then verify resume behavior | +| Headless server | `opencode serve` starts an HTTP API server; `opencode acp` starts an Agent Client Protocol server | Tycho v1 can use `opencode run`; server/ACP are future integration paths if process-per-run becomes limiting | +| Remote attach | `opencode run --attach http://localhost:4096` attaches to a running OpenCode server | Not needed for v1; useful if Tycho later manages an OpenCode server per project | +| Auth/readiness | `opencode auth list` lists configured provider credentials; local machine shows Anthropic, Google, and Z.AI credentials | Setup readiness can report credential count/provider names without validating every model | +| Model catalog | `opencode models [provider] [--verbose] [--refresh]` lists models | Harness catalog can call `opencode models` with timeout; avoid `--refresh` during normal readiness | +| Agent catalog | `opencode agent list` lists built-in/custom agents and permissions | Useful for diagnostics, but too verbose for every setup call unless summarized/cached | +| Sessions | `opencode session list --format json` exists; `opencode export ` exports session JSON | Use for diagnostics/backfill if raw log parsing misses a session id | +| Debug paths | `opencode debug paths` prints data/config/cache/state/tmp roots | Readiness can include path hints when troubleshooting auth, logs, and config | + +### OpenCode Agent, Permission, And Tool Model + +| Area | Fact | Tycho implication | +| --- | --- | --- | +| Built-in agents | Official docs describe primary agents `build` and `plan`, and subagents `general`, `explore`, and `scout`; local `agent list` also showed additional configured primary agents like `summary`, `title`, and `compaction` | Tycho should not equate OpenCode agents with Tycho harnesses. Harness remains `opencode`; OpenCode `--agent` is a harness-specific option or template setting | +| Agent permissions | Official docs describe permission actions `allow`, `ask`, and `deny`; local `agent list` prints merged rules | Tycho sandbox mapping should rely on OpenCode permission config plus `--dangerously-skip-permissions`; do not assume Tycho can fully emulate Codex sandbox modes | +| Plan-style operation | Official docs position `plan` as analysis/review without edits by default | Tycho can recommend `--agent plan` for review-only templates once adapter-specific template options exist | +| MCP | Official docs support local and remote MCP servers configured under `mcp`; local CLI has `opencode mcp add/list/auth/logout/debug` | Tycho should not manage OpenCode MCP directly in v1; readiness can mention MCP availability and rely on OpenCode config | +| Commands | Official docs support custom slash commands in global `~/.config/opencode/commands/` and project `.opencode/commands/` | This is parallel to Tycho skills, not a direct replacement. Skill autocomplete should not surface commands unless Tycho intentionally indexes them | +| Skills | Official docs list OpenCode-native, Claude, and Agents skill roots; local CLI has `opencode debug skill` | Add OpenCode roots to discovery, but make the UI/prompt behavior harness-specific because OpenCode uses a native skill tool instead of manual `$` or `/` insertion | + +### OpenCode Parser And Structured Result Plan + +| Tycho capability | OpenCode status | Notes | +| --- | --- | --- | +| Raw log parsing | Adaptable, partially fixture-backed | `--format json` provides raw events; Tycho now has sanitized basic and tool-use fixtures from OpenCode 1.17.10 | +| Assistant text | Adaptable | Real `type=text` / `part.type=text` events are parsed without duplicating step metadata | +| Tool calls | Adaptable, fixture-backed | Real completed `tool_use` events carry tool name, input, and output under `part.state`; a bash-denied fixture showed OpenCode hides/refuses Bash and may continue with other tools rather than emitting a separate denial event | +| Usage/cost | Adaptable | Real `step_finish` events include `part.tokens` and `part.cost`; `stats` and session export remain diagnostic/backfill options | +| Session id | Adaptable, fixture-backed | Use emitted session id if present; otherwise derive from session list/export only as a fallback | +| Structured output | Partial, fixture-backed | No schema flag found in local help or official CLI docs. Use prompt-only final JSON instructions and Tycho's existing fallback parser | +| Inquiry and attachments | Partial | Reuse Tycho normalizer after parser extracts a final JSON object; pass initial local files with `--file` later | +| Native resume prompt policy | Adaptable | Once a session id is known, send only the latest user message using `--session ` like Codex/Claude native resume paths | + +### OpenCode Implementation Slices + +1. Foundation: register `opencode`, add executable/version resolution, setup readiness, command-builder branch, and tests for `opencode run --format json --dir`. +2. Fixture capture: basic text, completed tool-use, structured JSON, bash-denied, and resumed-session fixtures are stored under `test/fixtures/parser/opencode/`. +3. Parser/session: implement `HQ::Parser::OpenCode`, capture session id, normalize assistant/tool/result events, and reuse native resume prompt policy. +4. Structured result: prompt for Tycho's final JSON shape, parse final assistant/result JSON best-effort, and document that schema enforcement is weaker than Codex/Claude. +5. Catalog/UI: expose version/auth/model readiness, map `reasoning_effort` to `--variant`, and consider adapter-specific OpenCode agent selection only after v1 works. +6. Smoke verification: a temp-state CLI run created an OpenCode agent, finalized the first detached run, sent a second message with the persisted `--session`, and captured both summaries plus token usage in memory. + +## Cursor Recommended Implementation Slices + +1. Foundation: add `cursor` built-in key, executable resolver, setup payload placeholder, command builder branch, and tests for argv/stdin command shape. +2. Parser: capture realistic Cursor NDJSON fixtures and implement `HQ::Parser::Cursor`, including partial-output dedupe and unknown event handling. +3. Session and summary: capture/persist session id, resume with latest user message only, normalize result/prose fallback, and append memory. +4. UI/catalog: add Cursor readiness, auth/model probes, Remote UI adapter fallback, and hide unsupported effort controls. +5. Skills/docs: add Cursor skill discovery only after verifying roots and trigger behavior; document auth, sandbox, and headless gotchas. + +## Cursor Verification Checklist + +- `bin/test` includes Cursor command-builder, parser, structured-result, session, registry, setup, and skill tests. +- A local real-run capture records Cursor CLI version, `agent --help`, `agent status/about/models` output shapes, and a small `stream-json` run with assistant text, tool calls, result, and session id. +- Browser verification covers Remote UI harness selection, setup readiness, chat streaming, details toggle, and skill picker behavior if Cursor skills are enabled. + +## Sources + +- Local Tycho files inspected: `lib/hq/harness_registry.rb`, `lib/hq/domain/agent_command_builder.rb`, `lib/hq/domain/managed_agent.rb`, `lib/hq/parser.rb`, `lib/hq/parser/codex.rb`, `lib/hq/parser/claude.rb`, `lib/hq/domain/agent_structured_result.rb`, `lib/hq/domain/harness_catalog.rb`, `lib/hq/domain/skill_discovery.rb`, `lib/hq/remote_server.rb`, `lib/hq/remote_ui/assets/app.js`, `lib/hq/cli_command.rb`, and related tests. +- Cursor CLI product/docs: , . +- Cursor CLI parser/sandbox references: , , . +- External integration reference: . +- Proposal reviewed: . +- Local OpenCode CLI inspected: `opencode --version`, `opencode --help`, `opencode run --help`, `opencode auth list`, `opencode models --help`, `opencode agent list`, `opencode session list --help`, `opencode serve --help`, `opencode acp --help`, `opencode mcp --help`, `opencode debug --help`, `opencode debug paths`, and `opencode debug skill --help`. +- OpenCode official docs: , , , , , , , , , and . diff --git a/docs/SETUP_REQUIREMENTS.md b/docs/SETUP_REQUIREMENTS.md index dad48c4..39f0fc0 100644 --- a/docs/SETUP_REQUIREMENTS.md +++ b/docs/SETUP_REQUIREMENTS.md @@ -31,6 +31,7 @@ install/build dependency, not a Tycho runtime subprocess dependency. | `kamal` | Deploy, maintenance, and live actions | Feature failure. Tycho prefers project `bin/kamal`, then `bundle exec kamal` inside the project | Warn if no usable project Kamal command is found for app projects | | `codex` | Built-in Codex managed-agent harness | Soft feature fail. Agent start records a failed run if the executable is missing | Warn if missing; hard fail only for a Codex-agent profile | | `claude` | Built-in Claude managed-agent harness | Soft feature fail. Agent start records a failed run if the executable is missing | Warn if missing; hard fail only for a Claude-agent profile | +| `opencode` | Built-in OpenCode managed-agent harness | Soft feature fail. Agent start records a failed run if the executable is missing | Warn if missing; hard fail only for an OpenCode-agent profile | | Custom Claude-compatible harnesses | Project-specific managed-agent execution | Soft feature fail. Tycho checks the configured executable before starting the agent | Validate configured command and warn with the harness key | | `tailscale` | Remote UI auto-bind, MagicDNS URL, HTTPS Serve detection, terminal QR URL | Soft fail. Missing or stopped Tailscale returns `nil`; `tycho serve` falls back to localhost | Warn only when remote/tailnet access is requested | | `osascript` | macOS terminal automation for Ghostty, iTerm, and Apple Terminal command launches | Soft fail. Tycho logs AppleScript failures and keeps the TUI running | Check only on macOS; warn if absent or if terminal automation is requested | diff --git a/lib/hq/cli_command.rb b/lib/hq/cli_command.rb index 4bd1664..cfe0119 100644 --- a/lib/hq/cli_command.rb +++ b/lib/hq/cli_command.rb @@ -142,7 +142,7 @@ class AgentCreate < Dry::CLI::Command argument :project_key, required: true, desc: "Project key" argument :prompt, required: true, desc: "Initial prompt for the agent" option :model, desc: "Model override (e.g. claude-opus-4-5)" - option :harness, desc: "Agent harness override (e.g. claude, codex)" + option :harness, desc: "Agent harness override (e.g. claude, codex, opencode)" option :name, desc: "Agent name override" option :template, desc: "Template key to use (defaults to project's first template)" option :run, type: :boolean, default: false, desc: "Start the agent immediately after creating" @@ -1006,9 +1006,7 @@ def native_lipgloss_features end def load_all_agents - return [] unless File.exist?(AGENTS_FILE) - - JSON.parse(File.read(AGENTS_FILE)).map { |hash| ManagedAgent.from_hash(hash) } + agent_store_for_all.load rescue StandardError [] end diff --git a/lib/hq/domain/agent_command_builder.rb b/lib/hq/domain/agent_command_builder.rb index 11f36a9..c810e2c 100644 --- a/lib/hq/domain/agent_command_builder.rb +++ b/lib/hq/domain/agent_command_builder.rb @@ -4,7 +4,7 @@ module HQ class AgentCommandBuilder def initialize(agent:, harness_adapter:, workspace:, sandbox_mode:, model:, reasoning_effort:, session_id:, session_bootstrapped:, prompt:, codex_executable:, claude_command_prefix:, - last_message_file_path:, result_schema_path:, claude_result_schema:) + opencode_executable:, last_message_file_path:, result_schema_path:, claude_result_schema:) @agent = agent @harness_adapter = harness_adapter @workspace = workspace @@ -16,6 +16,7 @@ def initialize(agent:, harness_adapter:, workspace:, sandbox_mode:, model:, reas @prompt = prompt @codex_executable = codex_executable @claude_command_prefix = claude_command_prefix + @opencode_executable = opencode_executable @last_message_file_path = last_message_file_path @result_schema_path = result_schema_path @claude_result_schema = claude_result_schema @@ -24,6 +25,7 @@ def initialize(agent:, harness_adapter:, workspace:, sandbox_mode:, model:, reas def build return build_claude_command if claude_like_agent? return build_codex_command if codex_agent? + return build_opencode_command if opencode_agent? raise "Unsupported managed-agent harness #{@agent.inspect}" end @@ -31,6 +33,7 @@ def build def interactive return build_interactive_claude_like_command(command_prefix: @claude_command_prefix) if claude_like_agent? return build_interactive_codex_command if codex_agent? + return build_interactive_opencode_command if opencode_agent? raise "Unsupported managed-agent harness #{@agent.inspect}" end @@ -76,6 +79,16 @@ def build_claude_command build_claude_like_command(command_prefix: @claude_command_prefix) end + def build_opencode_command + command = [@opencode_executable, "run", "--format", "json", "--dir", @workspace] + command.concat(model_arguments) + command.concat(opencode_variant_arguments) + command << "--dangerously-skip-permissions" if @sandbox_mode == "danger-full-access" + command.concat(["--session", @session_id]) unless @session_id.empty? + command << @prompt + { command: command } + end + def build_interactive_codex_command command = [@codex_executable] command.concat(model_arguments) @@ -103,6 +116,15 @@ def build_interactive_claude_like_command(command_prefix:, env: {}) { command: command, env: env } end + def build_interactive_opencode_command + command = [@opencode_executable, "run", "--interactive", "--dir", @workspace] + command.concat(model_arguments) + command.concat(opencode_variant_arguments) + command << "--dangerously-skip-permissions" if @sandbox_mode == "danger-full-access" + command.concat(["--session", @session_id]) unless @session_id.empty? + { command: command } + end + def build_claude_like_command(command_prefix:, env: {}) command = command_prefix.dup command.concat(model_arguments) @@ -127,6 +149,10 @@ def claude_effort_arguments @reasoning_effort.to_s.empty? ? [] : ["--effort", @reasoning_effort] end + def opencode_variant_arguments + @reasoning_effort.to_s.empty? ? [] : ["--variant", @reasoning_effort] + end + def claude_like_agent? @harness_adapter == "claude" end @@ -134,5 +160,9 @@ def claude_like_agent? def codex_agent? @harness_adapter == "codex" end + + def opencode_agent? + @harness_adapter == "opencode" + end end end diff --git a/lib/hq/domain/agent_structured_result.rb b/lib/hq/domain/agent_structured_result.rb index 6cecade..d18f1a1 100644 --- a/lib/hq/domain/agent_structured_result.rb +++ b/lib/hq/domain/agent_structured_result.rb @@ -37,6 +37,9 @@ def normalize_payload(parsed) codex_structured = codex_agent_message_payload(parsed) return codex_structured if codex_structured + assistant_text_structured = assistant_text_payload(parsed) + return assistant_text_structured if assistant_text_structured + result_event_payload(parsed) end @@ -67,6 +70,42 @@ def codex_agent_message_payload(parsed) normalize_payload(inner) if inner end + def assistant_text_payload(parsed) + text = assistant_text(parsed) + inner = parse_json_string(text) + normalize_payload(inner) if inner + end + + def assistant_text(parsed) + return "" unless parsed.is_a?(Hash) + + message = parsed["message"].is_a?(Hash) ? parsed["message"] : {} + item = parsed["item"].is_a?(Hash) ? parsed["item"] : {} + part = parsed["part"].is_a?(Hash) ? parsed["part"] : {} + role = parsed["role"].to_s + role = message["role"].to_s if role.empty? + type = parsed["type"].to_s + return "" unless role == "assistant" || type.match?(/assistant|message|result/i) || + (type == "text" && part["type"].to_s == "text") || + item["type"].to_s.match?(/agent_message|assistant|message/i) + + [ + parsed["text"], + parsed["content"], + parsed["result"], + message["text"], + message["content"], + item["text"], + item["content"], + part["text"], + part["content"] + ].each do |value| + text = stringify_text(value).strip + return text unless text.empty? + end + "" + end + def result_event_payload(parsed) return nil unless parsed["type"] == "result" @@ -102,9 +141,29 @@ def structured_json_field(value, expected_class) def parse_json_string(value) return nil unless value.is_a?(String) - JSON.parse(value) + text = value.strip + JSON.parse(text) rescue JSON::ParserError - nil + fenced = text.match(/\A```(?:json)?\s*(?.*?)\s*```\z/m) + return parse_json_string(fenced[:json]) if fenced + + object = text.match(/(?\{.*\})/m) + return nil if object && object[:json] == text + + object ? parse_json_string(object[:json]) : nil + end + + def stringify_text(value) + case value + when String + value + when Array + value.map { |entry| stringify_text(entry) }.reject(&:empty?).join("\n") + when Hash + stringify_text(value["text"] || value["content"]) + else + "" + end end end end diff --git a/lib/hq/domain/executable_resolver.rb b/lib/hq/domain/executable_resolver.rb index 1e64faa..c636878 100644 --- a/lib/hq/domain/executable_resolver.rb +++ b/lib/hq/domain/executable_resolver.rb @@ -13,6 +13,7 @@ def available? ENV_NAMES = { "claude" => "CLAUDE_BIN", "codex" => "CODEX_BIN", + "opencode" => "OPENCODE_BIN", "mise" => "MISE_BIN", "tailscale" => "TAILSCALE_BIN" }.freeze @@ -58,7 +59,7 @@ def executable_path(command) def fallback_paths_for(name) case name.to_s - when "claude", "codex", "mise" + when "claude", "codex", "opencode", "mise" [ File.join(Dir.home, ".local", "bin", name.to_s), "/opt/homebrew/bin/#{name}", diff --git a/lib/hq/domain/harness_catalog.rb b/lib/hq/domain/harness_catalog.rb index 14175c7..169c9ec 100644 --- a/lib/hq/domain/harness_catalog.rb +++ b/lib/hq/domain/harness_catalog.rb @@ -32,6 +32,8 @@ def build_builtin_catalog(name, resolution) codex_catalog(resolution) when "claude" claude_catalog(resolution) + when "opencode" + opencode_catalog(resolution) else empty_catalog end @@ -108,11 +110,33 @@ def claude_compatible_catalog(reasoning_efforts: CLAUDE_REASONING_EFFORTS, sourc } end + def opencode_catalog(resolution) + unless resolution&.available? + return { + model_suggestions: [], + reasoning_effort_suggestions: REASONING_EFFORT_ORDER, + catalog_source: "opencode defaults", + auth_providers: [] + } + end + + model_rows = opencode_model_rows(resolution.command) + source = model_rows.empty? ? "opencode models unavailable" : "opencode models" + { + model_suggestions: model_rows, + reasoning_effort_suggestions: REASONING_EFFORT_ORDER, + catalog_source: source, + auth_providers: opencode_auth_providers(resolution.command) + } + end + def claude_help_efforts(command) _out, err, status = nil out = "" - Timeout.timeout(COMMAND_TIMEOUT) do - out, err, status = Open3.capture3(command, "--help") + with_quiet_open3_timeout do + Timeout.timeout(COMMAND_TIMEOUT) do + out, err, status = Open3.capture3(command, "--help") + end end return [] unless status.success? @@ -128,8 +152,10 @@ def claude_help_efforts(command) def capture_json(command) out = "" status = nil - Timeout.timeout(COMMAND_TIMEOUT) do - out, _err, status = Open3.capture3(*command) + with_quiet_open3_timeout do + Timeout.timeout(COMMAND_TIMEOUT) do + out, _err, status = Open3.capture3(*command) + end end return nil unless status.success? @@ -138,6 +164,55 @@ def capture_json(command) nil end + def opencode_model_rows(command) + out = capture_stdout([command, "models"]) + return [] if out.to_s.empty? + + out.lines.filter_map do |line| + text = line.strip + next if text.empty? || text.start_with?("Provider", "MODEL", "─", "-") + + value = text.split(/\s+/).find { |part| part.include?("/") } + value ||= text.split(/\s+/).first + next if value.to_s.empty? || value == "ID" + + { value: value, label: value } + end.uniq { |item| item[:value] } + end + + def opencode_auth_providers(command) + out = capture_stdout([command, "auth", "list"]) + return [] if out.to_s.empty? + + out.lines.filter_map do |line| + text = line.strip + next if text.empty? || text.match?(/\A(provider|name)\b/i) + + text.split(/\s+/).first + end.uniq + end + + def capture_stdout(command) + out = "" + status = nil + with_quiet_open3_timeout do + Timeout.timeout(COMMAND_TIMEOUT) do + out, _err, status = Open3.capture3(*command) + end + end + status.success? ? out : "" + rescue StandardError + "" + end + + def with_quiet_open3_timeout + previous = Thread.report_on_exception + Thread.report_on_exception = false + yield + ensure + Thread.report_on_exception = previous + end + def sort_efforts(values) values = Array(values).map { |value| value.to_s.strip.downcase }.reject(&:empty?).uniq values.sort_by { |value| [REASONING_EFFORT_ORDER.index(value) || 99, value] } diff --git a/lib/hq/domain/managed_agent.rb b/lib/hq/domain/managed_agent.rb index 1525a79..71bec6f 100644 --- a/lib/hq/domain/managed_agent.rb +++ b/lib/hq/domain/managed_agent.rb @@ -757,6 +757,7 @@ def command_builder(prompt: prompt_for_execution) prompt: prompt, codex_executable: codex_executable, claude_command_prefix: claude_command_prefix, + opencode_executable: opencode_executable, last_message_file_path: last_message_file_path, result_schema_path: AGENT_RESULT_SCHEMA, claude_result_schema: compact_claude_result_schema @@ -1006,6 +1007,11 @@ def assistant_summary_from_event(event) item["text"].to_s.strip end.join("\n").strip + when "text" + part = event["part"] + return "" unless part.is_a?(Hash) && part["type"] == "text" + + Parser.assistant_display_text(part["text"].to_s.strip).to_s.strip else "" end @@ -1257,7 +1263,7 @@ def prompt_for_execution def native_resume? return false if @session_id.to_s.empty? - return false unless %w[codex claude].include?(harness_adapter) + return false unless %w[codex claude opencode].include?(harness_adapter) claude_like_agent? ? @session_bootstrapped : @runs.any? end @@ -1270,6 +1276,10 @@ def codex_agent? harness_adapter == "codex" end + def opencode_agent? + harness_adapter == "opencode" + end + def harness_adapter HQ.harness_adapter(@agent) end @@ -1344,6 +1354,10 @@ def claude_executable ExecutableResolver.command_for_tool("claude") end + def opencode_executable + ExecutableResolver.command_for_tool("opencode") + end + def compact_claude_result_schema return nil unless File.exist?(AGENT_RESULT_SCHEMA) @@ -1483,6 +1497,10 @@ def session_id_from_current_run id = if codex_agent? event["thread_id"] || event["session_id"] || event["id"] + elsif opencode_agent? + event["session_id"] || event["sessionID"] || event["sessionId"] || + event.dig("session", "id") || event.dig("session", "session_id") || + (event["id"] if event["type"].to_s.include?("session")) else event["session_id"] end diff --git a/lib/hq/domain/pull_request_diff.rb b/lib/hq/domain/pull_request_diff.rb index 7b23b14..081b60a 100644 --- a/lib/hq/domain/pull_request_diff.rb +++ b/lib/hq/domain/pull_request_diff.rb @@ -15,7 +15,7 @@ module HQ class PullRequestDiff GITHUB_PR_URL = %r{\Ahttps?://github\.com/([^/\s]+)/([^/\s]+)/pull/(\d+)(?:[/?#].*)?\z}i - DIFF_FORMAT = "github_diff_v1" + DIFF_FORMAT = "github_diff_v2" MAX_PATCH_BYTES = 768 * 1024 class Error < StandardError @@ -93,9 +93,9 @@ def metadata(reference) def patch(reference) output = gh_output( - "api", - "-H", "Accept: application/vnd.github.v3.diff", - "repos/#{reference.repository}/pulls/#{reference.number}" + "pr", "diff", reference.number.to_s, + "--repo", reference.repository, + "--color", "never" ) truncated = output.bytesize > MAX_PATCH_BYTES output = output.byteslice(0, MAX_PATCH_BYTES).to_s if truncated diff --git a/lib/hq/domain/skill_discovery.rb b/lib/hq/domain/skill_discovery.rb index 8b3b142..9e62408 100644 --- a/lib/hq/domain/skill_discovery.rb +++ b/lib/hq/domain/skill_discovery.rb @@ -46,11 +46,21 @@ def skill_files(root) def roots_for(kind, workspace) workspace = workspace.to_s - if HQ.harness_adapter(kind) == "claude" + case HQ.harness_adapter(kind) + when "claude" [ File.expand_path("~/.claude/skills"), workspace.empty? ? nil : File.join(workspace, ".claude", "skills") ] + when "opencode" + [ + File.expand_path("~/.config/opencode/skills"), + File.expand_path("~/.claude/skills"), + File.expand_path("~/.agents/skills"), + workspace.empty? ? nil : File.join(workspace, ".opencode", "skills"), + workspace.empty? ? nil : File.join(workspace, ".claude", "skills"), + workspace.empty? ? nil : File.join(workspace, ".agents", "skills") + ] else [ File.expand_path("~/.codex/skills"), diff --git a/lib/hq/harness_registry.rb b/lib/hq/harness_registry.rb index 0a572a2..63e3d3e 100644 --- a/lib/hq/harness_registry.rb +++ b/lib/hq/harness_registry.rb @@ -3,7 +3,7 @@ require "shellwords" module HQ - BUILTIN_HARNESSES = %w[codex claude].freeze + BUILTIN_HARNESSES = %w[codex claude opencode].freeze HarnessConfig = Struct.new(:key, :adapter, :execution_command, keyword_init: true) do def command_parts diff --git a/lib/hq/parser.rb b/lib/hq/parser.rb index 0235c42..6f557cf 100644 --- a/lib/hq/parser.rb +++ b/lib/hq/parser.rb @@ -34,6 +34,8 @@ def for(agent_type) Claude.new when "codex" Codex.new + when "opencode" + OpenCode.new else raise ArgumentError, "Unsupported agent parser #{agent_type.inspect}" end @@ -309,6 +311,10 @@ def format_system_metadata(entry) # `summary` (falling back to the inquiry message) so the chat viewport # doesn't render the raw payload. def assistant_display_text(text) + original = text + text = text.to_s.strip + fenced = text.match(/\A```(?:json)?\s*(?.*?)\s*```\z/m) + text = fenced[:json].strip if fenced parsed = JSON.parse(text) return text unless parsed.is_a?(Hash) return text unless parsed.key?("summary") || parsed.key?("inquiry") || parsed.key?("status") @@ -319,7 +325,7 @@ def assistant_display_text(text) inquiry = parsed["inquiry"] inquiry.is_a?(Hash) ? inquiry["message"].to_s.strip : "" rescue JSON::ParserError - text + original end # Both Claude and Codex raw logs prepend a `prompt=` block with @@ -421,3 +427,4 @@ def parse_lines(lines, include_prompt_header:) require_relative "parser/claude" require_relative "parser/codex" +require_relative "parser/opencode" diff --git a/lib/hq/parser/opencode.rb b/lib/hq/parser/opencode.rb new file mode 100644 index 0000000..0f24d1d --- /dev/null +++ b/lib/hq/parser/opencode.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require "json" +require "time" + +module HQ + module Parser + # Parser for OpenCode `opencode run --format json` raw logs. + # + # OpenCode's raw JSON event stream is adapter-specific and may evolve, so + # this parser intentionally accepts several common event shapes: + # assistant/message/result text, tool call/result payloads, usage hashes, + # and error/failure records. Real-run fixtures should tighten these cases + # as Tycho sees more OpenCode versions in the wild. + class OpenCode < Base + private + + def parse_event(event, conversation, system) + parse_error(event, system) + parse_usage(event, system) + parse_tool_event(event, system) + parse_assistant_event(event, conversation) + end + + def parse_assistant_event(event, conversation) + return if delta_event?(event) + + text = assistant_text(event) + return if text.empty? + + display = Parser.assistant_display_text(text) + return if display.empty? + + conversation << ConversationEntry.new( + role: "assistant", + content: display, + timestamp: Time.now, + metadata: event_metadata(event) + ) + end + + def parse_tool_event(event, system) + payload = tool_payload(event) + return unless payload + + tool_name = tool_name(payload, event) + body = tool_call_body(payload) + return if tool_name.empty? && body.empty? + + system << SystemEntry.new( + type: :tool_call, + content: body.empty? ? tool_name : body, + timestamp: Time.now, + tool_name: tool_name.empty? ? nil : tool_name, + metadata: event_metadata(event).merge("tool" => payload) + ) + + result = tool_result_body(payload) + return if result.empty? + + system << SystemEntry.new( + type: :tool_result, + content: result, + timestamp: Time.now, + tool_name: tool_name.empty? ? nil : tool_name, + metadata: event_metadata(event).merge("tool" => payload) + ) + end + + def parse_usage(event, system) + part = event["part"].is_a?(Hash) ? event["part"] : {} + usage = event["usage"].is_a?(Hash) ? event["usage"] : event.dig("message", "usage") + usage = part["tokens"] if usage.nil? && part["tokens"].is_a?(Hash) + return unless usage.is_a?(Hash) || event.key?("total_cost_usd") || event.key?("cost") || part.key?("cost") + + input_tokens = usage&.dig("input_tokens") || usage&.dig("input") || usage&.dig("prompt_tokens") + output_tokens = usage&.dig("output_tokens") || usage&.dig("output") || usage&.dig("completion_tokens") + cost = event["total_cost_usd"] || event["cost"] || part["cost"] || usage&.dig("cost") + turns = event["num_turns"] || event["turns"] + duration = event["duration_ms"] || event["duration"] + + parts = [] + parts << "$#{format("%.4f", cost)}" if cost + parts << "#{input_tokens} input" if input_tokens + parts << "#{output_tokens} output" if output_tokens + parts << "#{turns} turns" if turns + parts << "#{duration}ms" if duration + return if parts.empty? + + metadata = event_metadata(event) + metadata["input_tokens"] = input_tokens if input_tokens + metadata["output_tokens"] = output_tokens if output_tokens + metadata["total_cost_usd"] = cost if cost + metadata["num_turns"] = turns if turns + metadata["duration_ms"] = duration if duration + + system << SystemEntry.new( + type: :usage, + content: parts.join(", "), + timestamp: Time.now, + tool_name: nil, + metadata: metadata + ) + end + + def parse_error(event, system) + type = event["type"].to_s + error = event["error"] + message = if error.is_a?(Hash) + error["message"].to_s + else + error.to_s + end + message = event["message"].to_s if message.strip.empty? && type.match?(/error|fail/i) + return if message.strip.empty? + return unless type.match?(/error|fail/i) || event["is_error"] == true + + system << SystemEntry.new( + type: :error, + content: message.strip, + timestamp: Time.now, + tool_name: nil, + metadata: event_metadata(event) + ) + end + + def assistant_text(event) + nested = nested_message(event) + part = event["part"].is_a?(Hash) ? event["part"] : {} + role = event["role"].to_s + role = nested["role"].to_s if role.empty? && nested.is_a?(Hash) + type = event["type"].to_s + item = event["item"].is_a?(Hash) ? event["item"] : {} + + return "" unless role == "assistant" || + type.match?(/assistant|message|result/i) || + (type == "text" && part["type"].to_s == "text") || + item["type"].to_s.match?(/agent_message|assistant|message/i) + + text = text_from_value(event["text"]) + text = text_from_value(event["content"]) if text.empty? + text = text_from_value(event["result"]) if text.empty? + text = text_from_value(nested["content"]) if text.empty? && nested.is_a?(Hash) + text = text_from_value(nested["text"]) if text.empty? && nested.is_a?(Hash) + text = text_from_value(item["text"]) if text.empty? + text = text_from_value(item["content"]) if text.empty? + text = text_from_value(part["text"]) if text.empty? + text = text_from_value(part["content"]) if text.empty? + text.strip + end + + def nested_message(event) + message = event["message"] + message.is_a?(Hash) ? message : {} + end + + def text_from_value(value) + case value + when String + value + when Array + value.filter_map { |part| text_part(part) }.join("\n") + when Hash + text_part(value) + else + "" + end.to_s + end + + def text_part(part) + return part if part.is_a?(String) + return nil unless part.is_a?(Hash) + + if part.key?("text") + part["text"].to_s + elsif part.key?("content") + text_from_value(part["content"]) + end + end + + def tool_payload(event) + return event["tool"] if event["tool"].is_a?(Hash) + return event["tool_call"] if event["tool_call"].is_a?(Hash) + return event["toolCall"] if event["toolCall"].is_a?(Hash) + return event["call"] if event["call"].is_a?(Hash) && event["type"].to_s.match?(/tool/i) + + part = event["part"] + return part if part.is_a?(Hash) && part["type"].to_s == "tool" + + item = event["item"] + return item if item.is_a?(Hash) && item["type"].to_s.match?(/tool/i) + + nil + end + + def tool_name(payload, event) + [ + payload["tool"], + payload["name"], + payload["tool_name"], + payload["toolName"], + event["tool_name"], + event["toolName"] + ].map { |value| value.to_s.strip }.find { |value| !value.empty? }.to_s + end + + def tool_call_body(payload) + state = payload["state"].is_a?(Hash) ? payload["state"] : {} + scalar = %w[title description command path file filePath query pattern].filter_map do |key| + payload[key] || state[key] + end.find { |value| !value.to_s.strip.empty? } + return scalar.to_s.strip.lines.first.to_s.strip if scalar + + input = payload["input"] || payload["arguments"] || payload["args"] || state["input"] + if input.is_a?(Hash) + nested_scalar = %w[title description command path file filePath query pattern result output].map { |key| input[key] } + .find { |value| !value.to_s.strip.empty? } + return nested_scalar.to_s.strip.lines.first.to_s.strip if nested_scalar + end + return "" if input.nil? || input == {} + + input.is_a?(String) ? input.strip : JSON.pretty_generate(input) + rescue StandardError + input.to_s + end + + def tool_result_body(payload) + state = payload["state"].is_a?(Hash) ? payload["state"] : {} + output = state.dig("metadata", "preview") || state.dig("metadata", "output") || + state["output"] || payload["result"] || payload["output"] + return "" if output.to_s.strip.empty? + + output.to_s.strip.lines.first.to_s.strip + end + + def delta_event?(event) + event["partial"] == true || event["delta"] || event["type"].to_s.match?(/delta|part\.|updated/i) + end + + def event_metadata(event) + metadata = {} + %w[type id session_id sessionID sessionId message_id messageID].each do |key| + value = event[key] + metadata[key] = value unless value.nil? || value.to_s.empty? + end + metadata + end + end + end +end diff --git a/lib/hq/remote_server.rb b/lib/hq/remote_server.rb index 92bdf32..ca3fd23 100644 --- a/lib/hq/remote_server.rb +++ b/lib/hq/remote_server.rb @@ -2107,7 +2107,8 @@ def prompt_template_count def harness_readiness builtins = [ harness_resolver_payload("codex", ExecutableResolver.resolve_tool("codex")), - harness_resolver_payload("claude", ExecutableResolver.resolve_tool("claude")) + harness_resolver_payload("claude", ExecutableResolver.resolve_tool("claude")), + harness_resolver_payload("opencode", ExecutableResolver.resolve_tool("opencode")) ] custom = HQ.custom_harnesses.values.sort_by(&:key).map { |config| custom_harness_payload(config) } builtins + custom diff --git a/lib/hq/remote_ui/assets/app.js b/lib/hq/remote_ui/assets/app.js index 35270bf..d41aa76 100644 --- a/lib/hq/remote_ui/assets/app.js +++ b/lib/hq/remote_ui/assets/app.js @@ -1,6 +1,6 @@ const TOP_TABS = ["now", "agents", "settings"]; const PROJECT_ACTIONS = ["deploy", "maintenance", "live"]; -const BUILTIN_AGENT_HARNESSES = ["codex", "claude"]; +const BUILTIN_AGENT_HARNESSES = ["codex", "claude", "opencode"]; const DEFAULT_REFRESH_INTERVALS = { runningAgentMs: 2_000, activeAgentMs: 3_000, @@ -6134,7 +6134,8 @@ function harnessAdapter(name) { const normalized = String(name || "").trim().toLowerCase(); const configured = harnessSetupItem(normalized); if (configured?.adapter) return String(configured.adapter).trim().toLowerCase(); - return normalized === "claude" ? "claude" : "codex"; + if (["claude", "codex", "opencode"].includes(normalized)) return normalized; + return "codex"; } function skillTriggerForHarness(harness) { diff --git a/test/fixtures/parser/opencode/basic.jsonl b/test/fixtures/parser/opencode/basic.jsonl new file mode 100644 index 0000000..0118aa7 --- /dev/null +++ b/test/fixtures/parser/opencode/basic.jsonl @@ -0,0 +1,3 @@ +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_basic","part":{"id":"prt_fixture_basic_start","messageID":"msg_fixture_basic","sessionID":"ses_fixture_basic","type":"step-start"}} +{"type":"text","timestamp":0,"sessionID":"ses_fixture_basic","part":{"id":"prt_fixture_basic_text","messageID":"msg_fixture_basic","sessionID":"ses_fixture_basic","type":"text","text":"FIXTURE_OK","time":{"start":0,"end":0}}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_basic","part":{"id":"prt_fixture_basic_finish","reason":"stop","messageID":"msg_fixture_basic","sessionID":"ses_fixture_basic","type":"step-finish","tokens":{"total":9916,"input":3,"output":7,"reasoning":0,"cache":{"write":9906,"read":0}},"cost":0.0621025}} diff --git a/test/fixtures/parser/opencode/permission_bash_denied.jsonl b/test/fixtures/parser/opencode/permission_bash_denied.jsonl new file mode 100644 index 0000000..8978fca --- /dev/null +++ b/test/fixtures/parser/opencode/permission_bash_denied.jsonl @@ -0,0 +1,10 @@ +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_start_1","messageID":"msg_fixture_permission_1","sessionID":"ses_fixture_permission","type":"step-start"}} +{"type":"text","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_text_1","messageID":"msg_fixture_permission_1","sessionID":"ses_fixture_permission","type":"text","text":"I don't have access to a Bash tool in this environment, so I cannot run shell commands directly. However, I can read the file for you using the Read tool if it exists.","time":{"start":0,"end":0}}} +{"type":"tool_use","timestamp":0,"sessionID":"ses_fixture_permission","part":{"type":"tool","tool":"glob","callID":"toolu_fixture_glob","state":{"status":"completed","input":{"pattern":"**/fixture_tool_ok.txt"},"output":"/private/tmp/tycho-opencode-deny.fixture/fixture_tool_ok.txt","metadata":{"count":1,"truncated":false},"title":"private/tmp/tycho-opencode-deny.fixture","time":{"start":0,"end":0}},"metadata":{"anthropic":{"caller":{"type":"direct"}}},"id":"prt_fixture_permission_glob","sessionID":"ses_fixture_permission","messageID":"msg_fixture_permission_1"}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_finish_1","reason":"tool-calls","messageID":"msg_fixture_permission_1","sessionID":"ses_fixture_permission","type":"step-finish","tokens":{"total":8720,"input":3,"output":109,"reasoning":0,"cache":{"write":8608,"read":0}},"cost":0.05654}} +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_start_2","messageID":"msg_fixture_permission_2","sessionID":"ses_fixture_permission","type":"step-start"}} +{"type":"tool_use","timestamp":0,"sessionID":"ses_fixture_permission","part":{"type":"tool","tool":"read","callID":"toolu_fixture_read","state":{"status":"completed","input":{"filePath":"/private/tmp/tycho-opencode-deny.fixture/fixture_tool_ok.txt"},"output":"/private/tmp/tycho-opencode-deny.fixture/fixture_tool_ok.txt\nfile\n\n1: permission target\n\n(End of file - total 1 lines)\n","metadata":{"preview":"permission target","truncated":false,"loaded":[],"display":{"type":"file","path":"/private/tmp/tycho-opencode-deny.fixture/fixture_tool_ok.txt","text":"permission target","lineStart":1,"lineEnd":1,"totalLines":1,"truncated":false}},"title":"private/tmp/tycho-opencode-deny.fixture/fixture_tool_ok.txt","time":{"start":0,"end":0}},"metadata":{"anthropic":{"caller":{"type":"direct"}}},"id":"prt_fixture_permission_read","sessionID":"ses_fixture_permission","messageID":"msg_fixture_permission_2"}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_finish_2","reason":"tool-calls","messageID":"msg_fixture_permission_2","sessionID":"ses_fixture_permission","type":"step-finish","tokens":{"total":8835,"input":1,"output":77,"reasoning":0,"cache":{"write":149,"read":8608}},"cost":0.00716525}} +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_start_3","messageID":"msg_fixture_permission_3","sessionID":"ses_fixture_permission","type":"step-start"}} +{"type":"text","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_text_2","messageID":"msg_fixture_permission_3","sessionID":"ses_fixture_permission","type":"text","text":"Summary: The file `fixture_tool_ok.txt` exists and contains a single line: `permission target`. I was unable to run the exact bash command `cat fixture_tool_ok.txt` as requested because I don't have a Bash/shell execution tool available in this environment.","time":{"start":0,"end":0}}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_permission","part":{"id":"prt_fixture_permission_finish_3","reason":"stop","messageID":"msg_fixture_permission_3","sessionID":"ses_fixture_permission","type":"step-finish","tokens":{"total":8998,"input":1,"output":89,"reasoning":0,"cache":{"write":151,"read":8757}},"cost":0.00755225}} diff --git a/test/fixtures/parser/opencode/resume.jsonl b/test/fixtures/parser/opencode/resume.jsonl new file mode 100644 index 0000000..c1b1663 --- /dev/null +++ b/test/fixtures/parser/opencode/resume.jsonl @@ -0,0 +1,6 @@ +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_resume","part":{"id":"prt_fixture_resume_start_1","messageID":"msg_fixture_resume_1","sessionID":"ses_fixture_resume","type":"step-start"}} +{"type":"text","timestamp":0,"sessionID":"ses_fixture_resume","part":{"id":"prt_fixture_resume_text_1","messageID":"msg_fixture_resume_1","sessionID":"ses_fixture_resume","type":"text","text":"RESUME_ONE","time":{"start":0,"end":0}}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_resume","part":{"id":"prt_fixture_resume_finish_1","reason":"stop","messageID":"msg_fixture_resume_1","sessionID":"ses_fixture_resume","type":"step-finish","tokens":{"total":8530,"input":3,"output":7,"reasoning":0,"cache":{"write":8520,"read":0}},"cost":0.05344}} +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_resume","part":{"id":"prt_fixture_resume_start_2","messageID":"msg_fixture_resume_2","sessionID":"ses_fixture_resume","type":"step-start"}} +{"type":"text","timestamp":0,"sessionID":"ses_fixture_resume","part":{"id":"prt_fixture_resume_text_2","messageID":"msg_fixture_resume_2","sessionID":"ses_fixture_resume","type":"text","text":"RESUME_TWO","time":{"start":0,"end":0}}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_resume","part":{"id":"prt_fixture_resume_finish_2","reason":"stop","messageID":"msg_fixture_resume_2","sessionID":"ses_fixture_resume","type":"step-finish","tokens":{"total":8554,"input":3,"output":8,"reasoning":0,"cache":{"write":8543,"read":0}},"cost":0.05360875}} diff --git a/test/fixtures/parser/opencode/structured.jsonl b/test/fixtures/parser/opencode/structured.jsonl new file mode 100644 index 0000000..a941421 --- /dev/null +++ b/test/fixtures/parser/opencode/structured.jsonl @@ -0,0 +1,3 @@ +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_structured","part":{"id":"prt_fixture_structured_start","messageID":"msg_fixture_structured","sessionID":"ses_fixture_structured","type":"step-start"}} +{"type":"text","timestamp":0,"sessionID":"ses_fixture_structured","part":{"id":"prt_fixture_structured_text","messageID":"msg_fixture_structured","sessionID":"ses_fixture_structured","type":"text","text":"{\"status\":\"success\",\"summary\":\"STRUCTURED_OK\",\"attachments\":[{\"type\":\"file\",\"path\":\"/tmp/opencode-fixture.txt\"}]}","time":{"start":0,"end":0}}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_structured","part":{"id":"prt_fixture_structured_finish","reason":"stop","messageID":"msg_fixture_structured","sessionID":"ses_fixture_structured","type":"step-finish","tokens":{"total":8612,"input":3,"output":37,"reasoning":0,"cache":{"write":8572,"read":0}},"cost":0.054515}} diff --git a/test/fixtures/parser/opencode/tool_use.jsonl b/test/fixtures/parser/opencode/tool_use.jsonl new file mode 100644 index 0000000..613ee05 --- /dev/null +++ b/test/fixtures/parser/opencode/tool_use.jsonl @@ -0,0 +1,6 @@ +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_tool","part":{"id":"prt_fixture_tool_start_1","messageID":"msg_fixture_tool_1","sessionID":"ses_fixture_tool","type":"step-start"}} +{"type":"tool_use","timestamp":0,"sessionID":"ses_fixture_tool","part":{"type":"tool","tool":"bash","callID":"toolu_fixture","state":{"status":"completed","input":{"command":"cat fixture_tool_ok.txt"},"output":"fixture-file-value\n","metadata":{"output":"fixture-file-value\n","exit":0,"truncated":false},"title":"cat fixture_tool_ok.txt","time":{"start":0,"end":0}},"metadata":{"anthropic":{"caller":{"type":"direct"}}},"id":"prt_fixture_tool_use","sessionID":"ses_fixture_tool","messageID":"msg_fixture_tool_1"}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_tool","part":{"id":"prt_fixture_tool_finish_1","reason":"tool-calls","messageID":"msg_fixture_tool_1","sessionID":"ses_fixture_tool","type":"step-finish","tokens":{"total":9989,"input":3,"output":59,"reasoning":0,"cache":{"write":9927,"read":0}},"cost":0.06353375}} +{"type":"step_start","timestamp":0,"sessionID":"ses_fixture_tool","part":{"id":"prt_fixture_tool_start_2","messageID":"msg_fixture_tool_2","sessionID":"ses_fixture_tool","type":"step-start"}} +{"type":"text","timestamp":0,"sessionID":"ses_fixture_tool","part":{"id":"prt_fixture_tool_text","messageID":"msg_fixture_tool_2","sessionID":"ses_fixture_tool","type":"text","text":"TOOL_DONE fixture-file-value","time":{"start":0,"end":0}}} +{"type":"step_finish","timestamp":0,"sessionID":"ses_fixture_tool","part":{"id":"prt_fixture_tool_finish_2","reason":"stop","messageID":"msg_fixture_tool_2","sessionID":"ses_fixture_tool","type":"step-finish","tokens":{"total":10020,"input":1,"output":13,"reasoning":0,"cache":{"write":79,"read":9927}},"cost":0.00578725}} diff --git a/test/managed_agent_test.rb b/test/managed_agent_test.rb index dada1e7..dfbab70 100644 --- a/test/managed_agent_test.rb +++ b/test/managed_agent_test.rb @@ -3,8 +3,10 @@ require "tmpdir" require "fileutils" require "json" +require "stringio" require_relative "../lib/hq/domain/managed_agent" +require_relative "../lib/hq/cli_command" module ManagedAgentTest module_function @@ -12,10 +14,13 @@ module ManagedAgentTest def run! assert_new_agents_use_unique_log_stems assert_start_finalizes_unpolled_previous_run + assert_cli_status_finalizes_unpolled_dead_pid assert_start_reconciles_session_after_restart assert_fallback_summary_uses_assistant_message_not_tool_json + assert_opencode_fallback_summary_uses_text_event_not_prompt assert_structured_output_summary_beats_later_agent_message assert_claude_scalar_json_structured_output_normalizes + assert_opencode_assistant_json_structured_output_normalizes assert_final_output_checklist_is_ephemeral_execution_context assert_agent_result_schema_describes_summary assert_initial_user_message_attachments_seed_memory @@ -62,6 +67,69 @@ def assert_new_agents_use_unique_log_stems replace_constant(HQ, :AGENT_LOGS_DIR, old_logs_dir) if old_logs_dir end + def assert_cli_status_finalizes_unpolled_dead_pid + old_agents_file = nil + old_logs_dir = nil + old_archive_dir = nil + Dir.mktmpdir("hq-cli-agent-status-test") do |dir| + logs_dir = File.join(dir, "agents") + FileUtils.mkdir_p(logs_dir) + old_agents_file = replace_constant(HQ, :AGENTS_FILE, File.join(dir, "managed_agents.json")) + old_logs_dir = replace_constant(HQ, :AGENT_LOGS_DIR, logs_dir) + old_archive_dir = replace_constant(HQ, :AGENT_ARCHIVE_DIR, File.join(logs_dir, "archive")) + + started_at = Time.now - 60 + log_path = File.join(logs_dir, "demo.raw.log") + File.open(log_path, "w") do |f| + f.puts "=== [#{started_at.strftime("%Y-%m-%d %H:%M:%S")}] start ===" + f.puts "workspace=#{dir}" + f.puts "prompt=SYSTEM: test" + f.puts JSON.generate( + "type" => "text", + "sessionID" => "ses_cli_status", + "part" => { + "type" => "text", + "text" => "{\"status\":\"success\",\"summary\":\"CLI_STATUS_DONE\"}" + } + ) + end + File.write(log_path.sub(/\.raw\.log\z/, ".status"), "0") + + agent = HQ::ManagedAgent.new( + key: "demo-agent-1", + name: "Demo", + project_key: "demo", + template_key: "custom", + workspace: dir, + prompt: "test", + agent: "opencode", + pid: 999_999, + started_at: started_at, + log_path: log_path, + runs: [HQ::ManagedAgent::AgentRun.new( + started_at: started_at, + status: "running", + log_path: log_path, + command: "opencode run" + )] + ) + File.write(HQ::AGENTS_FILE, JSON.pretty_generate([agent.to_hash])) + + code = HQ::CLICommand.agent_status("demo-agent-1", out: StringIO.new, err: StringIO.new) + saved = JSON.parse(File.read(HQ::AGENTS_FILE)).first + + assert(code == 0, "expected CLI agent status to succeed") + assert(saved["last_exit_code"] == 0, "expected CLI status to persist finalized exit code") + assert(saved["summary"] == "CLI_STATUS_DONE", "expected CLI status to persist finalized summary") + assert(saved["session_id"] == "ses_cli_status", "expected CLI status to persist OpenCode session id") + assert(saved.dig("runs", 0, "finished_at"), "expected CLI status to persist run finish time") + end + ensure + replace_constant(HQ, :AGENTS_FILE, old_agents_file) if old_agents_file + replace_constant(HQ, :AGENT_LOGS_DIR, old_logs_dir) if old_logs_dir + replace_constant(HQ, :AGENT_ARCHIVE_DIR, old_archive_dir) if old_archive_dir + end + # Regression for a bug where a Claude run that exited before the 10s poll tick # could be followed by a user-triggered restart; the previous run was never # finalized, so `capture_session_id!` never flipped `session_bootstrapped`, @@ -305,6 +373,69 @@ def assert_fallback_summary_uses_assistant_message_not_tool_json end end + def assert_opencode_fallback_summary_uses_text_event_not_prompt + Dir.mktmpdir("hq-managed-agent-opencode-summary-test") do |dir| + log_path = File.join(dir, "opencode-summary.raw.log") + started_at = Time.parse("2026-06-27 22:21:20") + finished_at = started_at + 6 + File.open(log_path, "w") do |f| + f.puts "=== [#{started_at.strftime("%Y-%m-%d %H:%M:%S")}] start ===" + f.puts "workspace=#{dir}" + f.puts "prompt=SYSTEM:" + f.puts "this is just a test. reply with OK" + f.puts + f.puts "For `summary`, write a concise operator-facing Markdown summary." + f.puts + f.puts JSON.generate( + "type" => "step_start", + "sessionID" => "ses_test", + "part" => { "type" => "step-start", "sessionID" => "ses_test" } + ) + f.puts JSON.generate( + "type" => "text", + "sessionID" => "ses_test", + "part" => { "type" => "text", "text" => "OK" } + ) + f.puts JSON.generate( + "type" => "step_finish", + "sessionID" => "ses_test", + "part" => { + "type" => "step-finish", + "tokens" => { "input" => 3, "output" => 4 }, + "cost" => 0.08288375 + } + ) + end + + run = HQ::ManagedAgent::AgentRun.new( + started_at: started_at, + finished_at: finished_at, + exit_code: 0, + status: "succeeded", + log_path: log_path, + command: "opencode run" + ) + agent = HQ::ManagedAgent.new( + key: "opencode-summary-demo", + name: "OpenCode Summary Demo", + project_key: "demo", + template_key: "custom", + workspace: dir, + prompt: "this is just a test. reply with OK", + agent: "opencode", + started_at: started_at, + finished_at: finished_at, + last_exit_code: 0, + runs: [run], + log_path: log_path + ) + + summary = agent.build_summary! + assert(summary == "OK", + "OpenCode fallback summary should prefer text event, got #{summary.inspect}") + end + end + def assert_structured_output_summary_beats_later_agent_message Dir.mktmpdir("hq-managed-agent-structured-summary-test") do |dir| log_path = File.join(dir, "structured-summary.raw.log") @@ -464,6 +595,29 @@ def assert_claude_scalar_json_structured_output_normalizes end end + def assert_opencode_assistant_json_structured_output_normalizes + payload = { + "status" => "success", + "summary" => "OpenCode summary.", + "attachments" => [ + { "type" => "file", "path" => "/tmp/opencode-report.md" } + ] + } + structured = HQ::AgentStructuredResult.normalize_payload( + "type" => "text", + "sessionID" => "opencode-session-1", + "part" => { + "type" => "text", + "text" => "```json\n#{JSON.generate(payload)}\n```" + } + ) + + assert(structured["summary"] == "OpenCode summary.", + "expected OpenCode assistant JSON to normalize") + assert(structured["attachments"].first["path"] == "/tmp/opencode-report.md", + "expected OpenCode attachments to normalize") + end + def assert_final_output_checklist_is_ephemeral_execution_context Dir.mktmpdir("hq-managed-agent-checklist-test") do |dir| checklist = HQ::ManagedAgent::FINAL_OUTPUT_CHECKLIST @@ -755,6 +909,38 @@ def assert_model_and_reasoning_effort_arguments_apply_to_harnesses assert(argument_after(claude_command, "--effort") == "max", "expected Claude command to include --effort") + opencode = HQ::ManagedAgent.new( + key: "opencode-model-agent", + name: "OpenCode Model Agent", + project_key: "demo", + template_key: "custom", + workspace: Dir.tmpdir, + prompt: "System prompt", + agent: "opencode", + model: "anthropic/claude-sonnet-4", + reasoning_effort: "high" + ) + opencode_command = opencode.send(:build_command)[:command] + assert(File.basename(opencode_command[0]) == "opencode" && opencode_command[1..3] == ["run", "--format", "json"], + "expected OpenCode command to use opencode run --format json") + assert(argument_after(opencode_command, "--dir") == Dir.tmpdir, + "expected OpenCode command to include --dir") + assert(argument_after(opencode_command, "--model") == "anthropic/claude-sonnet-4", + "expected OpenCode command to include --model") + assert(argument_after(opencode_command, "--variant") == "high", + "expected OpenCode command to include --variant") + assert(opencode_command.include?("--dangerously-skip-permissions"), + "expected OpenCode full-access command to include dangerous permission flag") + + resumed = HQ::ManagedAgent.from_hash(opencode.to_hash.merge( + "session_id" => "opencode-session-1", + "session_bootstrapped" => true, + "runs" => [{ "finished_at" => Time.now.iso8601, "status" => "succeeded" }] + )) + resumed_command = resumed.send(:build_command)[:command] + assert(argument_after(resumed_command, "--session") == "opencode-session-1", + "expected OpenCode resume command to include --session") + old_harnesses = HQ.custom_harnesses HQ.custom_harnesses = [ HQ::HarnessConfig.new( diff --git a/test/parser_test.rb b/test/parser_test.rb index a2f6e05..89daa61 100644 --- a/test/parser_test.rb +++ b/test/parser_test.rb @@ -1,17 +1,16 @@ # frozen_string_literal: true require_relative "../lib/hq/parser" +require_relative "../lib/hq/domain/agent_structured_result" -# Per-tool parser regression tests for the Claude harness. Each fixture in -# test/fixtures/parser/claude/.jsonl is a literal pair from a real raw -# log: one `tool_use` event line followed by one `tool_result` event line. -# We parse the pair and assert the exact :tool_call and :tool_result content -# the parser emits, so any change to the per-tool body formatters or the -# tool-result preview logic is caught immediately. +# Per-tool parser regression tests for the Claude harness use real raw-log +# pairs. OpenCode fixtures are sanitized from `opencode run --format json` +# output so parser coverage stays anchored to observed event shapes. module ParserTest module_function - FIXTURE_DIR = File.expand_path("fixtures/parser/claude", __dir__) + CLAUDE_FIXTURE_DIR = File.expand_path("fixtures/parser/claude", __dir__) + OPENCODE_FIXTURE_DIR = File.expand_path("fixtures/parser/opencode", __dir__) def run! assert_bash_tool @@ -22,6 +21,11 @@ def run! assert_agent_tool assert_skill_tool assert_structured_output_tool + assert_opencode_basic_stream + assert_opencode_tool_use_stream + assert_opencode_structured_stream + assert_opencode_permission_denied_stream + assert_opencode_resume_stream assert_chat_blocks_use_sequence_for_equal_timestamps puts "parser_test: ok" end @@ -84,6 +88,67 @@ def assert_structured_output_tool assert_result(result, "StructuredOutput", "Structured output provided successfully") end + def assert_opencode_basic_stream + conversation, system = parse_opencode_fixture("basic") + usage = system.find { |entry| entry.type == :usage } + + assert(conversation.map(&:content) == ["FIXTURE_OK"], + "expected OpenCode assistant text, got #{conversation.map(&:content).inspect}") + assert(usage.content.include?("7 output"), "expected OpenCode usage summary, got #{usage.content.inspect}") + end + + def assert_opencode_tool_use_stream + conversation, system = parse_opencode_fixture("tool_use") + call = system.find { |entry| entry.type == :tool_call } + result = system.find { |entry| entry.type == :tool_result } + usage = system.select { |entry| entry.type == :usage }.last + + assert(conversation.map(&:content) == ["TOOL_DONE fixture-file-value"], + "expected OpenCode final text, got #{conversation.map(&:content).inspect}") + assert_call(call, "bash", "cat fixture_tool_ok.txt") + assert_result(result, "bash", "fixture-file-value") + assert(usage.content.include?("13 output"), "expected OpenCode final usage summary, got #{usage.content.inspect}") + end + + def assert_opencode_structured_stream + lines = opencode_fixture_lines("structured") + conversation, system = HQ::Parser.parse_stream(lines, agent_type: "opencode") + structured = HQ::AgentStructuredResult.from_log_lines(lines) + usage = system.find { |entry| entry.type == :usage } + + assert(conversation.map(&:content) == ["STRUCTURED_OK"], + "expected OpenCode structured text, got #{conversation.map(&:content).inspect}") + assert(structured["summary"] == "STRUCTURED_OK", + "expected structured summary from OpenCode fixture, got #{structured.inspect}") + assert(structured["attachments"].first["path"] == "/tmp/opencode-fixture.txt", + "expected structured attachment from OpenCode fixture, got #{structured.inspect}") + assert(usage.content.include?("37 output"), "expected OpenCode structured usage, got #{usage.content.inspect}") + end + + def assert_opencode_permission_denied_stream + conversation, system = parse_opencode_fixture("permission_bash_denied") + calls = system.select { |entry| entry.type == :tool_call } + results = system.select { |entry| entry.type == :tool_result } + + assert(conversation.first.content.include?("don't have access to a Bash tool"), + "expected denied bash explanation, got #{conversation.first&.content.inspect}") + assert(conversation.last.content.include?("permission target"), + "expected permission fixture final text, got #{conversation.last&.content.inspect}") + assert(calls.map(&:tool_name) == %w[glob read], + "expected glob/read calls after bash deny, got #{calls.map(&:tool_name).inspect}") + assert_result(results.last, "read", "permission target") + end + + def assert_opencode_resume_stream + conversation, system = parse_opencode_fixture("resume") + session_ids = (conversation + system).filter_map { |entry| entry.metadata["sessionID"] }.uniq + + assert(conversation.map(&:content) == %w[RESUME_ONE RESUME_TWO], + "expected two resumed OpenCode messages, got #{conversation.map(&:content).inspect}") + assert(session_ids == ["ses_fixture_resume"], + "expected one resumed session id, got #{session_ids.inspect}") + end + def assert_chat_blocks_use_sequence_for_equal_timestamps timestamp = Time.utc(2026, 6, 14, 8, 0, 0) conversation = [ @@ -113,7 +178,7 @@ def assert_chat_blocks_use_sequence_for_equal_timestamps end def parse_fixture(name) - path = File.join(FIXTURE_DIR, "#{name}.jsonl") + path = File.join(CLAUDE_FIXTURE_DIR, "#{name}.jsonl") lines = File.readlines(path) _conversation, system = HQ::Parser.parse_stream(lines, agent_type: "claude") @@ -125,6 +190,15 @@ def parse_fixture(name) [call, result] end + def parse_opencode_fixture(name) + HQ::Parser.parse_stream(opencode_fixture_lines(name), agent_type: "opencode") + end + + def opencode_fixture_lines(name) + path = File.join(OPENCODE_FIXTURE_DIR, "#{name}.jsonl") + File.readlines(path) + end + def assert_call(entry, expected_tool_name, expected_content) assert(entry.tool_name == expected_tool_name, "expected tool_call tool_name=#{expected_tool_name.inspect}, got #{entry.tool_name.inspect}") diff --git a/test/pull_request_diff_test.rb b/test/pull_request_diff_test.rb index 9ce13d9..cfb0e3e 100644 --- a/test/pull_request_diff_test.rb +++ b/test/pull_request_diff_test.rb @@ -7,6 +7,7 @@ module PullRequestDiffTest def run! assert_github_provider_fetches_current_diff_not_commit_patch + assert_previous_diff_snapshots_are_not_fresh assert_legacy_snapshots_are_not_fresh puts "pull_request_diff_test: ok" end @@ -41,8 +42,23 @@ def gh_output(*args) assert(!truncated, "expected small diff payload to stay untruncated") assert(diff.include?("diff --git"), "expected provider to return diff text") - assert(provider.commands.first.include?("Accept: application/vnd.github.v3.diff"), - "expected GitHub provider to request PR diff media type, not per-commit patch media type") + assert(provider.commands.first == ["pr", "diff", "123", "--repo", "example/web", "--color", "never"], + "expected GitHub provider to request the current PR diff, not per-commit patch output") + end + + def assert_previous_diff_snapshots_are_not_fresh + previous = { + "diff_format" => "github_diff_v1", + "head_sha" => "abc123", + "remote_updated_at" => "2026-06-29T07:43:31Z" + } + metadata = { + "head_sha" => "abc123", + "remote_updated_at" => "2026-06-29T07:43:31Z" + } + + assert(!HQ::PullRequestDiff.snapshot_summary(previous, metadata:)["fresh"], + "expected previous diff-format snapshots to be stale so bad cached patch snapshots refetch") end def assert_legacy_snapshots_are_not_fresh diff --git a/test/remote_server_test.rb b/test/remote_server_test.rb index 5b2edff..1227b5d 100644 --- a/test/remote_server_test.rb +++ b/test/remote_server_test.rb @@ -1330,7 +1330,7 @@ def assert_remote_setup_payload_includes_readiness assert(setup.dig(:build, :asset_version).to_s.length == 12, "expected setup payload to expose Remote UI build") assert(setup.dig(:counts, :projects) == 1, "expected active project count") assert(setup.dig(:counts, :archived_projects) == 1, "expected archived project count") - assert(setup[:harnesses].map { |item| item[:name] }.sort == %w[claude claude-wrapper codex], + assert(setup[:harnesses].map { |item| item[:name] }.sort == %w[claude claude-wrapper codex opencode], "expected harness readiness entries") assert(setup[:tools].map { |item| item[:name] }.sort == %w[kamal kamal-projects mise tailscale], "expected optional tool readiness entries") @@ -1345,7 +1345,7 @@ def assert_remote_setup_uses_shared_executable_resolution home = File.join(dir, "home") empty_path = File.join(dir, "empty-bin") workspace = File.join(dir, "workspace") - %w[claude codex mise].each do |command| + %w[claude codex opencode mise].each do |command| write_test_executable(File.join(home, ".local", "bin", command)) end FileUtils.mkdir_p(empty_path) @@ -1357,11 +1357,13 @@ def assert_remote_setup_uses_shared_executable_resolution "PATH" => empty_path, "TYCHO_CLAUDE_BIN" => nil, "TYCHO_CODEX_BIN" => nil, - "TYCHO_MISE_BIN" => nil + "TYCHO_MISE_BIN" => nil, + "TYCHO_OPENCODE_BIN" => nil ) do setup = HQ::RemoteService.new(registry: registry).setup - codex = setup[:harnesses].find { |item| item[:name] == "codex" } - claude = setup[:harnesses].find { |item| item[:name] == "claude" } + codex = setup[:harnesses].find { |item| item[:name] == "codex" } + claude = setup[:harnesses].find { |item| item[:name] == "claude" } + opencode = setup[:harnesses].find { |item| item[:name] == "opencode" } mise = setup[:tools].find { |item| item[:name] == "mise" } global_kamal = setup[:tools].find { |item| item[:name] == "kamal" } project_kamal = setup[:tools].find { |item| item[:name] == "kamal-projects" } @@ -1374,6 +1376,10 @@ def assert_remote_setup_uses_shared_executable_resolution "expected Remote setup to find fallback Claude") assert(claude[:reasoning_effort_suggestions].include?("low"), "expected Claude readiness to expose fallback effort suggestions") + assert(opencode[:ready] && opencode[:path].end_with?("/.local/bin/opencode"), + "expected Remote setup to find fallback OpenCode") + assert(opencode[:reasoning_effort_suggestions].include?("high"), + "expected OpenCode readiness to expose variant suggestions") assert(mise[:ready] && mise[:path].end_with?("/.local/bin/mise"), "expected Remote setup to find fallback mise") assert(!global_kamal[:ready], "expected global Kamal to remain PATH-only") @@ -1951,6 +1957,9 @@ def assert_remote_skills_payload_uses_discovery system_skill_dir = File.join(home, ".codex", "skills", ".system", "imagegen") FileUtils.mkdir_p(system_skill_dir) File.write(File.join(system_skill_dir, "SKILL.md"), "# Imagegen\n") + opencode_skill_dir = File.join(workspace, ".opencode", "skills", "plan") + FileUtils.mkdir_p(opencode_skill_dir) + File.write(File.join(opencode_skill_dir, "SKILL.md"), "# Plan\n") registry = registry_for_project(dir, workspace, apps: true) service = HQ::RemoteService.new(registry: registry) @@ -1961,6 +1970,13 @@ def assert_remote_skills_payload_uses_discovery "expected Codex discovery to include ~/.agents/skills") assert(payload[:skills].any? { |skill| skill["name"] == "imagegen" }, "expected Codex discovery to include nested ~/.codex/skills") + + opencode_payload = service.skills("web", "opencode") + assert(opencode_payload[:trigger] == "$", "expected OpenCode fallback skill trigger") + assert(opencode_payload[:skills].any? { |skill| skill["name"] == "plan" }, + "expected OpenCode discovery to include workspace .opencode/skills") + assert(opencode_payload[:skills].any? { |skill| skill["name"] == "teach" }, + "expected OpenCode discovery to include ~/.agents/skills") ensure ENV["HOME"] = old_home end