diff --git a/README.md b/README.md index 09760676..3c9549ef 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,19 @@ the spawn wrapper's own `--preamble`, so there is one copy of the answer). A renamed or deleted profile is reported and ignored — a delivery is never dropped over it. +That setting is box-wide. A single watch names its own worker, which beats it: + +```bash +agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" +``` + +So one repo can triage new issues cheaply and put something stronger on a red +build. The choice is stored on the subscription itself (`spawnConfig.profile`, +local-webhook 0.25.0) and shown by `agent-box-webhook ls`; `--profile ''` +clears it. + **New user or new session?** A user is the trust boundary; a session is a unit of work, and sessions of one user are *not* isolated from each other. The decision rule, the measurements behind it, and what "1 user = 1 diff --git a/bin/agentbox b/bin/agentbox index d1288469..f59e7e03 100755 --- a/bin/agentbox +++ b/bin/agentbox @@ -2666,6 +2666,14 @@ class Renderer: # the session CLI) has no flock on it. f"export AGENT_BOX_FLOCK_BIN=" f"{shlex.quote(f'{self.bin}/flock')}\n" + # webhook-spawn.sh reads the dispatch file with jq to find + # the watch's own agent profile (issue #321), and its + # --preamble caller is the settings daemon, whose PATH does + # not carry jq. Every jq use in that script is guarded, so + # an unfound binary reports the wrong worker instead of + # failing. Pinned here for the same reason the module pins + # it into webhookSpawn's own derivation. + f"export AGENT_BOX_JQ_BIN={shlex.quote(f'{self.bin}/jq')}\n" # Which agent a match starts, for --preamble's report: # the spawn calls `agent-box-session add` with no # --agent, so it is the box default. Unset, the report diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 40b5d3df..671e59e1 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -460,7 +460,11 @@ let profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane @@ -3605,7 +3609,7 @@ esac [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... [--when JSON] [--drop JSON] - [--claim SPEC]... + [--claim SPEC]... [--profile NAME] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -3710,6 +3714,22 @@ esac --claim and --include are mutually exclusive; write --include yourself only for rules --claim cannot express. + --profile NAME (subagent watches only) names the agent profile the sessions + THIS watch spawns start on — a harness, a model, an effort level, an appended + system prompt (agent-box-profile ls). It beats the box-wide + AGENT_BOX_HOOK_PROFILE, so one repo can triage new issues cheaply and put + something stronger on a red build: + + agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" + + It is stored on the subscription as spawnConfig.profile (webhook.py's + --spawn-config), so `agent-box-webhook ls` shows it. --profile ''' clears it. + A profile that does not exist when an event arrives is reported and ignored, + and the session starts on the box default — a delivery is never dropped for + a renamed profile. + --when / --drop attach payload rules to the subscription: deliver (or spawn) ONLY events matching --when, never those matching --drop. Rules are JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} @@ -4058,7 +4078,7 @@ esac ensure_state if [ "''${1:-}" != "-h" ] && [ "''${1:-}" != "--help" ]; then deliver_to=session; have_when=0; have_drop=0; topic=""; want="" - have_include=0; claims="" + have_include=0; claims=""; profile=""; have_profile=0 # Filter --claim out of the argument list as we scan it: webhook.py has # never heard of that flag, so it is translated to --include below and # must not survive into the exec. Rotate idiom — take from the front, @@ -4083,6 +4103,7 @@ esac exit 2 fi claims="$claims $a"; want=""; continue ;; + (profile) profile="$a"; have_profile=1; want=""; continue ;; esac want="" set -- "$@" "$a"; continue @@ -4100,6 +4121,13 @@ esac exit 2 fi claims="$claims ''${a#--claim=}"; continue ;; + # Filtered out like --claim, and for the same reason: webhook.py + # spells this --spawn-config profile=NAME, and the agent-box word for + # the thing is "profile" (issue #321). Empty is legal — it clears the + # profile on a re-subscribe — so, unlike --claim, only the missing + # VALUE is an error. + --profile) want=profile; continue ;; + --profile=*) profile="''${a#--profile=}"; have_profile=1; continue ;; --*) ;; *) [ -n "$topic" ] || topic="$a" ;; esac @@ -4109,6 +4137,35 @@ esac echo "agent-box-webhook: --$want needs a value" >&2 exit 2 fi + if [ "$have_profile" = 1 ]; then + # A session subscription spawns nothing, so a profile on one would be a + # setting no code path reads. webhook.py refuses it too; saying it here + # names the flag the caller actually typed. + if [ "$deliver_to" != subagent ]; then + echo "agent-box-webhook: --profile only applies to a standing watch —" \ + "it names the worker a spawned session starts on, and a session" \ + "subscription spawns nothing; add --deliver-to subagent" >&2 + exit 2 + fi + case "$profile" in + ("") set -- "$@" --no-spawn-config ;; + (*[!A-Za-z0-9_-]*) + echo "agent-box-webhook: --profile '$profile' is not a valid profile" \ + "name (A-Za-z0-9_-)" >&2 + exit 2 ;; + (*) + # A warning, never a refusal: profiles are runtime data, and + # subscribing a watch before creating its profile is a legitimate + # order to do things in. The spawn falls back to the box default + # and says so if the profile is still missing when an event lands. + if [ ! -r "$HOME/.config/agent-box/profiles/$profile.env" ]; then + echo "agent-box-webhook: no profile '$profile' on this box yet" \ + "(agent-box-profile ls) — subscribing anyway; a match starts" \ + "the box default until you create it" >&2 + fi + set -- "$@" --spawn-config "profile=$profile" ;; + esac + fi if [ -n "$claims" ]; then if [ "$have_include" = 1 ]; then echo "agent-box-webhook: pass --claim OR --include, not both —" \ @@ -5029,6 +5086,12 @@ exit "$rc" # (util-linux) is not on it, and the hook-session cap check has to hold # the registry lock through the add it decides on. export AGENT_BOX_FLOCK_BIN=${pkgs.util-linux}/bin/flock + # --preamble is run by the SETTINGS DAEMON, whose unit forces a PATH + # without jq (the receiver unit's PATH has it, but that is the other + # caller). Every jq use in this script is guarded, so an unfound binary + # would report the wrong worker rather than fail — pin it like the two + # above instead. + export AGENT_BOX_JQ_BIN=${pkgs.jq}/bin/jq # Which agent a match really starts. The spawn calls `agent-box-session # add` with no --agent, so it is the box default — and --preamble has to # NAME it, because that is the half of "what does this watch launch" the @@ -5061,10 +5124,18 @@ exit "$rc" export AGENT_BOX_HOOK_SESSION_ARGS=${lib.escapeShellArg (builtins.toJSON cfg.webhook.hookSessionArgs)} '' + '' set -eu -# jq/coreutils/agent-box-session resolve from the webhook daemon unit's -# PATH (issue #154, Phase 2) — this script only ever runs as that unit's +# coreutils/agent-box-session resolve from the webhook daemon unit's PATH +# (issue #154, Phase 2) — that unit is what runs this script as its # LOCAL_WEBHOOK_SPAWN_CMD child. -JQ=jq +# +# jq is pinned instead, the AGENT_BOX_*_BIN way the flock and env-store +# dependencies already are, because --preamble has a SECOND caller with a +# different PATH: the settings daemon, whose unit forces a PATH of the daemon +# itself plus coreutils/findutils/gnugrep/gnused/systemd. A bare `jq` there is +# not found, and every use of it in this script is guarded (`|| …`, or an +# `if`), so the failure would be silent — the watch panel would quietly report +# the wrong worker and the wrong arguments. +JQ="''${AGENT_BOX_JQ_BIN:-jq}" # The session registry — where it lives, how it is locked and how it is # rewritten — is one file every shell writer splices in (issue #254). This one # only LOCKS: the write is done by the `agent-box-session add` it execs into, @@ -5451,16 +5522,16 @@ fi # could name a profile that does not exist on the box. An unusable value is # reported and IGNORED below rather than failing the spawn — a webhook # delivery must never be dropped because a profile was renamed. -hook_profile="''${AGENT_BOX_HOOK_PROFILE:-}" -hook_profile_source="" +box_profile="''${AGENT_BOX_HOOK_PROFILE:-}" +box_profile_source="" # Read with the env store's own parser (issue #212), never a fifth copy of the # KEY=value loop: the file may hold a multi-line value now, and only the one # parser knows where such an entry ends. Only these two keys are read, so # nothing else in the file reaches this process. if [ -r "$hook_args_file" ]; then if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_PROFILE); then - hook_profile=$val - hook_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" + box_profile=$val + box_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" fi if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_SESSION_ARGS); then AGENT_BOX_HOOK_SESSION_ARGS=$val @@ -5470,28 +5541,89 @@ fi # A value that did not come from the env file was exported into this process # (a hand-run script, or a unit environment): say so rather than reporting a # source of "". -[ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ - || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" -if [ -n "$hook_profile" ]; then - # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 - # on an unknown profile: that exit would drop the batch, and the events do - # not come back. Name charset first (it reaches a file path), then the file. - case "$hook_profile" in - (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: AGENT_BOX_HOOK_PROFILE '$hook_profile' is not a" \ - "valid profile name; starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" - hook_profile="" - ;; - (*) - if [ ! -r "$HOME/.config/agent-box/profiles/$hook_profile.env" ]; then - echo "agent-box-webhook-spawn: no such profile '$hook_profile'" \ - "(agent-box-profile ls); starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, no such profile" - hook_profile="" - fi - ;; +[ -z "$box_profile" ] || [ -n "$box_profile_source" ] \ + || box_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" + +# The WATCH's own answer, which beats both box-wide settings above — the last +# step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every +# dispatched session on the box; a watch carrying spawnConfig.profile is naming +# the worker for its own events only, and the narrower answer is the one whoever +# wrote it meant. That is what lets cheap triage handle new issues while a red +# build starts something that can actually fix it. +# +# It arrives in LOCAL_WEBHOOK_SPAWN_CONFIG (local-channels 0.25.0), the one +# variable the dispatcher fills from the matched subscription rather than from +# the event. Older receivers set nothing, and an entry with no config sets `{}`, +# so both read as "no watch-level answer" and the box-wide setting stands. +# +# --preamble has no delivery and so no variable: the settings page asks what a +# named TOPIC would start, so that mode reads the watch straight out of the +# dispatch file. Same precedence either way, because the page must not advertise +# a worker the spawn would not use. +watch_profile="" +watch_config="" +if [ -n "''${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then + watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" +elif [ "''${1:-}" = "--preamble" ] && [ -n "''${2:-}" ] && [ -n "''${LOCAL_WEBHOOK_STATE_DIR:-}" ]; then + # Best effort, like every other read here: no file, bad JSON or no such topic + # all mean "this watch names no profile", never a failed render. + watch_config=$("$JQ" -r --arg t "$2" \ + '[(.topics // [])[] | select(type == "object" and (.topic // "") == $t)][0] + | (if type == "object" then (.spawnConfig // {}) else {} end) | tojson' \ + "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" +fi +if [ -n "$watch_config" ]; then + # `.profile` must be a STRING. webhook.py drops a non-string value when it + # reads the filter file, so a delivery never carries one — but --preamble + # reads that file directly, and the file is documented as hand-editable, so + # `{"profile": 5}` would otherwise render as the profile "5". + watch_profile=$("$JQ" -r \ + 'if type == "object" and (.profile | type) == "string" then .profile else empty end' \ + <<<"$watch_config" 2>/dev/null) || watch_profile="" +fi + +# ''' when NAME can be launched, else why not. Checked HERE, not left to +# `agent-box-session add --profile`, which exits 2 on an unknown profile: that +# exit would drop the batch, and the events do not come back. Charset first, +# because the name reaches a file path. +profile_problem() { + case "$1" in + (*[!A-Za-z0-9_-]*) printf 'not a valid profile name'; return 0 ;; esac + [ -r "$HOME/.config/agent-box/profiles/$1.env" ] || printf 'no such profile' +} + +# Most specific first, and each candidate is validated BEFORE it is allowed to +# displace the next one. An unusable watch profile must not cost the box-wide +# one: that would make naming a profile on one watch quietly downgrade every +# event it matches from the operator's chosen worker to the raw box default, +# for a typo in a field the box-wide setting has nothing to do with. +hook_profile="" +hook_profile_source="" +hook_profile_ignored="" +try_profile() { + [ -n "$1" ] || return 0 + [ -z "$hook_profile" ] || return 0 + why=$(profile_problem "$1") + if [ -z "$why" ]; then + hook_profile="$1" + hook_profile_source="$2" + return 0 + fi + echo "agent-box-webhook-spawn: ignoring profile '$1' from $2 — $why" >&2 + hook_profile_ignored="''${hook_profile_ignored:+$hook_profile_ignored; }$2 — IGNORED, $why" +} + +try_profile "$watch_profile" "this watch's own spawnConfig.profile" +try_profile "$box_profile" "$box_profile_source" +if [ -n "$hook_profile" ]; then + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on profile" \ + "'$hook_profile' ($hook_profile_source) instead" >&2 +else + hook_profile_source="$hook_profile_ignored" + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on the box default" >&2 fi extra=() @@ -5556,7 +5688,8 @@ environment come from that profile ($hook_profile_source) — read it back with printf '%s\n' "No agent profile is set, so a match starts the box default \ harness. Pick a worker for every LATER hook session with: agent-box-profile \ set triage HARNESS=claude MODEL=sonnet EFFORT=low && agent-box-session env set \ -AGENT_BOX_HOOK_PROFILE triage" +AGENT_BOX_HOOK_PROFILE triage — or for THIS watch alone, re-subscribe it with \ +'agent-box-webhook subscribe TOPIC --deliver-to subagent --profile triage'." fi if [ -n "$hook_args_source" ]; then printf 'The arguments after the agent come from %s.\n' "$hook_args_source" @@ -6048,8 +6181,15 @@ fi # For every entry whose topic is declared: the declaration REPLACES the # managed fields (the payload predicates, ignoreSenders, note) wholesale — # partial merges would let config and state drift apart. Runtime fields -# (ttlHours, renewOnEvent, timestamps) stay the entry's own. Bare-string -# topics normalize to the object form webhook.py itself writes. +# (ttlHours, renewOnEvent, spawnConfig, timestamps) stay the entry's own. +# Bare-string topics normalize to the object form webhook.py itself writes. +# +# spawnConfig is on that list deliberately (issue #321): it names an agent +# PROFILE, and a profile is runtime data a user creates with +# agent-box-profile — a declared value could name one that does not exist on +# the box, which is the same reason AGENT_BOX_HOOK_PROFILE has no NixOS option +# beside it. So `agent-box-webhook subscribe --profile` survives a receiver +# restart on a governed watch, rather than being reverted by a rebuild. # # local-webhook 0.19.0 renamed the two predicates when -> include and # drop -> exclude (local-channels#294). It still ACCEPTS the old names on @@ -9042,9 +9182,22 @@ in the marketplace itself and tracks its default branch — this pin only governs the copy the box runs. - 0.23.0 (this pin, issue #380) retired the built-in failures-only CI + 0.25.0 (issue #321) lets a dispatch entry carry + `spawnConfig`, a flat map of strings the receiver hands to the spawn + command in `LOCAL_WEBHOOK_SPAWN_CONFIG`. Every other + `LOCAL_WEBHOOK_SPAWN_*` variable describes the EVENT, so two watches + on one repo used to be indistinguishable to webhook-spawn.sh; this + one describes the WATCH, which is what `agent-box-webhook subscribe + --profile NAME` writes and what lets a watch name the agent profile + its own sessions start on. AGENT_BOX_HOOK_PROFILE stays the box-wide + fallback. The same release queues dispatch batches per (key, + spawnConfig) rather than per key, so two watches on one repo cannot + coalesce into a single spawn that would hand one watch's events to + the other's worker. + + 0.23.0 (issue #380) retired the built-in failures-only CI brake for rule-less dispatch entries: a `--deliver-to subagent` - watch now MUST carry its own `when`/`drop` rules or webhook_subscribe + watch MUST carry its own `when`/`drop` rules or webhook_subscribe refuses to create it. `ignoreSenders` also became a pure sender mute with no CI-outcome carve-out. webhook-cli.sh (see subscribeCmd below) fills in a default `when` for a rule-less GitHub subagent @@ -11630,7 +11783,8 @@ def webhook_unsubscribe(key, topic, dispatch): def hook_args_stamp(): - """(mtime_ns, size) of the env file and of the profiles directory. + """(mtime_ns, size) of the env file, the profiles directory and the + dispatch filter file. Part of hook_preamble's cache key. The dispatch script reports the hook-session arguments that file can override (#292), so its output is @@ -11644,9 +11798,14 @@ def hook_args_stamp(): profile still exists decides whether the watch uses it at all. Creating or removing one does not touch the env file, and every write goes through a rename inside this directory, so its mtime moves on both. + + The dispatch filter file joins them because a watch can now name its own + profile (spawnConfig.profile, issue #321): the report reads that file, so + re-subscribing a watch with a different --profile has to re-render. Every + write to it is an atomic rename, so the mtime moves. """ stamps = [] - for path in (ENV_FILE, PROFILES_DIR): + for path in (ENV_FILE, PROFILES_DIR, os.path.join(webhook_state_dir(), "filter.dispatch.json")): try: info = os.stat(path) except OSError: @@ -11678,6 +11837,10 @@ def hook_preamble(topic, note, stamp): try: proc = subprocess.run( [HOOK_SPAWN_CMD, "--preamble", topic, note], + # The script reads the dispatch file to find THIS watch's own + # profile (#321); under socket activation nothing else puts the + # state dir in this daemon's environment. + env=dict(os.environ, LOCAL_WEBHOOK_STATE_DIR=webhook_state_dir()), check=False, capture_output=True, text=True, diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 790098c7..76307342 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -753,6 +753,12 @@ let # (util-linux) is not on it, and the hook-session cap check has to hold # the registry lock through the add it decides on. export AGENT_BOX_FLOCK_BIN=${pkgs.util-linux}/bin/flock + # --preamble is run by the SETTINGS DAEMON, whose unit forces a PATH + # without jq (the receiver unit's PATH has it, but that is the other + # caller). Every jq use in this script is guarded, so an unfound binary + # would report the wrong worker rather than fail — pin it like the two + # above instead. + export AGENT_BOX_JQ_BIN=${pkgs.jq}/bin/jq # Which agent a match really starts. The spawn calls `agent-box-session # add` with no --agent, so it is the box default — and --preamble has to # NAME it, because that is the half of "what does this watch launch" the @@ -1940,9 +1946,22 @@ in the marketplace itself and tracks its default branch — this pin only governs the copy the box runs. - 0.23.0 (this pin, issue #380) retired the built-in failures-only CI + 0.25.0 (issue #321) lets a dispatch entry carry + `spawnConfig`, a flat map of strings the receiver hands to the spawn + command in `LOCAL_WEBHOOK_SPAWN_CONFIG`. Every other + `LOCAL_WEBHOOK_SPAWN_*` variable describes the EVENT, so two watches + on one repo used to be indistinguishable to webhook-spawn.sh; this + one describes the WATCH, which is what `agent-box-webhook subscribe + --profile NAME` writes and what lets a watch name the agent profile + its own sessions start on. AGENT_BOX_HOOK_PROFILE stays the box-wide + fallback. The same release queues dispatch batches per (key, + spawnConfig) rather than per key, so two watches on one repo cannot + coalesce into a single spawn that would hand one watch's events to + the other's worker. + + 0.23.0 (issue #380) retired the built-in failures-only CI brake for rule-less dispatch entries: a `--deliver-to subagent` - watch now MUST carry its own `when`/`drop` rules or webhook_subscribe + watch MUST carry its own `when`/`drop` rules or webhook_subscribe refuses to create it. `ignoreSenders` also became a pure sender mute with no CI-outcome carve-out. webhook-cli.sh (see subscribeCmd below) fills in a default `when` for a rule-less GitHub subagent diff --git a/modules/src/default-agents.md b/modules/src/default-agents.md index cd956435..3c0c72b9 100644 --- a/modules/src/default-agents.md +++ b/modules/src/default-agents.md @@ -108,7 +108,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/modules/src/settings-daemon.py b/modules/src/settings-daemon.py index 7f5f5ea2..a1934fdd 100644 --- a/modules/src/settings-daemon.py +++ b/modules/src/settings-daemon.py @@ -1325,7 +1325,8 @@ def webhook_unsubscribe(key, topic, dispatch): def hook_args_stamp(): - """(mtime_ns, size) of the env file and of the profiles directory. + """(mtime_ns, size) of the env file, the profiles directory and the + dispatch filter file. Part of hook_preamble's cache key. The dispatch script reports the hook-session arguments that file can override (#292), so its output is @@ -1339,9 +1340,14 @@ def hook_args_stamp(): profile still exists decides whether the watch uses it at all. Creating or removing one does not touch the env file, and every write goes through a rename inside this directory, so its mtime moves on both. + + The dispatch filter file joins them because a watch can now name its own + profile (spawnConfig.profile, issue #321): the report reads that file, so + re-subscribing a watch with a different --profile has to re-render. Every + write to it is an atomic rename, so the mtime moves. """ stamps = [] - for path in (ENV_FILE, PROFILES_DIR): + for path in (ENV_FILE, PROFILES_DIR, os.path.join(webhook_state_dir(), "filter.dispatch.json")): try: info = os.stat(path) except OSError: @@ -1373,6 +1379,10 @@ def hook_preamble(topic, note, stamp): try: proc = subprocess.run( [HOOK_SPAWN_CMD, "--preamble", topic, note], + # The script reads the dispatch file to find THIS watch's own + # profile (#321); under socket activation nothing else puts the + # state dir in this daemon's environment. + env=dict(os.environ, LOCAL_WEBHOOK_STATE_DIR=webhook_state_dir()), check=False, capture_output=True, text=True, diff --git a/modules/src/webhook-cli.sh b/modules/src/webhook-cli.sh index a0ab6f97..2c13d16f 100644 --- a/modules/src/webhook-cli.sh +++ b/modules/src/webhook-cli.sh @@ -24,7 +24,7 @@ usage: agent-box-webhook subscribe TOPIC [--note TEXT] [--ttl HOURS] [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... [--when JSON] [--drop JSON] - [--claim SPEC]... + [--claim SPEC]... [--profile NAME] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -129,6 +129,22 @@ reports through commit statuses, you stay exposed on that one shape. --claim and --include are mutually exclusive; write --include yourself only for rules --claim cannot express. +--profile NAME (subagent watches only) names the agent profile the sessions +THIS watch spawns start on — a harness, a model, an effort level, an appended +system prompt (agent-box-profile ls). It beats the box-wide +AGENT_BOX_HOOK_PROFILE, so one repo can triage new issues cheaply and put +something stronger on a red build: + + agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" + +It is stored on the subscription as spawnConfig.profile (webhook.py's +--spawn-config), so `agent-box-webhook ls` shows it. --profile '' clears it. +A profile that does not exist when an event arrives is reported and ignored, +and the session starts on the box default — a delivery is never dropped for +a renamed profile. + --when / --drop attach payload rules to the subscription: deliver (or spawn) ONLY events matching --when, never those matching --drop. Rules are JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} @@ -477,7 +493,7 @@ case "$cmd" in ensure_state if [ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ]; then deliver_to=session; have_when=0; have_drop=0; topic=""; want="" - have_include=0; claims="" + have_include=0; claims=""; profile=""; have_profile=0 # Filter --claim out of the argument list as we scan it: webhook.py has # never heard of that flag, so it is translated to --include below and # must not survive into the exec. Rotate idiom — take from the front, @@ -502,6 +518,7 @@ case "$cmd" in exit 2 fi claims="$claims $a"; want=""; continue ;; + (profile) profile="$a"; have_profile=1; want=""; continue ;; esac want="" set -- "$@" "$a"; continue @@ -519,6 +536,13 @@ case "$cmd" in exit 2 fi claims="$claims ${a#--claim=}"; continue ;; + # Filtered out like --claim, and for the same reason: webhook.py + # spells this --spawn-config profile=NAME, and the agent-box word for + # the thing is "profile" (issue #321). Empty is legal — it clears the + # profile on a re-subscribe — so, unlike --claim, only the missing + # VALUE is an error. + --profile) want=profile; continue ;; + --profile=*) profile="${a#--profile=}"; have_profile=1; continue ;; --*) ;; *) [ -n "$topic" ] || topic="$a" ;; esac @@ -528,6 +552,35 @@ case "$cmd" in echo "agent-box-webhook: --$want needs a value" >&2 exit 2 fi + if [ "$have_profile" = 1 ]; then + # A session subscription spawns nothing, so a profile on one would be a + # setting no code path reads. webhook.py refuses it too; saying it here + # names the flag the caller actually typed. + if [ "$deliver_to" != subagent ]; then + echo "agent-box-webhook: --profile only applies to a standing watch —" \ + "it names the worker a spawned session starts on, and a session" \ + "subscription spawns nothing; add --deliver-to subagent" >&2 + exit 2 + fi + case "$profile" in + ("") set -- "$@" --no-spawn-config ;; + (*[!A-Za-z0-9_-]*) + echo "agent-box-webhook: --profile '$profile' is not a valid profile" \ + "name (A-Za-z0-9_-)" >&2 + exit 2 ;; + (*) + # A warning, never a refusal: profiles are runtime data, and + # subscribing a watch before creating its profile is a legitimate + # order to do things in. The spawn falls back to the box default + # and says so if the profile is still missing when an event lands. + if [ ! -r "$HOME/.config/agent-box/profiles/$profile.env" ]; then + echo "agent-box-webhook: no profile '$profile' on this box yet" \ + "(agent-box-profile ls) — subscribing anyway; a match starts" \ + "the box default until you create it" >&2 + fi + set -- "$@" --spawn-config "profile=$profile" ;; + esac + fi if [ -n "$claims" ]; then if [ "$have_include" = 1 ]; then echo "agent-box-webhook: pass --claim OR --include, not both —" \ diff --git a/modules/src/webhook-policy-apply.sh b/modules/src/webhook-policy-apply.sh index 65867d97..e924d5c2 100644 --- a/modules/src/webhook-policy-apply.sh +++ b/modules/src/webhook-policy-apply.sh @@ -15,8 +15,15 @@ fi # For every entry whose topic is declared: the declaration REPLACES the # managed fields (the payload predicates, ignoreSenders, note) wholesale — # partial merges would let config and state drift apart. Runtime fields -# (ttlHours, renewOnEvent, timestamps) stay the entry's own. Bare-string -# topics normalize to the object form webhook.py itself writes. +# (ttlHours, renewOnEvent, spawnConfig, timestamps) stay the entry's own. +# Bare-string topics normalize to the object form webhook.py itself writes. +# +# spawnConfig is on that list deliberately (issue #321): it names an agent +# PROFILE, and a profile is runtime data a user creates with +# agent-box-profile — a declared value could name one that does not exist on +# the box, which is the same reason AGENT_BOX_HOOK_PROFILE has no NixOS option +# beside it. So `agent-box-webhook subscribe --profile` survives a receiver +# restart on a governed watch, rather than being reverted by a rebuild. # # local-webhook 0.19.0 renamed the two predicates when -> include and # drop -> exclude (local-channels#294). It still ACCEPTS the old names on diff --git a/modules/src/webhook-spawn.sh b/modules/src/webhook-spawn.sh index 9aa8b4cc..06824df6 100644 --- a/modules/src/webhook-spawn.sh +++ b/modules/src/webhook-spawn.sh @@ -1,8 +1,16 @@ set -eu -# jq/coreutils/agent-box-session resolve from the webhook daemon unit's -# PATH (issue #154, Phase 2) — this script only ever runs as that unit's +# coreutils/agent-box-session resolve from the webhook daemon unit's PATH +# (issue #154, Phase 2) — that unit is what runs this script as its # LOCAL_WEBHOOK_SPAWN_CMD child. -JQ=jq +# +# jq is pinned instead, the AGENT_BOX_*_BIN way the flock and env-store +# dependencies already are, because --preamble has a SECOND caller with a +# different PATH: the settings daemon, whose unit forces a PATH of the daemon +# itself plus coreutils/findutils/gnugrep/gnused/systemd. A bare `jq` there is +# not found, and every use of it in this script is guarded (`|| …`, or an +# `if`), so the failure would be silent — the watch panel would quietly report +# the wrong worker and the wrong arguments. +JQ="${AGENT_BOX_JQ_BIN:-jq}" # The session registry — where it lives, how it is locked and how it is # rewritten — is one file every shell writer splices in (issue #254). This one # only LOCKS: the write is done by the `agent-box-session add` it execs into, @@ -160,16 +168,16 @@ fi # could name a profile that does not exist on the box. An unusable value is # reported and IGNORED below rather than failing the spawn — a webhook # delivery must never be dropped because a profile was renamed. -hook_profile="${AGENT_BOX_HOOK_PROFILE:-}" -hook_profile_source="" +box_profile="${AGENT_BOX_HOOK_PROFILE:-}" +box_profile_source="" # Read with the env store's own parser (issue #212), never a fifth copy of the # KEY=value loop: the file may hold a multi-line value now, and only the one # parser knows where such an entry ends. Only these two keys are read, so # nothing else in the file reaches this process. if [ -r "$hook_args_file" ]; then if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_PROFILE); then - hook_profile=$val - hook_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" + box_profile=$val + box_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" fi if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_SESSION_ARGS); then AGENT_BOX_HOOK_SESSION_ARGS=$val @@ -179,28 +187,89 @@ fi # A value that did not come from the env file was exported into this process # (a hand-run script, or a unit environment): say so rather than reporting a # source of "". -[ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ - || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" -if [ -n "$hook_profile" ]; then - # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 - # on an unknown profile: that exit would drop the batch, and the events do - # not come back. Name charset first (it reaches a file path), then the file. - case "$hook_profile" in - (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: AGENT_BOX_HOOK_PROFILE '$hook_profile' is not a" \ - "valid profile name; starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" - hook_profile="" - ;; - (*) - if [ ! -r "$HOME/.config/agent-box/profiles/$hook_profile.env" ]; then - echo "agent-box-webhook-spawn: no such profile '$hook_profile'" \ - "(agent-box-profile ls); starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, no such profile" - hook_profile="" - fi - ;; +[ -z "$box_profile" ] || [ -n "$box_profile_source" ] \ + || box_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" + +# The WATCH's own answer, which beats both box-wide settings above — the last +# step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every +# dispatched session on the box; a watch carrying spawnConfig.profile is naming +# the worker for its own events only, and the narrower answer is the one whoever +# wrote it meant. That is what lets cheap triage handle new issues while a red +# build starts something that can actually fix it. +# +# It arrives in LOCAL_WEBHOOK_SPAWN_CONFIG (local-channels 0.25.0), the one +# variable the dispatcher fills from the matched subscription rather than from +# the event. Older receivers set nothing, and an entry with no config sets `{}`, +# so both read as "no watch-level answer" and the box-wide setting stands. +# +# --preamble has no delivery and so no variable: the settings page asks what a +# named TOPIC would start, so that mode reads the watch straight out of the +# dispatch file. Same precedence either way, because the page must not advertise +# a worker the spawn would not use. +watch_profile="" +watch_config="" +if [ -n "${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then + watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" +elif [ "${1:-}" = "--preamble" ] && [ -n "${2:-}" ] && [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ]; then + # Best effort, like every other read here: no file, bad JSON or no such topic + # all mean "this watch names no profile", never a failed render. + watch_config=$("$JQ" -r --arg t "$2" \ + '[(.topics // [])[] | select(type == "object" and (.topic // "") == $t)][0] + | (if type == "object" then (.spawnConfig // {}) else {} end) | tojson' \ + "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" +fi +if [ -n "$watch_config" ]; then + # `.profile` must be a STRING. webhook.py drops a non-string value when it + # reads the filter file, so a delivery never carries one — but --preamble + # reads that file directly, and the file is documented as hand-editable, so + # `{"profile": 5}` would otherwise render as the profile "5". + watch_profile=$("$JQ" -r \ + 'if type == "object" and (.profile | type) == "string" then .profile else empty end' \ + <<<"$watch_config" 2>/dev/null) || watch_profile="" +fi + +# '' when NAME can be launched, else why not. Checked HERE, not left to +# `agent-box-session add --profile`, which exits 2 on an unknown profile: that +# exit would drop the batch, and the events do not come back. Charset first, +# because the name reaches a file path. +profile_problem() { + case "$1" in + (*[!A-Za-z0-9_-]*) printf 'not a valid profile name'; return 0 ;; esac + [ -r "$HOME/.config/agent-box/profiles/$1.env" ] || printf 'no such profile' +} + +# Most specific first, and each candidate is validated BEFORE it is allowed to +# displace the next one. An unusable watch profile must not cost the box-wide +# one: that would make naming a profile on one watch quietly downgrade every +# event it matches from the operator's chosen worker to the raw box default, +# for a typo in a field the box-wide setting has nothing to do with. +hook_profile="" +hook_profile_source="" +hook_profile_ignored="" +try_profile() { + [ -n "$1" ] || return 0 + [ -z "$hook_profile" ] || return 0 + why=$(profile_problem "$1") + if [ -z "$why" ]; then + hook_profile="$1" + hook_profile_source="$2" + return 0 + fi + echo "agent-box-webhook-spawn: ignoring profile '$1' from $2 — $why" >&2 + hook_profile_ignored="${hook_profile_ignored:+$hook_profile_ignored; }$2 — IGNORED, $why" +} + +try_profile "$watch_profile" "this watch's own spawnConfig.profile" +try_profile "$box_profile" "$box_profile_source" +if [ -n "$hook_profile" ]; then + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on profile" \ + "'$hook_profile' ($hook_profile_source) instead" >&2 +else + hook_profile_source="$hook_profile_ignored" + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on the box default" >&2 fi extra=() @@ -265,7 +334,8 @@ environment come from that profile ($hook_profile_source) — read it back with printf '%s\n' "No agent profile is set, so a match starts the box default \ harness. Pick a worker for every LATER hook session with: agent-box-profile \ set triage HARNESS=claude MODEL=sonnet EFFORT=low && agent-box-session env set \ -AGENT_BOX_HOOK_PROFILE triage" +AGENT_BOX_HOOK_PROFILE triage — or for THIS watch alone, re-subscribe it with \ +'agent-box-webhook subscribe TOPIC --deliver-to subagent --profile triage'." fi if [ -n "$hook_args_source" ]; then printf 'The arguments after the agent come from %s.\n' "$hook_args_source" diff --git a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md index 1a414a22..77cbe507 100644 --- a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md @@ -123,7 +123,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md index c5c795b0..ddadacd9 100644 --- a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md @@ -123,7 +123,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings b/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings index 71e00bd9..3381e181 100644 --- a/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings +++ b/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings @@ -1657,7 +1657,8 @@ def webhook_unsubscribe(key, topic, dispatch): def hook_args_stamp(): - """(mtime_ns, size) of the env file and of the profiles directory. + """(mtime_ns, size) of the env file, the profiles directory and the + dispatch filter file. Part of hook_preamble's cache key. The dispatch script reports the hook-session arguments that file can override (#292), so its output is @@ -1671,9 +1672,14 @@ def hook_args_stamp(): profile still exists decides whether the watch uses it at all. Creating or removing one does not touch the env file, and every write goes through a rename inside this directory, so its mtime moves on both. + + The dispatch filter file joins them because a watch can now name its own + profile (spawnConfig.profile, issue #321): the report reads that file, so + re-subscribing a watch with a different --profile has to re-render. Every + write to it is an atomic rename, so the mtime moves. """ stamps = [] - for path in (ENV_FILE, PROFILES_DIR): + for path in (ENV_FILE, PROFILES_DIR, os.path.join(webhook_state_dir(), "filter.dispatch.json")): try: info = os.stat(path) except OSError: @@ -1705,6 +1711,10 @@ def hook_preamble(topic, note, stamp): try: proc = subprocess.run( [HOOK_SPAWN_CMD, "--preamble", topic, note], + # The script reads the dispatch file to find THIS watch's own + # profile (#321); under socket activation nothing else puts the + # state dir in this daemon's environment. + env=dict(os.environ, LOCAL_WEBHOOK_STATE_DIR=webhook_state_dir()), check=False, capture_output=True, text=True, diff --git a/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply b/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply index 05370e7a..8b4a178e 100644 --- a/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply +++ b/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply @@ -17,8 +17,15 @@ fi # For every entry whose topic is declared: the declaration REPLACES the # managed fields (the payload predicates, ignoreSenders, note) wholesale — # partial merges would let config and state drift apart. Runtime fields -# (ttlHours, renewOnEvent, timestamps) stay the entry's own. Bare-string -# topics normalize to the object form webhook.py itself writes. +# (ttlHours, renewOnEvent, spawnConfig, timestamps) stay the entry's own. +# Bare-string topics normalize to the object form webhook.py itself writes. +# +# spawnConfig is on that list deliberately (issue #321): it names an agent +# PROFILE, and a profile is runtime data a user creates with +# agent-box-profile — a declared value could name one that does not exist on +# the box, which is the same reason AGENT_BOX_HOOK_PROFILE has no NixOS option +# beside it. So `agent-box-webhook subscribe --profile` survives a receiver +# restart on a governed watch, rather than being reverted by a rebuild. # # local-webhook 0.19.0 renamed the two predicates when -> include and # drop -> exclude (local-channels#294). It still ACCEPTS the old names on diff --git a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn index e3cce8cb..2b3fd0c4 100644 --- a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn +++ b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn @@ -4,6 +4,12 @@ # (util-linux) is not on it, and the hook-session cap check has to hold # the registry lock through the add it decides on. export AGENT_BOX_FLOCK_BIN=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-util-linux-2.42.2-bin/bin/flock +# --preamble is run by the SETTINGS DAEMON, whose unit forces a PATH +# without jq (the receiver unit's PATH has it, but that is the other +# caller). Every jq use in this script is guarded, so an unfound binary +# would report the wrong worker rather than fail — pin it like the two +# above instead. +export AGENT_BOX_JQ_BIN=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-jq-1.8.2-bin/bin/jq # Which agent a match really starts. The spawn calls `agent-box-session # add` with no --agent, so it is the box default — and --preamble has to # NAME it, because that is the half of "what does this watch launch" the @@ -21,10 +27,18 @@ export AGENT_BOX_ENVSTORE_BIN=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-agent- # currently set to anything. export AGENT_BOX_HOOK_ARGS_OPTION_NAME='the NixOS option services.agent-box.webhook.hookSessionArgs' set -eu -# jq/coreutils/agent-box-session resolve from the webhook daemon unit's -# PATH (issue #154, Phase 2) — this script only ever runs as that unit's +# coreutils/agent-box-session resolve from the webhook daemon unit's PATH +# (issue #154, Phase 2) — that unit is what runs this script as its # LOCAL_WEBHOOK_SPAWN_CMD child. -JQ=jq +# +# jq is pinned instead, the AGENT_BOX_*_BIN way the flock and env-store +# dependencies already are, because --preamble has a SECOND caller with a +# different PATH: the settings daemon, whose unit forces a PATH of the daemon +# itself plus coreutils/findutils/gnugrep/gnused/systemd. A bare `jq` there is +# not found, and every use of it in this script is guarded (`|| …`, or an +# `if`), so the failure would be silent — the watch panel would quietly report +# the wrong worker and the wrong arguments. +JQ="${AGENT_BOX_JQ_BIN:-jq}" # The session registry — where it lives, how it is locked and how it is # rewritten — is one file every shell writer splices in (issue #254). This one # only LOCKS: the write is done by the `agent-box-session add` it execs into, @@ -411,16 +425,16 @@ fi # could name a profile that does not exist on the box. An unusable value is # reported and IGNORED below rather than failing the spawn — a webhook # delivery must never be dropped because a profile was renamed. -hook_profile="${AGENT_BOX_HOOK_PROFILE:-}" -hook_profile_source="" +box_profile="${AGENT_BOX_HOOK_PROFILE:-}" +box_profile_source="" # Read with the env store's own parser (issue #212), never a fifth copy of the # KEY=value loop: the file may hold a multi-line value now, and only the one # parser knows where such an entry ends. Only these two keys are read, so # nothing else in the file reaches this process. if [ -r "$hook_args_file" ]; then if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_PROFILE); then - hook_profile=$val - hook_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" + box_profile=$val + box_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" fi if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_SESSION_ARGS); then AGENT_BOX_HOOK_SESSION_ARGS=$val @@ -430,28 +444,89 @@ fi # A value that did not come from the env file was exported into this process # (a hand-run script, or a unit environment): say so rather than reporting a # source of "". -[ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ - || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" -if [ -n "$hook_profile" ]; then - # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 - # on an unknown profile: that exit would drop the batch, and the events do - # not come back. Name charset first (it reaches a file path), then the file. - case "$hook_profile" in - (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: AGENT_BOX_HOOK_PROFILE '$hook_profile' is not a" \ - "valid profile name; starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" - hook_profile="" - ;; - (*) - if [ ! -r "$HOME/.config/agent-box/profiles/$hook_profile.env" ]; then - echo "agent-box-webhook-spawn: no such profile '$hook_profile'" \ - "(agent-box-profile ls); starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, no such profile" - hook_profile="" - fi - ;; +[ -z "$box_profile" ] || [ -n "$box_profile_source" ] \ + || box_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" + +# The WATCH's own answer, which beats both box-wide settings above — the last +# step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every +# dispatched session on the box; a watch carrying spawnConfig.profile is naming +# the worker for its own events only, and the narrower answer is the one whoever +# wrote it meant. That is what lets cheap triage handle new issues while a red +# build starts something that can actually fix it. +# +# It arrives in LOCAL_WEBHOOK_SPAWN_CONFIG (local-channels 0.25.0), the one +# variable the dispatcher fills from the matched subscription rather than from +# the event. Older receivers set nothing, and an entry with no config sets `{}`, +# so both read as "no watch-level answer" and the box-wide setting stands. +# +# --preamble has no delivery and so no variable: the settings page asks what a +# named TOPIC would start, so that mode reads the watch straight out of the +# dispatch file. Same precedence either way, because the page must not advertise +# a worker the spawn would not use. +watch_profile="" +watch_config="" +if [ -n "${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then + watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" +elif [ "${1:-}" = "--preamble" ] && [ -n "${2:-}" ] && [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ]; then + # Best effort, like every other read here: no file, bad JSON or no such topic + # all mean "this watch names no profile", never a failed render. + watch_config=$("$JQ" -r --arg t "$2" \ + '[(.topics // [])[] | select(type == "object" and (.topic // "") == $t)][0] + | (if type == "object" then (.spawnConfig // {}) else {} end) | tojson' \ + "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" +fi +if [ -n "$watch_config" ]; then + # `.profile` must be a STRING. webhook.py drops a non-string value when it + # reads the filter file, so a delivery never carries one — but --preamble + # reads that file directly, and the file is documented as hand-editable, so + # `{"profile": 5}` would otherwise render as the profile "5". + watch_profile=$("$JQ" -r \ + 'if type == "object" and (.profile | type) == "string" then .profile else empty end' \ + <<<"$watch_config" 2>/dev/null) || watch_profile="" +fi + +# '' when NAME can be launched, else why not. Checked HERE, not left to +# `agent-box-session add --profile`, which exits 2 on an unknown profile: that +# exit would drop the batch, and the events do not come back. Charset first, +# because the name reaches a file path. +profile_problem() { + case "$1" in + (*[!A-Za-z0-9_-]*) printf 'not a valid profile name'; return 0 ;; esac + [ -r "$HOME/.config/agent-box/profiles/$1.env" ] || printf 'no such profile' +} + +# Most specific first, and each candidate is validated BEFORE it is allowed to +# displace the next one. An unusable watch profile must not cost the box-wide +# one: that would make naming a profile on one watch quietly downgrade every +# event it matches from the operator's chosen worker to the raw box default, +# for a typo in a field the box-wide setting has nothing to do with. +hook_profile="" +hook_profile_source="" +hook_profile_ignored="" +try_profile() { + [ -n "$1" ] || return 0 + [ -z "$hook_profile" ] || return 0 + why=$(profile_problem "$1") + if [ -z "$why" ]; then + hook_profile="$1" + hook_profile_source="$2" + return 0 + fi + echo "agent-box-webhook-spawn: ignoring profile '$1' from $2 — $why" >&2 + hook_profile_ignored="${hook_profile_ignored:+$hook_profile_ignored; }$2 — IGNORED, $why" +} + +try_profile "$watch_profile" "this watch's own spawnConfig.profile" +try_profile "$box_profile" "$box_profile_source" +if [ -n "$hook_profile" ]; then + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on profile" \ + "'$hook_profile' ($hook_profile_source) instead" >&2 +else + hook_profile_source="$hook_profile_ignored" + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on the box default" >&2 fi extra=() @@ -516,7 +591,8 @@ environment come from that profile ($hook_profile_source) — read it back with printf '%s\n' "No agent profile is set, so a match starts the box default \ harness. Pick a worker for every LATER hook session with: agent-box-profile \ set triage HARNESS=claude MODEL=sonnet EFFORT=low && agent-box-session env set \ -AGENT_BOX_HOOK_PROFILE triage" +AGENT_BOX_HOOK_PROFILE triage — or for THIS watch alone, re-subscribe it with \ +'agent-box-webhook subscribe TOPIC --deliver-to subagent --profile triage'." fi if [ -n "$hook_args_source" ]; then printf 'The arguments after the agent come from %s.\n' "$hook_args_source" diff --git a/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook b/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook index de890ae4..a720ba7d 100644 --- a/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook +++ b/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook @@ -26,7 +26,7 @@ usage: agent-box-webhook subscribe TOPIC [--note TEXT] [--ttl HOURS] [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... [--when JSON] [--drop JSON] - [--claim SPEC]... + [--claim SPEC]... [--profile NAME] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -131,6 +131,22 @@ reports through commit statuses, you stay exposed on that one shape. --claim and --include are mutually exclusive; write --include yourself only for rules --claim cannot express. +--profile NAME (subagent watches only) names the agent profile the sessions +THIS watch spawns start on — a harness, a model, an effort level, an appended +system prompt (agent-box-profile ls). It beats the box-wide +AGENT_BOX_HOOK_PROFILE, so one repo can triage new issues cheaply and put +something stronger on a red build: + + agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" + +It is stored on the subscription as spawnConfig.profile (webhook.py's +--spawn-config), so `agent-box-webhook ls` shows it. --profile '' clears it. +A profile that does not exist when an event arrives is reported and ignored, +and the session starts on the box default — a delivery is never dropped for +a renamed profile. + --when / --drop attach payload rules to the subscription: deliver (or spawn) ONLY events matching --when, never those matching --drop. Rules are JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} @@ -479,7 +495,7 @@ case "$cmd" in ensure_state if [ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ]; then deliver_to=session; have_when=0; have_drop=0; topic=""; want="" - have_include=0; claims="" + have_include=0; claims=""; profile=""; have_profile=0 # Filter --claim out of the argument list as we scan it: webhook.py has # never heard of that flag, so it is translated to --include below and # must not survive into the exec. Rotate idiom — take from the front, @@ -504,6 +520,7 @@ case "$cmd" in exit 2 fi claims="$claims $a"; want=""; continue ;; + (profile) profile="$a"; have_profile=1; want=""; continue ;; esac want="" set -- "$@" "$a"; continue @@ -521,6 +538,13 @@ case "$cmd" in exit 2 fi claims="$claims ${a#--claim=}"; continue ;; + # Filtered out like --claim, and for the same reason: webhook.py + # spells this --spawn-config profile=NAME, and the agent-box word for + # the thing is "profile" (issue #321). Empty is legal — it clears the + # profile on a re-subscribe — so, unlike --claim, only the missing + # VALUE is an error. + --profile) want=profile; continue ;; + --profile=*) profile="${a#--profile=}"; have_profile=1; continue ;; --*) ;; *) [ -n "$topic" ] || topic="$a" ;; esac @@ -530,6 +554,35 @@ case "$cmd" in echo "agent-box-webhook: --$want needs a value" >&2 exit 2 fi + if [ "$have_profile" = 1 ]; then + # A session subscription spawns nothing, so a profile on one would be a + # setting no code path reads. webhook.py refuses it too; saying it here + # names the flag the caller actually typed. + if [ "$deliver_to" != subagent ]; then + echo "agent-box-webhook: --profile only applies to a standing watch —" \ + "it names the worker a spawned session starts on, and a session" \ + "subscription spawns nothing; add --deliver-to subagent" >&2 + exit 2 + fi + case "$profile" in + ("") set -- "$@" --no-spawn-config ;; + (*[!A-Za-z0-9_-]*) + echo "agent-box-webhook: --profile '$profile' is not a valid profile" \ + "name (A-Za-z0-9_-)" >&2 + exit 2 ;; + (*) + # A warning, never a refusal: profiles are runtime data, and + # subscribing a watch before creating its profile is a legitimate + # order to do things in. The spawn falls back to the box default + # and says so if the profile is still missing when an event lands. + if [ ! -r "$HOME/.config/agent-box/profiles/$profile.env" ]; then + echo "agent-box-webhook: no profile '$profile' on this box yet" \ + "(agent-box-profile ls) — subscribing anyway; a match starts" \ + "the box default until you create it" >&2 + fi + set -- "$@" --spawn-config "profile=$profile" ;; + esac + fi if [ -n "$claims" ]; then if [ "$have_include" = 1 ]; then echo "agent-box-webhook: pass --claim OR --include, not both —" \ diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md index f117bf3e..2ef8f84b 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md @@ -160,7 +160,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md index f117bf3e..2ef8f84b 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md @@ -160,7 +160,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn b/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn index eee8fb2b..272e5596 100755 --- a/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn +++ b/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn @@ -1,6 +1,7 @@ #!/bin/sh # Generated by `agentbox apply` — do not edit. export AGENT_BOX_FLOCK_BIN=@PROFILE@/bin/flock +export AGENT_BOX_JQ_BIN=@PROFILE@/bin/jq export AGENT_BOX_DEFAULT_AGENT=claude export AGENT_BOX_ENVSTORE_BIN=@PROFILE@/bin/agent-box-envstore export AGENT_BOX_HOOK_ARGS_OPTION_NAME='webhook.hookSessionArgs in @CONFIG@' diff --git a/tests/webhook.nix b/tests/webhook.nix index f0a5c6c1..9c5d5f21 100644 --- a/tests/webhook.nix +++ b/tests/webhook.nix @@ -1265,6 +1265,215 @@ machine.succeed( f"sudo -u agent env HOME=/home/agent agent-box-session rm {ghosted}" ) + + # --- issue #321, last step: the WATCH names its own profile ------------- + dispatch_file = "/home/agent/.local/state/local-webhook/filter.dispatch.json" + # AGENT_BOX_HOOK_PROFILE above is box-wide: every dispatched session on the + # box gets one worker. What it cannot say is "cheap triage for new issues, + # something that can actually fix things for a red build" — two watches on + # one repo reached the spawn wrapper as the same six strings, all of them + # about the EVENT. local-webhook 0.25.0 adds the seventh, about the WATCH. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-profile set boxwide HARNESS=claude MODEL=sonnet" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-profile set watchbot HARNESS=codex MODEL=gpt-5.6 EFFORT=low" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-session env set AGENT_BOX_HOOK_PROFILE boxwide" + ) + # --profile is agent-box's word for it; webhook.py stores it as + # spawnConfig.profile, so the CLI translates rather than teaching the bus a + # new concept. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile" + " --deliver-to subagent --profile watchbot" + " --note 'standing watch: its own worker'" + ) + machine.succeed( + "jq -e '[.topics[] | select(.topic ==" + " \"github:defangdevs/watch-profile\")][0].spawnConfig.profile" + " == \"watchbot\"'" + " /home/agent/.local/state/local-webhook/filter.dispatch.json" + ) + # ...and it comes back out of the listing, where an operator looks. + listing = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main agent-box-webhook ls" + ) + assert "watchbot" in listing, listing + + # The watch beats the box-wide setting: both are in force here, and the + # session starts on the WATCH's harness. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_SOURCE=github" + " LOCAL_WEBHOOK_SPAWN_KEY=defangdevs/watch-profile" + " LOCAL_WEBHOOK_SPAWN_TOPIC=github:defangdevs/watch-profile" + " LOCAL_WEBHOOK_SPAWN_CONFIG='{\"profile\": \"watchbot\"}'" + f" {sw}/sh -c 'echo hi | {spawn_cmd}'" + ) + per_watch = machine.succeed( + "jq -r '.sessions | keys[]" + " | select(startswith(\"hook-defangdevs-watch-profile-\"))'" + " /home/agent/.config/agent-box/sessions.json" + ).strip() + machine.succeed( + f"jq -e '.sessions[\"{per_watch}\"].profile == \"watchbot\"" + f" and .sessions[\"{per_watch}\"].agent == \"codex\"'" + " /home/agent/.config/agent-box/sessions.json" + ) + machine.succeed( + f"sudo -u agent env HOME=/home/agent agent-box-session rm {per_watch}" + ) + # An entry that names no profile leaves the box-wide setting standing, so + # the new field never quietly disables the old one. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_SOURCE=github" + " LOCAL_WEBHOOK_SPAWN_KEY=defangdevs/no-watch-profile" + " LOCAL_WEBHOOK_SPAWN_TOPIC=github:defangdevs/no-watch-profile" + " LOCAL_WEBHOOK_SPAWN_CONFIG={}" + f" {sw}/sh -c 'echo hi | {spawn_cmd}'" + ) + fellback = machine.succeed( + "jq -r '.sessions | keys[]" + " | select(startswith(\"hook-defangdevs-no-watch-profile-\"))'" + " /home/agent/.config/agent-box/sessions.json" + ).strip() + machine.succeed( + f"jq -e '.sessions[\"{fellback}\"].profile == \"boxwide\"'" + " /home/agent/.config/agent-box/sessions.json" + ) + machine.succeed( + f"sudo -u agent env HOME=/home/agent agent-box-session rm {fellback}" + ) + + # --preamble has no delivery, so it reads the watch out of the dispatch + # file. The settings page prints exactly this, and a page naming the + # box-wide worker for a watch that overrides it would be a lie the operator + # only finds out about from a session that already started (#292). + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + f" {spawn_cmd} --preamble github:defangdevs/watch-profile" + ) + assert "agent profile watchbot" in launch, launch + assert "spawnConfig.profile" in launch, launch + # A topic with no watch-level profile still reports the box-wide one. + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + f" {spawn_cmd} --preamble github:defangdevs/no-watch-profile" + ) + assert "agent profile boxwide" in launch, launch + + # An unusable WATCH profile must not cost the box-wide one. It used to: + # the watch's name displaced the box-wide one BEFORE validation, so one + # typo in one watch's --profile downgraded every event it matched from the + # operator's chosen worker to the raw box default. + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_CONFIG='{\"profile\": \"ghost\"}'" + f" {spawn_cmd} --preamble github:defangdevs/no-watch-profile" + ) + assert "agent profile boxwide" in launch, launch + assert "IGNORED, no such profile" not in launch, launch + # A non-string value is ignored rather than stringified into a name. + # webhook.py drops one when it reads the filter file, so no delivery + # carries it — but that file is documented as hand-editable, and + # --preamble reads it directly. + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_CONFIG='{\"profile\": 5}'" + f" {spawn_cmd} --preamble github:defangdevs/no-watch-profile" + ) + assert "agent profile boxwide" in launch, launch + + # Both guards again, this time through the path --preamble actually uses + # in production: the DISPATCH FILE. Nothing sets LOCAL_WEBHOOK_SPAWN_CONFIG + # for the settings page — it names a topic and the script goes and reads + # the watch — so a regression that stringified a numeric profile only in + # the file branch would sail past the two assertions above. The file is + # documented as hand-editable, so these are values a box can really hold. + machine.succeed(f"cp {dispatch_file} /tmp/dispatch.bak") + machine.succeed( + "jq '.topics += [" + " {topic: \"github:defangdevs/numeric-profile\", ttlHours: 0," + " include: {path: \"event\", in: [\"issues\"]}," + " spawnConfig: {profile: 5}}," + " {topic: \"github:defangdevs/ghost-watch\", ttlHours: 0," + " include: {path: \"event\", in: [\"issues\"]}," + " spawnConfig: {profile: \"ghost\"}}]'" + f" {dispatch_file} > /tmp/hand-edited.json" + ) + machine.succeed(f"sudo -u agent cp /tmp/hand-edited.json {dispatch_file}") + for topic in ("numeric-profile", "ghost-watch"): + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + f" {spawn_cmd} --preamble github:defangdevs/{topic}" + ) + assert "agent profile boxwide" in launch, (topic, launch) + assert "agent profile 5" not in launch, (topic, launch) + machine.succeed(f"sudo -u agent cp /tmp/dispatch.bak {dispatch_file}") + + # --profile= (empty) clears it on a re-subscribe; two adjacent single + # quotes cannot be written in this Nix string, hence the = form. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile" + " --deliver-to subagent --profile=" + ) + machine.succeed( + "jq -e '[.topics[] | select(.topic ==" + " \"github:defangdevs/watch-profile\")][0] | has(\"spawnConfig\") | not'" + " /home/agent/.local/state/local-webhook/filter.dispatch.json" + ) + + # Refused where it would do nothing: a session subscription spawns no + # session, so a profile on one is a setting nothing reads. + machine.fail( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile --profile watchbot" + ) + # ...and a name that could escape into a file path is refused outright, + # unlike a merely-absent profile, which is a warning. + machine.fail( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile" + " --deliver-to subagent --profile ../escape" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook unsubscribe defangdevs/watch-profile" + " --deliver-to subagent" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent agent-box-profile rm watchbot" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent agent-box-profile rm boxwide" + ) machine.succeed( "sudo -u agent env HOME=/home/agent" " agent-box-session env rm AGENT_BOX_HOOK_PROFILE" @@ -1671,8 +1880,27 @@ " LOCAL_WEBHOOK_SESSION=agent-main" " agent-box-webhook subscribe defangdevs/panel --note 'shown in the UI' --ttl 8" ) + # A standing watch with its own --profile, rendered by the DAEMON. The + # panel prints the spawn wrapper's --preamble, and that lookup reads the + # dispatch file with jq — which the settings unit's forced PATH does not + # carry (it is the daemon, coreutils, findutils, gnugrep, gnused, systemd). + # jq is pinned as AGENT_BOX_JQ_BIN for exactly this: every jq use in that + # script is guarded, so an unfound binary is silent, and the panel would + # name the box-wide worker for a watch that overrides it. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-profile set panelbot HARNESS=codex MODEL=gpt-5.6" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/panel-profile" + " --deliver-to subagent --profile panelbot" + ) page = machine.succeed(f"{settings_curl} {settings_page}") for want in [ + "agent profile panelbot", # the watch's own worker, via jq "github:defangdevs/panel", # this session's own subscription... "shown in the UI", # ...and the note saying why it exists 'data-fold="subs-main"', # folded under the session it delivers to