Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions bin/setup
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -46,7 +46,7 @@ while (($#)); do
exit 2
fi
case "$1" in
app|codex|claude|all)
app|codex|claude|opencode|all)
PROFILES+=("$1")
;;
*)
Expand Down Expand Up @@ -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
Expand Down
229 changes: 229 additions & 0 deletions docs/HARNESS_INVENTORY.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/SETUP_REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 2 additions & 4 deletions lib/hq/cli_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion lib/hq/domain/agent_command_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -24,13 +25,15 @@ 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

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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -127,12 +149,20 @@ 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

def codex_agent?
@harness_adapter == "codex"
end

def opencode_agent?
@harness_adapter == "opencode"
end
end
end
63 changes: 61 additions & 2 deletions lib/hq/domain/agent_structured_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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*(?<json>.*?)\s*```\z/m)
return parse_json_string(fenced[:json]) if fenced

object = text.match(/(?<json>\{.*\})/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
Expand Down
3 changes: 2 additions & 1 deletion lib/hq/domain/executable_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def available?
ENV_NAMES = {
"claude" => "CLAUDE_BIN",
"codex" => "CODEX_BIN",
"opencode" => "OPENCODE_BIN",
"mise" => "MISE_BIN",
"tailscale" => "TAILSCALE_BIN"
}.freeze
Expand Down Expand Up @@ -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}",
Expand Down
83 changes: 79 additions & 4 deletions lib/hq/domain/harness_catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?

Expand All @@ -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?

Expand All @@ -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] }
Expand Down
Loading
Loading