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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ parallel = 3 # how many Tickets a Spec run runs at once; default 3
limit = 3 # how many open issues labelled in-progress stop a Pickup run taking another; default 3

[harness]
default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok or muse; default claude
default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok, muse or opencode; default claude

[harness.claude]
model = "" # the Model Claude Code's sessions run on; default blank, for Claude Code's own
Expand All @@ -271,11 +271,15 @@ effort = "" # how hard that Model reasons; default blank, for Grok Build's own
[harness.muse]
model = "" # the Model Muse Code's sessions run on; default blank, for Muse Code's own
effort = "" # how hard that Model reasons; default blank, for Muse Code's own

[harness.opencode]
model = "" # the Model OpenCode's sessions run on, provider/model; default blank, for OpenCode's own
effort = "" # the Model's variant, passed as #effort; default blank, for OpenCode's own
```

Every key holds its real value, so a Run reading it does exactly what it does with no file. `email.to` has no default, so `setup` suggests one: the public email of your GitHub profile (from `gh api user`), else your global git `user.email`, unless that is a `@users.noreply.github.com` address, which can't receive mail. With neither, `email.to` is the only line written commented out, as above. `setup` never asks `gh` for more scopes, so a private GitHub email is not read, and a Run never looks the suggestion up: `--email` with no address and no `email.to` still stops the Run. From a terminal (stdin and stderr both terminals), `setup` first asks, on stderr:

1. The Harness for every Run's sessions (`harness.default`), listing only those installed on `PATH`. An unavailable Harness or unknown name is refused and asked again. The default is the configured Harness when installed, else the first installed in `claude`, `codex`, `agy`, `grok`, `muse` order. Then the Model (`harness.<name>.model`) and the Effort (`harness.<name>.effort`) for the Harness chosen only, each defaulting to the User config's, where Enter on none keeps the Harness's own default and `-` clears one. They're checked as a Run checks them (see [Harness, Model and Effort](#harness-model-and-effort)). For Claude, a named Model gets the test call; if Claude refuses it, Setup shows what Claude said and asks the Model and Effort again. For Codex, Setup lists the Models `codex debug models` offers, by slug and display name, then the Efforts the Model chosen supports; each answer is matched regardless of case and written as Codex names it, so `GPT-6.1-Sol` and `Max` are written as `gpt-6.1-sol` and `max`, and a Model or Effort the catalog doesn't have is asked again, with the valid choices. For agy, Setup lists `agy models` and its supported Efforts, checks each answer without a model turn, and writes the canonical names regardless of case. For Grok Build, Setup lists `grok models` and the chosen Model's Efforts from its refreshed cache, and checks each answer against them. Muse proposes `muse-spark-1.3` and checks the answers against its cache, or with a minimal call; a refusal asks for the Model and Effort again. The other Harnesses' sections are left as they are. With no Harness installed here, Setup says so, asks none of this, and leaves the harness settings as they are.
1. The Harness for every Run's sessions (`harness.default`), listing only those installed on `PATH`. An unavailable Harness or unknown name is refused and asked again. The default is the configured Harness when installed, else the first installed in `claude`, `codex`, `agy`, `grok`, `muse`, `opencode` order. Then the Model (`harness.<name>.model`) and the Effort (`harness.<name>.effort`) for the Harness chosen only, each defaulting to the User config's, where Enter on none keeps the Harness's own default and `-` clears one. They're checked as a Run checks them (see [Harness, Model and Effort](#harness-model-and-effort)). For Claude, a named Model gets the test call; if Claude refuses it, Setup shows what Claude said and asks the Model and Effort again. For Codex, Setup lists the Models `codex debug models` offers, by slug and display name, then the Efforts the Model chosen supports; each answer is matched regardless of case and written as Codex names it, so `GPT-6.1-Sol` and `Max` are written as `gpt-6.1-sol` and `max`, and a Model or Effort the catalog doesn't have is asked again, with the valid choices. For agy, Setup lists `agy models` and its supported Efforts, checks each answer without a model turn, and writes the canonical names regardless of case. For Grok Build, Setup lists `grok models` and the chosen Model's Efforts from its refreshed cache, and checks each answer against them. Muse proposes `muse-spark-1.3` and checks the answers against its cache, or with a minimal call; a refusal asks for the Model and Effort again. OpenCode proposes no Model and checks the Model and Effort with a minimal standalone call, asking again on a refusal. The other Harnesses' sections are left as they are. With no Harness installed here, Setup says so, asks none of this, and leaves the harness settings as they are.
2. Every Run a Merge run? (`merge.always`)
3. With that on, every Run may start a Base fix when the Base branch's CI is red? (`base.fix`). With it off, this is not asked, and `base.fix` is written at its default, `false`.
4. Every Run first fast-forwards your checkout of the Base branch? (`launch.pull`)
Expand Down Expand Up @@ -306,7 +310,7 @@ A Run reads the file before any work. One that isn't valid TOML, or that has a k

### Harness, Model and Effort

The available Harnesses are Claude Code (`claude`), Codex (`codex`), Antigravity CLI (`agy`), Grok Build (`grok`) and Muse Code (`muse`).
The available Harnesses are Claude Code (`claude`), Codex (`codex`), Antigravity CLI (`agy`), Grok Build (`grok`), Muse Code (`muse`) and OpenCode (`opencode`).

Every session of a command, its implement session, each Repair and Resume, a Spec review, an Architecture review, each Ticket's Run and a Base fix, runs on one **Harness**, with one **Model** and one **Effort**. `harness <name>`, `model <name>` and `effort <level>` (or `--harness`, `--model` and `--effort`) choose them for one command, before or after the Issue URL, and on `architect` (but not with `--plan-only`) and `pickup` too:

Expand Down Expand Up @@ -338,6 +342,12 @@ Muse's named Model is checked against its cached catalog under `~/.local/share/m

