From 3e7d18fe36a4cc5a5d7bca7bf774dfd1ea1d376f Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Tue, 11 Aug 2026 15:32:59 +0200 Subject: [PATCH 01/12] Declare herdr transport metadata in the agent registry Modern herdr (0.7.5+) starts agents in an already-open pane via `agent start --kind --pane `, which the launcher cannot derive from a selector name or by parsing argv[0]. Each registry entry now declares it explicitly: - `herdr_mode=agent-start` + `herdr_kind` for claude/codex/grok, where argv[0] equals the kind so the launcher drops exactly that word - `herdr_mode=pane-run` + `herdr_kind=kimi` for the two-phase wrapper, plus `herdr_marker=WORKER_SEED_FAILED` for seed-failure detection - the metadata is generic, so a future dynamic wrapper entry needs no launcher change Also make a failed kimi seed unambiguous: it prints the machine-readable marker, states TASK.md was not started, and exits with the seed's own code instead of waiting for Enter and opening an empty `kimi -c --auto` session that looks like a healthy worker. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW --- plugins/work-system/scripts/agent-registry.sh | 117 +++++++++++++----- .../scripts/test_agent_registry.py | 91 ++++++++++---- 2 files changed, 152 insertions(+), 56 deletions(-) diff --git a/plugins/work-system/scripts/agent-registry.sh b/plugins/work-system/scripts/agent-registry.sh index b9b94eb..20d4af5 100755 --- a/plugins/work-system/scripts/agent-registry.sh +++ b/plugins/work-system/scripts/agent-registry.sh @@ -31,13 +31,34 @@ # the work-system continue skill resumes TASK.md deterministically) # codex -> codex -m # grok -> grok -m -# kimi -> sh -c 'kimi -m "$1" -p "$2"; exec kimi -c --auto' \ +# kimi -> sh -c '; exec kimi -c --auto' \ # kimi-worker (seed+continue) # The bootstrap prompt (codex/grok/kimi have no work-system skills) tells the # agent to read TASK.md and drive the task to a PR. `supports=` metadata records # which lifecycle hooks each agent honors, so /close and /continue can degrade # for non-claude workers instead of faking claude-only behavior. # +# HERDR TRANSPORT metadata (`herdr_mode=` / `herdr_kind=` / `herdr_marker=`). +# herdr 0.7.5+ starts agents in an ALREADY-OPEN pane (`agent start --kind +# --pane -- `) instead of placing them itself. That needs +# two facts the launcher must NOT guess from a selector name or by parsing argv[0]: +# herdr_mode=agent-start the argv IS a canonical CLI invocation, so the launcher +# drops argv[0] (which MUST equal herdr_kind, herdr's +# canonical executable for that kind) and hands the rest +# to `--kind`. Native claude/codex/grok entries. +# herdr_mode=pane-run the argv is a WRAPPER that cannot be projected onto +# `--kind` (kimi's two-phase seed+continue `sh -c`). The +# launcher sends `argv_shell=` as one `pane run` command +# and then waits until herdr detects `herdr_kind` in that +# exact pane. `herdr_marker=` is the ASCII token such a +# wrapper prints when its seed phase fails, so the +# launcher can tell a definitive failure from "still +# starting" instead of guessing. +# The pair is generic on purpose: a future dynamically-registered wrapper (e.g. a +# cc-harness agent that ends up as an interactive `claude`) declares +# `pane-run` + `herdr_kind=claude` without any launcher change. An entry whose +# mode the launcher does not know must fail CLOSED before anything is created. +# # Why kimi needs the two-phase seed+continue shape (all probed live, 0.31.1): # kimi has NO positional launch prompt (`kimi "text"` -> "unknown command"), no # initial-prompt env var, and piped stdin only prefills the input box without @@ -92,23 +113,39 @@ KIMI_CREDENTIALS_FILE="${KIMI_CREDENTIALS_FILE:-$HOME/.kimi-code/credentials/kim # argv word; the launch helper passes it verbatim. BOOTSTRAP_PROMPT='Read TASK.md in this worktree and continue the task. Commit on the current branch as you go, and open a PR when the work is complete.' +# The ASCII token a wrapper worker prints when its seed phase fails. Machine +# readable on purpose: herdr-launch.sh greps the pane for it while it waits for the +# worker to be detected, so a dead seed is a DEFINITIVE failure (roll the tab back) +# instead of an ambiguous "maybe still starting" timeout. Emitted to callers as +# `herdr_marker=` for every pane-run entry — never hardcode it in a consumer. +SEED_FAIL_MARKER='WORKER_SEED_FAILED' + # kimi's two-phase launch script (see the header). Defined once here so the shape # has exactly one home; emit_argv passes it as the `sh -c` word. # -# The seed's failure is made LOUD on purpose. `;` would run phase 2 regardless and -# the TUI's first repaint scrolls the error off-screen — leaving a tab that looks -# like a healthy worker but never read TASK.md. `&&` is not the fix either: it -# kills the pane, and the task's whole point is that the tab survives. So: report, -# wait for an explicit keypress, then hand over to an (empty) session the user now -# knows is empty. Verified: a failed seed in a fresh worktree yields a NEW empty -# session, never a foreign one — `kimi -c` is scoped to the working directory. -# Kept ASCII-only and free of backslash escapes so `printf %q` renders it as a +# A failed seed must be UNAMBIGUOUS, and it must not leave anything behind that +# could be mistaken for a working worker. The earlier shape ran phase 2 regardless +# (`;`) after a keypress, which produced exactly that trap: an empty `kimi -c +# --auto` session that never read TASK.md but looks alive to herdr's detection — +# and to the user. So on a seed failure this now prints the machine-readable +# marker, states that TASK.md was not started, and exits with the SEED'S exit code +# WITHOUT running phase 2 and without waiting for input (an unattended background +# tab has nobody to press Enter). Only a successful seed reaches `exec kimi -c +# --auto`; the `exec` re-roots the pane at kimi so herdr detects it. +# Consequence on the LEGACY herdr path (0.7.0-0.7.4), where the worker argv is the +# tab's ROOT process: a failed seed now ends that process, so herdr closes the tab. +# Accepted deliberately — a closed tab is honest, whereas the empty session it +# replaces was actively misleading. On modern herdr the wrapper runs inside a shell +# pane, so the marker and the error stay on screen and the launcher rolls the tab +# back itself. +# Kept ASCII-only and free of backslash escapes so `shell_quote` renders it as a # plain single-quoted word in `argv_shell=` — a `$'…'` form would be bash/zsh-only # and near-unreadable in the copy-paste block. -KIMI_LAUNCH_SCRIPT='kimi -m "$1" -p "$2" || { echo; echo "[work-system] kimi seed FAILED (see the error above): TASK.md was not read, nothing was started."; echo "Press Enter to open an empty kimi session in this worktree."; read -r _; }; exec kimi -c --auto' +KIMI_LAUNCH_SCRIPT='if kimi -m "$1" -p "$2"; then exec kimi -c --auto; else rc=$?; echo; echo "[work-system] '"$SEED_FAIL_MARKER"': kimi seed exited $rc - TASK.md was NOT started and no session was opened."; exit $rc; fi' # ---------- registry ---------- -# `flag|cli|model|supports`. flag `-` = no shorthand (name/--agent only). The +# `flag|cli|model|supports|herdr_mode|herdr_kind`. flag `-` = no shorthand +# (name/--agent only). The # FIRST entry of each CLI is that CLI's default model (for a bare `--agent codex`). # `supports` is per-agent capability metadata: which lifecycle hooks each agent # honors — @@ -120,13 +157,18 @@ KIMI_LAUNCH_SCRIPT='kimi -m "$1" -p "$2" || { echo; echo "[work-system] kimi see # currently hardcode the claude-vs-non-claude distinction in prose; this field is # the seed for the manager/worker-orchestration design to read per-agent # capabilities from one place. Keep it in sync when that lands. -REGISTRY='--fable|claude|fable|continue,close-exit,statusline,commit,pr ---opus|claude|opus|continue,close-exit,statusline,commit,pr --|claude|sonnet|continue,close-exit,statusline,commit,pr ---codex|codex|gpt-5.6-terra|commit,pr ---sol|codex|gpt-5.6-sol|commit,pr ---grok|grok|grok-4.5|commit,pr ---kimi|kimi|kimi-code/k3-256k|commit,pr' +# +# `herdr_mode|herdr_kind` is the modern-herdr transport contract (see the header): +# agent-start entries hand their argv TAIL to `--kind ` (so argv[0] +# MUST equal herdr_kind), pane-run entries are wrappers sent as one shell command +# and then waited for until herdr detects herdr_kind in that pane. +REGISTRY='--fable|claude|fable|continue,close-exit,statusline,commit,pr|agent-start|claude +--opus|claude|opus|continue,close-exit,statusline,commit,pr|agent-start|claude +-|claude|sonnet|continue,close-exit,statusline,commit,pr|agent-start|claude +--codex|codex|gpt-5.6-terra|commit,pr|agent-start|codex +--sol|codex|gpt-5.6-sol|commit,pr|agent-start|codex +--grok|grok|grok-4.5|commit,pr|agent-start|grok +--kimi|kimi|kimi-code/k3-256k|commit,pr|pane-run|kimi' usage() { # Usage = header comment from line 2 up to (not including) the registry @@ -136,34 +178,39 @@ usage() { } # ---------- registry access ---------- -# Emit one `flag|cli|model|supports` record per line (skips blank lines). +# Emit one `flag|cli|model|supports|herdr_mode|herdr_kind` record per line. registry_rows() { printf '%s\n' "$REGISTRY"; } +# Re-emit a record with a FIXED field count, so every row_for_* returns the same +# shape even if a row were ever short a trailing field (a short row must not shift +# the transport metadata into `supports`). One definition, used by all three. +emit_row() { printf '%s|%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" "$6"; } + # Print the whole record for a canonical name (cli:model), or nothing. row_for_name() { - local want="$1" flag cli model supports - while IFS='|' read -r flag cli model supports; do + local want="$1" flag cli model supports mode kind + while IFS='|' read -r flag cli model supports mode kind; do [ -n "$cli" ] || continue - [ "$cli:$model" = "$want" ] && { printf '%s|%s|%s|%s\n' "$flag" "$cli" "$model" "$supports"; return 0; } + [ "$cli:$model" = "$want" ] && { emit_row "$flag" "$cli" "$model" "$supports" "$mode" "$kind"; return 0; } done < <(registry_rows) return 1 } # Print the record whose shorthand flag matches, or nothing. row_for_flag() { - local want="$1" flag cli model supports - while IFS='|' read -r flag cli model supports; do + local want="$1" flag cli model supports mode kind + while IFS='|' read -r flag cli model supports mode kind; do [ "$flag" = "-" ] && continue - [ "$flag" = "$want" ] && { printf '%s|%s|%s|%s\n' "$flag" "$cli" "$model" "$supports"; return 0; } + [ "$flag" = "$want" ] && { emit_row "$flag" "$cli" "$model" "$supports" "$mode" "$kind"; return 0; } done < <(registry_rows) return 1 } # Print the default (first) record for a bare CLI name, or nothing. row_for_cli_default() { - local want="$1" flag cli model supports - while IFS='|' read -r flag cli model supports; do - [ "$cli" = "$want" ] && { printf '%s|%s|%s|%s\n' "$flag" "$cli" "$model" "$supports"; return 0; } + local want="$1" flag cli model supports mode kind + while IFS='|' read -r flag cli model supports mode kind; do + [ "$cli" = "$want" ] && { emit_row "$flag" "$cli" "$model" "$supports" "$mode" "$kind"; return 0; } done < <(registry_rows) return 1 } @@ -418,8 +465,8 @@ subcmd_resolve() { echo "Try: $(registry_rows | cut -d'|' -f1 | grep -v '^-$' | tr '\n' ' ')— a name (claude:opus), or a cli (codex)" >&2 exit 2 } - local flag cli model supports - IFS='|' read -r flag cli model supports <<<"$record" + local flag cli model supports mode kind + IFS='|' read -r flag cli model supports mode kind <<<"$record" local avail note IFS=$'\t' read -r avail note < <(entry_status "$cli" "$model") @@ -429,6 +476,12 @@ subcmd_resolve() { printf 'model=%s\n' "$model" printf 'available=%s\n' "$avail" printf 'supports=%s\n' "$supports" + # Modern-herdr transport (see the header). Always emitted — a consumer that + # cannot interpret the mode must fail closed rather than guess from the name. + printf 'herdr_mode=%s\n' "$mode" + printf 'herdr_kind=%s\n' "$kind" + # Only wrappers can fail their seed phase, so only they carry the marker. + [ "$mode" = pane-run ] && printf 'herdr_marker=%s\n' "$SEED_FAIL_MARKER" [ -n "$note" ] && printf 'note=%s\n' "$note" emit_argv "$cli" "$model" "$session" @@ -444,8 +497,8 @@ subcmd_list() { esac # Build rows: name cli model available note (TAB-separated internally). - local rows="" flag cli model supports avail note - while IFS='|' read -r flag cli model supports; do + local rows="" flag cli model supports mode kind avail note + while IFS='|' read -r flag cli model supports mode kind; do [ -n "$cli" ] || continue IFS=$'\t' read -r avail note < <(entry_status "$cli" "$model") rows+="$cli:$model $cli $model $avail $note"$'\n' diff --git a/plugins/work-system/scripts/test_agent_registry.py b/plugins/work-system/scripts/test_agent_registry.py index a5529c3..78e347b 100644 --- a/plugins/work-system/scripts/test_agent_registry.py +++ b/plugins/work-system/scripts/test_agent_registry.py @@ -122,12 +122,15 @@ def run(self, *args, project_state=True): def run_argv(self, argv): """Execute a resolved launch argv against the stubs; return the per-call - argv lines the kimi stub recorded.""" + argv lines the kimi stub recorded. The CompletedProcess is kept on + `self.last_proc` so a test can also assert the wrapper's exit code and + output (the seed-failure contract is exactly that).""" env = dict(self.env) env["KIMI_ARGLOG"] = str(self.kimi_arglog) self.kimi_arglog.write_text("") - subprocess.run(argv, env=env, cwd=str(self.home), stdin=subprocess.DEVNULL, - capture_output=True, text=True, timeout=30) + self.last_proc = subprocess.run( + argv, env=env, cwd=str(self.home), stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=30) return [line.split("\t")[:-1] for line in self.kimi_arglog.read_text().splitlines()] @@ -180,12 +183,11 @@ def kv(out): # kimi has no positional launch prompt and `-p` cannot be combined with --auto/-y, # so a worker is `-p` (seed, runs tools unattended) then `exec kimi -c --auto` # (interactive + autonomous, inheriting the seed's session history). +SEED_MARKER = "WORKER_SEED_FAILED" KIMI_SCRIPT = ( - 'kimi -m "$1" -p "$2" || { echo; ' - 'echo "[work-system] kimi seed FAILED (see the error above): ' - 'TASK.md was not read, nothing was started."; ' - 'echo "Press Enter to open an empty kimi session in this worktree."; ' - 'read -r _; }; exec kimi -c --auto' + 'if kimi -m "$1" -p "$2"; then exec kimi -c --auto; else rc=$?; echo; ' + f'echo "[work-system] {SEED_MARKER}: kimi seed exited $rc - ' + 'TASK.md was NOT started and no session was opened."; exit $rc; fi' ) r = kv(e.run("resolve", "--kimi").stdout) @@ -214,17 +216,19 @@ def kv(out): # legitimately mentions TASK.md in its seed-failure message. check("prompt is not spliced into the script text", r["argv"][5] not in script) check("-p takes the positional as its value", '-p "$2"' in script) -check("--auto is not in the same command as -p", - "--auto" not in script.split("||", 1)[0]) +check("--auto is only reachable from the SUCCESS branch (never in -p's value slot)", + "--auto" not in script.split("then", 1)[0]) check("no bare -p/--prompt argv word (it stays bound inside the script)", "-p" not in r["argv"] and "--prompt" not in r["argv"]) -# A failed seed must neither kill the pane (`&&`) nor slip past unseen (`;`): -# it reports, waits for a keypress, then still hands over to phase 2. -check("seed failure is announced, not silent", "seed FAILED" in script) -check("seed failure waits for acknowledgement", "read -r _" in script) -check("phase 2 still runs after a failed seed (tab survives)", - script.rstrip().endswith("exec kimi -c --auto") - and "&&" not in script.split("exec", 1)[0].replace("||", "")) +# A failed seed must be unambiguous: a machine-readable marker (herdr-launch.sh +# greps the pane for it), an explicit "not started", no phase 2, and no wait for a +# keypress nobody is there to press. +check("seed failure prints the machine-readable marker", SEED_MARKER in script) +check("seed failure states TASK.md was not started", "TASK.md was NOT started" in script) +check("seed failure does not wait for input", "read " not in script) +check("phase 2 is gated behind the seed's success", + script.startswith("if kimi ") and "then exec kimi -c --auto" in script) +check("seed failure exits with the seed's own code", "exit $rc" in script) # Execute the resolved argv for real against the stub and assert what each phase # actually received — string checks alone can't prove the shell binds the values @@ -241,24 +245,63 @@ def kv(out): "--auto" not in seed and "-y" not in seed) check("phase 2 is the interactive autonomous continue", cont == ["-c", "--auto"]) -# A FAILING seed must still reach phase 2 — the tab has to survive. Runs the real -# argv against a stub whose non-`provider` calls exit 1 (the first stub always -# exits 0, so this path was previously untested). +# A FAILING seed must be a hard, self-announcing stop: marker on stdout, the +# seed's own exit code, and NO phase 2 — an empty `kimi -c --auto` session that +# never read TASK.md is indistinguishable from a healthy worker to herdr's +# detection (and to the user), which is exactly the trap this replaces. Runs the +# real resolved argv against a stub whose non-`provider` calls exit 7, so code +# propagation is asserted on a value nothing else could produce. e_fail = Env() (Path(e_fail.env["PATH"].split(":")[0]) / "kimi").write_text( "#!/bin/sh\n" '[ -n "$KIMI_ARGLOG" ] && { printf \'%s\\t\' "$@" >> "$KIMI_ARGLOG"; ' 'printf \'\\n\' >> "$KIMI_ARGLOG"; }\n' 'if [ "$1" = "provider" ]; then echo \'{"models": {"kimi-code/k3-256k": {}}}\'; exit 0; fi\n' - "exit 1\n" + "exit 7\n" ) (Path(e_fail.env["PATH"].split(":")[0]) / "kimi").chmod(0o755) fail_calls = e_fail.run_argv(kv(e_fail.run("resolve", "--kimi").stdout)["argv"]) -check("a failed seed still reaches phase 2", len(fail_calls) == 2) -if len(fail_calls) == 2: - check("phase 2 after a failed seed is still -c --auto", fail_calls[1] == ["-c", "--auto"]) +check("a failed seed runs the seed and NOTHING else", len(fail_calls) == 1) +if fail_calls: + check("the one call was the -p seed", "-p" in fail_calls[0]) +check("no phase-2 `-c --auto` after a failed seed", + ["-c", "--auto"] not in fail_calls) +check("the seed's exit code propagates out of the wrapper", + e_fail.last_proc.returncode == 7) +check("the wrapper prints the machine-readable marker", + SEED_MARKER in e_fail.last_proc.stdout) +check("the marker line names the failing seed's code", + "exited 7" in e_fail.last_proc.stdout) e_fail.close() +# --- herdr transport metadata (modern `agent start --kind --pane`) --------- # +# The launcher must not infer transport from a selector name or by parsing argv[0] +# — every entry declares it. Native CLIs are `agent-start` with a kind that EQUALS +# their argv[0] (the launcher drops that word and hands the rest to --kind); kimi's +# wrapper is `pane-run` and carries the seed-failure marker. +for sel, mode, kind in (("--fable", "agent-start", "claude"), + ("--opus", "agent-start", "claude"), + ("claude:sonnet", "agent-start", "claude"), + ("--codex", "agent-start", "codex"), + ("--sol", "agent-start", "codex"), + ("--grok", "agent-start", "grok"), + ("--kimi", "pane-run", "kimi")): + t = kv(e.run("resolve", sel).stdout) + check(f"{sel}: herdr_mode={mode}", t.get("herdr_mode") == mode) + check(f"{sel}: herdr_kind={kind}", t.get("herdr_kind") == kind) + if mode == "agent-start": + check(f"{sel}: argv[0] equals the declared kind (no rebuild needed)", + t["argv"][0] == kind) + check(f"{sel}: no seed marker on a native entry", "herdr_marker" not in t) + else: + check(f"{sel}: wrapper declares the seed marker", + t.get("herdr_marker") == SEED_MARKER) + check(f"{sel}: wrapper argv[0] is NOT the kind (needs pane-run)", + t["argv"][0] != kind) +# `supports` must not absorb the new trailing fields (field-order regression). +check("supports is unchanged by the added transport columns", + kv(e.run("resolve", "--grok").stdout).get("supports") == "commit,pr") + # canonical name and bare-cli-default selectors check("name selector claude:sonnet", kv(e.run("resolve", "claude:sonnet").stdout).get("model") == "sonnet") From 5ac24f1ba4b3fda07c74a435e69313070cbdb532 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Tue, 11 Aug 2026 15:43:10 +0200 Subject: [PATCH 02/12] Speak herdr 0.8's tab-first launch contract, feature-detected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit herdr 0.7.5 changed `agent start` to require an already-open pane, so work-system 1.11.0 fails on 0.8 with "unknown option: --workspace". The launcher now reads which contract this herdr speaks from `agent start --help` (read-only, never a version compare — 0.7.x spans both) and refuses to touch anything if it recognizes neither. Modern path: create the final background tab first, then start the worker in its root pane. - native workers (claude/codex/grok) go through `--kind`: argv[0] must equal the registry's declared kind and is dropped, every remaining argument keeps its order and boundaries, and the returned pane id must match the requested one - `agent_pane_busy` — the real failure right after `tab create`, while the login shell is still running its rc files — is retried bounded, and no other error is ever retried - wrapper workers (kimi) are delivered as one `pane run` command after a bounded shell-prompt wait, then confirmed by polling until herdr detects the expected kind in that exact pane - definitive failures (usage error, no such pane, busy exhausted, seed marker) roll the created tab back exactly once via herdr-teardown's close-then-verify; ambiguous ones (readiness timeout, unknown error, pane-id mismatch, wrong detected kind) leave the tab alone and return the new fail-closed `blocked=unverified` result - an unconfirmed rollback also downgrades to `blocked=unverified` rather than claiming a clean failure The legacy 0.7.0-0.7.4 sequence, diagnostics and stdout contract are untouched, and `moved=yes` is kept on the modern path for callers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW --- plugins/work-system/scripts/herdr-launch.sh | 508 +++++++++++++-- .../work-system/scripts/test_herdr_launch.py | 580 ++++++++++++++++-- 2 files changed, 1001 insertions(+), 87 deletions(-) diff --git a/plugins/work-system/scripts/herdr-launch.sh b/plugins/work-system/scripts/herdr-launch.sh index c76fa04..ece2e0a 100755 --- a/plugins/work-system/scripts/herdr-launch.sh +++ b/plugins/work-system/scripts/herdr-launch.sh @@ -3,18 +3,44 @@ # # Two modes share one precondition/JSON-parse/output contract: # -# launch (/kickoff, /adopt) — spawn the chosen worker as ARGV via `agent start … -- -# `, then move it into its own background tab. +# launch (/kickoff, /adopt) — open a background tab running the chosen worker. +# herdr changed HOW that is done in 0.7.5, so the launcher +# feature-detects the contract (see "launch API" below) and +# speaks whichever this herdr offers: +# +# LEGACY (0.7.0-0.7.4) — herdr places the agent itself: +# agent start --workspace --cwd --no-focus +# -- +# pane move --new-tab --label