From 47dd897e1f0c76e9e80107e0013c86ddd33863a5 Mon Sep 17 00:00:00 2001 From: letur Date: Sun, 13 Sep 2026 14:59:18 +0200 Subject: [PATCH 1/2] feat(queue): dispatch without calling bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch` shelled out to `scripts/session-flags.sh` for a task's profile and `scripts/session-trust.sh` for the trust dialog. On a machine with no bash the first failure was swallowed, so a worker started without its profile, and the second came after `session create`, leaving a session that was never sent its brief. On native Windows these are a swallowed and an uncaught `WinError 193`. - Profiles render in-process through `session_profiles`. - The trust handshake is ported to `scripts/lib/session_trust.py` with every rule it had: per-agent gates, dialogs queued behind each other, the `agent.conf` teaching, whitespace-insensitive matching of psmux captures, and Enter alone when "Yes" is already selected. Dispatch calls it in-process; `scripts/session-trust.sh` forwards to it with the same CLI. - `FLEET_QUEUE_WATCH_CMD` is split into argv instead of run through `sh -c`. - queue-selftest §21b drives dispatch, watch and the trust CLI with neither bash nor sh on PATH. Claude-Session: https://claude.ai/code/session_01DZe2ZfGL5BpKrvf1BXzT83 --- .agents/skills/fleet-queue/SKILL.md | 2 +- .agents/skills/thurbox-session/SKILL.md | 5 +- .github/workflows/ci.yml | 4 + CONTRIBUTING.md | 2 +- orchestration/agent.example.conf | 2 +- scripts/lib/queue.py | 59 +-- scripts/lib/session_profiles.py | 6 +- scripts/lib/session_trust.py | 457 ++++++++++++++++++++++++ scripts/queue-selftest.sh | 100 +++++- scripts/session-trust.sh | 383 +------------------- 10 files changed, 614 insertions(+), 406 deletions(-) create mode 100644 scripts/lib/session_trust.py diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index 14773b0..8ddf77d 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -345,7 +345,7 @@ $THURBOX_SESSION` so `session list --parent` enumerates your workers — **excep on a task that names a `--host`**, where thurbox refuses a parent living on another machine and there is no way to spell one, so a remote worker has no parent and is enumerated by its task record instead — and the -task's session profile from `./scripts/session-flags.sh`. Each worker is sent +task's session profile from `orchestration/session-profiles.yaml`. Each worker is sent one line pointing at the absolute path of its own brief — nothing is copied into its worktree, so nothing can land in its PR. diff --git a/.agents/skills/thurbox-session/SKILL.md b/.agents/skills/thurbox-session/SKILL.md index 7af623a..6f81373 100644 --- a/.agents/skills/thurbox-session/SKILL.md +++ b/.agents/skills/thurbox-session/SKILL.md @@ -204,8 +204,9 @@ The per-agent differences, one of which is a trap: **An agent not in this table is refused, not guessed at** — a wrong keystroke can exit the agent instead of dismissing a dialog. Teach it one with `TRUST_SIGNATURE` and `TRUST_KEYS` in `orchestration/agent.conf` -(`TRUST_KEYS=none` for an agent with no dialog at all); `session-trust.sh`'s -header owns the mechanics. +(`TRUST_KEYS=none` for an agent with no dialog at all); +`scripts/lib/session_trust.py`, which `session-trust.sh` forwards to, owns the +mechanics. **Which path the trust is recorded against** (observed 2026-09-07, Claude Code): answering inside a worktree records it against the **repository's main worktree diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4557a72..da4af42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,10 @@ jobs: - 'tests/**' - 'pyproject.toml' - 'uv.lock' + # Dispatch calls both in-process, and the selftest drives both. + - 'scripts/session-trust.sh' + - 'scripts/lib/session_trust.py' + - 'scripts/lib/session_profiles.py' - 'scripts/check.sh' - '.github/workflows/ci.yml' reconcile: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee5152d..af1279c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -326,7 +326,7 @@ has exactly one copy; never write a second one under `.claude/`. and why completion is a stream plus a file rather than a message. `orchestration/queue/README.md` owns the on-disk record shape, and `.agents/skills/fleet-queue/SKILL.md` is the working reference for driving it — -a reference, not an owner. `scripts/session-trust.sh`'s header owns the +a reference, not an owner. `scripts/lib/session_trust.py` owns the trust-dialog mechanics and the per-agent table, and `scripts/trust-thurbox-dir.sh`'s owns the config-seeding fallback. `README.md` owns the human-facing version of all of it. Point at one of those rather than diff --git a/orchestration/agent.example.conf b/orchestration/agent.example.conf index 9f01ae9..7b53afd 100644 --- a/orchestration/agent.example.conf +++ b/orchestration/agent.example.conf @@ -40,7 +40,7 @@ FUEL_PROVIDER= # gone stale AND the agent itself saying it hit a limit. The second half is # agent-specific by nature — a banner on the pane, or a rate-limit record in a # transcript — so `scripts/lib/queue.py` keeps one entry per agent fleet has -# actually WATCHED do it, the same way `scripts/session-trust.sh` keeps one +# actually WATCHED do it, the same way `scripts/lib/session_trust.py` keeps one # entry per agent's trust dialog. # # AN AGENT WITH NO ENTRY IS NOT GUESSED AT: refuel reports it `undetermined`, diff --git a/scripts/lib/queue.py b/scripts/lib/queue.py index f61eb1a..8b54c2d 100644 --- a/scripts/lib/queue.py +++ b/scripts/lib/queue.py @@ -104,12 +104,17 @@ def _load_forge(): shares ONE registry, and therefore one answer about which forges are configured. """ - if "fleet_forge" in sys.modules: - return sys.modules["fleet_forge"] - path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "forge.py") - spec = importlib.util.spec_from_file_location("fleet_forge", path) + return _load_lib("forge.py", "fleet_forge") + + +def _load_lib(filename: str, name: str): + """A module beside this file, loaded once under `name` — see `_load_forge`.""" + if name in sys.modules: + return sys.modules[name] + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename) + spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) - sys.modules["fleet_forge"] = mod + sys.modules[name] = mod spec.loader.exec_module(mod) return mod @@ -2999,16 +3004,20 @@ def read_text(path: str) -> str: def profile_flags(profile: str) -> list: - """The agent settings for this task, from orchestration/session-profiles.yaml.""" - try: - out = subprocess.run( - ["./scripts/session-flags.sh", profile], - capture_output=True, - check=True, - ).stdout - except (OSError, subprocess.CalledProcessError): + """The agent settings for this task, from orchestration/session-profiles.yaml. + + In-process, and not through `scripts/session-flags.sh`: on a machine with + no bash that call failed, the failure was swallowed here, and the worker + started without its profile with nothing said (queue-selftest §21b). A + profile that is missing or breaks a rule still renders no flags. + """ + profiles_mod = _load_lib("session_profiles.py", "fleet_session_profiles") + errors: list[str] = [] + path = os.path.join(checkout_root(), "orchestration", "session-profiles.yaml") + profiles = profiles_mod.load_profiles(path, errors) + if profiles is None or errors or profile not in profiles: return [] - return [f for f in out.decode().split("\0") if f] + return profiles_mod.render(profiles[profile]) def brief_target(task: Task) -> str: @@ -3400,7 +3409,9 @@ def watch_command(extra: list) -> list: """The stream command, real or the selftest's recorded-stream override.""" override = os.environ.get("FLEET_QUEUE_WATCH_CMD") if override: - return ["sh", "-c", override] + # Split into argv with shell quoting and nothing else of a shell: there + # is no `sh` to hand a line to on native Windows. + return shlex.split(override) return ["thurbox-cli", "watch", "--json"] + extra @@ -5899,14 +5910,16 @@ def branch_checkout(repo: str, branch: str, slug: str) -> tuple[str, str]: def trust_and_send(session: str, text: str, timeout: int = 20) -> tuple[bool, str]: - """Answer the trust dialog, then type. The order is the whole point (§1b).""" - trust = subprocess.run( - ["./scripts/session-trust.sh", session, "--timeout", str(timeout)], - capture_output=True, - check=False, - ) - report = (trust.stdout + trust.stderr).decode().strip() - if trust.returncode != 0: + """Answer the trust dialog, then type. The order is the whole point (§1b). + + The dialog is answered in-process by `session_trust.py`, the module + `scripts/session-trust.sh` forwards to: a machine with no bash could not + run the script, and dispatch then failed after `session create`, leaving a + session that was never sent its brief. + """ + trust = _load_lib("session_trust.py", "fleet_session_trust") + code, report = trust.answer_dialogs(session, timeout) + if code != 0: return False, report # The returncode is READ. It used to be thrown away, so a send into a # session that had gone away returned `True` and every caller reported a diff --git a/scripts/lib/session_profiles.py b/scripts/lib/session_profiles.py index 6e1fcd9..c3743d6 100644 --- a/scripts/lib/session_profiles.py +++ b/scripts/lib/session_profiles.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Render one session profile into `thurbox-cli session create` flags. -Called by scripts/session-flags.sh, never on its own — that wrapper owns the -argument handling and the usage message. Two modes: +Called by scripts/session-flags.sh, which owns the argument handling and the +usage message, and in-process by `queue.py dispatch`, which calls +`load_profiles` and `render` directly so that no shell stands between a task +and its profile. Two modes on the command line: session_profiles.py --check validate every profile, print a one-line summary diff --git a/scripts/lib/session_trust.py b/scripts/lib/session_trust.py new file mode 100644 index 0000000..127dd1d --- /dev/null +++ b/scripts/lib/session_trust.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Get a freshly created session past its agent's trust dialog, without +touching anything the operator owns. + +THE BUG. thurbox mints a FRESH worktree path per session, and most agents ask +whether they may work in a directory they have not seen before. So every +worker fleet spawned sat on that dialog: the session existed, the pane was +live, the agent had not started. `session send` then typed the brief INTO the +dialog. Sessions fleet created were broken, every time. + +WHY A KEYSTROKE AND NOT A CONFIG EDIT. `scripts/trust-thurbox-dir.sh` can +seed Claude Code's trust into ~/.claude.json and it still works — it is the +right fallback when a dialog cannot be answered. It is the wrong DEFAULT: it +writes to a file the user owns, for a tool fleet did not install, and it +needs a different file format for every agent. Answering the prompt touches +nothing that outlives the session, and it is one mechanism for the agents +whose gate is a prompt at all. + +THE FAILURE MODE THIS IS BUILT AROUND: sending a key blindly. If the dialog +is not there, the key lands in a live agent's composer — noise at best, a +stray instruction at worst. So this never sends unless it can SEE the +dialog, and never reports success unless it can see the dialog is gone. + + confirm the pane shows one of this agent's dialogs + answer the keys for THAT dialog, which are not the same per agent + confirm the dialog is gone — and answer the next one if another + comes up behind it + +and if either confirmation fails it sends nothing more and says so. A session +waiting on a dialog is visible and fixable; a session that has been typed +into randomly is neither. + +It is safe to run only in the window between `session create` and the first +`session send`, which is when `scripts/queue.sh dispatch` runs it: nothing +has been typed into that pane yet, so there is no composer content to +corrupt. Do not run it against a session that is already working. + +IN-PROCESS, AND NO SHELL. `dispatch` calls `answer_dialogs` directly, and +`scripts/session-trust.sh` is a forwarder to this file. It needs thurbox-cli +and Python and nothing else — no bash, no jq — because a native Windows +machine has neither, and the bash version crashed dispatch there after +`session create`, leaving a session that was never sent its brief. + +A REMOTE SESSION IS ANSWERED THE SAME WAY, and this is the reason the +keystroke is the default rather than the config edit. `session get`, `session +capture` and `session key` each DELEGATE to the thurbox-cli on the host, so +every command below reaches a pane on another machine unchanged. The config +edit does not: `trust-thurbox-dir.sh` writes THIS machine's ~/.claude.json, +and a remote agent reads the remote one, so seeding here would do nothing at +all for a worker over there — silently. + +The one host this cannot answer is one whose hosts.toml entry sets +`share_sessions = false`, which switches that delegation off wholesale. +`queue.sh add --host` refuses such a host outright rather than dispatching a +worker that would sit on a dialog nothing can see. + +PER-AGENT, and the differences are real (see GATES below): + + claude a dialog whose default selection is "No, exit". A bare + Enter DISMISSES it. Down, then Enter. Under a directory + whose CLAUDE.md imports a file outside it, a second dialog + follows, and ITS default — No — is the answer. Enter. + codex a dialog; Enter accepts. Persists per repo root, so later + worktrees of the same project never show it. + pi, pi-signed a dialog; Enter accepts. Persists per path. + grok, kimi no dialog in a git worktree. Nothing to do. + cursor, muse NOT a keystroke — a launch flag (`--trust`, `--yolo`). + This refuses them and says where the flag goes: a profile + in orchestration/session-profiles.yaml. + +AN AGENT THAT IS NOT IN THE TABLE is the operator's to teach, not fleet's to +guess: `TRUST_SIGNATURE` and `TRUST_KEYS` in orchestration/agent.conf, with +`TRUST_KEYS=none` for an agent that shows no dialog at all. With neither set +this refuses and sends nothing, because the `claude` row above is why — +guessing a keystroke there exits the agent. + +Usage: + scripts/session-trust.sh [--timeout SECS] [--json] + python3 scripts/lib/session_trust.py + +Exit codes, so a caller can decide without parsing prose: + 0 the pane is ready for a prompt — every dialog was answered, or there was + none and the agent is up + 2 usage, or the session could not be read + 3 could NOT confirm. Nothing more was sent. Do not send a prompt either. + +Requires: thurbox-cli. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# After each answer the pane is watched this many more seconds for a dialog +# behind it; `MAX_ANSWERS` bounds a dialog that keeps coming back, which is not +# one this understands. +SETTLE = 3 +MAX_ANSWERS = 4 + +# --- the per-agent table ----------------------------------------------------- +# +# A GATE is one dialog: a signature — a regex matched case-insensitively +# against the pane — and the space-separated key sequence that answers it, in +# order. An agent can show more than one, one after another. An agent with no +# gate has no dialog to answer. + +GATES = { + "claude": [ + # Observed live on Claude Code, 2026-09-07, in a fresh thurbox worktree: + # + # Quick safety check: Is this a project you created or one you trust? + # ❯ No, exit + # Yes, I trust this folder + # Enter to confirm · Esc to cancel + # + # Matched on the accepting option's own label, which is specific enough + # that ordinary agent output cannot produce it by accident. + # + # THE TRAP, and it is right there in the capture above: the default + # selection is "No, exit". A bare Enter DISMISSES the dialog and the + # agent exits. Move the selection down to the accepting option first. + # This is the one dialog where the obvious answer is the wrong one. + ("yes, i trust this folder|quick safety check: is this a project you created", + "down enter"), + # Observed live on Claude Code, 2026-09-12, on a shepherd fixer started + # under a directory whose CLAUDE.md imports a file outside it — shown + # before anything else: + # + # Allow external CLAUDE.md file imports? + # ❯ No, disable external imports + # Yes, allow external imports + # + # Answered with its DEFAULT, a bare Enter. `Yes` would load a guide + # written for someone else — the lead's, in the case that found it — + # into a worker. + (r"allow external claude\.md file imports|no, disable external imports", "enter"), + ], + "codex": [("do you trust the contents of this directory|do you trust this directory", "enter")], + "pi": [("trust this project|do you trust", "enter")], + "pi-signed": [("trust this project|do you trust", "enter")], + # No dialog when launched inside a git repo root, which a thurbox worktree + # always is. Nothing to answer; still confirmed as up below. + "grok": [], + "kimi": [], +} +FLAG_ONLY = {"cursor", "muse"} + +# WHERE THE SELECTOR ALREADY IS, for claude, and it outranks the table. The +# folder-trust dialog above defaults to "No, exit"; the one Claude Code 2.1.247 +# draws on a Windows 11 host (2026-09-12) is numbered and defaults to the other +# option: +# +# ❯ 1. Yes, I trust this folder +# 2. No, exit +# +# `down enter` there selects "No, exit" and the agent exits — observed, not +# supposed. So when the selector is already on the accepting option, Enter +# alone accepts, whichever layout drew it. Matched against the squeezed pane. +YES_SELECTED = re.compile(r"❯([0-9]+\.)?yes,itrustthisfolder", re.IGNORECASE) + + +# --- talking to thurbox ------------------------------------------------------ + + +def _run(args: list) -> subprocess.CompletedProcess | None: + try: + return subprocess.run(["thurbox-cli", *args], capture_output=True, check=False) + except OSError: + return None + + +def _json(proc: subprocess.CompletedProcess | None) -> dict: + if proc is None: + return {} + try: + value = json.loads(proc.stdout.decode("utf-8", errors="replace")) + except ValueError: + return {} + return value if isinstance(value, dict) else {} + + +def session_info(session: str) -> dict: + """`session get --json`, or {} when thurbox could not answer it.""" + proc = _run(["session", "get", session, "--json"]) + return _json(proc) if proc is not None and proc.returncode == 0 else {} + + +def squeeze(text: str) -> str: + """Text with every whitespace character gone. + + WHITESPACE IS NOT PART OF THE MATCH, on either side. psmux 3.3.6 — the + multiplexer of a Windows host — captures Claude Code's dialog with every + space gone (`❯1.Yes,Itrustthisfolder`, observed 2026-09-12), so a signature + spelled with spaces never matched there and the worker sat on its dialog + unprompted. Stripping both sides changes nothing a tmux pane matched, and it + also survives a dialog wrapped at the pane's width. + """ + return re.sub(r"\s+", "", text) + + +def pane(uuid: str) -> str: + """The pane, squeezed. + + `--json` and `.output`, not the plain capture: the human format wraps the + pane in metadata lines, and a signature could in principle match one of + those instead of the pane itself. + """ + output = _json(_run(["session", "capture", uuid, "--lines", "60", "--json"])).get("output") + return squeeze(output) if isinstance(output, str) else "" + + +def shows(signature: str, text: str) -> bool: + try: + return re.search(squeeze(signature), text, re.IGNORECASE) is not None + except re.error: + # A signature the operator wrote that is not a valid pattern matches + # nothing, which is what `grep -E` made of one. + return False + + +def agent_reported(uuid: str) -> bool: + """An agent whose hooks have fired is running its own loop, which is proof + there is no modal dialog in front of it.""" + return session_info(uuid).get("hook_reported") is True + + +def send_key(uuid: str, key: str) -> bool: + proc = _run(["session", "key", uuid, key]) + return proc is not None and proc.returncode == 0 + + +# --- an agent fleet has not watched ------------------------------------------ + + +def conf_value(path: str, key: str) -> str: + """The first `KEY=value` line's value, verbatim, or "".""" + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.rstrip("\r\n") + if line.startswith(key + "="): + return line[len(key) + 1:] + except OSError: + pass + return "" + + +def taught_gates(agent_root: str) -> list | None: + """What `orchestration/agent.conf` teaches: a gate list, or None for nothing. + + NOT IN THE TABLE IS NOT THE END. The table is what fleet has WATCHED, and + the operator has watched their own agent — so `TRUST_SIGNATURE` and + `TRUST_KEYS` there teach it one, the same way `LIMIT_BANNER` and + `TRANSCRIPT_DIR` teach `refuel` one. Without them this still refuses rather + than guessing a keystroke: a bare Enter into Claude Code's dialog exits the + agent, and an invented answer would do that to somebody's. + """ + conf = os.path.join(agent_root, "orchestration", "agent.conf") + if not os.path.isfile(conf): + conf = os.path.join(agent_root, "orchestration", "agent.example.conf") + signature = conf_value(conf, "TRUST_SIGNATURE") + keys = conf_value(conf, "TRUST_KEYS") + if keys == "none": + # The operator says this agent shows no dialog. Nothing to answer; it + # is still confirmed as up. + return [] + if not signature: + return None + return [(signature, keys or "enter")] + + +# --- the whole handshake ----------------------------------------------------- + + +def answer_dialogs(session: str, timeout: int = 20, as_json: bool = False) -> tuple[int, str]: + """Confirm, answer and confirm every dialog in front of the agent. + + Returns the exit code and the one line of report, formatted for the + command line (`session-trust: ...`) or as the `--json` object. Every + outcome is terminal and says exactly one thing, which is why this returns + rather than prints. + """ + agent = "" + + def say(detail: str, outcome: str) -> str: + if as_json: + return json.dumps( + {"session": session, "agent": agent, "outcome": outcome, "detail": detail}, + ensure_ascii=False, + separators=(",", ":"), + ) + return f"session-trust: {detail}" + + # --- who is in the pane -------------------------------------------------- + info = session_info(session) + if not info: + return 2, say(f"no such session: {session}", "error") + uuid = str(info.get("id") or session) + # `detected_agent` is what is observably running and wins over the row's + # `agent` when they disagree — a session created as a bare shell that a + # harness launched an agent into is exactly that case. + for key in ("detected_agent", "reports_as", "agent"): + if info.get(key) not in (None, False): + agent = str(info[key]) + break + + if agent in GATES: + gates = GATES[agent] + elif agent in FLAG_ONLY: + return 3, say( + f"'{agent}' is not answered by a keystroke — it takes a launch flag\n" + " (cursor: --trust, muse: --yolo). Put it in a profile in\n" + " orchestration/session-profiles.yaml and spawn under that profile.\n" + " Nothing was sent.", + "flag-required", + ) + elif not agent: + return 3, say("could not tell which agent holds the pane; sending nothing", "unconfirmed") + else: + agent_root = os.environ.get("FLEET_AGENT_ROOT") or os.path.dirname(os.path.dirname(HERE)) + gates = taught_gates(agent_root) + if gates is None: + return 3, say( + f"no trust gate is known for '{agent}'; sending nothing. Teach fleet\n" + " one with TRUST_SIGNATURE and TRUST_KEYS in\n" + " orchestration/agent.conf, or add it to the table in\n" + f" {os.path.join(HERE, 'session_trust.py')}", + "unknown-agent", + ) + + def gate_on_pane() -> int | None: + """The index of the gate the pane shows right now, if any.""" + if not gates: + return None + text = pane(uuid) + for i, (signature, _keys) in enumerate(gates): + if shows(signature, text): + return i + return None + + def answer(i: int) -> tuple[int, str] | None: + """Answer one gate, then confirm it took. None when it did. + + A send that reports success is not proof the dialog was answered. The + dialog being GONE is. On either failure nothing more is sent. + """ + signature, keys = gates[i] + if agent == "claude" and keys == "down enter" and YES_SELECTED.search(pane(uuid)): + keys = "enter" + for k in keys.split(): + if not send_key(uuid, k): + return 3, say(f"could not send '{k}' to the pane; the dialog is still up", + "send-failed") + time.sleep(1) + for _ in range(10): + if not shows(signature, pane(uuid)): + return None + time.sleep(1) + return 3, say( + f"sent '{keys}' but {agent}'s dialog is still on the pane. Do not\n" + " prompt this session; look at it:\n" + f" thurbox-cli session capture {uuid}\n" + " The config-seeding fallback is:\n" + f" {os.path.join(os.path.dirname(HERE), 'trust-thurbox-dir.sh')}" + " \n" + " — which seeds THIS machine. For a session on a remote host, run\n" + " that script ON THE HOST, against the worktree path there.", + "unconfirmed", + ) + + # --- watch, answering every dialog in turn ------------------------------- + # + # Another dialog can come up BEHIND the one just answered — Claude Code + # shows folder trust, then external imports — and returning after the first + # would hand the send to the second. So after each answer the pane is + # watched for SETTLE more seconds, cut short by the agent reporting. + answered: list[str] = [] + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + i = gate_on_pane() + if i is not None: + if len(answered) >= MAX_ANSWERS: + return 3, say( + f"answered {len(answered)} dialogs ({', '.join(answered)}) and another" + " is still coming.\n" + " Nothing more was sent. Look at the pane before prompting it:\n" + f" thurbox-cli session capture {uuid}", + "unconfirmed", + ) + failed = answer(i) + if failed: + return failed + answered.append(f"'{gates[i][1]}'") + deadline = time.monotonic() + SETTLE + continue + if agent_reported(uuid): + break + time.sleep(1) + + if answered: + return 0, say(f"answered {agent}'s dialog(s) with {', '.join(answered)}; " + "none is left on the pane", "answered") + if agent_reported(uuid): + return 0, say(f"no dialog: {agent} is already reporting; nothing sent", "ready") + if not gates: + return 0, say(f"{agent} shows no trust dialog in a git worktree; nothing sent", "ready") + return 3, say( + f"no trust dialog seen in {timeout}s and {agent} has not reported.\n" + " Nothing was sent. Look at the pane before prompting it:\n" + f" thurbox-cli session capture {uuid}", + "unconfirmed", + ) + + +def main(argv: list | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + timeout, as_json, session = 20, False, "" + while args: + arg = args.pop(0) + if arg in ("-h", "--help"): + print(__doc__.strip()) + return 0 + if arg == "--timeout": + value = args.pop(0) if args else "20" + try: + timeout = int(value) + except ValueError: + print(f"error: --timeout wants whole seconds, not {value!r}", file=sys.stderr) + return 2 + elif arg == "--json": + as_json = True + elif arg.startswith("-"): + print(f"error: unknown option {arg}", file=sys.stderr) + return 2 + else: + session = arg + if not session: + print(__doc__.strip()) + return 2 + if shutil.which("thurbox-cli") is None: + print("error: thurbox-cli not found", file=sys.stderr) + return 2 + code, report = answer_dialogs(session, timeout, as_json) + print(report) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/queue-selftest.sh b/scripts/queue-selftest.sh index d713061..40e4261 100755 --- a/scripts/queue-selftest.sh +++ b/scripts/queue-selftest.sh @@ -145,8 +145,8 @@ # Usage: scripts/queue-selftest.sh (also: ./scripts/check.sh queue) # # Requires: python3 (with PyYAML), plus git and jq for test 9 — `gh` and -# `thurbox-cli` are stubbed on PATH there, but session-trust.sh, which test 9 -# drives for real, reads their output with jq. +# `thurbox-cli` are stubbed on PATH there, and the trust step test 9 drives for +# real is scripts/lib/session_trust.py reading the stub's output. set -uo pipefail @@ -1054,7 +1054,15 @@ capqueue attach "$capt/01-task-a" "$sesa" >/dev/null capline 103 "$sesa" "working" } >"$capev" -export FLEET_QUEUE_WATCH_CMD="FLEET_QUEUE_WATCH_CMD='cat $capseed' FLEET_QUEUE_DIR='$capq' $QUEUE attach '$capt/02-task-b' $sesb >/dev/null 2>&1; cat $capev" +# The override is an argv list and not a shell line (§21b), so a stream command +# that does two things is a script of its own. +cat >"$captmp/attach-then-read.sh" </dev/null 2>&1 +cat '$capev' +SH +chmod +x "$captmp/attach-then-read.sh" +export FLEET_QUEUE_WATCH_CMD="$captmp/attach-then-read.sh" capqueue watch --for-secs 0 >/dev/null 2>&1 export FLEET_QUEUE_WATCH_CMD="cat $capev" capqueue watch --for-secs 0 >/dev/null 2>&1 @@ -7020,6 +7028,92 @@ count_is "each with its own keys, in the order they appeared" \ "$(tr '\n' ';' <"$tmp/keys.log")" \ "session key $both down;session key $both enter;session key $both enter;" "$out" +# --- 21b. dispatch runs with no bash on the machine -------------------------- +# +# `dispatch` used to shell out to two bash scripts, `session-flags.sh` for the +# task's profile and `session-trust.sh` for the dialog, and on a machine with no +# bash each broke its own way. The profile was SWALLOWED: `profile_flags` +# caught the failure and returned no flags, so the worker started without its +# settings and nothing said so. The trust step failed AFTER `session create`, +# leaving a session that was never sent its brief. On native Windows the first +# is a swallowed `WinError 193` and the second an uncaught one. Both run +# in-process now, and so does the watch override that used to be `sh -c`. +# +# Driven through queue.py itself with neither `bash` nor `sh` on PATH — +# queue.sh is a bash script, so it cannot be the entry point here — against the +# key-answering stub above, behind a dialog that must be answered for the send +# to go at all. + +nobash="$tmp/no-bash" +mkdir -p "$nobash" +for t in python3 git cat mv rm; do ln -sf "$(command -v "$t")" "$nobash/$t"; done +py="$(command -v python3)" +nobash_path="$t21bin:$tbxbin:$ghbin:$sshbin:$noglab:$nobash" +if env PATH="$nobash_path" "$py" -c \ + 'import shutil, sys; sys.exit(bool(shutil.which("bash") or shutil.which("sh")))'; then + pass "the PATH this section runs under has neither bash nor sh on it" +else + fail "the PATH this section runs under has neither bash nor sh on it" "$nobash_path" +fi +nbq() { env PATH="$nobash_path" "$py" scripts/lib/queue.py "$@" 2>&1; } + +queue_before_21b="$FLEET_QUEUE_DIR" +next_before_21b="$(cat "$tmp/next-session.json" 2>/dev/null)" +export FLEET_QUEUE_DIR="$tmp/queue-nobash" +nbtopic="$(env PATH="$nobash_path" "$py" scripts/lib/queue.py topic add no-bash \ + --title 'Dispatch with no bash' --prompt 'dispatch must not call bash' 2>/dev/null)" +nbq add "$nbtopic" profiled --title 'Profiled' --repo /tmp/repo-nobash \ + --branch fix/nobash --number 01 --profile sweep >/dev/null +printf 'Do the thing without bash.\n' >"$FLEET_QUEUE_DIR/$nbtopic/01-profiled/BRIEF.md" + +nbsid=d1a10900-0000-0000-0000-00000000000b +printf '{"id":"%s","created":true}\n' "$nbsid" >"$tmp/next-session.json" +behind_dialog "$nbsid" +printf '%s\n' "$folder_dialog" >"$panes/$nbsid.txt" +: >"$tmp/keys.log" +out="$( + unset FLEET_QUEUE_WATCH_CMD + nbq dispatch +)" +refute "dispatch with no bash does not crash" "Traceback" "$out" +expect "the task's profile reaches session create without bash" \ + "--env MAX_THINKING_TOKENS=8000" "$(grep -F -- '--repo-path /tmp/repo-nobash' "$creates" | tail -1)" +count_is "the trust dialog is answered in-process, down and then enter" \ + "$(tr '\n' ';' <"$tmp/keys.log")" "session key $nbsid down;session key $nbsid enter;" "$out" +expect "and only then is the brief sent" "session send $nbsid Read" "$(cat "$sends")" +refute "so the session is not left unprompted" "NOT PROMPTED" "$out" + +# The watch override is an argv list: with no `sh` to hand a line to, it still +# runs, and what it read is folded into the task it belongs to. +nbev="$tmp/events-nobash.jsonl" +printf '{"seq":900,"at":1788793000000,"session":"%s","event":"state","from_state":null,"to_state":"working","state":"working","reason":"hook"}\n' \ + "$nbsid" >"$nbev" +out="$(FLEET_QUEUE_WATCH_CMD="cat $nbev" nbq watch --for-secs 0)" +refute "the watch override runs with no sh to run it" "could not read the event stream" "$out" +count_is "and folds what it read into the task" \ + "$(capseqs "$FLEET_QUEUE_DIR/$nbtopic/01-profiled/progress.jsonl")" "900" "$out" + +# The trust step on its own, the way a skill or a driver calls it: the Python +# answers a dialog with no bash either, and its --json shape is unchanged. +nbcli=d1a10900-0000-0000-0000-00000000000c +behind_dialog "$nbcli" +printf '%s\n' "$imports_dialog" >"$panes/$nbcli.txt" +out="$(env PATH="$nobash_path" "$py" scripts/lib/session_trust.py "$nbcli" --timeout 5 --json 2>&1)" +expect "session_trust.py answers a dialog with no bash on PATH" '"outcome":"answered"' "$out" +expect "and says which session and agent it answered for" \ + "{\"session\":\"$nbcli\",\"agent\":\"claude\"" "$out" +expect "session-trust.sh keeps its CLI, --help and all" "Exit codes" \ + "$(./scripts/session-trust.sh --help 2>&1)" +expect "and still exits 2 on a session it cannot read" "2" \ + "$(./scripts/session-trust.sh no-such-session >/dev/null 2>&1; echo $?)" + +export FLEET_QUEUE_DIR="$queue_before_21b" +if [ -n "$next_before_21b" ]; then + printf '%s\n' "$next_before_21b" >"$tmp/next-session.json" +else + rm -f "$tmp/next-session.json" +fi + # --- 22. a finished task closes itself, without --allow-unverified ----------- # # Fifteen records in the operator's own queue were closed by diff --git a/scripts/session-trust.sh b/scripts/session-trust.sh index 599eda4..c1929e6 100755 --- a/scripts/session-trust.sh +++ b/scripts/session-trust.sh @@ -2,388 +2,25 @@ # Get a freshly created session past its agent's trust dialog, without touching # anything the operator owns. # -# THE BUG. thurbox mints a FRESH worktree path per session, and most agents ask -# whether they may work in a directory they have not seen before. So every -# worker fleet spawned sat on that dialog: the session existed, the pane was -# live, the agent had not started. `session send` then typed the brief INTO the -# dialog. Sessions fleet created were broken, every time. -# -# WHY A KEYSTROKE AND NOT A CONFIG EDIT. `scripts/trust-thurbox-dir.sh` can -# seed Claude Code's trust into ~/.claude.json and it still works — it is the -# right fallback when a dialog cannot be answered. It is the wrong DEFAULT: it -# writes to a file the user owns, for a tool fleet did not install, and it -# needs a different file format for every agent. Answering the prompt touches -# nothing that outlives the session, and it is one mechanism for the agents -# whose gate is a prompt at all. -# -# THE FAILURE MODE THIS IS BUILT AROUND: sending a key blindly. If the dialog -# is not there, the key lands in a live agent's composer — noise at best, a -# stray instruction at worst. So this script never sends unless it can SEE the -# dialog, and never reports success unless it can see the dialog is gone. -# -# confirm the pane shows one of this agent's dialogs -# answer the keys for THAT dialog, which are not the same per agent -# confirm the dialog is gone — and answer the next one if another -# comes up behind it -# -# and if either confirmation fails it sends nothing more and says so. A session -# waiting on a dialog is visible and fixable; a session that has been typed -# into randomly is neither. -# -# It is safe to run only in the window between `session create` and the first -# `session send`, which is when `scripts/queue.sh dispatch` runs it: nothing -# has been typed into that pane yet, so there is no composer content to -# corrupt. Do not run it against a session that is already working. -# -# A REMOTE SESSION IS ANSWERED THE SAME WAY, and this is the reason the -# keystroke is the default rather than the config edit. `session get`, `session -# capture` and `session key` each DELEGATE to the thurbox-cli on the host, so -# every command below reaches a pane on another machine unchanged. The config -# edit does not: `trust-thurbox-dir.sh` writes THIS machine's ~/.claude.json, -# and a remote agent reads the remote one, so seeding here would do nothing at -# all for a worker over there — silently. -# -# The one host this cannot answer is one whose hosts.toml entry sets -# `share_sessions = false`, which switches that delegation off wholesale. -# `queue.sh add --host` refuses such a host outright rather than dispatching a -# worker that would sit on a dialog nothing can see. -# -# PER-AGENT, and the differences are real (see the table in the code): -# -# claude a dialog whose default selection is "No, exit". A bare -# Enter DISMISSES it. Down, then Enter. Under a directory -# whose CLAUDE.md imports a file outside it, a second dialog -# follows, and ITS default — No — is the answer. Enter. -# codex a dialog; Enter accepts. Persists per repo root, so later -# worktrees of the same project never show it. -# pi, pi-signed a dialog; Enter accepts. Persists per path. -# grok, kimi no dialog in a git worktree. Nothing to do. -# cursor, muse NOT a keystroke — a launch flag (`--trust`, `--yolo`). -# This script refuses them and says where the flag goes: -# a profile in orchestration/session-profiles.yaml. -# -# AN AGENT THAT IS NOT IN THE TABLE is the operator's to teach, not fleet's to -# guess: `TRUST_SIGNATURE` and `TRUST_KEYS` in orchestration/agent.conf, with -# `TRUST_KEYS=none` for an agent that shows no dialog at all. With neither set -# this refuses and sends nothing, because the `claude` row above is why — -# guessing a keystroke there exits the agent. +# A FORWARDER. The rules — confirm the dialog is on the pane, answer it with +# that agent's keys, confirm it is gone — and the per-agent table live in +# scripts/lib/session_trust.py, which `scripts/queue.sh dispatch` calls +# in-process so that a machine with no bash can dispatch. This file keeps the +# command every skill, playbook and hook names working, with the same CLI and +# the same exit codes. `--help` prints the whole of it. # # Usage: # scripts/session-trust.sh [--timeout SECS] [--json] # -# Exit codes, so a caller can decide without parsing prose: -# 0 the pane is ready for a prompt — every dialog was answered, or there was -# none and the agent is up -# 2 usage, or the session could not be read -# 3 could NOT confirm. Nothing more was sent. Do not send a prompt either. -# -# Requires: thurbox-cli, jq. +# Requires: python3, thurbox-cli. set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -timeout_secs=20 -as_json=0 -session="" -usage() { - awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$here/session-trust.sh" -} - -while [ $# -gt 0 ]; do - case "$1" in - -h | --help) - usage - exit 0 - ;; - --timeout) - timeout_secs="${2:-20}" - shift 2 - ;; - --json) - as_json=1 - shift - ;; - -*) - printf 'error: unknown option %q\n' "$1" >&2 - exit 2 - ;; - *) - session="$1" - shift - ;; - esac -done - -[ -n "$session" ] || { - usage - exit 2 -} -command -v thurbox-cli >/dev/null || { - echo "error: thurbox-cli not found" >&2 - exit 2 -} -command -v jq >/dev/null || { - echo "error: jq is required" >&2 +command -v python3 >/dev/null || { + echo "error: python3 not found" >&2 exit 2 } -say() { - if [ "$as_json" -eq 1 ]; then - jq -nc --arg session "$session" --arg agent "$agent" \ - --arg outcome "$2" --arg detail "$1" \ - '{session:$session, agent:$agent, outcome:$outcome, detail:$detail}' - else - printf 'session-trust: %s\n' "$1" - fi -} - -# --- who is in the pane ------------------------------------------------------ - -info="$(thurbox-cli session get "$session" --json 2>/dev/null)" || info="" -[ -n "$info" ] || { - agent="" - say "no such session: $session" error - exit 2 -} -uuid="$(jq -r '.id' <<<"$info")" -# `detected_agent` is what is observably running and wins over the row's -# `agent` when they disagree — a session created as a bare shell that a harness -# launched an agent into is exactly that case. -agent="$(jq -r '.detected_agent // .reports_as // .agent // ""' <<<"$info")" - -# --- the per-agent table ----------------------------------------------------- -# -# A GATE is one dialog: a signature — an extended regex matched -# case-insensitively against the pane — and the space-separated key sequence -# that answers it, in order. An agent can show more than one, one after -# another. An agent with no gate has no dialog to answer. - -sigs=() -keyseqs=() -gate() { - sigs+=("$1") - keyseqs+=("$2") -} -flag_only="" - -case "$agent" in -claude) - # Observed live on Claude Code, 2026-09-07, in a fresh thurbox worktree: - # - # Quick safety check: Is this a project you created or one you trust? - # ❯ No, exit - # Yes, I trust this folder - # Enter to confirm · Esc to cancel - # - # Matched on the accepting option's own label, which is specific enough that - # ordinary agent output cannot produce it by accident. - # - # THE TRAP, and it is right there in the capture above: the default - # selection is "No, exit". A bare Enter DISMISSES the dialog and the agent - # exits. Move the selection down to the accepting option first. This is the - # one dialog where the obvious answer is the wrong one. - gate 'yes, i trust this folder|quick safety check: is this a project you created' \ - "down enter" - # Observed live on Claude Code, 2026-09-12, on a shepherd fixer started - # under a directory whose CLAUDE.md imports a file outside it — shown - # before anything else: - # - # Allow external CLAUDE.md file imports? - # ❯ No, disable external imports - # Yes, allow external imports - # - # Answered with its DEFAULT, a bare Enter. `Yes` would load a guide written - # for someone else — the lead's, in the case that found it — into a worker. - gate 'allow external claude\.md file imports|no, disable external imports' \ - "enter" - ;; -codex) - gate 'do you trust the contents of this directory|do you trust this directory' "enter" - ;; -pi | pi-signed) - gate 'trust this project|do you trust' "enter" - ;; -grok | kimi) - # No dialog when launched inside a git repo root, which a thurbox worktree - # always is. Nothing to answer; still confirmed as up below. - ;; -cursor | muse) - flag_only="yes" - ;; -"") - say "could not tell which agent holds the pane; sending nothing" unconfirmed - exit 3 - ;; -*) - # NOT IN THE TABLE IS NOT THE END. The table is what fleet has WATCHED, - # and the operator has watched their own agent — so `TRUST_SIGNATURE` and - # `TRUST_KEYS` in `orchestration/agent.conf` teach it one, the same way - # `LIMIT_BANNER` and `TRANSCRIPT_DIR` there teach `refuel` one. Without - # them this still refuses rather than guessing a keystroke: a bare Enter - # into Claude Code's dialog exits the agent, and an invented answer would - # do that to somebody's. - signature="" - keys="" - agent_root="${FLEET_AGENT_ROOT:-$(dirname "$here")}" - agent_conf="$agent_root/orchestration/agent.conf" - [ -f "$agent_conf" ] || agent_conf="$agent_root/orchestration/agent.example.conf" - if [ -f "$agent_conf" ]; then - signature="$(sed -n 's/^TRUST_SIGNATURE=//p' "$agent_conf" | head -1)" - keys="$(sed -n 's/^TRUST_KEYS=//p' "$agent_conf" | head -1)" - fi - if [ "$keys" = none ]; then - # The operator says this agent shows no dialog. Nothing to answer; - # it is still confirmed as up below. - : - elif [ -z "$signature" ]; then - say "no trust gate is known for '$agent'; sending nothing. Teach fleet - one with TRUST_SIGNATURE and TRUST_KEYS in - orchestration/agent.conf, or add it to the table in - $here/session-trust.sh" unknown-agent - exit 3 - else - gate "$signature" "${keys:-enter}" - fi - ;; -esac - -if [ -n "$flag_only" ]; then - say "'$agent' is not answered by a keystroke — it takes a launch flag - (cursor: --trust, muse: --yolo). Put it in a profile in - orchestration/session-profiles.yaml and spawn under that profile. - Nothing was sent." flag-required - exit 3 -fi - -# --- reading the pane -------------------------------------------------------- - -# `--json` and `.output`, not the plain capture: the human format wraps the -# pane in metadata lines, and a signature could in principle match one of -# those instead of the pane itself. -# -# WHITESPACE IS NOT PART OF THE MATCH, on either side. psmux 3.3.6 — the -# multiplexer of a Windows host — captures Claude Code's dialog with every -# space gone (`❯1.Yes,Itrustthisfolder`, seen on a Windows 11 host 2026-09-12), -# so a signature spelled with spaces never matched there and the worker sat on -# its dialog unprompted. Stripping both sides changes nothing a tmux pane -# matched, and it also survives a dialog wrapped at the pane's width. -pane() { - thurbox-cli session capture "$uuid" --lines 60 --json 2>/dev/null | - jq -r '.output // ""' | tr -d '[:space:]' -} - -# The index of the gate the pane shows right now, if any. -gate_on_pane() { - local text i - [ "${#sigs[@]}" -gt 0 ] || return 1 - text="$(pane)" - for i in "${!sigs[@]}"; do - if grep -qiE -- "$(printf %s "${sigs[$i]}" | tr -d '[:space:]')" <<<"$text"; then - echo "$i" - return 0 - fi - done - return 1 -} - -# An agent whose hooks have fired is running its own loop, which is proof there -# is no modal dialog in front of it. -agent_reported() { - thurbox-cli session get "$uuid" --json 2>/dev/null | - jq -e '.hook_reported == true' >/dev/null -} - -# --- answer one gate, then confirm it took ----------------------------------- -# -# A send that reports success is not proof the dialog was answered. The dialog -# being GONE is. Exits the script on either failure: nothing more is sent. - -answer() { - local i="$1" k keys - keys="${keyseqs[$i]}" - # WHERE THE SELECTOR ALREADY IS, for claude, and it outranks the table. The - # folder-trust dialog above defaults to "No, exit"; the one Claude Code - # 2.1.247 draws on a Windows 11 host (2026-09-12) is numbered and defaults - # to the other option: - # - # ❯ 1. Yes, I trust this folder - # 2. No, exit - # - # `down enter` there selects "No, exit" and the agent exits — observed, not - # supposed. So when the selector is already on the accepting option, Enter - # alone accepts, whichever layout drew it. - if [ "$agent" = claude ] && [ "$keys" = "down enter" ] && - grep -qiE -- '❯([0-9]+\.)?yes,itrustthisfolder' <<<"$(pane)"; then - keys="enter" - fi - for k in $keys; do - if ! thurbox-cli session key "$uuid" "$k" >/dev/null 2>&1; then - say "could not send '$k' to the pane; the dialog is still up" send-failed - exit 3 - fi - sleep 1 - done - for _ in 1 2 3 4 5 6 7 8 9 10; do - grep -qiE -- "$(printf %s "${sigs[$i]}" | tr -d '[:space:]')" <<<"$(pane)" || return 0 - sleep 1 - done - say "sent '$keys' but $agent's dialog is still on the pane. Do not - prompt this session; look at it: - thurbox-cli session capture $uuid - The config-seeding fallback is: - $here/trust-thurbox-dir.sh - — which seeds THIS machine. For a session on a remote host, run - that script ON THE HOST, against the worktree path there." unconfirmed - exit 3 -} - -# --- watch, answering every dialog in turn ----------------------------------- -# -# Another dialog can come up BEHIND the one just answered — Claude Code shows -# folder trust, then external imports — and returning after the first would -# hand the send to the second. So after each answer the pane is watched for -# `settle` more seconds, cut short by the agent reporting. `max_answers` bounds -# a dialog that keeps coming back, which is not one this script understands. - -settle=3 -max_answers=4 -answered="" -count=0 -deadline=$((SECONDS + timeout_secs)) -while [ "$SECONDS" -lt "$deadline" ]; do - if i="$(gate_on_pane)"; then - if [ "$count" -ge "$max_answers" ]; then - say "answered $count dialogs ($answered) and another is still coming. - Nothing more was sent. Look at the pane before prompting it: - thurbox-cli session capture $uuid" unconfirmed - exit 3 - fi - answer "$i" - count=$((count + 1)) - answered="${answered:+$answered, }'${keyseqs[$i]}'" - deadline=$((SECONDS + settle)) - continue - fi - if agent_reported; then - break - fi - sleep 1 -done - -if [ -n "$answered" ]; then - say "answered $agent's dialog(s) with $answered; none is left on the pane" answered - exit 0 -fi -if agent_reported; then - say "no dialog: $agent is already reporting; nothing sent" ready - exit 0 -fi -if [ "${#sigs[@]}" -eq 0 ]; then - say "$agent shows no trust dialog in a git worktree; nothing sent" ready - exit 0 -fi -say "no trust dialog seen in ${timeout_secs}s and $agent has not reported. - Nothing was sent. Look at the pane before prompting it: - thurbox-cli session capture $uuid" unconfirmed -exit 3 +exec python3 "$here/lib/session_trust.py" "$@" From 3a965d4746117a20302b8fff2b3f0cedfada6d4c Mon Sep 17 00:00:00 2001 From: letur Date: Sun, 13 Sep 2026 15:26:20 +0200 Subject: [PATCH 2/2] chore: no-mistakes document - Point dispatch's trust-dialog docs at session_trust.py, called in-process --- .agents/skills/fleet-queue/SKILL.md | 9 +++++---- AGENTS.md | 11 ++++++----- scripts/lib/queue.py | 4 ++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index 8ddf77d..67a6f63 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -402,10 +402,11 @@ commits. ### The trust dialog, handled here rather than remembered -Every spawn runs `./scripts/session-trust.sh` between `session create` and the -first `session send`. An agent started in a fresh worktree asks whether it may +Every spawn runs `scripts/lib/session_trust.py` in-process (dispatch calls it +directly rather than shelling out) between `session create` and the first +`session send`. An agent started in a fresh worktree asks whether it may work there, and sending the brief while that dialog is up types the brief INTO -the dialog — which is how every fleet-spawned worker used to break. The script +the dialog — which is how every fleet-spawned worker used to break. It confirms the dialog is really there before sending a key, answers with the sequence that agent needs (Claude's default selection is **`No, exit`**, so a bare Enter dismisses it), and confirms the dialog is gone. A dialog queued @@ -854,7 +855,7 @@ get --json` carries no usage field at all — do not look for one. The restart is `session restart` (kills the window, re-spawns with `--resume`, so the conversation and the brief survive) followed by dispatch's own handoff: -`session-trust.sh` first, because a re-spawned agent in a worktree can ask the +`session_trust.py` first, because a re-spawned agent in a worktree can ask the trust question again and sending into that dialog types the prompt INTO it. Every restart is recorded on the task and **capped at three** — a session that runs dry, resumes and runs dry again is a task too big for its window, and a diff --git a/AGENTS.md b/AGENTS.md index e6ac351..d94dc98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,11 +194,12 @@ a second copy — read the skill before you run any of it: guess. A queue that runs one task at a time is slower than no queue at all. 4. Each worker targets a real repo and its own git worktree — the control plane holds the plan and the log, never the workers' branches. `dispatch` gets each - new session past its agent's trust dialog before it sends the brief - (`./scripts/session-trust.sh`), because sending one into that dialog is how - every fleet-spawned worker used to break. A task may name a `--host` and run - on that machine instead, probed first and carried by ssh, so that completion - stays one model. + new session past its agent's trust dialog before it sends the brief, calling + `scripts/lib/session_trust.py` in-process (`scripts/session-trust.sh` is a + thin forwarder to the same module, kept for skills and hooks that name it), + because sending one into that dialog is how every fleet-spawned worker used + to break. A task may name a `--host` and run on that machine instead, probed + first and carried by ssh, so that completion stays one model. 5. **Completion is two things you read, never something that interrupts you.** `queue.sh watch` folds thurbox's event stream into each task's record and closes nothing; `queue.sh collect` reads the `result.md` the worker wrote, diff --git a/scripts/lib/queue.py b/scripts/lib/queue.py index 8b54c2d..5a0cf40 100644 --- a/scripts/lib/queue.py +++ b/scripts/lib/queue.py @@ -2404,7 +2404,7 @@ def host_entry(name: str) -> tuple[dict | None, str]: # THE TRUST DIALOG, decided here. `session capture`, `key` and `send` all # work against a remote session — thurbox delegates each verb to the - # thurbox-cli on the host — so `session-trust.sh` answers a remote dialog + # thurbox-cli on the host — so `session_trust.py` answers a remote dialog # exactly as it answers a local one. That delegation is switched off # wholesale by `share_sessions = false`, and then nothing can see the pane: # the worker would sit on its dialog with the brief unread, which is the @@ -4579,7 +4579,7 @@ def cmd_reap(args) -> int: REFUEL_CAP = 3 # HOW EACH AGENT SAYS IT RAN OUT, one entry per agent fleet has actually -# WATCHED do it — the same shape as `scripts/session-trust.sh`'s per-agent +# WATCHED do it — the same shape as `scripts/lib/session_trust.py`'s per-agent # table, and for the same reason: fleet drives several agents, so knowing one # of them is a fact about that agent and not a assumption about all of them. #