Muse's session id comes from `stream.id`, and a Resume uses `--session-id <id>` with every flag and environment override again. Progress lines name session starts, tools and errors. After exit, thirdshift reads the last reply and token totals from Muse's own `sessions/<year>/<month>/<day>/<id>/session.jsonl`. Architecture reviews see that last reply alone, rather than Muse's terminal event's joined replies. A missing or unreadable log falls back to the stream text with no usage. `run.terminal.failed` or a non-zero exit fails the Run with Muse's error. An interrupt sends SIGTERM, then SIGKILL after ten seconds if needed.

With `harness opencode`, every session, Resume and preflight check runs `opencode run --standalone --format json --auto`, with the prompt on stdin and `OPENCODE_DISABLE_AUTOUPDATE=1`. Standalone starts a private server in the session's process tree and adds about 2–3 seconds per session; it keeps the work and its commands under thirdshift's interruption handling rather than in the shared service ([ADR-0013](docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md)). OpenCode reads project `AGENTS.md` and `~/.config/opencode/AGENTS.md`; it never reads `CLAUDE.md` or `~/.claude/CLAUDE.md` itself. When root `CLAUDE.md` exists and `AGENTS.md` does not, thirdshift links `AGENTS.md` to it and excludes that link from git. Factory skills are linked into `.agents/skills/`, and the prompt tells the model to load its skill with the skill tool. A missing successful `skill` load produces a warning without failing or retrying the session.

OpenCode takes the Model id from your provider config, as `<provider>/<model>`, including MiMo-V2.6-Pro when your provider routes it. Model and Effort are passed together as `-m <provider>/<model>#<effort>`; an explicit Effort replaces a variant already in the Model id, and needs a Model set too. Before any work, thirdshift makes a minimal standalone test call with the same stdin, flags and environment. `provider.no-route` identifies a bad Model or Effort without a model turn; a successful check uses a model call because OpenCode has no free catalog without its service. Setup checks its answers the same way, proposing no default Model.

The session id comes from `sessionID` in the stream, and a Resume uses `-s <id>` with every flag and environment override again. OpenCode creates a new session for an unknown id, so thirdshift only resumes an id it read from the stream. Progress comes from `step_start`, `tool_use`, `step_finish` and `error`. After exit, thirdshift reads `opencode session export --standalone <id>`: an exported `failed` outcome fails the Run even with exit 0, the final message is the last assistant message's last text part, and usage and cost are summed over assistant messages to exclude the automatic title call. Input totals include cache reads and writes, output totals include reasoning, and each subtotal is shown. If export fails or is unreadable, the last stream `text` supplies the final message and no usage is shown. A stream error or nonzero exit also fails the Run with OpenCode's error. An interrupt sends SIGTERM through the whole process tree, then SIGKILL after the grace period if needed.

The Command log's opening lines (`sessions run on claude · claude-opus-5-5 · high`), the Activity log's start line, the pull request's body and the Run notification each name the Harness, Model and Effort, `default model` or `default effort` where the Harness's own is used. In the pull request's body, the Spec PR's included, thirdshift writes the line itself once the opening session has ended, such as `Built with claude · claude-opus-5-5 · high`, replacing any it wrote there before.

