| name | trinity-zc |
| description | Multi-model orchestration for the ZCode runtime — a runtime-adapted peer of trinity. Dispatches tasks to external LLM providers via Bash background processes (not Agent sub-agents), with a self-managed state machine, provider health checks, and reuse of trinity's provider config, session resume pointers, and review synthesis. Use when the user says /trinity-zc or wants to delegate work to another model from the ZCode runtime. |
| metadata | {"short-description":"trinity-zc — ZCode-optimized multi-model dispatch","peer-of":"trinity","runtime":"zcode"} |
trinity-zc — Multi-Model Orchestration for the ZCode Runtime
Dispatch tasks to external LLM providers via Bash background processes with a
self-managed state machine. Reuses trinity's provider configuration, session-resume
pointers, and review-synthesis pipeline, but replaces trinity's Agent-sub-agent dispatch
layer (unreachable in this runtime) with Bash(run_in_background=true).
Why this skill exists (honest capability statement)
trinity (the ~/.claude/skills/trinity/ skill) dispatches by spawning Agent(subagent_type="general-purpose", run_in_background=true) sub-agents. In the ZCode runtime the Agent tool only exposes the read-only Explore subagent type — general-purpose background sub-agents cannot be spawned. trinity's native dispatch is therefore unreachable here.
trinity-zc is an honest adaptation: it reaches the same provider backends directly via Bash, and reuses trinity's Python assets for everything that is reachable. It is not a drop-in replacement for trinity in Claude Code — both skills coexist. Use trinity in Claude Code; use trinity-zc in the ZCode runtime.
What is reused vs self-built
| Capability | Source |
|---|
| Provider config + CLI strings | Reused — merged ~/.claude/trinity.json + project .claude/trinity.json (identical merge to trinity) |
| Session resume pointers | Reused — ~/.claude/skills/trinity/scripts/session.py (read/write/clear/heartbeat CLI) |
Review synthesis (synthesis.md) | Reused — _review.write_synthesis() via python3 -c |
| Structured review parsing (TRN-3022) | Reused — review_schema.parse_structured_review() |
Provider live probe (doctor) | Reused — _doctor._probe_provider (own process group + killpg on timeout; rc-based health) |
| Background dispatch execution | Self-built — Bash(run_in_background=true) (harness isolates the process; no setsid needed — it doesn't exist on macOS) |
| Dispatch state machine | Self-built — .claude/trinity-zc.json (separate file, see §State Store) |
| Heartbeat / timeout | Self-built — output-file byte count + time comparison |
Startup Check (run once per session before first dispatch)
command -v python3 >/dev/null 2>&1 || { echo "trinity-zc: python3 not found"; exit 1; }
SCRIPTS_VERSION=$(python3 ~/.claude/skills/trinity/scripts/session.py --version 2>/dev/null)
REQUIRED_VERSION="3.3.0"
[ "$SCRIPTS_VERSION" = "$REQUIRED_VERSION" ] || {
echo "trinity-zc: trinity scripts missing/outdated (found ${SCRIPTS_VERSION:-none}, need ${REQUIRED_VERSION})"
echo "Run: make install (trinity repo) or cp -r trinity/scripts/. ~/.claude/skills/trinity/scripts/"
exit 1
}
[ "${TRINITY_DISABLE_DISPATCH:-}" = "1" ] && { echo "trinity-zc: nested dispatch disabled"; exit 1; }
If all three pass, proceed.
Syntax
/trinity-zc <provider>[:instance] "task" [provider2 "task2" ...] # dispatch (background)
/trinity-zc <provider>*N "task" # N parallel same-provider
/trinity-zc review|fast-review|deep-review "task" # preset dispatch
/trinity-zc status # all sessions + provider health
/trinity-zc heartbeat [instance] # on-demand liveness check
/trinity-zc clear [<instance> | all] # remove session entries
/trinity-zc result <instance> # read a finished provider's output
/trinity-zc doctor # smoke-test all providers
/trinity-zc help
Provider Discovery
On every dispatch and status call, resolve usable providers exactly as trinity does:
- Load global config
~/.claude/trinity.json → providers, presets, preset_aliases.
- Load project overlay
<cwd>/.claude/trinity.json → project entries win on key conflict for providers; presets merge by key (a project preset replaces the same-named global preset; other global presets are preserved — matches scripts/config.py::merge_configs, never a wholesale replace); defaults shallow-merge.
- For each config provider, verify a matching agent file exists:
~/.claude/agents/trinity-<name>.md or <cwd>/.claude/agents/trinity-<name>.md.
A provider is usable only with BOTH a config entry AND an agent file. registry.json is NOT read at runtime (install-time artifact only) — ignore it. All CLI strings come from the merged config's providers.<name>.cli.
Read the merged config with inline Python (no trinity dependency needed beyond json+flock):
import json, os
def load(p):
try:
with open(p) as f: return json.load(f)
except FileNotFoundError: return {}
g = load(os.path.expanduser("~/.claude/trinity.json"))
proj = load(os.path.join(os.getcwd(), ".claude/trinity.json"))
providers = {**g.get("providers",{}), **proj.get("providers",{})}
presets = {**g.get("presets",{}), **proj.get("presets",{})}
preset_aliases = {**g.get("preset_aliases",{}), **proj.get("preset_aliases",{})}
defaults = {**g.get("defaults",{}), **proj.get("defaults",{})}
Presets
Expand a preset keyword to its provider list (from merged config presets.<name>.providers; built-in defaults below if absent). Required providers are always included (mirrors scripts/codex.py::resolve_preset_providers, which adds every required provider unconditionally); optional providers (presets.<name>.optional_providers) are included only when discovery marks them usable.
| Preset | Required | Optional |
|---|
review | glm, gemini, deepseek | codex, claude-code |
fast-review | glm, deepseek | — |
deep-review | glm, gemini, deepseek | codex, claude-code |
(Mirrors trinity's own SKILL.md preset defaults so a stock install — which ships providers but often no presets — still dispatches the panel. Resolve per preset name, NOT on whether the map is empty: prefer a live same-named entry in the merged presets; if a requested built-in preset (review/fast-review/deep-review) is absent from the merged map, use its built-in default row here — even when the map already contains other (custom) presets. A non-built-in name absent from the map is an unknown preset. Gating the fallback on "map is empty" would break the advertised review/fast-review/deep-review the moment a project adds any custom preset. Do NOT silently drop a required provider that is unusable on this machine (e.g. a missing gemini): trinity keeps every required provider and lets the absence surface as a failed/misconfigured participant, so a review never synthesizes a PASS while quietly missing a required reviewer. Report the unusable required provider (❌ missing agent file/missing config) as part of the dispatch result rather than reducing the quorum. Only optional providers are skipped when unusable.)
Dispatch Protocol
Parse tokens left-to-right:
- Built-in subcommand first:
status, clear, heartbeat, result, doctor, help.
- Preset/alias name → expand to providers, dispatch the SAME task to each. Recognize, in order: built-in preset names (
review/fast-review/deep-review), built-in aliases (r→review, fr→fast-review, dr→deep-review), and any key in the merged preset_aliases map loaded by discovery (a config alias whose value names a preset). The built-ins are defaults only — a config preset_aliases entry with the same key wins. A token matching none of these is not a preset; fall through to provider syntax.
- Provider syntax:
provider, provider:instance, or provider*N.
For EACH (provider, task) pair:
1. Parse + validate. Base provider + optional instance (key = provider or provider:instance). provider*N → N instances with keys provider:<uuid4-hex-6>. Verify provider is usable (config + agent file). If not usable, report the reason ("missing agent file" / "missing config") and DO NOT spawn.
2. Resolve resume pointer. Query trinity's session store (reused):
SESSION_ID=$(python3 ~/.claude/skills/trinity/scripts/session.py read "$PWD" "$INSTANCE_KEY")
3. Allocate output file. Use mktemp -d for a guaranteed-unique run dir — uuidgen is absent on minimal Linux/ZCode images, where $(uuidgen | cut ...) expands to an empty suffix so parallel dispatches collide on one shared dir. mktemp honors TMPDIR for sandboxed runtimes.
RUN_DIR=$(mktemp -d "${TMPDIR:-/tmp}/trinity-zc.XXXXXXXX")
OUTPUT_FILE="$RUN_DIR/${INSTANCE_KEY//[:\/]/-}.out"
4. Build the command. Read cli from merged config; expand ~ and $HOME. If resume pointer != NEW and the provider supports resume, append the resume flag:
- droid-backed (glm/minimax):
<cli> -s "$SESSION_ID"
- claude-wrapper (deepseek and claude-code):
<cli> --resume "$SESSION_ID" — pass the saved $SESSION_ID; a bare -r/--resume with no id makes the underlying claude resume the latest/wrong conversation instead of the stored pointer (the bin forwards args verbatim to claude; see providers/deepseek.md, providers/claude-code.md). Both registry entries set supports_resume:true, resume_arg:"--resume", so both resume here. Flag order is safe: the cli ends in -p, and claude -p --resume "$ID" "<task>" parses correctly — -p does not consume --resume (verified live: a bogus id returns "No conversation found", i.e. --resume was parsed, not swallowed).
- codex:
<cli> (codex resume is experimental/session-scoped; omit unless explicitly requested)
- any provider with registry
supports_resume:false (e.g. openrouter): no resume arg
Build the prompt — review task_types MUST append the schema addendum. $TASK_TYPE comes from the resolved preset's declared task_type when dispatch is via a preset, else from keyword inference (see §Task-type inference) — do NOT assume every preset is a review. For task_type == review the dispatched prompt MUST be <task> plus trinity's structured-output addendum. Skip this and the provider emits free-form findings with no trailing fenced JSON; _review.write_synthesis then sees an rc=0 raw output with no structured block and takes its legacy PASS path (scripts/_review.py), silently synthesizing PASS with zero findings — a real FIX/BLOCK verdict is lost. Append it BEFORE the prompt becomes the final argument (the addendum is empty for non-review task_types, so this is safe to apply unconditionally):
ADDENDUM=$(python3 -c "import sys; sys.path.insert(0,'$HOME/.claude/skills/trinity/scripts'); import review_schema; print(review_schema._review_schema_addendum('$TASK_TYPE'), end='')")
PROMPT="$TASK$ADDENDUM"
The prompt ($PROMPT above, task + any addendum) is the final argument (mirrors provider_runtime.run_provider, which appends the prompt handoff last, and the providers/*.md final-<prompt> convention). The full arg vector is therefore <cli> [resume-flag] "$PROMPT", and that is exactly the <cli-and-args...> placeholder substituted into step 5. Omitting the prompt launches the provider with no task and yields an empty or unrelated result; omitting the addendum on a review yields a silent false PASS.
5. Spawn via Bash background. Use Bash(run_in_background=true). The harness's background mode already provides process isolation — do NOT add a trailing & to the whole invocation, and do NOT background the provider without an immediate wait: either makes the harness-tracked shell exit at launch while the provider runs on as an untracked orphan, so the completion notification fires on launch rather than on provider completion. (Backgrounding the provider inside the wrapper and waiting on it — below — is different and required: the wrapper stays blocked in wait until the provider exits, so the harness still tracks real completion.) Likewise do NOT wrap in setsid (it does not exist on macOS/BSD). Pass OUTPUT_FILE via environment, sanitize the environment before invoking the provider, capture both streams with a sentinel separator, and record the return code:
export OUTPUT_FILE
for v in $(env | sed -n 's/^\([A-Za-z0-9_]*_BASE_URL\)=.*/\1/p; s/^\([A-Za-z0-9_]*_API_BASE\)=.*/\1/p; s/^\([A-Za-z0-9_]*_API_HOST\)=.*/\1/p; s/^\(OTEL_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$v"; done
unset TRINITY_DISABLE_DISPATCH TRINITY_MCP_TOKEN
KILL_MARKER="trinity-zc-kill:$(basename "$RUN_DIR")"
bash -c '
# reap(): TERM the provider, grace, then KILL — synchronously inside the trap so
# the wrapper does not append the sentinel / write .rc until the child is dead.
reap() { kill -TERM "$1" 2>/dev/null; for _ in 1 2 3 4 5; do kill -0 "$1" 2>/dev/null || return 0; sleep 1; done; kill -KILL "$1" 2>/dev/null; }
trap "reap \"\$CHILD\"" TERM INT # \$CHILD: expand at FIRE time, not trap-definition time (CHILD is set below)
"$@" >"$OUTPUT_FILE" 2>"$OUTPUT_FILE.err" </dev/null & # </dev/null: claude-wrapper providers (deepseek/openrouter/claude-code) otherwise wait 3s for stdin and warn
CHILD=$!
wait "$CHILD"; RC=$? # 143 (128+SIGTERM) if reap fired on timeout/clear
while kill -0 "$CHILD" 2>/dev/null; do sleep 0.2; done # `wait` can return interrupted before the child is gone — confirm death before finishing
printf "\n%%%%TRINITY-RAW-STDERR-BOUNDARY-9c3d2a1f7e%%%%\n" >> "$OUTPUT_FILE"
cat "$OUTPUT_FILE.err" >> "$OUTPUT_FILE" 2>/dev/null; rm -f "$OUTPUT_FILE.err"
echo "$RC" > "$OUTPUT_FILE.rc"
' "$KILL_MARKER" <cli-and-args...>
Note on the sentinel escaping: the literal sentinel is %%TRINITY-RAW-STDERR-BOUNDARY-9c3d2a1f7e%% (it contains double-percent signs). Inside a printf format string, every literal % must be written as %%, so the sentinel appears as %%%%...%%%% in the format — four percents at each end produce the two literal percents the sentinel requires. Getting this wrong produces a file the synthesis parser cannot split.
The provider is a background child of the wrapper, but the wrapper blocks in wait, so the harness tracks the wrapper for the provider's whole lifetime and the completion notification still fires on real exit. No $OUTPUT_FILE.pid file is written — the in-wrapper $CHILD (the provider PID) is held in shell scope and the trap targets it directly. Process cleanup on timeout: the harness killing the backgrounded Bash cascades to the child via the trap. For the rare fallback where the runtime exposes no cancel handle, the KILL_MARKER (the wrapper's $0, = the run-dir basename) is the precise targeting handle: pgrep -f "$KILL_MARKER" matches only this run's wrapper, then kill -TERM on it fires the wrapper's trap, which runs reap() — TERM the provider child, grace, then KILL — and does not return until the child is dead, so killing the marker shell reliably reaps the provider (not just the shell, and not a half-killed orphan that keeps writing). Always send TERM to the wrapper (so the trap can run); if you must escalate to KILL on the wrapper, wait longer than the wrapper's internal reap grace (~5s) first, otherwise KILLing the wrapper mid-reap can orphan the child.
Heartbeat visibility: because stdout is streamed straight into OUTPUT_FILE (the "$@" >"$OUTPUT_FILE" redirect creates it the instant the provider starts and grows it as the provider writes), the byte-delta heartbeat (§Heartbeat) observes real progress mid-run — a healthy long-running review is never misread as failed to start. The sentinel + stderr are appended only after the provider exits, so the completion handler still reads the full stdout + SENTINEL + stderr format. One residual edge: a provider that has started but not yet emitted any stdout leaves a 0-byte OUTPUT_FILE; treat "0 bytes + elapsed < the task-type max_at" as 🔄 running (no output yet), not failed (see §Heartbeat).
The sentinel %%TRINITY-RAW-STDERR-BOUNDARY-9c3d2a1f7e%% MUST match provider_runtime.raw_output so parse_structured_review can split stdout/stderr back apart. Do not alter this string.
Environment sanitization — provider_runtime.build_provider_env is a denylist, not an allowlist: it keeps every inherited variable EXCEPT a small set it strips. Mirror that — keep the inherited environment (provider API keys / auth tokens like ANTHROPIC_API_KEY, DEEPSEEK_API_KEY, droid/factory creds MUST survive) and strip only: *_BASE_URL, *_API_BASE, *_API_HOST, OTEL_*, TRINITY_DISABLE_DISPATCH, TRINITY_MCP_TOKEN. Do NOT switch to an allowlist — dropping everything not on a fixed list would strip provider credentials and break auth on every dispatch.
6. Record dispatch state in .claude/trinity-zc.json (see §State Store). State = running.
7. Reply with a launch summary. List each dispatched instance, its task, output file, and the background process marker. Do NOT block waiting for results — the harness re-invokes you when the background process exits.
On background-completion notification
When the harness notifies that a background Bash finished:
- Read
$OUTPUT_FILE and $OUTPUT_FILE.rc.
- Update the session entry:
state = done (rc 0) or failed (rc != 0), set end_time, returncode, bytes.
- If
task_type == review: parse TRN-3022 structured output via reused review_schema.parse_structured_review and present decision/score/blocking findings to the user.
- Otherwise present a focused summary (not the full raw log).
- Optionally write the resume pointer for future runs:
python3 ~/.claude/skills/trinity/scripts/session.py write "$PWD" "$INSTANCE_KEY" "$SESSION_ID" "<task_summary>"
(Session ID capture is provider-specific — droid exposes it via droid search; for others leave NEW. Resume is best-effort.)
State Store (.claude/trinity-zc.json)
Separate file from trinity's .claude/trinity.json to avoid schema collision (trinity flocks its data file and expects a specific sessions shape; our richer fields would corrupt its reads).
{
"sessions": {
"glm": {
"provider": "glm",
"instance": "glm",
"task": "review FXA-2100 dispatch",
"task_type": "review",
"state": "running",
"cli": "droid exec --auto medium --model custom:GLM-5.2",
"output_file": "/tmp/trinity-zc.a1b2c3d4/glm.out",
"start_time": "2026-06-27T20:30:00",
"end_time": null,
"returncode": null,
"last_checked": null,
"bytes": 0
}
}
}
States: running → done | failed | timeout.
Atomic writes (match session.py:cmd_write locking): use fcntl.flock(LOCK_EX) on the data file itself — open O_RDWR|O_CREAT, flock, read+mutate+truncate+rewrite, unlock. Inline Python:
import json, os, fcntl
path = os.path.join(os.getcwd(), ".claude", "trinity-zc.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
with os.fdopen(fd, "r+") as f:
fcntl.flock(f, fcntl.LOCK_EX)
f.seek(0); data = json.loads(f.read() or "{}")
data.setdefault("sessions", {})[key] = entry
f.seek(0); f.truncate()
json.dump(data, f, indent=2, ensure_ascii=False); f.write("\n")
f.flush(); fcntl.flock(f, fcntl.LOCK_UN)
Task-type inference
A preset's declared task_type wins over keyword inference. When dispatch came from a preset, take TASK_TYPE from the resolved preset metadata (presets.<name>.task_type, validated by scripts/codex.py::resolve_preset_providers to one of tdd/review/prp/general); the three built-in presets (review/fast-review/deep-review) imply task_type=review. Only when no preset task_type is set (e.g. an ad-hoc provider "task" dispatch) infer from task keywords (mirrors trinity): review/审查 → review; tdd/test/测试 → tdd; prp/proposal → prp; else general. TASK_TYPE sets both the timeout threshold AND whether the review schema addendum is appended (step 4) — so a custom task_type: "tdd"/"general" preset is NOT forced into review handling.
Timeout thresholds (per task_type)
| task_type | warn_at | max_at |
|---|
| tdd | 10 min | 15 min |
| review | 6 min | 10 min |
| prp | 5 min | 8 min |
| general | 10 min | 20 min |
This table is the fallback. If discovery's merged defaults.timeout defines a
per-task-type override (e.g. {"review": {"warn_at": 480, "max_at": 720}}, seconds),
use it for that task_type and fall back to the table only for unset types — mirrors
trinity reading thresholds from merged config rather than a frozen constant.
On each heartbeat/status check, compare now - start_time. At warn_at report ⚠️ possibly slow. At max_at report 🚨 timed out and kill the dispatched process. Step 5 runs the provider as a wait-ed background child of the harness-backgrounded Bash under a TERM trap (no separate PID file is recorded), so the kill path depends on the harness: the orchestrator signals "stop/cancel this background task", which terminates the backgrounded Bash process group, cascading to the child. If the runtime exposes a task/cancel handle (e.g. a background-task id returned by the Bash tool), use that. As a fallback, target the per-run KILL_MARKER from step 5, reconstructed from the stored output_file: pgrep -f "trinity-zc-kill:$(basename "$(dirname "$output_file")")" finds the wrapper PID, then kill -TERM it — the wrapper's trap runs reap(), which TERMs the provider child, then escalates to KILL after a grace, and does not finish until the child is dead. Wait longer than that internal grace (≥8s) before kill -9-ing the wrapper itself, and only if still alive (KILLing the wrapper mid-reap can orphan the child). Match the marker, never a generic <provider-cli-token> — the bare token (codex/droid) can match another project's job and kill it. Do NOT reference $OUTPUT_FILE.pid (no such file is written) and do NOT use pkill -g (its group semantics differ on BSD/macOS).
Review Synthesis (reused from trinity)
For multi-provider review dispatch, build the trinity-shaped results list and call trinity's write_synthesis:
mkdir -p "$REVIEW_DIR/raw"
cp "$OUTPUT_FILE" "$REVIEW_DIR/raw/glm.txt"
python3 -c "
import sys; sys.path.insert(0, '$HOME/.claude/skills/trinity/scripts')
from pathlib import Path
import _review
results = [
{'provider':'glm','returncode':0,'raw':'raw/glm.txt','started_at':'...','finished_at':'...'},
# ... one per provider; 'raw' is relative to review_dir
]
summary, synth_path = _review.write_synthesis(Path('$REVIEW_DIR'), '$SCOPE', results)
print(synth_path)
"
Pre-conditions the reused function expects:
- A required preset provider that could not be dispatched (unusable: missing agent file/config, or a launch failure) must still appear in the results list as a failed participant (
returncode != 0), never omitted. Dropping it would let write_synthesis compute a PASS from the remaining providers while silently missing a required reviewer — exactly the quorum hole §Presets warns against. Only optional providers may be absent from the results.
- Each
review_dir/raw/<provider>.txt must be populated in sentinel format (stdout + %%TRINITY-RAW-STDERR-BOUNDARY-9c3d2a1f7e%% + stderr) before the call. Dispatch (step 5) writes $RUN_DIR/<instance>.out; the cp above stages it under the raw/ name. A missing raw/<provider>.txt makes write_synthesis treat an rc=0 provider as PASS with no findings.
- For TRN-3022 parsing to succeed, the provider's prompt must instruct it to emit a trailing fenced
```json block with {decision, weighted_score, blocking, advisories, confidence?}. trinity's _review_schema_addendum provides this text. This is appended at dispatch time — §Dispatch Protocol step 4 ("Build the prompt") — NOT here: by the time synthesis runs the providers have already finished, so a prompt dispatched without the addendum can no longer be fixed and synthesis will read free-form output as a legacy PASS.
Review-dir layout (matches trinity): .trinity/reviews/<YYYYMMDD-HHMMSS-slug>-<rand8>/{raw,logs,prompt.md,metadata.json,synthesis.md} (the -<rand8> tail is tempfile.mkdtemp's 8-char random suffix, always present since TRN-3051).
Subcommands
status
Run provider discovery. Show:
- Provider health table — for each usable provider, mark ✅ (recently verified) / ⚠️ (not verified this session) / ❌ (known failed).
- Active sessions — for each
.claude/trinity-zc.json session with state == running: instance, state icon, elapsed, last byte-delta, task. Run a heartbeat read for each.
| Provider | Status | CLI |
|----------|--------|--------------------------------------|
| glm | ✅ | droid exec ... custom:GLM-5.2 |
| codex | ✅ | codex exec ... |
| Instance | State | Elapsed | Δbytes | Task |
|-----------|--------------|---------|--------|-------------------|
| glm | 🔄 running | 2m 10s | +1.2K | review FXA-2100 |
heartbeat [<instance>]
If instance given, check only it; else check all running sessions. For each:
wc -c <output_file> → current bytes; compare to stored bytes. The step-5 >"$OUTPUT_FILE" redirect creates the file the instant the provider launches, so distinguish missing from empty:
- file missing and elapsed > a short startup grace (~5s) →
❌ failed to launch — the shell never reached the redirect or the recorded path is wrong; surface it now, do NOT wait for max_at.
- file missing but still within the ~5s launch grace → treat as starting (the launch race).
- Δ > 0 →
🔄 alive (+N bytes); file present but 0 bytes and elapsed < max_at → 🔄 running (no output yet); Δ == 0 with prior output and elapsed > 60s → ⚠️ possibly stalled.
- Update
bytes = current, last_checked = now (atomic flock write).
- Apply timeout thresholds.
clear [<instance> | all]
clear glm:auth → delete that key.
clear glm → delete glm and all glm:*.
clear all → write {"sessions": {}}.
Kill any still-running provider first (same path as §Timeout: signal the harness to cancel the backgrounded Bash, or pgrep -f "trinity-zc-kill:$(basename "$(dirname "$output_file")")" then kill -TERM the wrapper — its trap runs reap(), which TERMs the provider child and escalates to KILL after a grace, finishing only once the child is dead — and kill -9 the wrapper only if still alive after a grace longer (≥8s) than that internal reap; match the per-run marker, not a bare CLI token; no PID/PGID file is recorded and pkill -g is unreliable on macOS). Confirm before deleting.
result <instance>
Read the finished provider's output_file. If task_type == review, parse TRN-3022 and show decision/score/blocking. Else show the stdout portion (above the sentinel) as a focused summary.
doctor
Smoke-test EVERY usable provider by reusing trinity's own live probe, _doctor._probe_provider, rather than a hand-rolled bash wrapper. That function (scripts/_doctor.py) already does the things a shell wrapper gets wrong: it sanitizes the env via build_provider_env, runs the provider in its own session/process group (start_new_session=True) so a timeout can os.killpg the whole group — descendants that inherited the pipe would otherwise hang the probe long past the timeout — and judges health by process exit code, not by grepping stdout for a marker (so a claude-wrapper that replies via its session transcript is not falsely failed). Do NOT reimplement this in perl/bash: the earlier setsid/perl-alarm smoke leaked process groups and grepped stdout, exactly the bugs _probe_provider avoids.
python3 - <<'PY'
import sys, os, json
sys.path.insert(0, os.path.expanduser("~/.claude/skills/trinity/scripts"))
import _doctor
def load(p):
try:
with open(p) as f: return json.load(f)
except FileNotFoundError: return {}
g = load(os.path.expanduser("~/.claude/trinity.json"))
proj = load(os.path.join(os.getcwd(), ".claude/trinity.json"))
providers = {**g.get("providers", {}), **proj.get("providers", {})}
root = os.getcwd()
def usable(name):
return any(os.path.exists(os.path.expanduser(p)) for p in (
f"~/.claude/agents/trinity-{name}.md", os.path.join(root, f".claude/agents/trinity-{name}.md")))
for name, cfg in providers.items():
if not usable(name):
continue
res = _doctor._probe_provider(name, cfg, root)
if res is None:
print(f"{name:12} ⏭️ SKIP (cli unresolved / not executable)")
elif res.get("status") == "pass":
print(f"{name:12} ✅ PASS")
else:
print(f"{name:12} ❌ {res.get('cause','error').upper()}: {res.get('detail','')}")
PY
_probe_provider returns {"status":"pass"}, or {"status":"fail","cause":"auth|quota|timeout|error","detail":...}, or None when a static check (cli resolution / executable) already disqualifies the provider. Only usable providers (a merged-config entry AND an agent file — see §Provider Discovery) are probed; a provider absent from the merged config or missing its agent file is simply not discovered (config-dependent per machine, not a hard-coded retirement). The probe timeout is trinity's _LIVE_PROBE_TIMEOUT (read live from the codex module), so trinity-zc and trinity stay in lock-step.
help
Print this SKILL.md's Syntax + architecture-summary sections.
Error Handling
- Unknown provider → run discovery, report
Unknown provider: X. Usable: <list>.
- Usable list empty →
No providers registered. (point to /trinity install <provider> in Claude Code; trinity-zc does not install providers itself — it reuses trinity's config).
- Missing agent file / config → report which, point to
trinity install.
- Empty task →
Task cannot be empty.
output_file missing at heartbeat: step 5 streams via >"$OUTPUT_FILE", which creates the file the instant the provider launches. So elapsed < ~5s startup grace → 🟡 starting; missing past the grace → ❌ failed to launch (bad output path, or the shell never reached the redirect) — surface it immediately, do NOT hold until the task timeout. A present-but-0-byte file within max_at is 🔄 running (no output yet) (see §Heartbeat).
- Background process cleanup on timeout/abort: signal the harness to cancel the backgrounded Bash (which cascades to the child). Fallback:
pgrep -f "trinity-zc-kill:$(basename "$(dirname "$output_file")")" (the per-run KILL_MARKER, never a bare CLI token that could match another project's job) to find the wrapper PID, then kill -TERM (the wrapper's trap runs reap() — TERM the provider child, grace, then KILL — finishing only once the child is dead, so it is reaped rather than orphaned), wait ≥8s (longer than the internal reap grace), kill -9 the wrapper only if still alive. No $OUTPUT_FILE.pid is written; no setsid/pkill -g (absent/unreliable on macOS).
Out of scope (v1)
plan / auto-decompose mode — not implemented; use explicit provider+task pairs.
- Provider installation — delegated to trinity's
/trinity install (run in Claude Code).
- Anthropic-transcript JSONL emission — trinity-zc uses plain output files; the reused
session.py heartbeat will report no assistant activity for our output files, which is fine (we track liveness via byte deltas, not transcript parsing).
Reserved Words
status, clear, heartbeat, result, doctor, help are subcommands, not provider/preset names. A token that is both a provider and a preset/alias is an ambiguity error.
Examples
/trinity-zc glm "review FXA-2100 dispatch logic"
/trinity-zc glm:auth "实现认证模块" codex "review 认证代码"
/trinity-zc glm*2 "并行实现 auth 和 order 模块"
/trinity-zc fast-review "review PR #242 changes"
/trinity-zc status
/trinity-zc heartbeat glm:auth
/trinity-zc result glm
/trinity-zc clear glm:auth
/trinity-zc doctor