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
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The message thirdshift starts or resumes an agent session with. It carries the *
_Avoid_: instructions, system prompt

**Harness**:
The headless agent CLI that runs every agent session of a **Command**: Claude Code (`claude`) or Codex (`codex`). One per Command, its child Runs and every **Resume** and **Repair** included. Chosen by the command, else the **User config**, else Claude Code.
The headless agent CLI that runs every agent session of a **Command**: Claude Code (`claude`), Codex (`codex`), Antigravity CLI (`agy`), Grok Build (`grok`), Muse Code (`muse`) or OpenCode (`opencode`). One per Command, its child Runs and every **Resume** and **Repair** included. Chosen by the command, else the **User config**, else Claude Code.
_Avoid_: agent, provider, backend

**Model**:
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ The user account that runs thirdshift needs:

- **`claude`** (Claude Code), logged in.
- **`codex`** (Codex CLI), logged in, only for `harness codex`.
- **`agy`** (Google Antigravity CLI), logged in, only for `harness agy`.
- **`gh`** (GitHub CLI), logged in.
- **`git`** with a global `user.name` and `user.email`, and credentials that can push to the repository (`gh auth setup-git` makes git use `gh`'s login). The agents commit as this identity; without it, an agent may borrow the author of the last commit.

Expand Down Expand Up @@ -213,6 +214,10 @@ effort = "high" # how hard that Model reasons; blank for Claude Code's own defa
[harness.codex]
model = ""
effort = ""

[harness.agy]
model = "gemini-3.8-flash"
effort = "medium"
```

`thirdshift setup` writes this file for you, listing every setting at its default so the file itself shows what can be changed:
Expand Down Expand Up @@ -245,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 or codex; default claude
default = "claude" # the Harness every Run's sessions run on, claude, codex or agy; default claude

[harness.claude]
model = "" # the Model Claude Code's sessions run on; default blank, for Claude Code's own
Expand All @@ -254,11 +259,15 @@ effort = "" # how hard that Model reasons; default blank, for Claude Code's ow
[harness.codex]
model = "" # the Model Codex's sessions run on; default blank, for Codex's own
effort = "" # how hard that Model reasons; default blank, for Codex's own

[harness.agy]
model = "" # the Model Antigravity CLI's sessions run on; default blank, for agy's own
effort = "" # how hard that Model reasons; default blank, for agy'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 both, each marked `(not installed)` where its CLI isn't on `PATH`. A marked Harness, or a name that isn't one, is refused and asked again. The default is the first one installed, `claude` when both are, and `codex` when only it is. 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. The other Harness's section is left as it is. 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 whose CLI is installed on `PATH`. An unavailable Harness, or a name that isn't one, is refused and asked again. The default is the configured one when installed, otherwise the first installed in `claude`, `codex`, `agy` order, so `claude` when it is installed. 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. 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 @@ -299,6 +308,12 @@ For each of the three, the command wins, then the [User config](#user-config), t

With `harness codex`, every session runs `codex exec --json --dangerously-bypass-approvals-and-sandbox` in the Run's worktree, with stdin set to null, and with `-c project_doc_fallback_filenames=["CLAUDE.md"]`, so Codex reads `CLAUDE.md` where a repository has no `AGENTS.md`. Codex's sandbox is off: on a stock Ubuntu machine it can't start, and commits and pushes from a worktree write outside it, so the worktree is the only boundary, as with Claude's auto mode ([ADR-0012](docs/adr/0012-factory-skills-linked-into-the-worktree-codex-unsandboxed.md)). The Factory skills are linked into the worktree's `.agents/skills/`, kept out of git with `/.agents/skills/thirdshift-*` in `.git/info/exclude`, and each prompt's first line loads its skill as `$thirdshift-<skill>`. `codex exec` kills what the agent left running as it exits, so a session whose turn ends with a command or a sub-agent call still running (started, never completed) gets a Resume, as on Claude: `codex exec … resume <session id>`, with the Model, Effort, bypass flag and `CLAUDE.md` fallback given again, as Codex keeps none of them. An interrupt sends a Codex session SIGINT, the only signal Codex stops cleanly on, before the SIGTERM and SIGKILL every session gets, each 10 seconds apart. Progress lines come from Codex's `item.*` events, the session-ended line gives its token totals, as Codex reports no turns or cost, and the final message is the last `agent_message`. A session whose turn fails (`turn.failed`), or that exits non-zero, fails the Run with Codex's error in the cause; an `error` event Codex retries does not. Each Codex session adds a `trust_level = "trusted"` entry for the repository to `~/.codex/config.toml`, and your `~/.claude/` instructions and hooks don't reach it.

With `harness agy`, every session runs Google **Antigravity CLI** as `agy -p --dangerously-skip-permissions --output-format stream-json`, with `--model` and `--effort` when set, stdin null, and `AGY_CLI_DISABLE_AUTO_UPDATE=true` (agy ignores `=1`). The sandbox is opt-in and is never enabled, so unattended sessions can write the shared `.git` ([ADR-0013](docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md)). Factory skills are linked into `.agents/skills/`, excluded from git, and loaded by the `/thirdshift-<skill>` first line. agy reads project `AGENTS.md`, `GEMINI.md` and `.agents/rules/`, and user instructions under `~/.gemini/`; it does not read `~/.claude/`. When root `CLAUDE.md` exists and neither `AGENTS.md` nor `GEMINI.md` does, thirdshift adds a `GEMINI.md` link to `CLAUDE.md` and excludes it through `.git/info/exclude`; existing instruction files always win.

agy's `init` supplies the conversation id and `step_update` supplies tool progress. The final `result` supplies the final message, success/error status, error and cumulative usage (including on Resume). `ERROR` or a nonzero exit fails the session with agy's error. A Resume uses `--conversation <id>` with the same flags, Model, Effort and environment; an interrupt sends SIGTERM, then SIGKILL after the grace period. Its start-up can be slow and variable: the research measured 8–95 seconds. No speed assumption or backend retry is made.

Before any work on agy, a named Model or Effort is checked against the free `agy models` catalog, which lists IDs, labels and effort-suffixed variants. IDs, display labels and bare aliases are matched regardless of case, as are Efforts. `model Gemini-3.8-Flash effort Medium` becomes `--model gemini-3.8-flash --effort medium`; `model gemini-3.8-flash-medium` already carries its Effort. A bare alias requires an Effort; a Model or Effort the catalog lacks fails naming valid choices. This check uses null stdin and disables self-update too, without a model turn.

Interrupting a Command stops every session's process tree on every Harness, including commands that started their own process group or session. thirdshift snapshots the descendants with `ps` on Linux and macOS, sends each process group the Harness's stop signals with a 10-second grace period each, then takes another snapshot before SIGKILL to catch commands started in the meantime. If the CLI exits on an earlier signal, thirdshift kills the remaining commands immediately without another grace period.

Before any work, where an Origin match failure would stop it, and so before the Claim, the worktree and any Command log, a command checks what it chose: the Harness's CLI must be on `PATH`, else it fails naming the CLI and the setting that chose it; and on Claude a named Model gets a minimal test call, `claude -p --model <model>` with the Effort, if any, which must succeed, else the command fails with what Claude said. No test call is made when no Model is named. On Codex a named Model and Effort are checked against `codex debug models`, which costs no tokens: each matches regardless of case, a Model by its slug or its display name, and becomes the name Codex takes, so `model GPT-6.1-Sol effort Max` reaches Codex as `gpt-6.1-sol` and `max`. An unknown Model, or an Effort that Model doesn't support (with no Model, one no Model supports), fails naming the valid choices. What the command records is what the check settled on. A Pickup run or an Architect run makes these checks only once it has decided not to skip, and a Spec run makes them once for all its Tickets.
Expand Down
27 changes: 27 additions & 0 deletions docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Every Harness runs unattended and fully trusted in the worktree

Sessions run with no human to answer an approval prompt or a trust question. Their commits and pushes write to the main repository's shared `.git`, outside the worktree. So every **Harness** runs its sessions, and every Resume of one, with approvals off, its sandbox off, and the worktree trusted. Each Harness carries the flags and environment that do this. They are fixed, and no user setting changes them:

| Harness | Flags and environment |
|---|---|
| Claude Code | `--permission-mode auto` |
| Codex | `--dangerously-bypass-approvals-and-sandbox` |
| agy | `--dangerously-skip-permissions`, never `--sandbox` |
| Grok Build | `--always-approve`, `GROK_FOLDER_TRUST=0` |
| Muse Code | `--yolo` |
| OpenCode | `--auto`, `--standalone` |

The worktree is the only boundary. This generalises the Codex part of ADR 0012. Sources: `docs/research/harness-candidates.md`, `docs/research/codex-headless-harness.md`.

## Considered Options

- **Each CLI's own sandbox.** Rejected. On stock Ubuntu (`kernel.apparmor_restrict_unprivileged_userns = 1`), bubblewrap can't start, so every sandboxed command fails under Codex and Muse. The sandboxes of agy and Grok make the main checkout's `.git` read-only, which blocks commits. A sandboxed mode for machines where it works would be a separate issue.
- **A one-time `grok --trust` in each repository's main checkout**, done or documented by Setup. Rejected. A repository nobody had trusted would run its sessions without `AGENTS.md` or the Factory skills, and nothing would fail. A scheduled Pickup run is exactly the case where nobody would notice.
- **Using the CLI's settings for approval and trust**, such as a user config that already sets always-approve. Rejected. A session would then behave differently on every machine. Passing the flags each time makes them explicit.
- **OpenCode through the user's shared background service** (its default). Rejected. A SIGTERM leaves the turn and its shell command running in the service, and thirdshift doesn't control that service's version or environment. `--standalone` keeps the server in the session's process group, and costs 2–3 s per session.

## Consequences

- A Harness's self-update is turned off in each session's environment too (`MUSE_NO_AUTO_UPDATE=1`, `GROK_DISABLE_AUTOUPDATER=1`, `AGY_CLI_DISABLE_AUTO_UPDATE=true`, `OPENCODE_DISABLE_AUTOUPDATE=1`). An update in the middle of a Run could replace the binary a Resume needs. agy ignores `=1`; only `=true` works.
- Each Harness reads its own instruction file and falls back to the other. thirdshift adds the fallback where a CLI lacks one: a `GEMINI.md` link for agy, and an `AGENTS.md` link for OpenCode, both to `CLAUDE.md` at the worktree root and kept out of git. Grok reads both files when both exist, and thirdshift leaves that as it is.
- Instructions in the user's `~/.claude/` reach only the Harnesses that read them: Claude, Grok and Muse. They don't reach Codex, agy or OpenCode.
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",
"harness must be followed by claude or codex or agy",
),
(
vec![URL, "harness", "gemini"],
"harness must be followed by claude or codex, not gemini",
"harness must be followed by claude or codex or agy, not gemini",
),
(
vec!["--harness", "claude", URL, "harness", "codex"],
Expand Down
1 change: 1 addition & 0 deletions src/asks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ mod tests {
default: Some(Harness::Codex),
claude: model_and_effort("opus", "high"),
codex: model_and_effort("gpt-6.1-sol", "max"),
..Default::default()
},
..no_settings()
}
Expand Down
13 changes: 10 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 or codex; default claude
default = "claude" # the Harness every Run's sessions run on, claude or codex or agy; default claude

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

[harness.agy]
model = "" # the Model Antigravity CLI's sessions run on; default blank, for agy's own
effort = "" # how hard that Model reasons; default blank, for agy's own
"#;

/// The line `DEFAULTS` holds for `email.to`, which has no default.
Expand Down Expand Up @@ -527,7 +531,9 @@ mod tests {
"harness.claude.model",
"harness.claude.effort",
"harness.codex.model",
"harness.codex.effort"
"harness.codex.effort",
"harness.agy.model",
"harness.agy.effort"
]
);
let commented_out: Vec<&str> = DEFAULTS
Expand Down Expand Up @@ -577,6 +583,7 @@ mod tests {
model: Some("gpt-6.1-sol".to_string()),
effort: Some("max".to_string()),
},
..Default::default()
}
);
assert_eq!(parse("").unwrap().harness, harness::Settings::default());
Expand All @@ -587,7 +594,7 @@ mod tests {
for (text, error) in [
(
"[harness]\ndefault = \"gemini\"\n",
"harness.default must be claude or codex, not \"gemini\"",
"harness.default must be claude or codex or agy, not \"gemini\"",
),
(
"[harness]\ndefault = true\n",
Expand Down
6 changes: 6 additions & 0 deletions src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ use std::process::Output;
use anyhow::{Result, bail};

mod adapter;
pub(crate) mod agy;
pub(crate) mod claude;
pub(crate) mod codex;
mod instructions;
mod process_tree;

pub use adapter::{Adapter, Invocation};
Expand All @@ -22,6 +24,7 @@ pub use codex::Catalog;
pub enum Harness {
Claude,
Codex,
Agy,
}

impl Harness {
Expand Down Expand Up @@ -91,6 +94,8 @@ pub struct Settings {
pub claude: ModelAndEffort,
/// `[harness.codex]`.
pub codex: ModelAndEffort,
/// `[harness.agy]`.
pub agy: ModelAndEffort,
}

impl Settings {
Expand Down Expand Up @@ -281,6 +286,7 @@ mod tests {
default: Some(Harness::Codex),
claude: set("opus", "high"),
codex: set("gpt-6.1-sol", "max"),
..Default::default()
}
}

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, claude, codex};
use super::{Choice, Harness, ModelAndEffort, Settings, agy, claude, codex};
use crate::progress::Stream;
use crate::setup::Outside;

Expand Down Expand Up @@ -91,4 +91,5 @@ impl SkillLoading {
}

/// Registration comes from the adapters, including Setup's order.
pub const REGISTERED: [(Harness, &dyn Adapter); 2] = [claude::REGISTRATION, codex::REGISTRATION];
pub const REGISTERED: [(Harness, &dyn Adapter); 3] =
[claude::REGISTRATION, codex::REGISTRATION, agy::REGISTRATION];
Loading
Loading