### Email
Expand Down
4 changes: 2 additions & 2 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,11 +995,11 @@ mod tests {
for (args, error) in [
(
vec![URL, "harness"],
"harness must be followed by claude or codex or agy or grok or muse",
"harness must be followed by claude or codex or agy or grok or muse or opencode",
),
(
vec![URL, "harness", "gemini"],
"harness must be followed by claude or codex or agy or grok or muse, not gemini",
"harness must be followed by claude or codex or agy or grok or muse or opencode, not gemini",
),
(
vec!["--harness", "claude", URL, "harness", "codex"],
Expand Down
12 changes: 9 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ parallel = 3 # how many Tickets a Spec run runs at once; default 3
limit = 3 # how many open issues labelled in-progress stop a Pickup run taking another; default 3

[harness]
default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok or muse; default claude
default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok, muse or opencode; default claude

[harness.claude]
model = "" # the Model Claude Code's sessions run on; default blank, for Claude Code's own
Expand All @@ -408,6 +408,10 @@ effort = "" # how hard that Model reasons; default blank, for Grok Build's own
[harness.muse]
model = "" # the Model Muse Code's sessions run on; default blank, for Muse Code's own
effort = "" # how hard that Model reasons; default blank, for Muse Code's own

[harness.opencode]
model = "" # the Model OpenCode's sessions run on, provider/model; default blank, for OpenCode's own
effort = "" # the Model's variant, passed as #effort; default blank, for OpenCode's own
"#;

/// The line `DEFAULTS` holds for `email.to`, which has no default.
Expand Down Expand Up @@ -545,7 +549,9 @@ mod tests {
"harness.grok.model",
"harness.grok.effort",
"harness.muse.model",
"harness.muse.effort"
"harness.muse.effort",
"harness.opencode.model",
"harness.opencode.effort"
]
);
let commented_out: Vec<&str> = DEFAULTS
Expand Down Expand Up @@ -606,7 +612,7 @@ mod tests {
for (text, error) in [
(
"[harness]\ndefault = \"gemini\"\n",
"harness.default must be claude or codex or agy or grok or muse, not \"gemini\"",
"harness.default must be claude or codex or agy or grok or muse or opencode, not \"gemini\"",
),
(
"[harness]\ndefault = true\n",
Expand Down
5 changes: 5 additions & 0 deletions src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ pub(crate) mod codex;
pub(crate) mod grok;
mod instructions;
pub(crate) mod muse;
pub(crate) mod opencode;
mod process_tree;
mod skill_load;

pub use adapter::{Adapter, Invocation};
pub use codex::Catalog;
Expand All @@ -29,6 +31,7 @@ pub enum Harness {
Agy,
Grok,
Muse,
OpenCode,
}

impl Harness {
Expand Down Expand Up @@ -104,6 +107,8 @@ pub struct Settings {
pub grok: ModelAndEffort,
/// `[harness.muse]`.
pub muse: ModelAndEffort,
/// `[harness.opencode]`.
pub opencode: ModelAndEffort,
}

impl Settings {
Expand Down
5 changes: 3 additions & 2 deletions src/harness/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::process::Child;

use anyhow::Result;

use super::{Choice, Harness, ModelAndEffort, Settings, agy, claude, codex, grok, muse};
use super::{Choice, Harness, ModelAndEffort, Settings, agy, claude, codex, grok, muse, opencode};
use crate::progress::Stream;
use crate::setup::Outside;

Expand Down Expand Up @@ -90,10 +90,11 @@ impl SkillLoading {
}

/// Registration comes from the adapters, including Setup's order.
pub const REGISTERED: [(Harness, &dyn Adapter); 5] = [
pub const REGISTERED: [(Harness, &dyn Adapter); 6] = [
claude::REGISTRATION,
codex::REGISTRATION,
agy::REGISTRATION,
grok::REGISTRATION,
muse::REGISTRATION,
opencode::REGISTRATION,
];
27 changes: 6 additions & 21 deletions src/harness/muse/stream.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Muse's JSONL event envelopes. Usage is read from its session log after exit.

use crate::harness::skill_load::SkillLoad;
use crate::progress::{Stream, bash, shorten};
use serde_json::Value;

Expand All @@ -9,20 +10,15 @@ pub struct MuseProgress {
started: bool,
final_message: Option<String>,
failure: Option<String>,
expected_skill: Option<String>,
skill_loaded: bool,
skill_load: SkillLoad,
tasks: Vec<(String, String)>,
killed: Vec<String>,
}

impl MuseProgress {
pub fn for_prompt(prompt: &str) -> Self {
Self {
expected_skill: prompt
.strip_prefix('/')
.and_then(|rest| rest.split_whitespace().next())
.filter(|name| name.starts_with("thirdshift-"))
.map(String::from),
skill_load: SkillLoad::for_prompt(prompt),
..Self::default()
}
}
Expand All @@ -38,11 +34,8 @@ impl MuseProgress {
.and_then(|rest| rest.split_once('"'))
.map(|(name, _)| name)
.unwrap_or("");
if facts["outcome"] == "success"
&& header.contains("status=\"ok\"")
&& self.expected_skill.as_deref() == Some(skill)
{
self.skill_loaded = true;
if facts["outcome"] == "success" && header.contains("status=\"ok\"") {
self.skill_load.observed(skill);
}
return vec![format!("skill {skill}")];
}
Expand Down Expand Up @@ -158,15 +151,7 @@ impl Stream for MuseProgress {
self.failure.as_deref()
}
fn warnings(&self) -> Vec<String> {
self.expected_skill
.as_ref()
.filter(|_| !self.skill_loaded)
.map(|skill| {
vec![format!(
"warning: the session never loaded {skill} with its skill tool"
)]
})
.unwrap_or_default()
self.skill_load.warnings()
}
}

Expand Down
Loading
Loading