omnilane 0.34.0 → 0.42.1

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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +71 -1
  4. package/README.ja.md +63 -33
  5. package/README.ko.md +63 -32
  6. package/README.md +147 -86
  7. package/README.zh-CN.md +61 -30
  8. package/README.zh-TW.md +124 -75
  9. package/VERSION +1 -1
  10. package/config/aa-model-policy.json +3046 -0
  11. package/docs/aa-model-coverage-2026-09-05.json +29204 -0
  12. package/docs/completion-wakeup.md +126 -0
  13. package/docs/model-capabilities-2026-09.md +380 -0
  14. package/docs/native-executor.md +264 -0
  15. package/docs/release-notes-0.42.1.md +32 -0
  16. package/hooks/routing-instruction.md +101 -40
  17. package/package.json +8 -2
  18. package/plugin.json +2 -2
  19. package/routing.local.yaml.example +8 -3
  20. package/routing.yaml +16 -16
  21. package/scripts/completion-wakeup.py +390 -0
  22. package/scripts/configure.sh +4 -4
  23. package/scripts/dispatch.sh +323 -32
  24. package/scripts/doctor.sh +55 -1
  25. package/scripts/jobs.sh +64 -17
  26. package/scripts/lib/aa_policy.py +473 -0
  27. package/scripts/lib/aa_retry.py +77 -0
  28. package/scripts/lib/common.sh +106 -1
  29. package/scripts/lib/job-worker.sh +314 -20
  30. package/scripts/lib/live-protocol.sh +147 -2
  31. package/scripts/lib/native.py +507 -0
  32. package/scripts/lib/normalize-claude-stream.py +72 -0
  33. package/scripts/lib/prepare-agy-mode.py +374 -0
  34. package/scripts/release-audit.sh +103 -0
  35. package/scripts/runners/run-claude.sh +81 -47
  36. package/scripts/runners/run-codex-live.py +462 -0
  37. package/scripts/runners/run-codex.sh +62 -3
  38. package/scripts/runners/run-gemini.sh +85 -10
  39. package/scripts/runners/run-grok-live.py +426 -0
  40. package/scripts/runners/run-grok.sh +117 -6
  41. package/scripts/runners/run-vote.sh +6 -3
  42. package/skills/omnilane/SKILL.md +217 -81
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Reconstruct only a validated job-owned authorizer; never borrow retry shell identity."""
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import aa_policy
8
+
9
+
10
+ def retry_args(directory):
11
+ root = Path(directory)
12
+ os.environ.pop("OMNILANE_AA_TRANSPORT_OVERLAY", None)
13
+ os.environ.pop("OMNILANE_AA_OVERLAY_SHA256", None)
14
+ lineage, _ = aa_policy._read_json(root / "aa-lineage.json")
15
+ aa_policy._check(lineage.get("schema_version") == 1, "unsupported retry lineage")
16
+ registry_path = root / "aa-registry.json"
17
+ registry, _ = aa_policy.load_registry(registry_path, lineage["registry_sha256"])
18
+ result = ["--aa-policy", str(registry_path)]
19
+ original = None
20
+ original_limit = 100
21
+ if lineage.get("operator_asserted_human") is not True:
22
+ context = root / "aa-authorizer.json"
23
+ aa_policy._check(isinstance(lineage.get("authorizer_sha256"), str), "missing retry authorizer hash")
24
+ original, _ = aa_policy.load_caller(context, registry, lineage["authorizer_sha256"])
25
+ rows = aa_policy._matching_rows(registry, original["caller"])
26
+ aa_policy._check(len(rows) == 1, "unknown original retry authorizer")
27
+ original_limit = min(rows[0]["score"], original["inherited_ceiling"])
28
+ current_path = os.environ.get("OMNILANE_AA_CALLER_CONTEXT")
29
+ current_human = os.environ.get("OMNILANE_AA_OPERATOR_ASSERTED_HUMAN") == "1"
30
+ aa_policy._check(bool(current_path) != current_human, "retry requires one current caller context or explicit current human assertion")
31
+ if current_path:
32
+ current, _ = aa_policy.load_caller(current_path, registry)
33
+ rows = aa_policy._matching_rows(registry, current["caller"])
34
+ aa_policy._check(len(rows) == 1, "unknown current retry caller")
35
+ narrowed = dict(current)
36
+ narrowed["inherited_ceiling"] = min(original_limit, current["inherited_ceiling"], rows[0]["score"])
37
+ digest = aa_policy.hashlib.sha256(aa_policy._json_line(narrowed).encode()).hexdigest()
38
+ context = root / ("aa-retry-authorizer-" + digest + ".json")
39
+ aa_policy.atomic_json(context, narrowed)
40
+ result += ["--caller-context", str(context)]
41
+ elif original is not None:
42
+ result += ["--caller-context", str(root / "aa-authorizer.json")]
43
+ else:
44
+ result += ["--operator-asserted-human"]
45
+ if lineage.get("transport_overlay_sha256"):
46
+ overlay = root / "aa-transport-overlay.json"
47
+ _, digest = aa_policy._read_json(overlay)
48
+ aa_policy._check(digest == lineage["transport_overlay_sha256"], "retry transport overlay changed")
49
+ result += ["--transport-overlay", str(overlay)]
50
+ config = lineage.get("target_config_id")
51
+ if config:
52
+ aa_policy._check(isinstance(config, str) and aa_policy.IDENTIFIER.fullmatch(config), "invalid retry target config")
53
+ result += ["--target-config", config]
54
+ return result
55
+
56
+
57
+ def metadata_fields(directory):
58
+ value, _ = aa_policy._read_json(Path(directory) / "meta.json")
59
+ keys = ("lane", "vendor", "model", "effort", "timeout", "job_timeout", "mode", "workdir")
60
+ aa_policy._check(value.get("mode") in ("advise", "work"), "invalid retry mode")
61
+ aa_policy._check(type(value.get("timeout")) is int and value["timeout"] > 0, "invalid retry timeout")
62
+ aa_policy._check(value.get("job_timeout") is None or (type(value["job_timeout"]) is int and value["job_timeout"] > 0), "invalid retry job timeout")
63
+ result = []
64
+ for key in keys:
65
+ field = value[key]
66
+ text = "null" if field is None else str(field)
67
+ aa_policy._check("\n" not in text and "\r" not in text and "\x00" not in text, "invalid retry metadata value")
68
+ result.append(text)
69
+ return result
70
+
71
+
72
+ if __name__ == "__main__":
73
+ try:
74
+ print("\n".join(metadata_fields(sys.argv[2]) if sys.argv[1] == "--metadata" else retry_args(sys.argv[1])))
75
+ except (aa_policy.PolicyError, OSError, KeyError, TypeError):
76
+ print("omnilane: retry requires intact AA lineage; current caller is missing or original authorizer/registry is changed", file=sys.stderr)
77
+ sys.exit(3)
@@ -37,6 +37,110 @@ json_escape() {
37
37
  printf '%s' "$out"
38
38
  }
39
39
 
40
+ json_file_round_trip_valid() {
41
+ local path="$1"
42
+ local valid=1
43
+ if [[ ! -f "$path" || -L "$path" ]]; then
44
+ valid=0
45
+ elif command -v python3 >/dev/null 2>&1; then
46
+ python3 - "$path" <<'PY' >/dev/null 2>&1 || valid=0
47
+ import json
48
+ import pathlib
49
+ import sys
50
+
51
+ value = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
52
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"))
53
+ PY
54
+ elif command -v perl >/dev/null 2>&1; then
55
+ perl -MJSON::PP -e '
56
+ use strict;
57
+ use warnings;
58
+ my ($path) = @ARGV;
59
+ open my $fh, "<", $path or die $!;
60
+ local $/;
61
+ my $value = decode_json(<$fh>);
62
+ JSON::PP->new->canonical->encode($value);
63
+ ' "$path" >/dev/null 2>&1 || valid=0
64
+ else
65
+ valid=0
66
+ fi
67
+ if [[ "$valid" -ne 1 ]]; then
68
+ printf 'omnilane: JSON round-trip validation failed for %s\n' "$path" >&2
69
+ return 1
70
+ fi
71
+ }
72
+
73
+ file_sha256() {
74
+ local path="$1"
75
+ if command -v sha256sum >/dev/null 2>&1; then
76
+ sha256sum "$path" | awk '{print $1}'
77
+ elif command -v shasum >/dev/null 2>&1; then
78
+ shasum -a 256 "$path" | awk '{print $1}'
79
+ else
80
+ printf 'omnilane: no sha256sum/shasum available for worker provenance\n' >&2
81
+ return 1
82
+ fi
83
+ }
84
+
85
+ # Re-run the same provider-free AA decision immediately before a runner or one
86
+ # of its internal constituents/retries. Inputs are cooperative workflow
87
+ # metadata; hashes detect mutation after dispatch but do not authenticate the
88
+ # operator or prove the provider's actual model identity.
89
+ aa_policy_gate() {
90
+ local vendor="$1" model="$2" effort="$3" target_config="${4:-${OMNILANE_AA_TARGET_CONFIG:-}}"
91
+ local policy_file="${OMNILANE_AA_POLICY_FILE:-$OMNILANE_REPO/config/aa-model-policy.json}" output rc=0
92
+ local authorizer="${OMNILANE_AA_AUTHORIZER_CONTEXT-${OMNILANE_AA_CALLER_CONTEXT:-}}"
93
+ local authorizer_sha="${OMNILANE_AA_AUTHORIZER_SHA256-${OMNILANE_AA_CALLER_SHA256:-}}"
94
+ local args=()
95
+ if [[ "$vendor" == "vote" && "${OMNILANE_AA_VOTE_PREFLIGHT:-0}" == "1" ]]; then
96
+ return 0
97
+ fi
98
+ [[ -n "$policy_file" ]] || {
99
+ echo 'omnilane: AA policy metadata missing before provider invocation' >&2
100
+ return 3
101
+ }
102
+ args=(--registry "$policy_file" --vendor "$vendor" --model "$model" --effort "$effort")
103
+ [[ -z "${OMNILANE_AA_REGISTRY_SHA256:-}" ]] ||
104
+ args+=(--expected-registry-sha256 "$OMNILANE_AA_REGISTRY_SHA256")
105
+ [[ -z "$target_config" ]] || args+=(--target-config "$target_config")
106
+ if [[ "${OMNILANE_AA_AUTHORIZER_HUMAN-${OMNILANE_AA_OPERATOR_ASSERTED_HUMAN:-0}}" == "1" ]]; then
107
+ args+=(--operator-asserted-human)
108
+ elif [[ -n "$authorizer" ]]; then
109
+ args+=(--caller-context "$authorizer")
110
+ [[ -z "$authorizer_sha" ]] ||
111
+ args+=(--expected-caller-sha256 "$authorizer_sha")
112
+ fi
113
+ output="$(python3 "$OMNILANE_REPO/scripts/lib/aa_policy.py" "${args[@]}")" || rc=$?
114
+ if [[ "$rc" -ne 0 ]]; then
115
+ printf '%s\n' "$output" >&2
116
+ return "$rc"
117
+ fi
118
+ if [[ -n "${OMNILANE_AA_CONTEXT_DIR:-}" ]]; then
119
+ local child_path
120
+ child_path="$(python3 - "$OMNILANE_REPO" "$OMNILANE_AA_CONTEXT_DIR" "$output" <<'AA_CHILD'
121
+ import hashlib, json, sys
122
+ from pathlib import Path
123
+ sys.path.insert(0, str(Path(sys.argv[1]) / "scripts/lib"))
124
+ import aa_policy
125
+ decision = json.loads(sys.argv[3])
126
+ child = decision.get("child_context")
127
+ if child is not None:
128
+ name = "aa-child-" + hashlib.sha256(aa_policy._json_line(child).encode()).hexdigest() + ".json"
129
+ path = Path(sys.argv[2]) / name
130
+ aa_policy.atomic_json(path, child)
131
+ print(path)
132
+ AA_CHILD
133
+ )" || return $?
134
+ export OMNILANE_AA_CALLER_CONTEXT="$child_path"
135
+ export OMNILANE_AA_CALLER_SHA256=""
136
+ if [[ -n "$child_path" ]]; then
137
+ OMNILANE_AA_CALLER_SHA256="$(file_sha256 "$child_path")"
138
+ export OMNILANE_AA_CALLER_SHA256
139
+ fi
140
+ fi
141
+ return 0
142
+ }
143
+
40
144
  resolve_timeout_cmd() {
41
145
  if command -v timeout &>/dev/null; then echo "timeout";
42
146
  elif command -v gtimeout &>/dev/null; then echo "gtimeout";
@@ -456,7 +560,8 @@ acquire_cwd_lock() { # vendor, workdir — the lock keys on the TARGET dir, not
456
560
  empty_since=-1
457
561
  fi
458
562
  if [[ "$waited" -ge "$lock_timeout" ]]; then
459
- echo "omnilane: lock timeout for $vendor in $dir" >&2; exit 87
563
+ echo "omnilane: lock timeout for $vendor in $dir; advise/read-only tasks may use a different --workdir to avoid this lock" >&2
564
+ exit 87
460
565
  fi
461
566
  sleep 2; waited=$((waited + 2))
462
567
  done
@@ -4,9 +4,19 @@ set -euo pipefail
4
4
  # Internal worker boundary for one dispatch. An optional whole-job supervisor
5
5
  # wraps this process so lock wait, retries, and vote rounds share one budget.
6
6
 
7
- # Runtime-relative shared library.
7
+ # A per-job worker snapshot lives outside the repository tree. The dispatcher
8
+ # pins its library root explicitly; direct invocations remain runtime-relative.
9
+ JOB_WORKER_REPO="${OMNILANE_JOB_WORKER_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
8
10
  # shellcheck disable=SC1091
9
- source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
11
+ source "$JOB_WORKER_REPO/scripts/lib/common.sh"
12
+ if [[ -n "${OMNILANE_JOB_WORKER_EXPECTED_SHA256:-}" ]]; then
13
+ JOB_WORKER_STARTUP_SHA256="$(file_sha256 "${BASH_SOURCE[0]}")" || exit 2
14
+ if [[ "$JOB_WORKER_STARTUP_SHA256" != "$OMNILANE_JOB_WORKER_EXPECTED_SHA256" ]]; then
15
+ echo "omnilane: job worker snapshot SHA changed before startup" >&2
16
+ exit 2
17
+ fi
18
+ fi
19
+ unset OMNILANE_JOB_WORKER_REPO OMNILANE_JOB_WORKER_EXPECTED_SHA256
10
20
  # shellcheck disable=SC1091
11
21
  source "$OMNILANE_REPO/scripts/lib/live-protocol.sh"
12
22
 
@@ -33,6 +43,7 @@ emit_mode_notice() {
33
43
 
34
44
  run_single_shot() {
35
45
  local notice="$1" rc
46
+ aa_policy_gate "$VENDOR" "$MODEL" "$EFFORT" || return $?
36
47
  set +e
37
48
  (
38
49
  unset OMNILANE_INBOX
@@ -56,13 +67,41 @@ LIVE_REQUIRED="${OMNILANE_LIVE_REQUIRED:-0}"
56
67
  [[ "$LIVE_REQUIRED" == "0" || "$LIVE_REQUIRED" == "1" ]] || {
57
68
  echo "omnilane: invalid worker live requirement" >&2; exit 2
58
69
  }
70
+ if [[ "$VENDOR" == "grok" && "$MODE" != "sysops" && "$SESSION_MODE" != "single-shot" ]]; then
71
+ if [[ "$LIVE_REQUIRED" -eq 1 ]]; then
72
+ echo "omnilane: Grok --live supports only explicit --mode sysops; restricted ACP policy is unavailable" >&2
73
+ exit 2
74
+ fi
75
+ SESSION_MODE="single-shot"
76
+ fi
59
77
  if [[ "$SESSION_MODE" == "auto" ]]; then
60
- if live_vendor_capable "$VENDOR"; then SESSION_MODE="live"; else SESSION_MODE="single-shot"; fi
78
+ if [[ "$VENDOR" == "codex" || "$VENDOR" == "grok" ]]; then
79
+ SESSION_MODE="single-shot"
80
+ elif live_vendor_capable "$VENDOR"; then
81
+ SESSION_MODE="live"
82
+ else
83
+ SESSION_MODE="single-shot"
84
+ fi
85
+ fi
86
+
87
+ LIVE_SURFACE_FALLBACK=""
88
+ if [[ "$VENDOR" == "codex" && "$SESSION_MODE" != "single-shot" ]]; then
89
+ if ! codex_live_surface_available "${CODEX_BIN:-codex}"; then
90
+ SESSION_MODE="single-shot"
91
+ LIVE_SURFACE_FALLBACK="codex"
92
+ fi
93
+ elif [[ "$VENDOR" == "grok" && "$SESSION_MODE" != "single-shot" ]]; then
94
+ if ! grok_live_surface_available "${GROK_BIN:-grok}"; then
95
+ SESSION_MODE="single-shot"
96
+ LIVE_SURFACE_FALLBACK="grok"
97
+ fi
61
98
  fi
62
99
 
63
100
  if [[ "$SESSION_MODE" == "single-shot" ]]; then
64
101
  set +e
65
- if live_vendor_capable "$VENDOR"; then
102
+ if [[ -n "$LIVE_SURFACE_FALLBACK" ]]; then
103
+ run_single_shot "omnilane: $LIVE_SURFACE_FALLBACK live surface unavailable; ran in single-shot mode"
104
+ elif live_vendor_capable "$VENDOR"; then
66
105
  run_single_shot "omnilane: vendor '$VENDOR' was resolved to single-shot mode"
67
106
  else
68
107
  run_single_shot "omnilane: vendor '$VENDOR' is not live-capable; ran in single-shot mode"
@@ -87,14 +126,24 @@ EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
87
126
  EVENTS_ALIAS="$JOB_DIR/events.jsonl"
88
127
  close_requested=0
89
128
  close_reason=""
129
+ close_drain_failed=0
130
+ close_partial=""
131
+ natural_runner_exit=0
132
+ CLOSE_DRAIN_TIMEOUT=0.1
133
+ CLOSE_RUNNER_GRACE=7.5
134
+ CLOSE_TERM_GRACE=0.1
135
+ CLOSE_KILL_GRACE=0.1
90
136
  runner_writer_open=0
91
137
  inbox_open=0
92
138
  events_reader_open=0
139
+ forward_spool_open=0
93
140
  runner_pid=""
94
141
 
95
142
  # Invoked by the EXIT trap below.
96
143
  # shellcheck disable=SC2329
97
144
  cleanup_live_mailbox() {
145
+ if [[ -n "${forward_spool:-}" ]]; then rm "$forward_spool" 2>/dev/null || true; fi
146
+ if [[ "$forward_spool_open" -eq 1 ]]; then exec 6<&-; exec 7>&-; forward_spool_open=0; fi
98
147
  if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
99
148
  if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
100
149
  if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
@@ -126,15 +175,52 @@ prepare_live_mailbox() {
126
175
  return "$rc"
127
176
  }
128
177
 
178
+ # The Claude normalizer appends a result event of its own when it recovers a
179
+ # partial transcript, so that marker is what separates "the turn finished" from
180
+ # "we salvaged what the model had written so far".
181
+ live_event_is_vendor_result() {
182
+ local event="$1"
183
+ live_event_is_result "$VENDOR" "$event" || return 1
184
+ [[ "$event" != *'"normalized_by"'* ]]
185
+ }
186
+
129
187
  last_result_status() {
130
- local event last_result=""
188
+ local event last_result="" vendor_only="${1:-0}"
131
189
  while IFS= read -r event || [[ -n "$event" ]]; do
132
- if live_event_is_result "$VENDOR" "$event"; then last_result="$event"; fi
190
+ if [[ "$vendor_only" -eq 1 ]]; then
191
+ if live_event_is_vendor_result "$event"; then last_result="$event"; fi
192
+ elif live_event_is_result "$VENDOR" "$event"; then
193
+ last_result="$event"
194
+ fi
133
195
  done < "$EVENTS_FILE"
134
196
  [[ -n "$last_result" ]] || return 1
135
197
  live_event_is_success "$VENDOR" "$last_result"
136
198
  }
137
199
 
200
+ close_had_result() {
201
+ local vendor_only="$1"
202
+ if [[ -n "$last_result_event" ]] \
203
+ && { [[ "$vendor_only" -eq 0 ]] || live_event_is_vendor_result "$last_result_event"; } \
204
+ && live_event_is_success "$VENDOR" "$last_result_event"; then
205
+ return 0
206
+ fi
207
+ last_result_status "$vendor_only"
208
+ }
209
+
210
+ recover_close_result_output() {
211
+ # Claude's stream-json process may remain resident after stdin EOF. The
212
+ # worker then enforces its bounded close deadline, so the runner's signal
213
+ # trap is not guaranteed enough time to normalize the already-completed
214
+ # result. Preserve that successful vendor result before declaring the job
215
+ # done; never synthesize output for an incomplete turn or overwrite output
216
+ # the runner already committed.
217
+ [[ "$VENDOR" == "claude" ]] || return 0
218
+ [[ ! -s "$OUTPUT_FILE" ]] || return 0
219
+ command -v python3 >/dev/null 2>&1 || return 1
220
+ python3 "$OMNILANE_REPO/scripts/lib/normalize-claude-stream.py" \
221
+ "$EVENTS_FILE" "$OUTPUT_FILE"
222
+ }
223
+
138
224
  if ! prepare_live_mailbox; then
139
225
  if [[ "$LIVE_REQUIRED" -eq 1 ]]; then
140
226
  echo "omnilane: required $VENDOR live mailbox unavailable because FIFO setup failed" >&2
@@ -150,7 +236,72 @@ fi
150
236
  trap 'close_requested=1' USR1
151
237
  trap 'close_requested=1' PIPE
152
238
  trap cleanup_live_mailbox EXIT
239
+ if [[ "$VENDOR" == "codex" ]]; then
240
+ # Allocate once before publishing the holder PID. Reader and writer offsets
241
+ # are independent; unlink immediately and keep this 0600 file private to us.
242
+ forward_spool="$(mktemp "$JOB_DIR/.forward.XXXXXX")" || exit 1
243
+ exec 6< "$forward_spool"
244
+ forward_spool_open=1
245
+ exec 7> "$forward_spool"
246
+ rm "$forward_spool"
247
+ forward_spool=""
248
+ fi
153
249
  truncate_payload "$PROMPT_FILE" 102400
250
+ # Codex input must not be consumed by Bash read: an interrupted partial read
251
+ # can discard bytes before the close trap runs. Pump raw bytes instead. The
252
+ # anonymous file holds at most one 64 KiB chunk, with shared read offset on FD 6
253
+ # and an independent append/reset offset on FD 7. A close drains its remaining
254
+ # suffix first; successful sends truncate/reset it instead of growing a spool.
255
+ pump_codex_input() {
256
+ python3 - <<'PYPUMP'
257
+ import os
258
+ import select
259
+ import sys
260
+ import time
261
+
262
+ pending = b""
263
+ progress = False
264
+
265
+ def reset_spool():
266
+ os.ftruncate(7, 0)
267
+ os.lseek(6, 0, os.SEEK_SET)
268
+ os.lseek(7, 0, os.SEEK_SET)
269
+
270
+ os.set_blocking(3, False)
271
+ os.set_blocking(4, False)
272
+ try:
273
+ pending = os.read(6, 65536)
274
+ if not pending:
275
+ reset_spool()
276
+ readable, _, _ = select.select([4], [], [], 1)
277
+ if not readable:
278
+ sys.exit(3)
279
+ chunk = os.read(4, 65536)
280
+ if not chunk:
281
+ sys.exit(1)
282
+ # FD 7 is a private regular file, never the backpressured FIFO.
283
+ while chunk:
284
+ chunk = chunk[os.write(7, chunk):]
285
+ pending = os.read(6, 65536)
286
+ progress = True
287
+ deadline = time.monotonic() + 0.05
288
+ while pending and time.monotonic() < deadline:
289
+ _, writable, _ = select.select([], [3], [], max(0, deadline - time.monotonic()))
290
+ if writable:
291
+ pending = pending[os.write(3, pending):]
292
+ progress = True
293
+ if not pending:
294
+ reset_spool()
295
+ except OSError:
296
+ if pending:
297
+ os.lseek(6, -len(pending), os.SEEK_CUR)
298
+ sys.exit(1)
299
+ if pending:
300
+ os.lseek(6, -len(pending), os.SEEK_CUR)
301
+ sys.exit((4 if progress else 2) if pending else 0)
302
+ PYPUMP
303
+ }
304
+
154
305
  INITIAL_TEXT="$(cat "$PROMPT_FILE")"
155
306
  if [[ "${FOREMAN_SESSION+x}" == "x" ]]; then
156
307
  FOREMAN_SESSION_VALUE="$FOREMAN_SESSION"
@@ -160,7 +311,8 @@ fi
160
311
 
161
312
  write_current_pid_file "$HOLDER_PID_FILE"
162
313
  export OMNILANE_INBOX="$RUNNER_INBOX_FIFO"
163
- "$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE" &
314
+ aa_policy_gate "$VENDOR" "$MODEL" "$EFFORT" || exit $?
315
+ "$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE" 6<&- 7>&- &
164
316
  runner_pid=$!
165
317
 
166
318
  # Open the runner writer only after its FIFO reader starts.
@@ -175,16 +327,109 @@ if ! printf '%s\n' "$initial_payload" >&3; then close_requested=1; fi
175
327
  (umask 077; : > "$READY_FILE")
176
328
 
177
329
  last_activity=$SECONDS
178
- while kill -0 "$runner_pid" 2>/dev/null; do
179
- [[ "$close_requested" -eq 0 ]] || break
180
- incoming=""
181
- if IFS= read -r -t 1 incoming <&4; then
182
- if ! printf '%s\n' "$incoming" >&3; then close_requested=1; fi
183
- last_activity=$SECONDS
330
+ last_result_event=""
331
+ # A dead Codex reader can leave accepted bytes in the public FIFO even when
332
+ # the previous pump was idle. Always take one final bounded drain in that case.
333
+ while kill -0 "$runner_pid" 2>/dev/null || [[ "$VENDOR" == "codex" || "$close_requested" -ne 0 ]]; do
334
+ if [[ "$VENDOR" == "codex" && "$close_requested" -eq 0 ]] && ! kill -0 "$runner_pid" 2>/dev/null; then
335
+ natural_runner_exit=1
336
+ close_requested=1
337
+ fi
338
+ if [[ "$close_requested" -ne 0 ]]; then
339
+ rm "$READY_FILE" 2>/dev/null || true
340
+ if [[ "$VENDOR" == "codex" ]]; then
341
+ # Bash 3.2 has no fractional read timeout. Bound the *whole* drain,
342
+ # including backpressure, rather than granting each line another second.
343
+ # Retain unforwarded bytes and report failure instead of silently dropping
344
+ # an accepted follow-up when the runner stalls or a writer never stops.
345
+ if ! CLOSE_PARTIAL="$close_partial" FORWARD_PENDING="$forward_spool_open" python3 - "$JOB_DIR/close-pending.jsonl" "$CLOSE_DRAIN_TIMEOUT" <<'PY'
346
+ import array
347
+ import fcntl
348
+ import os
349
+ import select
350
+ import sys
351
+ import termios
352
+ import time
353
+
354
+ deadline = time.monotonic() + float(sys.argv[2])
355
+ pending = b""
356
+ if os.environ.get("FORWARD_PENDING") == "1":
357
+ while True:
358
+ chunk = os.read(6, 65536)
359
+ if not chunk:
360
+ break
361
+ pending += chunk
362
+ pending += os.environ.get("CLOSE_PARTIAL", "").encode()
363
+ for fd in (3, 4):
364
+ os.set_blocking(fd, False)
365
+ try:
366
+ while time.monotonic() < deadline:
367
+ readable, writable, _ = select.select([4], [3] if pending else [], [], 0)
368
+ if not readable and not pending:
369
+ sys.exit(0)
370
+ if readable and len(pending) < 65536:
371
+ pending += os.read(4, 65536 - len(pending))
372
+ if pending:
373
+ _, writable, _ = select.select([], [3], [], max(0, deadline - time.monotonic()))
374
+ if writable:
375
+ pending = pending[os.write(3, pending):]
376
+ except (BrokenPipeError, OSError):
377
+ pass
378
+ # Snapshot only bytes already queued at the deadline; a continuous writer
379
+ # cannot extend this phase. New send callers no longer see inbox.ready.
380
+ available = array.array("i", [0])
381
+ fcntl.ioctl(4, termios.FIONREAD, available, True)
382
+ remaining = available[0]
383
+ while remaining:
384
+ chunk = os.read(4, min(remaining, 65536))
385
+ if not chunk:
386
+ break
387
+ pending += chunk
388
+ remaining -= len(chunk)
389
+ if pending:
390
+ fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
391
+ with os.fdopen(fd, "wb") as saved:
392
+ saved.write(pending)
393
+ sys.exit(1)
394
+ PY
395
+ then
396
+ close_drain_failed=1
397
+ emit_mode_notice "omnilane: close drain incomplete; unforwarded input retained in $JOB_DIR/close-pending.jsonl" || true
398
+ fi
399
+ fi
400
+ break
401
+ fi
402
+ if [[ "$VENDOR" == "codex" ]]; then
403
+ if pump_codex_input; then
404
+ last_activity=$SECONDS
405
+ else
406
+ pump_status=$?
407
+ # 2/4 retain a suffix (4 made progress); 3 is an idle poll. A reader
408
+ # that dies with pending bytes still enters the close drain, even after
409
+ # its PID disappears, so those bytes are retained rather than dropped.
410
+ if [[ "$pump_status" -eq 2 || "$pump_status" -eq 4 ]]; then
411
+ [[ "$pump_status" -ne 4 ]] || last_activity=$SECONDS
412
+ kill -0 "$runner_pid" 2>/dev/null || close_requested=1
413
+ elif [[ "$pump_status" -ne 3 ]]; then
414
+ close_requested=1
415
+ fi
416
+ fi
417
+ else
418
+ incoming=""
419
+ if IFS= read -r -t 1 incoming <&4; then
420
+ if ! printf '%s\n' "$incoming" >&3; then close_requested=1; fi
421
+ last_activity=$SECONDS
422
+ elif [[ "$close_requested" -ne 0 && -n "$incoming" ]]; then
423
+ close_partial="$incoming"
424
+ fi
184
425
  fi
185
426
  while IFS= read -r event <&5; do
186
- if live_event_is_result "$VENDOR" "$event"; then
427
+ [[ "$close_requested" -eq 0 ]] || break
428
+ if live_event_is_valid "$event"; then
187
429
  last_activity=$SECONDS
430
+ if live_event_is_result "$VENDOR" "$event"; then
431
+ last_result_event="$event"
432
+ fi
188
433
  fi
189
434
  done
190
435
  if [[ "$IDLE_TIMEOUT" -gt 0 && $((SECONDS - last_activity)) -ge "$IDLE_TIMEOUT" ]]; then
@@ -193,16 +438,47 @@ while kill -0 "$runner_pid" 2>/dev/null; do
193
438
  fi
194
439
  done
195
440
 
441
+ if [[ "$forward_spool_open" -eq 1 ]]; then exec 6<&-; exec 7>&-; forward_spool_open=0; fi
196
442
  if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
197
443
  if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
198
444
  if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
199
445
 
200
446
  set +e
201
447
  if [[ "$close_requested" -eq 1 ]]; then
202
- kill -TERM "$runner_pid" 2>/dev/null || true
448
+ # 0.1s drain + 7.5s grace + 0.1s TERM + 0.1s KILL = 7.8s.
449
+ # With the input pump's 1s read + 0.05s write, 8.85s also precedes
450
+ # the shortest ~9s interval of jobs close's integer SECONDS + 10 deadline.
451
+ # Codex retains its full 3+2+1+1=7s normal shutdown budget.
452
+ # A monotonic timer avoids accumulating 75 shell/sleep launch overheads.
453
+ if ! perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC,sleep -e '
454
+ my ($pid, $grace, $term, $kill) = @ARGV;
455
+ sub wait_until {
456
+ my ($pid, $duration) = @_;
457
+ my $deadline = clock_gettime(CLOCK_MONOTONIC) + $duration;
458
+ while (kill 0, $pid) {
459
+ my $left = $deadline - clock_gettime(CLOCK_MONOTONIC);
460
+ return 0 if $left <= 0;
461
+ sleep($left < 0.02 ? $left : 0.02);
462
+ }
463
+ return 1;
464
+ }
465
+ exit 0 if wait_until($pid, $grace);
466
+ kill "TERM", $pid;
467
+ exit 0 if wait_until($pid, $term);
468
+ kill "KILL", $pid;
469
+ exit(wait_until($pid, $kill) ? 0 : 1);
470
+ ' "$runner_pid" "$CLOSE_RUNNER_GRACE" "$CLOSE_TERM_GRACE" "$CLOSE_KILL_GRACE"; then
471
+ close_drain_failed=1
472
+ fi
473
+ fi
474
+ if [[ "$close_requested" -eq 1 ]] && kill -0 "$runner_pid" 2>/dev/null; then
475
+ runner_rc=1
476
+ close_drain_failed=1
477
+ emit_mode_notice "omnilane: runner did not stop within close deadline" || true
478
+ else
479
+ wait "$runner_pid" 2>/dev/null
480
+ runner_rc=$?
203
481
  fi
204
- wait "$runner_pid" 2>/dev/null
205
- runner_rc=$?
206
482
  set -e
207
483
 
208
484
  if [[ -n "$close_reason" ]]; then
@@ -210,9 +486,27 @@ if [[ -n "$close_reason" ]]; then
210
486
  emit_mode_notice "$close_reason" || true
211
487
  fi
212
488
 
213
- if [[ "$close_requested" -eq 1 ]]; then
214
- if last_result_status; then
215
- rc=0
489
+ if [[ "$natural_runner_exit" -eq 1 ]]; then
490
+ # Draining after a natural/failed reader exit must not turn its exit status
491
+ # into success because an earlier turn happened to emit a result event.
492
+ rc="$runner_rc"
493
+ if [[ "$rc" -eq 0 && "$close_drain_failed" -ne 0 ]]; then rc=1; fi
494
+ elif [[ "$close_requested" -eq 1 ]]; then
495
+ # A vendor-emitted result always stands. A transcript the normalizer recovered
496
+ # stands only for an idle-cap close whose runner still exited on its own: that
497
+ # is the case the idle-cap recovery was written for, and out.txt carries the
498
+ # cap notice next to it. An operator close is an abort, so its recovered
499
+ # transcript is written but must not be reported as a finished turn.
500
+ recovered_counts=0
501
+ if [[ -n "$close_reason" && "$runner_rc" -eq 0 ]]; then recovered_counts=1; fi
502
+ if [[ "$close_drain_failed" -eq 0 ]] && { close_had_result 1 \
503
+ || { [[ "$recovered_counts" -eq 1 ]] && close_had_result 0; }; }; then
504
+ if recover_close_result_output; then
505
+ rc=0
506
+ else
507
+ rc=1
508
+ emit_mode_notice "omnilane: Claude live result completed but output recovery failed" || true
509
+ fi
216
510
  else
217
511
  rc=1
218
512
  emit_mode_notice "omnilane: $VENDOR live mailbox closed without a successful result event" || true