agentradar-cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ // ===========================================================================
2
+ // I2 — session injection frame (pure, testable).
3
+ // The interactive-host variant of the vendor runner does NOT spawn a fresh claude
4
+ // per event; it injects each drained event as the NEXT turn of a persistent,
5
+ // always-on claude running in a tmux pane (real in-session continuity). These pure
6
+ // helpers build (a) the single-line frame the agent sees and (b) the exact tmux
7
+ // send-keys argv. Kept dependency-free so they run under `node --test`.
8
+ //
9
+ // Two hard requirements bake in here:
10
+ // 1. EXPLICIT conversationId — the frame tells the agent which conversation to
11
+ // reply into, so `reply` never has to guess "most-recent" under concurrent
12
+ // turns (the concurrent-turn robustness the lead/disc flagged).
13
+ // 2. SINGLE LINE — a TUI submits on the first newline, so the frame must be one
14
+ // line (no embedded \n) to land as exactly one turn on send-keys + Enter.
15
+ // ===========================================================================
16
+
17
+ /** Collapse any whitespace/newlines to single spaces so the frame stays ONE line. */
18
+ function oneLine(s) {
19
+ return String(s ?? "")
20
+ .replace(/\s+/g, " ")
21
+ .trim();
22
+ }
23
+
24
+ /**
25
+ * Build the single-line injection frame for one drained next_event event.
26
+ * event: { eventId, source, channel?, conversationId?, text }
27
+ * Returns a one-line string safe to `tmux send-keys -l` then Enter.
28
+ */
29
+ export function formatInjectionFrame(event) {
30
+ const e = event ?? {};
31
+ const tags = [`source=${e.source ?? "unknown"}`];
32
+ if (e.channel) tags.push(`channel=${e.channel}`);
33
+ if (e.conversationId) tags.push(`conversationId=${e.conversationId}`);
34
+ if (e.eventId) tags.push(`eventId=${e.eventId}`);
35
+ const header = `[AgentRadar inbound · ${tags.join(" · ")}]`;
36
+ const body = oneLine(e.text) || "(no text)";
37
+ const evId = e.eventId ? String(e.eventId) : null;
38
+ // The reply instruction pins the EXPLICIT conversationId (threading) + inResponseToEventId
39
+ // (exact provenance under concurrent same-conversation turns — disc S2).
40
+ const parts = [];
41
+ if (e.conversationId) {
42
+ parts.push(
43
+ `Reply via your MCP \`reply\` tool with conversationId="${e.conversationId}"${evId ? ` and inResponseToEventId="${evId}"` : ""} (do NOT guess the channel).`,
44
+ );
45
+ } else {
46
+ parts.push(`Reply via your MCP \`reply\` tool${evId ? ` with inResponseToEventId="${evId}"` : ""}.`);
47
+ }
48
+ // L2 (disc #104): the feeder is at-least-once — a crash in the deliver→ack window
49
+ // re-injects this event. Tying report_usage's idempotencyKey to THIS eventId makes a
50
+ // re-delivery a no-op charge (#65 dedups on key) instead of a double bill, while a
51
+ // per-line suffix keeps distinct usage lines separate.
52
+ if (evId) {
53
+ parts.push(
54
+ `For billable work call \`report_usage\` with an idempotencyKey derived from this eventId (e.g. "${evId}:<usage-code>") so a re-delivered event never double-charges.`,
55
+ );
56
+ } else {
57
+ parts.push("Use `report_usage` for billable work.");
58
+ }
59
+ parts.push("Then stop.");
60
+ return oneLine(`${header} ${body} — ${parts.join(" ")}`);
61
+ }
62
+
63
+ /**
64
+ * #173b — build the single-line frame for a #156 liveness PROBE.
65
+ *
66
+ * A security-hardened agent (correctly) refuses to call a tool on the strength of an unverified
67
+ * in-chat instruction — it treats a bare/raw probe as prompt-injection. The fix: deliver the probe
68
+ * with the SAME trusted control-plane provenance as a real event — the `[AgentRadar inbound · … ]`
69
+ * header — so the agent recognizes it as legitimate platform traffic, not an in-chat instruction.
70
+ *
71
+ * #176 (canary finding): the eventId is a server-minted real UUID (matching a genuine event's eventId
72
+ * shape) — because a security-hardened agent DECLINES a probe whose eventId visibly differs from its real
73
+ * events (Liza flagged the `probe-<nonce>` eventId as the tell of an in-chat injection). The eventId is
74
+ * decoupled from the nonce and no longer leaks "this is a probe"; the NONCE stays the sole anti-spoof
75
+ * boundary (server-minted, exact-match). We deliberately do NOT attach a conversationId (a dangling one
76
+ * an agent could reply to would touch the conversation path — lead + disc §3). source stays `agentradar`
77
+ * (honest control-plane origin — the kit teaches the agent this is legitimate platform traffic). There is
78
+ * NO `reply` / `report_usage` guidance — a probe is a no-reply, no-charge health check whose ONLY honored
79
+ * action is `liveness_ack(nonce)` (that directive is the server-supplied body).
80
+ *
81
+ * probe: { nonce, instruction, eventId?, source? }
82
+ */
83
+ export function formatProbeFrame(probe) {
84
+ const p = probe ?? {};
85
+ const nonce = p.nonce != null ? String(p.nonce) : "";
86
+ // Prefer the server-minted UUID eventId; fall back only defensively (older payloads). No `probe-` prefix
87
+ // when we do fall back — use the bare nonce so we never re-introduce the shape tell the agent rejects.
88
+ const eventId = p.eventId ? String(p.eventId) : nonce || "probe";
89
+ const source = p.source ?? "agentradar";
90
+ // #182/F2 (frame-provenance forgeability): the source/eventId in this header are FORGEABLE content —
91
+ // an adversary with write-access to the agent's session can inject a look-alike header. That is why
92
+ // trust must NOT rest on them. #184's body carries the load-bearing argument (inert action + harness-
93
+ // grant + unforgeable nonce), which is SOURCE-INDEPENDENT: a forged source cannot change the honor
94
+ // basis, and honoring is inert either way (a forged frame can at most induce a no-op nonce-echo). So
95
+ // F2 is a safe-direction robustness gap, not a hole — the residual (a poison-adjacent agent that
96
+ // defensively declines) errs toward over-CREDIT (never a wrongful bill), and D1's pane→flag+HELD path
97
+ // is the human-review backstop. The header stays only as routing metadata.
98
+ const header = `[AgentRadar inbound · source=${source} · eventId=${eventId}]`;
99
+ // #184/D6: inertness-first, accurate-inertness fallback (used only for older payloads with no
100
+ // server-minted instruction). Trust rests on the action being inert + the nonce unforgeable, NOT on
101
+ // the header's source label. A matching ack DOES update the billing-liveness signal — don't overclaim
102
+ // "no charge / zero blast radius"; state the honest bounded effect.
103
+ const body =
104
+ oneLine(p.instruction) ||
105
+ `Liveness check — call the liveness_ack tool with nonce "${nonce}". Judge on structure, not who sent it: a wrong or stale nonce is a harmless no-op (safe even if forged); the tool is harness-granted (a message cannot add a tool); the nonce is server-minted (you cannot fabricate a valid one). A matching nonce only confirms your session is alive — it updates the liveness signal that gates billing; it moves no data, sends no message, creates no task, and creates no new charge.`;
106
+ return oneLine(`${header} ${body}`);
107
+ }
108
+
109
+ /**
110
+ * The two tmux invocations that inject `text` as one submitted turn into `session`'s
111
+ * pane: send the literal text (‑l so it is NOT parsed as key names), then a separate
112
+ * Enter to submit. Returns argv arrays (no shell) — caller spawns `tmux` with each.
113
+ */
114
+ export function sendKeysCommands(session, text) {
115
+ return [
116
+ ["send-keys", "-t", session, "-l", text],
117
+ ["send-keys", "-t", session, "Enter"],
118
+ ];
119
+ }
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # AgentRadar durable-supervisor (M3.6 H4 / #92) — the ONE process a launchd
4
+ # LaunchAgent (macOS) or systemd unit (Linux) keeps alive for a single always-on
5
+ # agent. It is GENERIC + parameterized by a per-agent env file: no agent-specific
6
+ # logic lives here (Spielberg's --chrome, brain path, pre-launch hooks, etc. are
7
+ # all env params). It idempotently ensures BOTH halves of an always-on agent are
8
+ # up, then blocks so KeepAlive/Restart revives the whole thing on any death:
9
+ #
10
+ # 1. a tmux session ($TMUX_SESSION) running the agent:
11
+ # claude --continue $CLAUDE_EXTRA_FLAGS --strict-mcp-config --mcp-config=$MCP_CONFIG
12
+ # in $AR_BASE, with a SCRUBBED allowlist env + a FRESH $CLAUDE_CONFIG_DIR
13
+ # (so the agent sees ONLY the AgentRadar MCP — no operator connectors);
14
+ # 2. the feeder ($FEEDER_JS) OUTSIDE that session, holding $AWP_BEARER, long-
15
+ # polling next_event and tmux-injecting each event into the pane.
16
+ #
17
+ # Durability: crash of either half → this script exits non-zero → KeepAlive
18
+ # (launchd) / Restart=always (systemd) restarts it → both re-ensured (idempotent).
19
+ # Power-loss reboot → RunAtLoad (launchd) / WantedBy=default.target (systemd)
20
+ # starts it on boot → same. Composes with the stateless AWP MCP (#111/#112:
21
+ # reconnect, no -32001) + `claude --continue` (context resumes) = hands-free revive.
22
+ #
23
+ # HARD RULE: claude.ai OAuth ONLY. Refuses to start if ANTHROPIC_API_KEY is set.
24
+ # =============================================================================
25
+ set -euo pipefail
26
+
27
+ log() { printf '%s [supervise-agent%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${AGENT_NAME:+:$AGENT_NAME}" "$*"; }
28
+ die() { log "FATAL: $*"; exit 1; }
29
+
30
+ # A launchd LaunchAgent (macOS) / systemd unit gives a MINIMAL PATH (typically /usr/bin:/bin) that
31
+ # lacks Homebrew — where tmux / claude / node live on a dev box. Without this, `command -v tmux` fails
32
+ # on boot and the supervisor crash-loops ("tmux not found"). Prepend the common tool dirs (Apple-Silicon
33
+ # + Intel Homebrew) so the unit finds them; this PATH also flows to the scrubbed agent launch below.
34
+ export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
35
+
36
+ # --- 1. Load the per-agent env ------------------------------------------------
37
+ # Env file: $1, or $AGENT_ENV, or ~/.agentradar/agents/<name>.env
38
+ ENV_FILE="${1:-${AGENT_ENV:-}}"
39
+ if [ -z "${ENV_FILE}" ] && [ -n "${AGENT_NAME:-}" ]; then
40
+ ENV_FILE="${HOME}/.agentradar/agents/${AGENT_NAME}.env"
41
+ fi
42
+ [ -n "${ENV_FILE}" ] || die "no agent env file (pass a path, or set AGENT_ENV / AGENT_NAME)"
43
+ [ -f "${ENV_FILE}" ] || die "agent env file not found: ${ENV_FILE}"
44
+ set -a
45
+ # shellcheck source=/dev/null
46
+ . "${ENV_FILE}"
47
+ set +a
48
+
49
+ # --- 2. Validate + defaults ---------------------------------------------------
50
+ : "${AGENT_NAME:?AGENT_NAME required in env}"
51
+ : "${AR_BASE:?AR_BASE required (the agent working dir)}"
52
+ : "${AWP_MCP_URL:?AWP_MCP_URL required}"
53
+ : "${AWP_BEARER:?AWP_BEARER required}"
54
+ # CLAUDE_CODE_OAUTH_TOKEN is OPTIONAL. If SET → claude uses the setup-token (bills "API Usage",
55
+ # subject to the org spend cap). If UNSET → claude falls through to SUBSCRIPTION OAuth (keychain login,
56
+ # a one-time `/login`) → draws on the Max plan's included usage. Either way it's claude.ai auth only —
57
+ # ANTHROPIC_API_KEY is still hard-refused below (never a pay-as-you-go key).
58
+ : "${CLAUDE_CODE_OAUTH_TOKEN:=}"
59
+ : "${MCP_CONFIG:?MCP_CONFIG required (path to awp-only.json)}"
60
+ TMUX_SESSION="${TMUX_SESSION:-${AGENT_NAME}}"
61
+ CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${HOME}/.agentradar/config/${AGENT_NAME}}"
62
+ FEEDER_JS="${FEEDER_JS:-$(cd "$(dirname "$0")/.." && pwd)/feeder.mjs}"
63
+ PANE_LIVE_SH="${PANE_LIVE_SH:-$(cd "$(dirname "$0")/.." && pwd)/pane-live.sh}"
64
+ # #133 M4 live terminal — the OPTIONAL feeder bridge (streams the tmux pane to the portals). It's an
65
+ # INDEPENDENT process that NEVER touches the liveness poll loop; its death is best-effort-restarted and
66
+ # never exits the supervisor (the terminal view is nice-to-have; the feeder + agent are billing-critical).
67
+ BRIDGE_JS="${BRIDGE_JS:-$(cd "$(dirname "$0")/.." && pwd)/terminal-bridge.mjs}"
68
+ TERMINAL_BRIDGE_ENABLED="${TERMINAL_BRIDGE_ENABLED:-1}"
69
+ CLAUDE_BIN="${CLAUDE_BIN:-claude}"
70
+ TMUX_BIN="${TMUX_BIN:-tmux}"
71
+ CLAUDE_EXTRA_FLAGS="${CLAUDE_EXTRA_FLAGS:-}" # e.g. "--chrome" (Spielberg/Sam); empty (Liza)
72
+ PRE_LAUNCH_HOOK="${PRE_LAUNCH_HOOK:-}" # optional per-agent script run once before claude
73
+ BROWSER_APP="${BROWSER_APP:-Google Chrome}" # #150: app to ensure-up for --chrome agents (macOS)
74
+ BROWSER_READY_MAX_SEC="${BROWSER_READY_MAX_SEC:-20}" # #150: bounded wait for the browser proc on cold boot
75
+ BROWSER_READY_SETTLE_SEC="${BROWSER_READY_SETTLE_SEC:-3}" # #150: settle for extension/native-host after cold launch
76
+ HEALTH_POLL_SEC="${HEALTH_POLL_SEC:-15}"
77
+ # Pane-alive predicate — MUST be unique per agent. Default to the FULL mcp-config path (each agent's
78
+ # differs by directory), NOT the basename: on a multi-agent box every agent's config is named
79
+ # `awp-only.json`, so a basename match would find ANOTHER agent's live claude and mask THIS agent's
80
+ # death (silent death — the exact failure #145 kills). Override per agent for extra scoping if needed.
81
+ AGENT_PID_PATTERN="${AGENT_PID_PATTERN:-mcp-config.*${MCP_CONFIG}}"
82
+
83
+ # Optional per-agent TOOL credentials — a vendor brain's OWN MCP-tool API keys (e.g. an SEO agent's
84
+ # WEBFLOW_TOKEN / PERPLEXITY_API_KEY). These are ADDED to the scrubbed launch env so the agent's own
85
+ # tool servers (declared in its --mcp-config) get their creds, WITHOUT widening the daemon's
86
+ # inheritance: AWP_BEARER and everything else the supervisor holds still NEVER reach the pane. Point
87
+ # AGENT_TOOL_ENV_FILE at a 0600 file of KEY=VALUE lines (blank / `#`-comment lines ignored). Only
88
+ # well-formed KEY=VALUE entries pass through; anything else is warned + skipped. This reproduces a
89
+ # non-isolated launcher that used to `. tool.env` before claude, but keeps the strict allowlist.
90
+ AGENT_TOOL_ENV_FILE="${AGENT_TOOL_ENV_FILE:-}"
91
+ TOOL_ENV_ARGS=()
92
+ if [ -n "${AGENT_TOOL_ENV_FILE}" ]; then
93
+ [ -f "${AGENT_TOOL_ENV_FILE}" ] || die "AGENT_TOOL_ENV_FILE not found: ${AGENT_TOOL_ENV_FILE}"
94
+ while IFS= read -r _tline || [ -n "${_tline}" ]; do
95
+ _tline="${_tline#"${_tline%%[![:space:]]*}"}" # left-trim whitespace
96
+ case "${_tline}" in ''|'#'*) continue ;; esac # skip blanks + comments
97
+ _tline="${_tline#export }" # tolerate a leading `export `
98
+ case "${_tline}" in
99
+ [A-Za-z_][A-Za-z0-9_]*=*) : ;; # a well-formed KEY=VALUE — fall through
100
+ *) log "WARN ignoring non KEY=VALUE line in ${AGENT_TOOL_ENV_FILE}"; continue ;;
101
+ esac
102
+ # Defence-in-depth: a tool file must NOT shadow a reserved supervisor/agent var (isolation +
103
+ # identity). Reject at parse time (visible warning) AND — belt-and-suspenders — the launch lists
104
+ # the reserved allowlist AFTER TOOL_ENV_ARGS so `env` last-wins keeps reserved vars authoritative.
105
+ case "${_tline%%=*}" in
106
+ HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
107
+ log "WARN ignoring reserved key '${_tline%%=*}' in ${AGENT_TOOL_ENV_FILE} (cannot override supervisor/agent env)"; continue ;;
108
+ esac
109
+ TOOL_ENV_ARGS+=("${_tline}")
110
+ done < "${AGENT_TOOL_ENV_FILE}"
111
+ log "loaded ${#TOOL_ENV_ARGS[@]} tool-env var(s) from ${AGENT_TOOL_ENV_FILE}"
112
+ fi
113
+
114
+ # HARD RULE — OAuth only, never a pay-as-you-go API key.
115
+ [ -z "${ANTHROPIC_API_KEY:-}" ] || die "ANTHROPIC_API_KEY is set — refuse to start (claude.ai OAuth only)"
116
+
117
+ command -v "${TMUX_BIN}" >/dev/null 2>&1 || die "tmux not found (${TMUX_BIN})"
118
+ command -v "${CLAUDE_BIN}" >/dev/null 2>&1 || die "claude CLI not found (${CLAUDE_BIN})"
119
+ [ -f "${FEEDER_JS}" ] || die "feeder not found: ${FEEDER_JS}"
120
+ [ -f "${PANE_LIVE_SH}" ] || die "pane liveness check not found: ${PANE_LIVE_SH}"
121
+ [ -d "${AR_BASE}" ] || die "AR_BASE dir not found: ${AR_BASE}"
122
+ mkdir -p "${CLAUDE_CONFIG_DIR}"
123
+
124
+ # --- 2b. Credential hygiene (#152): secrets to 0600 files, OUT of argv+env ------
125
+ # The AWP bearer (feeder) and — when used — the OAuth setup-token (claude) must not linger in any
126
+ # long-lived process's ARGV (`ps aux`) or ENVIRONMENT (`ps eww`), where a co-resident process on the
127
+ # box could read them. We stage them into per-agent 0600 files and hand each consumer the FILE PATH
128
+ # (not a secret): the feeder reads AWP_BEARER_FILE; the scrubbed claude wrapper reads AR_OAUTH_TOKEN_FILE.
129
+ # Then we UNSET the raw vars from this supervisor's env so neither the supervisor nor the feeder it
130
+ # spawns inherits them. (The agent's own outbound bearer already lives only in awp-only.json @0600.)
131
+ _SECRETS_DIR="${HOME}/.agentradar/run/${AGENT_NAME}"
132
+ ( umask 077; mkdir -p "${_SECRETS_DIR}" )
133
+ chmod 700 "${_SECRETS_DIR}" 2>/dev/null || true
134
+ _BEARER_FILE="${_SECRETS_DIR}/awp_bearer"
135
+ ( umask 077; printf '%s' "${AWP_BEARER}" > "${_BEARER_FILE}" )
136
+ AWP_BEARER_FILE="${_BEARER_FILE}"
137
+ unset AWP_BEARER # drop from the supervisor's exported env → feeder no longer inherits it; ps eww clean
138
+ # OAuth setup-token: same treatment. `set -a; . ENV_FILE` EXPORTS CLAUDE_CODE_OAUTH_TOKEN into the
139
+ # supervisor env, from which the feeder (spawned WITHOUT env -i) would inherit it → ps eww leak. So when
140
+ # it's set, stage it to a 0600 file and UNSET the raw var here (mirror of the bearer). ensure_agent then
141
+ # passes ONLY AR_OAUTH_TOKEN_FILE (the path) on the scrubbed env -i line, and the sh wrapper reads it into
142
+ # claude's env just before exec. When UNSET (subscription-login mode) _OAUTH_TOKEN_FILE stays empty → the
143
+ # claude launch is a pure no-op and falls through to subscription OAuth (no API-billing regression).
144
+ _OAUTH_TOKEN_FILE=""
145
+ if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
146
+ _OAUTH_TOKEN_FILE="${_SECRETS_DIR}/oauth_token"
147
+ ( umask 077; printf '%s' "${CLAUDE_CODE_OAUTH_TOKEN}" > "${_OAUTH_TOKEN_FILE}" )
148
+ fi
149
+ unset CLAUDE_CODE_OAUTH_TOKEN # out of the supervisor+feeder env (ps eww); staged to the 0600 file above
150
+ # Vendor MCP-tool creds (#166): the agent's OWN tool API keys (WEBFLOW_TOKEN, PERPLEXITY_API_KEY, …) were
151
+ # being passed INLINE as KEY=VALUE on the env -i line → stored PLAINTEXT in tmux's pane_start_command
152
+ # (`tmux list-panes` = any co-resident proc) and in claude's env (ps eww). Same inline-secret class as the
153
+ # bearer/token. Stage the already-validated entries to a 0600 file and pass ONLY AR_TOOL_ENV_FILE (a PATH);
154
+ # the wrapper reads+exports them just before exec, so the VALUES never appear on the pane command / argv
155
+ # (still in claude's env by necessity, exactly like the OAuth token — acceptable, same-user only).
156
+ _TOOL_ENV_FILE=""
157
+ if [ "${#TOOL_ENV_ARGS[@]}" -gt 0 ]; then
158
+ _TOOL_ENV_FILE="${_SECRETS_DIR}/tool_env"
159
+ ( umask 077; : > "${_TOOL_ENV_FILE}"; for _kv in "${TOOL_ENV_ARGS[@]}"; do printf '%s\n' "${_kv}" >> "${_TOOL_ENV_FILE}"; done )
160
+ TOOL_ENV_ARGS=() # #166: no longer passed inline on the env -i line
161
+ fi
162
+
163
+ session_exists() { "${TMUX_BIN}" has-session -t "${TMUX_SESSION}" 2>/dev/null; }
164
+ pane_alive() {
165
+ TMUX_BIN="${TMUX_BIN}" TMUX_SESSION="${TMUX_SESSION}" AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" \
166
+ /bin/sh "${PANE_LIVE_SH}"
167
+ }
168
+
169
+ # #150: for a --chrome agent, ensure the browser app is up BEFORE claude launches. `claude --chrome`
170
+ # connects to a running browser via the native-messaging host; if the browser isn't up at connect time
171
+ # the connection can fail. Generalized from Sam's per-agent PRE_LAUNCH_HOOK so ANY --chrome vendor agent
172
+ # is robust-by-construction. Idempotent (`open -ga` never spawns a second instance; -g = don't foreground).
173
+ # Overridable via BROWSER_APP (default "Google Chrome"). macOS-only; documented no-op elsewhere (on Linux,
174
+ # launch the browser in your runner env / at login). Belt-and-suspenders alongside a Login-Items entry.
175
+ ensure_browser() {
176
+ case " ${CLAUDE_EXTRA_FLAGS} " in
177
+ *" --chrome "*) : ;; # --chrome as a WHOLE space-bounded token (won't false-match e.g. --chromeless)
178
+ *) return 0 ;; # not a --chrome agent → nothing to do
179
+ esac
180
+ if ! command -v open >/dev/null 2>&1; then
181
+ log "NOTE --chrome set but no macOS 'open'; ensure '${BROWSER_APP}' is running out-of-band (e.g. launch it at login in your runner env)"
182
+ return 0
183
+ fi
184
+ # WARM path (common): browser already up → extension/native-host already initialized, no wait needed.
185
+ if pgrep -f "${BROWSER_APP}" >/dev/null 2>&1; then
186
+ log "browser '${BROWSER_APP}' already up — --chrome can connect"
187
+ return 0
188
+ fi
189
+ # COLD path (#150 + disc's race fix): `open -ga` returns BEFORE the app is actually up, so claude --chrome
190
+ # could race ahead and hit the native-host connect dialog. Launch, then BOUNDED-wait for the browser
191
+ # process to appear (poll pgrep up to BROWSER_READY_MAX_SEC), then a short settle for the extension/
192
+ # native-host to initialize. Degrades gracefully — never hangs (proceeds with a WARN on timeout).
193
+ log "browser '${BROWSER_APP}' not up (cold) — launching + waiting for readiness"
194
+ open -ga "${BROWSER_APP}" 2>/dev/null || log "WARN could not launch '${BROWSER_APP}' (continuing; --chrome may fail to connect)"
195
+ _bw=0
196
+ while [ "${_bw}" -lt "${BROWSER_READY_MAX_SEC}" ]; do
197
+ if pgrep -f "${BROWSER_APP}" >/dev/null 2>&1; then
198
+ log "browser process up after ${_bw}s; settling ${BROWSER_READY_SETTLE_SEC}s for extension/native-host"
199
+ sleep "${BROWSER_READY_SETTLE_SEC}"
200
+ return 0
201
+ fi
202
+ sleep 1; _bw=$((_bw + 1))
203
+ done
204
+ log "WARN browser '${BROWSER_APP}' not detected within ${BROWSER_READY_MAX_SEC}s — launching --chrome anyway (it will retry-connect)"
205
+ }
206
+
207
+ # --- 3. Ensure the agent's tmux+claude session (idempotent) -------------------
208
+ ensure_agent() {
209
+ if session_exists && pane_alive; then
210
+ log "agent session '${TMUX_SESSION}' already live"
211
+ return 0
212
+ fi
213
+ if session_exists && ! pane_alive; then
214
+ log "session '${TMUX_SESSION}' exists but claude pane is dead — recreating"
215
+ "${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
216
+ fi
217
+ ensure_browser # #150: bring up the browser first for --chrome agents (idempotent, no-op otherwise)
218
+ if [ -n "${PRE_LAUNCH_HOOK}" ] && [ -x "${PRE_LAUNCH_HOOK}" ]; then
219
+ log "running pre-launch hook: ${PRE_LAUNCH_HOOK}"
220
+ "${PRE_LAUNCH_HOOK}" || log "WARN pre-launch hook exited non-zero (continuing)"
221
+ fi
222
+ # SCRUBBED launch: env -i with a strict allowlist so the agent inherits nothing from this daemon
223
+ # (AWP_BEARER, AWP_MCP_URL, etc. never reach the pane). CONNECTOR ISOLATION is enforced by
224
+ # --strict-mcp-config (the agent loads ONLY $MCP_CONFIG's servers) — NOT by the config dir, which is
225
+ # deliberately the agent's EXISTING CLAUDE_CONFIG_DIR (often the shared ~/.claude) so `claude
226
+ # --continue` resumes the real conversation. The agent's own tool creds (from AGENT_TOOL_ENV_FILE) are
227
+ # NOT inlined here anymore (#166): they were staged to a 0600 file in 2b and are passed only as the PATH
228
+ # AR_TOOL_ENV_FILE, which the wrapper reads+exports before exec — so no tool VALUE appears on the env -i
229
+ # argv / tmux pane_start_command. Reserved keys were already rejected at tool-file parse time (2b).
230
+ log "starting agent in tmux '${TMUX_SESSION}' (flags: ${CLAUDE_EXTRA_FLAGS:-none}; tool-env file: ${_TOOL_ENV_FILE:-none})"
231
+ # SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
232
+ # _tool_env / _oauth_env use the ${arr[@]+"${arr[@]}"} form so an EMPTY array is safe under `set -u` (bash 3.2).
233
+ # First-launch handling (#153/H4 durable-feeder): `claude --continue` EXITS 1 when the cwd has NO prior
234
+ # conversation (a fresh engagement folder / brand-new CLAUDE_CONFIG_DIR) → the pane dies → the supervisor
235
+ # crash-loops. So try `--continue`, and FALL BACK to a fresh `claude` (which stays up AND creates the
236
+ # session) when there's nothing to resume. Self-healing: once a conversation exists, --continue resumes it;
237
+ # if it's ever lost, we cleanly start fresh instead of exit-looping. (--continue exits 1 on no-session,
238
+ # verified — so the `||` fallback is reliable.) The `/bin/sh -c` wrapper keeps EXTRA_FLAGS word-splitting.
239
+ # shellcheck disable=SC2086
240
+ _launch_flags=( ${CLAUDE_EXTRA_FLAGS} --strict-mcp-config --mcp-config="${MCP_CONFIG}" )
241
+ # OAuth setup-token (#152): the token was already staged to a 0600 file + UNSET from this env in section
242
+ # 2b (so it's off both the supervisor+feeder env / ps eww). Here we only pass AR_OAUTH_TOKEN_FILE (a PATH,
243
+ # not the secret) on the env -i line — so the token is NOT in the `env`/claude argv (`ps aux`) either. The
244
+ # wrapper below reads the file into CLAUDE_CODE_OAUTH_TOKEN just before exec'ing claude. When it was UNSET
245
+ # (subscription-login mode) _OAUTH_TOKEN_FILE is empty → we pass NOTHING token-related and the wrapper's
246
+ # read is a no-op → claude falls through to subscription OAuth exactly as before. bash 3.2-safe array form.
247
+ _oauth_env=()
248
+ [ -n "${_OAUTH_TOKEN_FILE}" ] && _oauth_env=( AR_OAUTH_TOKEN_FILE="${_OAUTH_TOKEN_FILE}" )
249
+ # #166: pass the tool creds as a FILE PATH (AR_TOOL_ENV_FILE), never inline KEY=VALUE on env -i.
250
+ _tool_env=()
251
+ [ -n "${_TOOL_ENV_FILE}" ] && _tool_env=( AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" )
252
+ # #1: do NOT inherit TERM. Under launchd the supervisor's TERM is `dumb` (or absent), and `dumb` is
253
+ # neither unset nor empty, so the old `${TERM:-xterm-256color}` default never fired and the agent's
254
+ # claude ran with TERM=dumb. chalk/ink then disable colour ENTIRELY, so the TUI emitted zero SGR —
255
+ # measured: a real agent pane captured with `capture-pane -ep` was 3216 bytes with ZERO ESC bytes,
256
+ # against a control pane that showed 2. That, not any client-side defect, is why the live terminal
257
+ # rendered monochrome in both portals. The pane lives inside tmux, so match tmux's own
258
+ # `default-terminal` (tmux-256color); `infocmp tmux-256color` is present on the fleet mini. Both are
259
+ # overridable per agent via AR_AGENT_TERM / AR_AGENT_COLORTERM — names NO shell sets, which is the
260
+ # whole point: defaulting off `${TERM:-…}` is exactly the construct that already failed silently.
261
+ #
262
+ # LC_CTYPE was previously passed EMPTY, which leaves the agent in the C/POSIX character type even
263
+ # though LANG says UTF-8 — that garbles claude's `╭─╮` / `⏵⏵` box-drawing glyphs, which only becomes
264
+ # visible once there is colour to look at. LC_ALL stays empty on purpose: an empty LC_ALL does not
265
+ # override, so LC_CTYPE governs and a vendor can still set LANG per agent.
266
+ #
267
+ # 🔴 This comment block lives ABOVE the command, not inside it. A `#` comment between two
268
+ # `\`-continued lines TERMINATES the command at its unescaped newline: tmux would receive only the
269
+ # args up to SHELL=…, the pane would start with no `claude` at all, and the `TERM=… claude` tail
270
+ # would run as a separate, UNISOLATED command in the supervisor's own shell. `bash -n` passes on
271
+ # that — it is semantic, not syntactic — and a colour gate cannot see it either, because a pane that
272
+ # never starts has nothing to colour. Caught by disc counting the args tmux actually received.
273
+ "${TMUX_BIN}" new-session -d -s "${TMUX_SESSION}" -c "${AR_BASE}" \
274
+ env -i \
275
+ HOME="${HOME}" PATH="${PATH}" USER="${USER:-}" LOGNAME="${LOGNAME:-}" SHELL="${SHELL:-/bin/sh}" \
276
+ TERM="${AR_AGENT_TERM:-tmux-256color}" COLORTERM="${AR_AGENT_COLORTERM:-truecolor}" \
277
+ LANG="${LANG:-en_US.UTF-8}" LC_ALL="${LC_ALL:-}" LC_CTYPE="${AR_AGENT_LC_CTYPE:-en_US.UTF-8}" \
278
+ TMPDIR="${TMPDIR:-/tmp}" NODE_OPTIONS="${NODE_OPTIONS:-}" \
279
+ DISABLE_AUTOUPDATER=1 \
280
+ ${_tool_env[@]+"${_tool_env[@]}"} ${_oauth_env[@]+"${_oauth_env[@]}"} CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" \
281
+ /bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; if [ -n "${AR_OAUTH_TOKEN_FILE:-}" ]; then CLAUDE_CODE_OAUTH_TOKEN="$(cat "$AR_OAUTH_TOKEN_FILE")"; export CLAUDE_CODE_OAUTH_TOKEN; unset AR_OAUTH_TOKEN_FILE; fi; CB="$1"; shift; "$CB" --continue "$@" || exec "$CB" "$@"' sh "${CLAUDE_BIN}" "${_launch_flags[@]}" || {
282
+ log "tmux could not start agent session '${TMUX_SESSION}'"
283
+ return 1
284
+ }
285
+ # wait for the pane to come up
286
+ for _ in $(seq 1 30); do pane_alive && { log "agent pane is live"; return 0; }; sleep 1; done
287
+ log "agent pane did not come up within 30s"
288
+ return 1
289
+ }
290
+
291
+ # --- 4. Run: ensure agent, run feeder in foreground, monitor both -------------
292
+ FEEDER_PID=""
293
+ cleanup() {
294
+ [ -n "${FEEDER_PID}" ] && kill "${FEEDER_PID}" 2>/dev/null || true
295
+ [ -n "${BRIDGE_PID:-}" ] && kill "${BRIDGE_PID}" 2>/dev/null || true # #133 terminal bridge
296
+ # #152: remove the staged 0600 secret files (bearer + optional oauth token) on exit.
297
+ [ -n "${_SECRETS_DIR:-}" ] && rm -rf "${_SECRETS_DIR}" 2>/dev/null || true
298
+ }
299
+ trap cleanup EXIT INT TERM
300
+
301
+ until ensure_agent; do
302
+ log "agent start failed — retrying in ${HEALTH_POLL_SEC}s"
303
+ sleep "${HEALTH_POLL_SEC}"
304
+ done
305
+
306
+ log "starting feeder (${FEEDER_JS}) → session '${TMUX_SESSION}'"
307
+ # #152: pass AWP_BEARER_FILE (a 0600 PATH), NEVER the raw AWP_BEARER — the feeder reads the file, so the
308
+ # bearer is not in the feeder process's environment (`ps eww`) or argv. AWP_MCP_URL is not a secret.
309
+ AWP_MCP_URL="${AWP_MCP_URL}" AWP_BEARER_FILE="${AWP_BEARER_FILE}" TMUX_SESSION="${TMUX_SESSION}" \
310
+ AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" \
311
+ node "${FEEDER_JS}" &
312
+ FEEDER_PID=$!
313
+ log "feeder pid ${FEEDER_PID}"
314
+
315
+ # #133 M4 — start the optional feeder bridge (best-effort, isolated). Same 0600 bearer FILE + session env
316
+ # as the feeder; NEVER the raw bearer. Its failure NEVER affects the feeder/agent (nice-to-have terminal).
317
+ BRIDGE_PID=""
318
+ start_bridge() {
319
+ case "${TERMINAL_BRIDGE_ENABLED}" in
320
+ 1|true|yes|on)
321
+ if [ -f "${BRIDGE_JS}" ]; then
322
+ AWP_MCP_URL="${AWP_MCP_URL}" AWP_BEARER_FILE="${AWP_BEARER_FILE}" TMUX_SESSION="${TMUX_SESSION}" \
323
+ node "${BRIDGE_JS}" &
324
+ BRIDGE_PID=$!
325
+ log "terminal bridge pid ${BRIDGE_PID}"
326
+ else
327
+ log "terminal bridge enabled but ${BRIDGE_JS} not found — skipping (pull the latest template)"
328
+ fi
329
+ ;;
330
+ *) log "terminal bridge disabled (TERMINAL_BRIDGE_ENABLED=${TERMINAL_BRIDGE_ENABLED})" ;;
331
+ esac
332
+ }
333
+ start_bridge
334
+
335
+ # Monitor. Recover the agent pane in place so direct supervisor invocation survives pane death too;
336
+ # launchd/systemd remains the parent-level/reboot safety net. The feeder self-pauses while the owned pane
337
+ # next_event heartbeat cannot paint the runner Live during recovery. Restart the bridge after a new pane is
338
+ # created because tmux pipe-pane was bound to the dead pane.
339
+ while true; do
340
+ if ! kill -0 "${FEEDER_PID}" 2>/dev/null; then
341
+ log "feeder exited — exiting so the service manager restarts the whole unit"
342
+ exit 1
343
+ fi
344
+ if ! pane_alive; then
345
+ log "agent claude pane died — recovering it in place"
346
+ if ensure_agent; then
347
+ if [ -n "${BRIDGE_PID}" ]; then
348
+ kill "${BRIDGE_PID}" 2>/dev/null || true
349
+ wait "${BRIDGE_PID}" 2>/dev/null || true
350
+ BRIDGE_PID=""
351
+ fi
352
+ start_bridge
353
+ else
354
+ log "agent recovery failed — retrying in ${HEALTH_POLL_SEC}s"
355
+ fi
356
+ fi
357
+ if [ -n "${BRIDGE_PID}" ] && ! kill -0 "${BRIDGE_PID}" 2>/dev/null; then
358
+ log "terminal bridge exited — restarting in place (best-effort; does not affect feeder/agent)"
359
+ start_bridge
360
+ fi
361
+ sleep "${HEALTH_POLL_SEC}"
362
+ done