Skill Tracer
Skills › Compound Engineering › ce-pov › cross-model-pov.sh

cross-model-pov.sh

1124 lines · skills/ce-pov/scripts/cross-model-pov.sh at 3449230

1#!/usr/bin/env bash
2# cross-model-pov.sh
3#
4# Runs one pre-sanctioned different-model route in a read-only, least-privilege
5# process and writes its POV as JSON into the run dir.
6# Every peer receives the canonical POV persona, schema, and a caller-prepared
7# subject payload. The peer also receives the caller-declared repository read
8# scope; private prompt/result scratch stays outside that repository.
9#
10# Independence is by PROVIDER, not CLI brand. A provider is reached by a ROUTE:
11# its dedicated CLI, or (for the fixed grok-cursor / composer routes) cursor-agent. All
12# peer runs on ONE model at HIGH reasoning (composer's
13# -fast tier is its ceiling, an accepted exception).
15# Usage:
16# cross-model-pov.sh <host-serving-family> <fixed-route> <subject-payload> <run-dir>
18# <host-serving-family>
19# the peer-key of the host's OWN serving family, attested by
20# the calling skill (it knows its harness). A peer-key, never
21# a provider name: openai->codex, anthropic->claude,
22# xai->grok, cursor/composer->composer, google->gemini.
23# Used to verify independence. `unknown` is allowed for an
24# explicitly named peer, but its receipt remains unverified;
25# automatic discovery must exclude it before calling this worker.
26# <fixed-route> one host-resolved and pre-sanctioned route: codex, claude,
27# grok-cli, grok-cursor, cursor, composer, opencode, or
28# antigravity. A route failure
29# returns no artifact; only the host may disclose and retry a
30# different recipient.
31# <subject-payload> framed question plus any conversation-only subject material.
32# Point to repository files instead of copying their contents;
33# the peer grounds itself from the shared working tree.
34# <run-dir> existing private dir outside the repository; output ->
35# <run-dir>/pov-<target>.json, where <target> is the resolved
36# <fixed-route> target (grok-cli/grok-cursor both collapse to
37# grok) -- NOT the <host-serving-family> key.
39# Test/introspection mode (no model call, no side effects):
40# cross-model-pov.sh --emit-adapter <route>
41# prints the exact argv the given route would run (route in:
42# codex | claude | grok-cli | grok-cursor | cursor | composer | opencode |
43# antigravity). Both this mode and the
44# live run build their argv from adapter_argv(), so the route-safety test
45# asserts on the same command string the peer actually runs.
47# Self-locates its sibling reference files via BASH_SOURCE (NOT the CWD, which is
48# the user's project on every host). The agent passes the values above.
50# NON-BLOCKING BY DESIGN: every failure logs to stderr and exits 0 without an
51# output file. The cross-model pass is additive and must never fail the POV;
52# the caller detects success purely by the presence of the output file(s).
54# DATA-EGRESS NOTE: this embeds the prepared subject payload into an external
55# model CLI prompt. The caller must disclose its content scope and actual provider
56# before launch; route receipts let it reconcile the fixed target afterward.
58set -uo pipefail
60# Survive SIGHUP when the orchestrator backgrounds this script and the parent
61# shell exits (common on Cursor/Codex Bash tools). Without this, a detached
62# peer process group can keep running while this script dies
63# before normalize — leaving fold-in files without route/model receipts.
64trap '' HUP
66# Filled while a peer process group is live; TERM/INT handler (installed after
67# reap() is defined) reaps it so an orchestrator kill cannot leave orphans.
68ACTIVE_PEER_PID=""
69PEER_WORKDIR=""
70PROMPT_FILE=""
71PEERLOG=""
72PEERERR=""
73RAW_OUT=""
74RUN_SUCCEEDED=false
75PEER_ROUTE=""
76SESSION_STALLED=false
78cleanup_private_scratch() {
79 [ -n "${PEER_WORKDIR:-}" ] && rm -rf "$PEER_WORKDIR"
80 PEER_WORKDIR=""
83log() { printf '[cross-model-pov] %s\n' "$*" >&2; }
84skip() { log "$*"; exit 0; } # non-blocking: announce reason, exit clean, no output
86# --- model + reasoning per provider ----------------------------------------
87# ONE model per provider at its editorial tier (native Grok is xhigh; Codex and Claude stay high). Concrete IDs are the CURRENT instance of the tier principle
88# and the single maintenance point when model families change.
89M_CODEX="gpt-6.1-sol" # codex (reasoning_effort=high)
90M_CLAUDE="claude-opus-5-5" # claude (effort=high)
91M_GROK="grok-4.7" # grok CLI (reasoning_effort=xhigh)
92# Cursor's ACP server offers one preset per model and rejects effort variants,
93# so grok-cursor runs at high/fast and composer at its fast tier.
94M_GROK_CURSOR="grok-4.7[context=256k,reasoning_effort=high,fast=true]"
95M_COMPOSER="composer-2.5[fast=true]"
96# Antigravity offers only the models the account is entitled to. Argon is
97# preferred; an account without it falls back once to Flash before any prompt,
98# and the non-final retry reuses whichever model was selected.
99M_ANTIGRAVITY="gemini-4-argon" # antigravity (thinking_level=high)
100M_ANTIGRAVITY_FALLBACK="gemini-3.8-flash"
101ANTIGRAVITY_MODEL="" # set by the fallback; empty requests M_ANTIGRAVITY
103route_model() { # <route> -> the M_* constant that route requests
104 local target
105 target="$(route_target "$1")"
106 if [ -n "${CROSS_MODEL_MODEL_OVERRIDE:-}" ] &&
107 [ "${CROSS_MODEL_MODEL_OVERRIDE_TARGET:-}" = "$target" ] &&
108 [ "$target" != "cursor" ]; then
109 printf '%s' "$CROSS_MODEL_MODEL_OVERRIDE"
110 return 0
111 fi
112 case "$1" in
113 codex) printf '%s' "$M_CODEX" ;;
114 claude) printf '%s' "$M_CLAUDE" ;;
115 grok-cli) printf '%s' "$M_GROK" ;;
116 grok-cursor) printf '%s' "$M_GROK_CURSOR" ;;
117 cursor) printf 'auto' ;;
118 composer) printf '%s' "$M_COMPOSER" ;;
119 opencode) printf 'auto' ;;
120 antigravity) printf '%s' "${ANTIGRAVITY_MODEL:-$M_ANTIGRAVITY}" ;;
121 esac
124route_target() {
125 case "$1" in
126 codex|claude|cursor|composer|antigravity) printf '%s' "$1" ;;
127 grok-cli|grok-cursor) printf 'grok' ;;
128 opencode) printf 'opencode' ;;
129 esac
132route_harness() {
133 case "$1" in
134 codex) printf 'codex' ;;
135 claude) printf 'claude' ;;
136 grok-cli) printf 'grok' ;;
137 grok-cursor|cursor|composer) printf 'cursor-agent' ;;
138 opencode) printf 'opencode' ;;
139 antigravity) printf 'antigravity' ;;
140 esac
143target_serving_family() {
144 case "$1" in
145 codex|claude|grok|composer) printf '%s' "$1" ;;
146 antigravity) printf 'gemini' ;;
147 cursor) printf 'unknown' ;;
148 opencode) printf 'unknown' ;;
149 esac
152route_available() {
153 case "$1" in
154 codex) command -v codex >/dev/null 2>&1 ;;
155 claude) command -v claude >/dev/null 2>&1 ;;
156 grok-cli) command -v grok >/dev/null 2>&1 ;;
157 grok-cursor|cursor|composer) command -v cursor-agent >/dev/null 2>&1 ;;
158 opencode) command -v opencode >/dev/null 2>&1 ;;
159 antigravity) command -v agy_acp_server.par >/dev/null 2>&1 ;;
160 *) return 1 ;;
161 esac
164# --- acpx transport (keep byte-identical across migrated peer workers) -----
165# Every route reaches its agent through acpx, which speaks the Agent Client
166# Protocol and prints the JSON-RPC stream, so one parser covers every CLI.
167# The calling worker supplies route_available, route_model, and log.
168ACPX_VERSION="0.21.0"
169# 0.18.0 fixes approve-reads allowing ambiguous destructive permission requests.
170ACPX_MIN_VERSION="0.18.0"
171ACPX_UNAVAILABLE="" # pre-egress reason; ACPX_SCOPE says whether every route shares it
172ACPX_SCOPE=""
173ACPX_NPM_REFRESH=false
175acpx_package_spec() {
176 printf 'acpx@>=%s <=%s' "$ACPX_MIN_VERSION" "$ACPX_VERSION"
179acpx_agent() { # <route> -> acpx built-in agent name
180 case "$1" in
181 codex) printf 'codex' ;;
182 claude) printf 'claude' ;;
183 grok-cli) printf 'grok-build' ;;
184 grok-cursor|cursor|composer) printf 'cursor' ;;
185 opencode) printf 'opencode' ;;
186 antigravity) printf 'antigravity' ;;
187 *) return 1 ;;
188 esac
191# acpx launches Google's Antigravity ACP runtime by name. It is a separate
192# download from the agy CLI and needs its localharness_external helper from the
193# same release, beside it or named by ANTIGRAVITY_HARNESS_PATH.
194acpx_antigravity_helper() {
195 local runtime
196 if [ -n "${ANTIGRAVITY_HARNESS_PATH:-}" ]; then printf '%s' "$ANTIGRAVITY_HARNESS_PATH"; return 0; fi
197 runtime="$(command -v agy_acp_server.par 2>/dev/null)" || return 1
198 printf '%s/localharness_external' "$(dirname "$runtime")"
201# The runtime reads skills and settings from GEMINI_HOME, so every run gets a
202# private one, deleted with the caller's scratch. A file-backed login (no macOS
203# keychain) is read only from there, so it is copied in; refreshes are not
204# written back, so concurrent peers cannot clobber the user's token.
205acpx_antigravity_home() { # <private-dir> -> GEMINI_HOME path
206 local home="$1/gemini-home"
207 local token="${GEMINI_HOME:-${HOME:-}/.gemini}/antigravity-acp/acp_token.json"
208 (umask 077; mkdir -p "$home/antigravity-acp") || return 1
209 printf '{"auth":{"type":"oauth-personal"}}\n' > "$home/antigravity-acp/settings.json" || return 1
210 if [ -f "$token" ]; then
211 (umask 077; cp "$token" "$home/antigravity-acp/acp_token.json") || return 1
212 fi
213 printf '%s' "$home"
216acpx_unavailable() { # <shared|route> <reason>
217 ACPX_SCOPE="$1"; ACPX_UNAVAILABLE="$2"
218 return 1
221# acpx lets ~/.acpx/config.json and <cwd>/.acpxrc.json replace a built-in
222# agent's launch command; a route that would run something else is skipped.
223# opencode launches the installed CLI by path, which no config entry overrides.
224acpx_config_guard() { # <route> <cwd>
225 local agent file
226 [ "$1" = opencode ] && return 0
227 agent="$(acpx_agent "$1")" || return 1
228 for file in "${HOME:-}/.acpx/config.json" "$2/.acpxrc.json"; do
229 [ -e "$file" ] || continue
230 if ! jq -e --arg a "$agent" '(.agents // {}) | keys | map(ascii_downcase) | index($a) == null' "$file" >/dev/null 2>&1; then
231 acpx_unavailable route "$file overrides or cannot be checked for the '$agent' agent launch"
232 return 1
233 fi
234 done
237acpx_preflight() { # <route> <cwd>; nothing is sent to a provider here
238 local v major minor
239 ACPX_UNAVAILABLE=""; ACPX_SCOPE=""
240 command -v node >/dev/null 2>&1 || { acpx_unavailable shared "node not found; acpx needs Node 22.13 or newer"; return 1; }
241 v="$(node -p 'process.versions.node' 2>/dev/null)"
242 major="${v%%.*}"; minor="${v#*.}"; minor="${minor%%.*}"
243 case "$major$minor" in ''|*[!0-9]*) acpx_unavailable shared "cannot read the Node version; acpx needs Node 22.13 or newer"; return 1 ;; esac
244 if [ "$major" -lt 22 ] || { [ "$major" -eq 22 ] && [ "$minor" -lt 13 ]; }; then
245 acpx_unavailable shared "Node $v is too old; acpx needs Node 22.13 or newer"
246 return 1
247 fi
248 command -v npx >/dev/null 2>&1 || { acpx_unavailable shared "npx not found; acpx runs through npx"; return 1; }
249 if [ "$1" = antigravity ]; then
250 case "$(uname -s 2>/dev/null)" in
251 MINGW*|MSYS*|CYGWIN*) acpx_unavailable route "the Antigravity route is not yet supported on native Windows"; return 1 ;;
252 esac
253 route_available "$1" || { acpx_unavailable route "Antigravity's ACP runtime (agy_acp_server.par) is not on PATH; it is a separate download from the agy CLI (run ce-setup for the link)"; return 1; }
254 [ -x "$(acpx_antigravity_helper)" ] || { acpx_unavailable route "Antigravity's localharness_external helper is not beside agy_acp_server.par or at ANTIGRAVITY_HARNESS_PATH; install both from the same release (run ce-setup for the link)"; return 1; }
255 fi
256 route_available "$1" || { acpx_unavailable route "the agent CLI for route '$1' is not installed"; return 1; }
257 if [ "$1" = opencode ]; then
258 case "$(uname -s 2>/dev/null)" in
259 MINGW*|MSYS*|CYGWIN*) acpx_unavailable route "opencode launches through acpx's raw --agent command, which acpx rejects on native Windows"; return 1 ;;
260 esac
261 fi
262 acpx_config_guard "$1" "$2"
265# Claude's ACP adapter always loads project settings, so the launch goes
266# through a wrapper that adds --safe-mode.
267acpx_claude_wrapper() { # <private-dir> -> wrapper path
268 local real wrapper="$1/claude-safe-mode"
269 real="$(command -v claude)" || return 1
270 printf '#!/bin/sh\nexec %s --safe-mode "$@"\n' "$(printf '%q' "$real")" > "$wrapper" && chmod 700 "$wrapper" || return 1
271 printf '%s' "$wrapper"
274acpx_npm_failure_reason() {
275 if grep -qE 'No matching version found for .* with a date before ' "$1" 2>/dev/null; then
276 printf '%s' "npm could not resolve $(acpx_package_spec) or a dependency before its date cutoff; check min-release-age/before policy or wait for the cooldown"
277 else
278 grep -m1 '^npm error' "$1" 2>/dev/null
279 fi
282# ETARGET is ambiguous: refresh metadata once while preserving npm's date policy.
283# npm ignores prefer_online while prefer_offline is set, so refresh disables it.
284acpx_npm_resolution_retryable() { # <stderr-log> <stream-log>
285 [ "$ACPX_NPM_REFRESH" = true ] && return 1
286 grep -qE 'ETARGET|notarget|No matching version found' "$1" "$2" 2>/dev/null
289# The codex and claude adapters are pointed at the installed CLIs so the pin
290# bounds the transport version, not which models the agent can serve.
291acpx_base_argv() { # <route> <cwd> <mcp-config> <timeout-secs> <claude-wrapper> <gemini-home>
292 printf '%s\0' env
293 # An ambient Google API key or cloud project would bill it instead of the
294 # Antigravity subscription. env takes -u only before its first assignment.
295 case "$1" in
296 antigravity) printf '%s\0' -u GEMINI_API_KEY -u GOOGLE_API_KEY -u GOOGLE_APPLICATION_CREDENTIALS \
297 -u GOOGLE_CLOUD_PROJECT -u GOOGLE_CLOUD_LOCATION -u GOOGLE_GENAI_USE_VERTEXAI -u AGY_LLM_GATEWAY_URL ;;
298 esac
299 if [ "$ACPX_NPM_REFRESH" = true ]; then
300 printf '%s\0' npm_config_prefer_offline=false npm_config_fetch_retries=0 npm_config_prefer_online=true
301 else
302 printf '%s\0' npm_config_prefer_offline=true npm_config_fetch_retries=0
303 fi
304 case "$1" in
305 # codex-acp ignores app-server CLI flags.
306 codex) printf '%s\0' "CODEX_PATH=$(command -v codex)" 'CODEX_CONFIG={"features":{"plugins":false}}' ;;
307 claude) printf '%s\0' "CLAUDE_CODE_EXECUTABLE=$5" ;;
308 antigravity) printf '%s\0' "GEMINI_HOME=$6" "ANTIGRAVITY_HARNESS_PATH=$(acpx_antigravity_helper)" ;;
309 esac
310 # Explicit --package avoids npx reusing a global binary that violates npm age policy.
311 # npx also exports npm_config_package, which breaks the nested npx acpx uses
312 # to launch the codex adapter when npm is not beside node (exit 127).
313 printf '%s\0' npx -y --package "$(acpx_package_spec)" -- env -u npm_config_package acpx --cwd "$2" --format json --mcp-config "$3" --timeout "$4"
316acpx_agent_argv() { # <route>
317 # The preflight refuses opencode when it is not installed; the bare name keeps
318 # --emit-adapter output readable on a machine without it.
319 if [ "$1" = opencode ]; then printf '%s\0' --agent "$(command -v opencode || printf opencode) acp" exec
320 else printf '%s\0' "$(acpx_agent "$1")" exec; fi
323# The run's outcome is the result of its own session/prompt request, matched by
324# id because --model adds a session/set_model request before the prompt.
325acpx_outcome() { # <stream-log> -> end_turn | <other stopReason> | error | incomplete | not-sent
326 local id outcome
327 id="$(jq -R 'fromjson? | select(.method == "session/prompt") | .id' "$1" 2>/dev/null | tail -1)"
328 [ -n "$id" ] || { printf 'not-sent'; return 0; }
329 outcome="$(jq -rR --argjson id "$id" 'fromjson? | select(.method == null and .id == $id) | if .error then "error" else (.result.stopReason // "error") end' "$1" 2>/dev/null | tail -1)"
330 printf '%s' "${outcome:-incomplete}"
333# Antigravity holds session/new open while it waits for a browser sign-in it
334# will never get, so a run without a usable login would otherwise sit out the
335# whole idle cap. Nothing has been sent while the session is pending.
336ACPX_SESSION_START_SECS="${CROSS_MODEL_SESSION_START_SECS:-60}"
337acpx_session_state() { # <stream-log> -> starting | pending | open
338 local id
339 jq -eR 'fromjson? | select(.result.protocolVersion? != null)' "$1" >/dev/null 2>&1 || { printf 'starting'; return 0; }
340 id="$(jq -R 'fromjson? | select(.method == "session/new") | .id' "$1" 2>/dev/null | tail -1)"
341 if [ -n "$id" ] && jq -eR --argjson id "$id" 'fromjson? | select(.method == null and .id == $id)' "$1" >/dev/null 2>&1; then
342 printf 'open'
343 else
344 printf 'pending'
345 fi
348acpx_text() { # <stream-log> <outfile>: the agent's reply text
349 jq -jR 'fromjson? | select(.method == "session/update" and .params.update.sessionUpdate == "agent_message_chunk") | .params.update.content.text // empty' "$1" > "$2" 2>/dev/null
352# Model ids the adapter reports in the prompt result's _meta. acpx documents
353# _meta as adapter-defined, so these are the adapter's assertion, not proof.
354acpx_served_models() { # <stream-log> -> "<model> <tokens>" lines
355 jq -rR 'fromjson? | select(.result.stopReason?) | .result._meta // {} |
356 ((.quota.model_usage // [])[] | "\(.model) \(.token_count.totalTokens // 0)"),
357 (.modelId // empty | "\(.) 0")' "$1" 2>/dev/null
360# Bounded failure evidence from the stream. The stream echoes the outbound
361# prompt, so only error messages and the stop reason are read from it.
362acpx_failure_evidence() { # <stream-log>
363 local evidence
364 evidence="$(jq -rR 'fromjson? | (.error.message? // empty), (.result.stopReason? // empty | select(. != "end_turn") | "stopReason=" + .)' "$1" 2>/dev/null | tr '\n' ' ')"
365 evidence="${evidence% }"
366 [ "${#evidence}" -gt 300 ] && evidence="${evidence:0:147} ... ${evidence: -147}"
367 printf '%s' "$evidence"
370# codex-acp streams a provider rejection (such as a model the account may not
371# use) as reply text and still ends the turn with end_turn, so only the reply
372# carries it. Bounded inside jq so a long reply never enters a bash string.
373acpx_reply_evidence() { # <stream-log>
374 jq -nrR '[inputs | fromjson? | select(.method == "session/update" and .params.update.sessionUpdate == "agent_message_chunk") | .params.update.content.text // empty]
375 | join("") | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ")
376 | if length > 300 then .[0:147] + " ... " + .[-147:] else . end' "$1" 2>/dev/null
379acpx_advertised_models() { # <stream-log>
380 jq -nrR '[inputs | fromjson? | .result.models.availableModels[]?.modelId | sub("\\[.*$"; "")]
381 | reduce .[] as $m ([]; if any(.[]; . == $m) then . else . + [$m] end)
382 | join(", ") | if length > 300 then .[0:296] + " ..." else . end' "$1" 2>/dev/null
385# Exit 5 is acpx's permission refusal. Paired with stopReason cancelled, the
386# refusal ended the turn. Exit 5 with end_turn means the turn finished. A
387# cancelled turn with any other exit is an interrupt.
388acpx_permission_cancel_reason() { # <stream-log> <exit-code>
389 [ "$(acpx_outcome "$1")" = cancelled ] && [ "$2" = 5 ] || return 0
390 printf '%s' "permission request cancelled the turn"
393acpx_node_startup_line() { # <stderr-log>
394 local line=""
395 line="$(grep -m1 -E '^Error ' "$1" 2>/dev/null)"
396 [ -n "$line" ] || line="$(grep -m1 -E '^node:' "$1" 2>/dev/null)"
397 printf '%s' "$line"
399# --- end acpx transport -----------------------------------------------------
401# --- model-identity receipt ---------------------------------------------------
402# "Which model ran" comes from the adapter's _meta report, recorded as the
403# served id; it is never filled from the requested value. Prefer the served id
404# in the requested family (Claude also reports an auxiliary Haiku); otherwise
405# record the model that used the most tokens and warn about the mismatch.
406MODEL_ACTUAL="unverified"
407extract_model_receipt() { # <route>; reads $PEERLOG, sets MODEL_ACTUAL
408 local requested family served m tokens most=-1 heaviest=""
409 MODEL_ACTUAL="unverified"
410 requested="$(route_model "$1")"
411 family="${requested%%\[*}"
412 case "$family" in
413 fable|opus|sonnet|haiku) family="claude-$family" ;;
414 esac
415 served="$(acpx_served_models "$PEERLOG")"
416 [ -n "$served" ] || return 0
417 while read -r m tokens; do
418 [ -n "$m" ] || continue
419 case "$m" in
420 "$family"|"$family"-*|"$family"\[*) MODEL_ACTUAL="$m"; return 0 ;;
421 esac
422 if [ "${tokens:-0}" -gt "$most" ]; then most="${tokens:-0}"; heaviest="$m"; fi
423 done <<EOF
424$served
425EOF
426 MODEL_ACTUAL="$heaviest"
427 # A route that requested no model (Cursor default, OpenCode auto) has nothing to mismatch.
428 [ "$requested" = auto ] && return 0
429 log "WARNING: model mismatch - requested $requested, adapter reported $MODEL_ACTUAL; reconcile must surface this"
432# --- adapter argv (single source of truth for route flags) -----------------
433# Read-only intent on every route: reads are approved, every other permission
434# request is denied, and each adapter runs in its read-only mode where it has
435# one (codex mode=read-only, claude mode=default, cursor mode=ask, opencode
436# mode=plan with edits denied, antigravity mode=default). Claude's --allowed-tools is an auto-approve
437# list, not a restriction: its other tools still ask and are denied. The
438# codex adapter does not enforce its read-only mode, so write denial is best
439# effort. PEER_WORKDIR / PROMPT_FILE / MCP_CONFIG / CLAUDE_WRAPPER / GEMINI_HOME_DIR are resolved
440# by the caller (placeholders in --emit-adapter mode).
441adapter_argv() {
442 local route="$1" model
443 acpx_agent "$route" >/dev/null || return 1
444 model="$(route_model "$route")"
445 case "$route" in
446 # OpenCode's plan mode keeps the peer read-only; denying bash outright makes
447 # its free tier reject the session over ACP.
448 opencode) printf '%s\0' env OPENCODE_DISABLE_PROJECT_CONFIG=1 \
449 'OPENCODE_CONFIG_CONTENT={"permission":{"edit":"deny","webfetch":"deny","task":"deny"}}' ;;
450 esac
451 acpx_base_argv "$route" "$READ_ROOT" "$MCP_CONFIG" "$HARD_SECS" "$CLAUDE_WRAPPER" "$GEMINI_HOME_DIR"
452 printf '%s\0' --approve-reads --non-interactive-permissions deny
453 case "$route" in
454 claude) printf '%s\0' --allowed-tools Read,Glob,Grep,WebSearch,WebFetch --max-turns 15 ;;
455 esac
456 [ "$model" = auto ] || printf '%s\0' --model "$model"
457 acpx_agent_argv "$route"
458 case "$route" in
459 codex) printf '%s\0' --config-option mode=read-only --config-option reasoning_effort=high ;;
460 claude) printf '%s\0' --config-option mode=default --config-option effort=high ;;
461 grok-cli) printf '%s\0' --config-option reasoning_effort=xhigh ;;
462 grok-cursor|cursor|composer) printf '%s\0' --config-option mode=ask ;;
463 opencode) printf '%s\0' --config-option mode=plan ;;
464 antigravity) printf '%s\0' --config-option mode=default --config-option thinking_level=high ;;
465 esac
466 printf '%s\0' --file "$PROMPT_FILE"
469# The host may replace a stale concrete model only within the fixed route's
470# target family. Values are passed as one argv token; they never enter eval.
471# A codex id may carry the serving provider's own namespace (openai.gpt-...)
472# when the CLI routes through a non-default model_provider.
473apply_model_override() {
474 local route="$1" override="${CROSS_MODEL_MODEL_OVERRIDE:-}" override_target="${CROSS_MODEL_MODEL_OVERRIDE_TARGET:-}" target
475 [ -n "$override" ] || { [ -z "$override_target" ]; return; }
476 target="$(route_target "$route")" || return 1
477 [ "$override_target" = "$target" ] || return 1
478 [ "$target" != "cursor" ] || return 1
479 case "$route:$override" in
480 codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]* ) ;;
481 claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:claude-* ) ;;
482 grok-cli:grok-* ) ;;
483 grok-cursor:grok-*|grok-cursor:cursor-grok-* ) ;;
484 composer:composer-* ) ;;
485 opencode:*/* ) ;;
486 antigravity:gemini-* ) ;;
487 *) return 1 ;;
488 esac
491# --- --emit-adapter <route>: print the argv, no model call, no side effects --
492if [ "${1:-}" = "--emit-adapter" ]; then
493 READ_ROOT="<read-root>"
494 PROMPT_FILE="<prompt-file>"; MCP_CONFIG="<mcp-config>"; CLAUDE_WRAPPER="<claude-safe-mode-wrapper>"; GEMINI_HOME_DIR="<gemini-home>"
495 HARD_SECS="${CROSS_MODEL_HARD_SECS:-600}"
496 route="${2:-}"
497 apply_model_override "$route" 2>/dev/null || { echo "model override '${CROSS_MODEL_MODEL_OVERRIDE:-}' not compatible with route '$route'" >&2; exit 2; }
498 # adapter_argv emits NUL-delimited argv (can't be captured in a shell var), so
499 # validate the route first, then render for humans with NUL -> space.
500 adapter_argv "$route" >/dev/null 2>&1 || { echo "unknown route '$route' (want codex|claude|grok-cli|grok-cursor|cursor|composer|opencode|antigravity)" >&2; exit 2; }
501 adapter_argv "$route" | tr '\0' ' '; echo
502 exit 0
503fi
505HOST_PROVIDER="${1:-unknown}"
506HOST_HARNESS="${CROSS_MODEL_HOST_HARNESS:-unknown}"
507FIXED_ROUTE="${2:-}"
508PAYLOAD_PATH="${3:-}"
509RUN_DIR="${4:-}"
511# --- validate inputs -------------------------------------------------------
512[ -n "$PAYLOAD_PATH" ] && [ -f "$PAYLOAD_PATH" ] || skip "subject payload '${PAYLOAD_PATH:-<empty>}' not readable on disk; skipping"
513READ_ROOT="${CROSS_MODEL_READ_ROOT:-$(pwd -P)}"
514[ -d "$READ_ROOT" ] || skip "declared repository/read root '$READ_ROOT' is not a directory"
515READ_ROOT="$(cd "$READ_ROOT" && pwd -P)" || skip "cannot resolve repository/read root '$READ_ROOT'"
516if [ -n "${CROSS_MODEL_REPO_ROOT:-}" ]; then
517 REPO_ROOT="$CROSS_MODEL_REPO_ROOT"
518elif command -v git >/dev/null 2>&1 && _git_root="$(git -C "$READ_ROOT" rev-parse --show-toplevel 2>/dev/null)"; then
519 REPO_ROOT="$_git_root"
520else
521 REPO_ROOT="$(pwd -P)"
522fi
523[ -d "$REPO_ROOT" ] || skip "declared repository root '$REPO_ROOT' is not a directory"
524REPO_ROOT="$(cd "$REPO_ROOT" && pwd -P)" || skip "cannot resolve repository root '$REPO_ROOT'"
525case "$READ_ROOT/" in "$REPO_ROOT/"*) ;; *) skip "read root '$READ_ROOT' is outside repository root '$REPO_ROOT'" ;; esac
527[ -n "$RUN_DIR" ] || skip "run-dir not given; skipping"
528if [ -d "$RUN_DIR" ]; then
529 RUN_DIR_RESOLVED="$(cd "$RUN_DIR" && pwd -P)" || skip "cannot resolve run-dir '$RUN_DIR'"
530else
531 RUN_PARENT="$(dirname "$RUN_DIR")"
532 RUN_BASENAME="$(basename "$RUN_DIR")"
533 [ -d "$RUN_PARENT" ] || skip "run-dir parent '$RUN_PARENT' is not a directory"
534 RUN_PARENT="$(cd "$RUN_PARENT" && pwd -P)" || skip "cannot resolve run-dir parent '$RUN_PARENT'"
535 RUN_DIR_RESOLVED="$RUN_PARENT/$RUN_BASENAME"
536fi
537case "$RUN_DIR_RESOLVED/" in "$REPO_ROOT/"*) skip "run-dir must be outside the repository" ;; esac
538[ -d "$RUN_DIR_RESOLVED" ] || skip "run-dir '$RUN_DIR' must already exist"
539RUN_DIR="$RUN_DIR_RESOLVED"
540chmod 700 "$RUN_DIR" 2>/dev/null || skip "run-dir '$RUN_DIR' could not be made private"
541command -v jq >/dev/null 2>&1 || skip "jq not installed; skipping"
542INCLUDE_PATHS="${CROSS_MODEL_INCLUDE_PATHS:-}"
543EXCLUDE_PATHS="${CROSS_MODEL_EXCLUDE_PATHS:-}"
545case "$HOST_PROVIDER" in
546 codex|claude|grok|composer|gemini|unknown) ;;
547 *) skip "host serving family '$HOST_PROVIDER' invalid (want codex|claude|grok|composer|gemini|unknown)" ;;
548esac
549case "$HOST_HARNESS" in
550 codex|claude|grok|cursor|opencode|antigravity|omp|unknown) ;;
551 *) skip "host harness '$HOST_HARNESS' invalid (want codex|claude|grok|cursor|opencode|antigravity|omp|unknown)" ;;
552esac
554case "$FIXED_ROUTE" in
555 codex|claude|grok-cli|grok-cursor|cursor|composer|opencode|antigravity) ;;
556 *) skip "unknown fixed route '${FIXED_ROUTE:-<empty>}'; host must resolve one route before egress" ;;
557esac
558TARGET="$(route_target "$FIXED_ROUTE")" || skip "unknown fixed route '${FIXED_ROUTE:-<empty>}'; host must resolve one route before egress"
559apply_model_override "$FIXED_ROUTE" || skip "model override '${CROSS_MODEL_MODEL_OVERRIDE:-}' not compatible with route '$FIXED_ROUTE'"
560# An Antigravity host can serve several families; never send it to itself.
561if [ "$FIXED_ROUTE" = antigravity ] && { [ "$HOST_PROVIDER" = gemini ] || [ "$HOST_HARNESS" = antigravity ]; }; then
562 skip "fixed route 'antigravity' is the host's own Gemini family or Antigravity harness; skipping before egress"
563fi
565# --- self-locate skill root + canonical sibling files ----------------------
566SKILL_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || skip "cannot resolve skill root; skipping"
567PERSONA="$SKILL_ROOT/references/agents/pov-peer.md"
568SCHEMA="$SKILL_ROOT/references/pov-schema.json"
569[ -f "$PERSONA" ] || skip "persona brief not found at $PERSONA; skipping"
570[ -f "$SCHEMA" ] || skip "POV schema not found at $SCHEMA; skipping"
571SCHEMA_CONTENT="$(cat "$SCHEMA")" || skip "cannot read POV schema; skipping"
573# --- validate the host-resolved fixed route and egress allowlist -------------
574ALLOW="${CROSS_MODEL_PEERS:-}" # optional egress allowlist
576in_csv() { case ",$2," in *",$1,"*) return 0 ;; *) return 1 ;; esac; }
577# Require a usable POV, not merely valid JSON. Error envelopes and incomplete
578# objects fail the fixed route and return control to the host without publishing
579# a cross-check artifact.
580pov_shaped() { # <file>: schema-shaped POV (finality is a separate gate)
581 [ -s "$1" ] && jq -e \
582 '(.voice|type)=="string" and (.voice|length)>0 and (.position|type)=="string" and (.position|length)>0 and (.reasoning|type)=="string" and (.reasoning|length)>0 and (.evidence|type)=="array" and all(.evidence[]; type=="string" and length>0) and (.external_check=="ran" or .external_check=="unavailable") and (.mode=="independent" or .mode=="skeptic") and (.movement=="initial" or .movement=="moved" or .movement=="held")' \
583 "$1" >/dev/null 2>&1
585out_missing_or_invalid() { ! pov_shaped "$RAW_OUT"; }
587# A usable position is a settled answer to the framed question. The peer
588# declares that itself through the schema's required `final` boolean: a
589# schema-shaped artifact whose `final` is not true is non-final (a placeholder
590# emitted before the peer finished inspecting -- observed on grok-cli in the
591# #1402 panel, where the model returned its final schema object on turn one)
592# and must not be published as a peer voice. Finality lives in the owned output
593# contract, never in a phrase list over model prose.
594out_final() { [ -s "$RAW_OUT" ] && jq -e '.final == true' "$RAW_OUT" >/dev/null 2>&1; }
596# Backward-compatible matrix: legacy `composer` continues to sanction Cursor as
597# the Grok intermediary, while the distinct Cursor-default target requires the
598# new `cursor` key. Composer itself remains sanctioned by `composer`.
599route_allowlisted() {
600 [ -z "$ALLOW" ] && return 0
601 case "$1" in
602 codex|claude|grok-cli) in_csv "$(route_target "$1")" "$ALLOW" ;;
603 cursor) in_csv cursor "$ALLOW" ;;
604 composer) in_csv composer "$ALLOW" ;;
605 grok-cursor)
606 in_csv grok "$ALLOW" && { in_csv cursor "$ALLOW" || in_csv composer "$ALLOW"; }
607 ;;
608 opencode|antigravity) in_csv "$1" "$ALLOW" ;;
609 *) return 1 ;;
610 esac
613# Soft size gate: peer prompt embeds the full subject payload. Over-budget payloads skip
614# cleanly rather than collapsing silently inside the provider context window.
615MAX_PAYLOAD_CHARS="${CROSS_MODEL_MAX_PAYLOAD_CHARS:-200000}"
616case "$MAX_PAYLOAD_CHARS" in ''|*[!0-9]*) MAX_PAYLOAD_CHARS=200000 ;; esac
617PAYLOAD_CHARS="$(wc -c <"$PAYLOAD_PATH" | tr -d '[:space:]')"
618if [ "$PAYLOAD_CHARS" -gt "$MAX_PAYLOAD_CHARS" ]; then
619 skip "subject payload is ${PAYLOAD_CHARS} bytes (limit ${MAX_PAYLOAD_CHARS}); skipping cross-model pass rather than truncating"
620fi
622# The Codex desktop app (Codex.app, or ChatGPT.app since the July 2026 merger)
623# ships `codex` at Contents/Resources without linking it onto PATH (#1272).
624# Append, never prepend, so a PATH-installed CLI stays authoritative.
625# CROSS_MODEL_CODEX_APP_DIRS (colon-separated) overrides the probed dirs.
626if ! command -v codex >/dev/null 2>&1; then
627 OLDIFS="$IFS"; IFS=':'
628 for d in ${CROSS_MODEL_CODEX_APP_DIRS-"${HOME:-}/Applications/ChatGPT.app/Contents/Resources:/Applications/ChatGPT.app/Contents/Resources:${HOME:-}/Applications/Codex.app/Contents/Resources:/Applications/Codex.app/Contents/Resources"}; do
629 if [ -n "$d" ] && [ -x "$d/codex" ]; then PATH="${PATH:+$PATH:}$d"; export PATH; break; fi
630 done
631 IFS="$OLDIFS"
632fi
634route_allowlisted "$FIXED_ROUTE" || skip "fixed route '$FIXED_ROUTE' is not fully sanctioned by CROSS_MODEL_PEERS; skipping before egress"
635acpx_preflight "$FIXED_ROUTE" "$READ_ROOT" || skip "transport unavailable (pre-egress, $ACPX_SCOPE): $ACPX_UNAVAILABLE"
636log "fixed cross-model POV route: target=$TARGET route=$FIXED_ROUTE (host $HOST_PROVIDER excluded)"
638# --- compose the peer prompt from the canonical persona (single source) ----
639# The payload is prepared by ce-pov and embeds the framed question plus any
640# conversation-only subject material needed for this round. Repository evidence
641# stays in the shared working tree for the peer to inspect directly.
642SCRATCH_PARENT="${CROSS_MODEL_SCRATCH_PARENT:-${TMPDIR:-/tmp}}"
643[ -d "$SCRATCH_PARENT" ] || mkdir -p "$SCRATCH_PARENT" 2>/dev/null || skip "private scratch parent '$SCRATCH_PARENT' unavailable"
644SCRATCH_PARENT="$(cd "$SCRATCH_PARENT" && pwd -P)" || skip "cannot resolve private scratch parent"
645case "$SCRATCH_PARENT/" in "$REPO_ROOT/"*) skip "private scratch parent must be outside the repository" ;; esac
646if ! PEER_WORKDIR="$(mktemp -d "$SCRATCH_PARENT/xmodel-pov-peer-XXXXXX")"; then
647 skip "provider $TARGET workspace isolation unavailable; skipping provider"
648fi
649trap 'cleanup_private_scratch' EXIT
650chmod 700 "$PEER_WORKDIR" 2>/dev/null || { cleanup_private_scratch; skip "cannot make peer scratch private"; }
651PROMPT_FILE="$PEER_WORKDIR/prompt.md"
652PEERLOG="$PEER_WORKDIR/stdout.log"
653# acpx prints the ACP stream on stdout; npm and adapter diagnostics go to stderr.
654PEERERR="$PEER_WORKDIR/stderr.log"
655RAW_OUT="$PEER_WORKDIR/pov-$TARGET.raw.json"
656TEXT_OUT="$PEER_WORKDIR/reply.txt"
657MCP_CONFIG="$PEER_WORKDIR/mcp.json"
658: > "$PROMPT_FILE"; : > "$PEERLOG"; : > "$PEERERR"
659printf '{"mcpServers":[]}\n' > "$MCP_CONFIG"
660chmod 600 "$PROMPT_FILE" "$PEERLOG" "$PEERERR" "$MCP_CONFIG" 2>/dev/null || { cleanup_private_scratch; skip "cannot make peer scratch files private"; }
661CLAUDE_WRAPPER=""
662if [ "$FIXED_ROUTE" = claude ]; then
663 CLAUDE_WRAPPER="$(acpx_claude_wrapper "$PEER_WORKDIR")" || { cleanup_private_scratch; skip "transport unavailable (pre-egress, route): cannot prepare the Claude --safe-mode launcher"; }
664fi
665GEMINI_HOME_DIR=""
666if [ "$FIXED_ROUTE" = antigravity ]; then
667 GEMINI_HOME_DIR="$(acpx_antigravity_home "$PEER_WORKDIR")" || { cleanup_private_scratch; skip "transport unavailable (pre-egress, route): cannot prepare a private Antigravity home"; }
668fi
670 cat "$PERSONA"
671 printf '\n\n---\n\n'
672 printf 'This is an authorized, read-only point-of-view cross-check on the maintainer\047s own project.\n'
673 printf 'Return ONE JSON object and nothing else (no prose, no code fence) matching this schema:\n\n'
674 printf '%s' "$SCHEMA_CONTENT"
675 printf '\n\nSet the top-level "voice" field to "peer" (it will be namespaced to the provider on fold-in).\n'
676 printf '\n<repository-read-scope enforcement="cooperative-unless-adapter-supported">\n'
677 printf 'root: %s\nincludes: %s\nexcludes: %s\n' "$READ_ROOT" "${INCLUDE_PATHS:-<all>}" "${EXCLUDE_PATHS:-<none>}"
678 printf '</repository-read-scope>\n'
679 printf '\n<subject-payload>\n'
680 cat "$PAYLOAD_PATH"
681 printf '\n</subject-payload>\n'
682} > "$PROMPT_FILE"
683# codex-acp's only refusal option is cancel, which ends the turn.
684if [ "$FIXED_ROUTE" = codex ]; then
685 printf '\nYour sandbox is read-only and no approval is available: never request escalated or out-of-sandbox permissions (for example to launch a browser, use the network, or write outside the repository). Any permission request ends this review with no result. When a check needs more access than the sandbox gives, reason from the source instead and name the unrun check in reasoning.\n' >> "$PROMPT_FILE"
686fi
688# --- run machinery -----------------------------------------------------------
689# Every route streams the ACP session, so the idle cap is the liveness guard and
690# HARD_SECS backstops a peer that stays productive past any useful budget. acpx
691# gets the same budget as --timeout, but it applies that per phase, so this
692# script's wall clock stays authoritative. This skill's default HARD_SECS stays
693# at 600s because its codex route runs the lower sol/high tier -- ce-code-review
694# and ce-doc-review run luna/xhigh and default higher. `CROSS_MODEL_HARD_SECS`
695# is shared across all three, and the orchestrator's aggregate deadline derives
696# from it (see references/cross-model-panel.md), so a raised knob raises both windows.
697IDLE_SECS="${CROSS_MODEL_IDLE_SECS:-180}"
698HARD_SECS="${CROSS_MODEL_HARD_SECS:-600}"
699RETRY_MIN_SECS="${CROSS_MODEL_RETRY_MIN_SECS:-60}" # least window worth spending on a non-final retry
701# Reap a backgrounded job's whole process group: TERM, then KILL after a grace.
702# True while $1 is a live (non-zombie) process. kill -0 succeeds on zombies
703# until wait reaps them, so idle polls must not treat zombies as still running.
704# macOS/BSD often report defunct state as "Z+" (not bare "Z").
705# Match peer-job-runner._pid_running: empty state after ps means not alive
706# (avoids zombie spin). Fall back to kill -0 only when ps itself is missing.
707peer_alive() {
708 local st
709 kill -0 "$1" 2>/dev/null || return 1
710 if ! command -v ps >/dev/null 2>&1; then
711 return 0
712 fi
713 # Git Bash's ps has no -o; fall back to its -p lookup, which has no zombie state.
714 if ! st="$(ps -o state= -p "$1" 2>/dev/null)"; then
715 ps -p "$1" >/dev/null 2>&1
716 return
717 fi
718 st="$(printf '%s' "$st" | tr -d ' \n')"
719 [ -n "$st" ] || return 1
720 [ "${st#Z}" = "$st" ]
723reap() {
724 # Signal the process group and grace-poll without wait(). The caller alone
725 # wait()s the leader so PEER_EXIT is the real exit status — a second
726 # wait here would fail after we already reaped and mark healthy exits as
727 # timed-out (#1270 Bugbot). No background KILL timer: orphaned timers can
728 # hit recycled PIDs under bun --parallel.
729 local pid="$1" grp
730 if kill -TERM -- -"$pid" 2>/dev/null; then grp=1; else kill -TERM "$pid" 2>/dev/null || true; grp=0; fi
731 for _ in 1 2 3 4 5; do
732 if ! peer_alive "$pid"; then
733 # Leader exited/zombied — sweep any group survivors; caller wait()s.
734 [ "$grp" = 1 ] && kill -KILL -- -"$pid" 2>/dev/null || true
735 return 0
736 fi
737 sleep 1
738 done
739 if [ "$grp" = 1 ]; then kill -KILL -- -"$pid" 2>/dev/null; else kill -KILL "$pid" 2>/dev/null; fi
742# TERM/INT: reap the live peer group, then exit cleanly (HUP remains ignored).
743on_term() {
744 if [ -n "${_HEARTBEAT_PID:-}" ]; then
745 stop_heartbeat
746 fi
747 if [ -n "${ACTIVE_PEER_PID:-}" ]; then
748 log "received TERM/INT; reaping peer process group $ACTIVE_PEER_PID"
749 _term_peer="$ACTIVE_PEER_PID"
750 reap "$_term_peer" 2>/dev/null || true
751 # reap only signals the group; wait reaps the leader so it cannot orphan.
752 wait "$_term_peer" 2>/dev/null || true
753 ACTIVE_PEER_PID=""
754 fi
755 exit 0
757trap 'on_term' TERM INT
759# Build the CMD array for a route (bash 3.2-safe: no mapfile).
760build_cmd() {
761 CMD=()
762 # NUL-delimited so a token containing spaces or newlines stays ONE argv element.
763 while IFS= read -r -d '' tok; do CMD+=("$tok"); done < <(adapter_argv "$1")
766# --- liveness heartbeat -----------------------------------------------------
767# The peer CLI streams into $PEERLOG (private), so nothing reaches this script's
768# own stdout/stderr during a long model call. An outer supervisor that watches
769# THIS process's output for liveness (the peer-job runner's out.log byte-growth
770# idle window) would mistake a healthy multi-minute run for a wedge. A background
771# writer emits one stderr line every CROSS_MODEL_HEARTBEAT_SECS (default 60s) so
772# that liveness is visible; it is torn down as soon as the foreground wait returns,
773# so it adds no latency to a fast run. Keep this block byte-identical across
774# the peer worker scripts (lifecycle parity).
775_HEARTBEAT_PID=""
776start_heartbeat() {
777 local every="${CROSS_MODEL_HEARTBEAT_SECS:-60}" parent_pid="$$"
778 # Floor to 1s: a non-numeric or 0 value would make `sleep` return instantly and
779 # spin the loop, flooding out.log into the runner's byte cap.
780 case "$every" in ''|*[!0-9]*) every=60 ;; esac; [ "$every" -lt 1 ] && every=1
781 _HEARTBEAT_READY=0
782 trap '_HEARTBEAT_READY=1' USR1
783 # Callers restore set +m after launching the peer, so without this the
784 # heartbeat inherits the worker pgid and kill -- -PID cannot reach the sleep.
785 local prev_m; case "$-" in *m*) prev_m=1;; *) prev_m=0;; esac
786 set -m
787 ( local t0 n sleeper=""
788 trap 'kill "${sleeper:-}" 2>/dev/null || true; exit 0' TERM INT
789 kill -USR1 "$parent_pid"
790 t0="$(date +%s)"
791 while kill -0 "$parent_pid" 2>/dev/null; do
792 sleep "$every" & sleeper=$!
793 wait "$sleeper" 2>/dev/null || exit 0
794 sleeper=""
795 kill -0 "$parent_pid" 2>/dev/null || break
796 n="$(date +%s)"; log "peer alive ($(( n - t0 ))s elapsed)"
797 done ) &
798 _HEARTBEAT_PID=$!
799 [ "$prev_m" = 0 ] && set +m
800 while [ "$_HEARTBEAT_READY" != 1 ] && kill -0 "$_HEARTBEAT_PID" 2>/dev/null; do sleep 0.01 || true; done
801 trap - USR1
803stop_heartbeat() {
804 if [ -n "$_HEARTBEAT_PID" ]; then
805 # Leader-only TERM is deferred until the inner `wait $sleeper` returns, so
806 # the default 60s interval would block this wait. Signal the process group.
807 kill -- -"$_HEARTBEAT_PID" 2>/dev/null || kill "$_HEARTBEAT_PID" 2>/dev/null || true
808 wait "$_HEARTBEAT_PID" 2>/dev/null || true
809 fi
810 _HEARTBEAT_PID=""
813run_peer_cmd() { # CMD already built for PEER_ROUTE; streams to PEERLOG, diagnostics to PEERERR
814 local prev; case "$-" in *m*) prev=1;; *) prev=0;; esac
815 SESSION_STALLED=false
816 set -m
817 # Start npx from private scratch: npx resolves packages from its working
818 # directory's node_modules and .npmrc first, and acpx gets the agent's cwd from --cwd.
819 ( cd "$(dirname "$PEERLOG")" && exec "${CMD[@]}" ) < /dev/null > "$PEERLOG" 2>"$PEERERR" &
820 local pid=$!
821 ACTIVE_PEER_PID="$pid"
822 [ "$prev" = 0 ] && set +m
823 start_heartbeat
824 local start last=-1 lastchg now size session=open
825 [ "$PEER_ROUTE" = antigravity ] && session=starting
826 start="$(date +%s)"; lastchg="$start"
827 while peer_alive "$pid"; do
828 now="$(date +%s)"; size="$(wc -c <"$PEERLOG" 2>/dev/null || echo 0)"
829 [ "$size" != "$last" ] && { last="$size"; lastchg="$now"; }
830 if [ $(( now - lastchg )) -ge "$IDLE_SECS" ]; then
831 log "peer output idle ${IDLE_SECS}s; reaping peer process group"; reap "$pid"; break
832 fi
833 if [ $(( now - start )) -ge "$HARD_SECS" ]; then
834 log "peer exceeded hard cap ${HARD_SECS}s; reaping peer process group"; reap "$pid"; break
835 fi
836 if [ "$session" != open ] && [ $(( now - start )) -ge "$ACPX_SESSION_START_SECS" ]; then
837 session="$(acpx_session_state "$PEERLOG")"
838 if [ "$session" = pending ]; then
839 SESSION_STALLED=true
840 log "Antigravity opened no session within ${ACPX_SESSION_START_SECS}s; reaping peer process group"; reap "$pid"; break
841 fi
842 fi
843 sleep 1
844 done
845 wait "$pid" 2>/dev/null
846 PEER_EXIT=$?
847 # Sweep any survivor the provider left in its OWN process group. `set -m` puts
848 # the provider in a separate pgid, and on a clean worker exit the runner's
849 # final sweep only kills the worker's pgid while a group-orphan reparents off
850 # the worker's process tree -- so it must be reaped here, where the pgid is
851 # known. reap() returns immediately when the group is already empty.
852 reap "$pid" 2>/dev/null || true
853 stop_heartbeat
854 ACTIVE_PEER_PID=""
857# Recover a POV object from the agent's reply text, which may wrap it in prose or a fence.
858recover_pov_json() { # <logfile> <outfile>
859 # Probe execution, not just PATH presence — Windows Store's python3 stub
860 # satisfies `command -v` then exits nonzero (see resolve-python convention).
861 local py
862 py="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"
863 [ -n "$py" ] || return 1
864 "$py" - "$1" "$2" <<'PY' 2>/dev/null
865import sys, json
866txt = open(sys.argv[1], encoding="utf-8", errors="replace").read()
867best = None
868best_score = -1
869decoder = json.JSONDecoder()
870def shaped(d):
871 # Mirror of pov_shaped() in the shell: the same field types and enums,
872 # so ranking cannot promote a fully keyed but invalid draft.
873 return (
874 isinstance(d.get("voice"), str) and d["voice"] != ""
875 and isinstance(d.get("position"), str) and d["position"] != ""
876 and isinstance(d.get("reasoning"), str) and d["reasoning"] != ""
877 and isinstance(d.get("evidence"), list)
878 and all(isinstance(e, str) and e != "" for e in d["evidence"])
879 and d.get("external_check") in ("ran", "unavailable")
880 and d.get("mode") in ("independent", "skeptic")
881 and d.get("movement") in ("initial", "moved", "held")
882 )
884def score(d):
885 # Prefer a schema-shaped final POV over a shaped non-final one over any
886 # dict that merely carries a position; ties go to the later candidate.
887 if shaped(d) and d.get("final") is True:
888 return 2
889 if shaped(d):
890 return 1
891 return 0
893def inspect(value):
894 global best, best_score
895 if isinstance(value, dict):
896 if "position" in value:
897 sc = score(value)
898 if sc >= best_score:
899 best, best_score = value, sc
900 for child in value.values():
901 inspect(child)
902 elif isinstance(value, list):
903 for child in value:
904 inspect(child)
905 elif isinstance(value, str):
906 for i, ch in enumerate(value):
907 if ch not in "{[":
908 continue
909 try:
910 child, _ = decoder.raw_decode(value, i)
911 inspect(child)
912 except Exception:
913 pass
915inspect(txt)
916if best is not None: open(sys.argv[2], "w").write(json.dumps(best))
917PY
918 [ -s "$2" ]
921bounded_failure_evidence() { # <logfile>; bounded head+tail of a plain-text log
922 local path="$1" evidence
923 # bash 3.2 rewrites newlines in a large string superlinearly, so read only the
924 # ends of a long log instead of the whole file.
925 if [ "$(wc -c <"$path")" -le 600 ]; then evidence="$(cat "$path")"
926 else IFS= read -r -d '' -n 300 evidence <"$path"; evidence="$evidence ... $(tail -c 300 "$path")"; fi
927 evidence="${evidence//$'\n'/ }"
928 if [ "${#evidence}" -gt 300 ]; then
929 evidence="${evidence:0:147} ... ${evidence: -147}"
930 fi
931 printf '%s' "$evidence"
934# Run one route for a provider; leaves a schema-shaped (pre-normalization) $RAW_OUT on success.
935# Success is the prompt's own end_turn result, not acpx's exit status.
936attempt_route() { # <provider> <route>
937 local provider="$1" route="$2" outcome reason started remaining node_line cancel_reason
938 : > "$PEERLOG"; : > "$PEERERR"; rm -f "$RAW_OUT" "$OUT" "$TEXT_OUT"
939 RUN_SUCCEEDED=false
940 build_cmd "$route"
941 PEER_ROUTE="$route"
942 log "peer run: provider=$provider route=$route model=$(route_model "$route") transport=$(acpx_package_spec) POV read-only least-privilege (idle ${IDLE_SECS}s / hard ${HARD_SECS}s)"
943 started="$(date +%s)"
944 run_peer_cmd
945 if acpx_npm_resolution_retryable "$PEERERR" "$PEERLOG" && [ "$(acpx_outcome "$PEERLOG")" = not-sent ]; then
946 remaining=$(( HARD_SECS - ( $(date +%s) - started ) ))
947 if [ "$remaining" -gt 0 ]; then
948 log "npm could not resolve a package; retrying the same route once with fresh metadata in the ${remaining}s left (nothing was sent; npm policy unchanged)"
949 ACPX_NPM_REFRESH=true
950 HARD_SECS="$remaining"
951 ROUTE_STARTED_AT="$(date +%s)"
952 build_cmd "$route"
953 run_peer_cmd
954 fi
955 fi
956 outcome="$(acpx_outcome "$PEERLOG")"
957 case "$outcome" in
958 end_turn)
959 RUN_SUCCEEDED=true
960 acpx_text "$PEERLOG" "$TEXT_OUT" && recover_pov_json "$TEXT_OUT" "$RAW_OUT"
961 extract_model_receipt "$route"
962 ;;
963 not-sent)
964 # Nothing reached the provider: npm could not fetch acpx (every route
965 # fails alike), or the adapter refused before the prompt (this route only).
966 reason="$(acpx_npm_failure_reason "$PEERERR")"
967 if [ -n "$reason" ]; then
968 PRE_EGRESS="transport unavailable (pre-egress, shared): ${reason:0:200}"
969 elif [ "$SESSION_STALLED" = true ]; then
970 PRE_EGRESS="transport unavailable (pre-egress, route): Antigravity is waiting for a Google sign-in; the agy CLI's sign-in does not carry over, so sign the ACP runtime in once: put {\"auth\":{\"type\":\"oauth-personal\"}} in ${GEMINI_HOME:-~/.gemini}/antigravity-acp/settings.json, run npx -y --package '$(acpx_package_spec)' -- acpx antigravity exec ok in a terminal and finish the browser sign-in, then retry"
971 else
972 node_line="$(acpx_node_startup_line "$PEERERR")"
973 if [ -n "$node_line" ]; then
974 PRE_EGRESS="transport unavailable (pre-egress, route): ${node_line:0:200}"
975 else
976 reason="$(acpx_npm_failure_reason "$PEERLOG")"
977 [ -n "$reason" ] || reason="$(acpx_failure_evidence "$PEERLOG")"
978 PRE_EGRESS="transport unavailable (pre-egress, route): ${reason:-acpx exited $PEER_EXIT before sending the prompt}"
979 fi
980 fi
981 ;;
982 *)
983 cancel_reason="$(acpx_permission_cancel_reason "$PEERLOG" "$PEER_EXIT")"
984 if [ -n "$cancel_reason" ]; then
985 log "peer run ended: $cancel_reason (acpx exit $PEER_EXIT)"
986 else
987 log "peer run ended with $outcome (acpx exit $PEER_EXIT)"
988 fi
989 ;;
990 esac
993# Run the one fixed route. Any failure returns control to the host without
994# trying a different target, provider, or intermediary.
995run_fixed_route() {
996 local provider="$TARGET"
997 OUT="$RUN_DIR/pov-$provider.json"
998 ACTUAL_ROUTE="$FIXED_ROUTE"
999 ROUTE_STARTED_AT="$(date +%s)"
1000 PRE_EGRESS=""
1001 attempt_route "$provider" "$FIXED_ROUTE"
1002 # A configured model is exact; only the editorial Argon request falls back.
1003 if [ "$FIXED_ROUTE" = antigravity ] && [ "$(route_model antigravity)" = "$M_ANTIGRAVITY" ] &&
1004 [ "${CROSS_MODEL_MODEL_OVERRIDE_TARGET:-}" != antigravity ]; then
1005 case "$PRE_EGRESS" in
1006 *"did not advertise"*)
1007 # The fallback shares the route's window; the non-final retry below
1008 # then measures what is left from this restart.
1009 remaining=$(( HARD_SECS - ( $(date +%s) - ROUTE_STARTED_AT ) ))
1010 if [ "$remaining" -gt 0 ]; then
1011 log "Antigravity does not offer $M_ANTIGRAVITY on this account; falling back to $M_ANTIGRAVITY_FALLBACK"
1012 ANTIGRAVITY_MODEL="$M_ANTIGRAVITY_FALLBACK"
1013 PRE_EGRESS=""
1014 HARD_SECS="$remaining"
1015 ROUTE_STARTED_AT="$(date +%s)"
1016 attempt_route "$provider" "$FIXED_ROUTE"
1017 fi
1018 ;;
1019 esac
1020 fi
1021 [ -z "$PRE_EGRESS" ] || { cleanup_private_scratch; skip "$PRE_EGRESS"; }
1022 # One bounded retry on the same route, target, model, and scope; the only
1023 # change is a final-answer instruction. The retry gets only what is left of
1024 # this worker's HARD_SECS window so both attempts stay inside the panel's
1025 # aggregate deadline (cross-model-panel.md: CROSS_MODEL_HARD_SECS + 10s);
1026 # too little left means no retry. A second non-final position drops the
1027 # voice with skip evidence -- no route hopping.
1028 nonfinal_position=""
1029 if [ "$RUN_SUCCEEDED" = true ] && ! out_missing_or_invalid && ! out_final; then
1030 position="$(jq -r '.position' "$RAW_OUT" 2>/dev/null)"
1031 remaining=$(( HARD_SECS - ( $(date +%s) - ROUTE_STARTED_AT ) ))
1032 if [ "$remaining" -lt "$RETRY_MIN_SECS" ]; then
1033 log "peer returned a non-final position (\"${position:0:120}\") with ${remaining}s of the ${HARD_SECS}s window left; not retrying"
1034 nonfinal_position="$position"
1035 rm -f "$RAW_OUT"
1036 else
1037 log "peer returned a non-final position (\"${position:0:120}\"); retrying once on the same route with a final-answer requirement (${remaining}s left)"
1038 printf '\n\nYour previous response set final to false. This response is the final one: inspect the subject and shared working tree now, then return the settled position with its evidence and final set to true.\n' >> "$PROMPT_FILE"
1039 HARD_SECS="$remaining"
1040 attempt_route "$provider" "$FIXED_ROUTE"
1041 if [ "$RUN_SUCCEEDED" = true ] && ! out_missing_or_invalid && ! out_final; then
1042 nonfinal_position="$(jq -r '.position' "$RAW_OUT" 2>/dev/null)"
1043 rm -f "$RAW_OUT"
1044 fi
1045 fi
1046 fi
1048 # --- normalize + validate against the peer POV contract ------------------
1049 # Force voice = peer-<provider>, preserve the POV fields, and add route/model
1050 # receipts from the route that actually ran. The peer never self-attributes an
1051 # unverifiable serving model.
1052 # Publish ONLY the normalized OUT into RUN_DIR. RAW_OUT lives in the per-peer
1053 # workspace and is never a fold-in artifact — if this script dies before normalize
1054 # (orphaned launch), synthesis finds no .json in RUN_DIR.
1055 rm -f "$OUT"
1056 if [ -s "$RAW_OUT" ]; then
1057 _norm="$PEER_WORKDIR/normalized.json"
1058 case "$ACTUAL_ROUTE:$MODEL_ACTUAL" in
1059 cursor:*) serving_family="unknown" ;;
1060 composer:unverified|grok-cursor:unverified) serving_family="unknown" ;;
1061 *) serving_family="$(target_serving_family "$provider")" ;;
1062 esac
1063 independence=false
1064 [ "$HOST_PROVIDER" != "unknown" ] && [ "$serving_family" != "unknown" ] && [ "$HOST_PROVIDER" != "$serving_family" ] && independence=true
1065 if jq --arg v "peer-$provider" --arg route "$ACTUAL_ROUTE" \
1066 --arg target "$provider" --arg harness "$(route_harness "$ACTUAL_ROUTE")" \
1067 --arg family "$serving_family" \
1068 --arg mreq "$(route_model "$ACTUAL_ROUTE")" --arg mact "$MODEL_ACTUAL" \
1069 --argjson independent "$independence" \
1070 'if ((.voice|type)=="string" and (.voice|length)>0 and (.position|type)=="string" and (.position|length)>0 and (.reasoning|type)=="string" and (.reasoning|length)>0 and (.evidence|type)=="array" and all(.evidence[]; type=="string" and length>0) and (.external_check=="ran" or .external_check=="unavailable") and (.mode=="independent" or .mode=="skeptic") and (.movement=="initial" or .movement=="moved" or .movement=="held") and .final==true)
1071 then { voice: $v,
1072 cross_model_route: $route,
1073 cross_model_target: $target,
1074 cross_model_harness: $harness,
1075 serving_family: $family,
1076 model_requested: $mreq,
1077 model_actual: $mact,
1078 independence_verified: $independent,
1079 position: .position,
1080 reasoning: .reasoning,
1081 evidence: .evidence,
1082 external_check: .external_check,
1083 mode: .mode,
1084 movement: .movement,
1085 final: true }
1086 else empty end' \
1087 "$RAW_OUT" > "$_norm" 2>/dev/null; then
1088 mv "$_norm" "$OUT"
1089 chmod 600 "$OUT" 2>/dev/null || { rm -f "$OUT"; log "could not make result artifact private"; }
1090 else
1091 rm -f "$_norm"
1092 fi
1093 rm -f "$RAW_OUT"
1094 fi
1095 if [ -s "$OUT" ] && jq -e \
1096 '(.voice|type)=="string" and (.position|type)=="string" and (.position|length)>0 and (.reasoning|type)=="string" and (.reasoning|length)>0 and (.evidence|type)=="array" and all(.evidence[]; type=="string" and length>0) and (.external_check=="ran" or .external_check=="unavailable") and (.mode=="independent" or .mode=="skeptic") and (.movement=="initial" or .movement=="moved" or .movement=="held") and (.independence_verified|type)=="boolean"' \
1097 "$OUT" >/dev/null 2>&1; then
1098 log "wrote peer POV to $OUT (voice peer-$provider)"
1099 else
1100 log "provider $provider produced no usable schema-shaped output; skipping fold-in"
1101 [ -n "$nonfinal_position" ] && log " peer skip evidence: non-final position: ${nonfinal_position:0:200}"
1102 # Surface bounded, actionable peer evidence so the orchestrator can
1103 # reason about WHY it was skipped (quota/usage-limit exhaustion vs an ordinary
1104 # empty review) and, in a repeated-pass session, deprioritize an exhausted
1105 # route. Provider errors arrive as ACP error messages or as reply text on
1106 # stdout; npm and adapter diagnostics on stderr.
1107 _pt="$(acpx_permission_cancel_reason "$PEERLOG" "$PEER_EXIT")"
1108 [ -n "$_pt" ] || _pt="$(acpx_failure_evidence "$PEERLOG")"
1109 [ -n "$_pt" ] && log " peer skip evidence: $_pt"
1110 _pr="$(acpx_reply_evidence "$PEERLOG")"
1111 [ -n "$_pr" ] && log " peer skip evidence (reply): $_pr"
1112 _pm="$(acpx_advertised_models "$PEERLOG")"
1113 [ -n "$_pm" ] && log " peer skip evidence (advertised models): $_pm"
1114 if [ -s "$PEERERR" ]; then
1115 _pe="$(bounded_failure_evidence "$PEERERR")"
1116 log " peer skip evidence (stderr): $_pe"
1117 fi
1118 rm -f "$OUT" "$RAW_OUT"
1119 fi
1120 cleanup_private_scratch
1123run_fixed_route
1124exit 0