omnilane 0.34.0 → 0.41.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +47 -1
- package/README.ja.md +44 -33
- package/README.ko.md +44 -32
- package/README.md +71 -77
- package/README.zh-CN.md +42 -30
- package/README.zh-TW.md +65 -68
- package/VERSION +1 -1
- package/docs/aa-model-coverage-2026-09-05.json +29204 -0
- package/docs/model-capabilities-2026-09.md +380 -0
- package/package.json +3 -1
- package/plugin.json +1 -1
- package/routing.local.yaml.example +8 -3
- package/routing.yaml +16 -16
- package/scripts/configure.sh +4 -4
- package/scripts/dispatch.sh +83 -14
- package/scripts/doctor.sh +55 -1
- package/scripts/jobs.sh +6 -2
- package/scripts/lib/common.sh +47 -1
- package/scripts/lib/job-worker.sh +312 -20
- package/scripts/lib/live-protocol.sh +147 -2
- package/scripts/lib/normalize-claude-stream.py +72 -0
- package/scripts/lib/prepare-agy-mode.py +374 -0
- package/scripts/release-audit.sh +103 -0
- package/scripts/runners/run-claude.sh +81 -47
- package/scripts/runners/run-codex-live.py +462 -0
- package/scripts/runners/run-codex.sh +62 -3
- package/scripts/runners/run-gemini.sh +85 -10
- package/scripts/runners/run-grok-live.py +426 -0
- package/scripts/runners/run-grok.sh +113 -6
- package/scripts/runners/run-vote.sh +3 -3
- package/skills/omnilane/SKILL.md +106 -59
|
@@ -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
|
-
#
|
|
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 "$
|
|
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
|
|
|
@@ -56,13 +66,41 @@ LIVE_REQUIRED="${OMNILANE_LIVE_REQUIRED:-0}"
|
|
|
56
66
|
[[ "$LIVE_REQUIRED" == "0" || "$LIVE_REQUIRED" == "1" ]] || {
|
|
57
67
|
echo "omnilane: invalid worker live requirement" >&2; exit 2
|
|
58
68
|
}
|
|
69
|
+
if [[ "$VENDOR" == "grok" && "$MODE" != "sysops" && "$SESSION_MODE" != "single-shot" ]]; then
|
|
70
|
+
if [[ "$LIVE_REQUIRED" -eq 1 ]]; then
|
|
71
|
+
echo "omnilane: Grok --live supports only explicit --mode sysops; restricted ACP policy is unavailable" >&2
|
|
72
|
+
exit 2
|
|
73
|
+
fi
|
|
74
|
+
SESSION_MODE="single-shot"
|
|
75
|
+
fi
|
|
59
76
|
if [[ "$SESSION_MODE" == "auto" ]]; then
|
|
60
|
-
|
|
77
|
+
if [[ "$VENDOR" == "codex" || "$VENDOR" == "grok" ]]; then
|
|
78
|
+
SESSION_MODE="single-shot"
|
|
79
|
+
elif live_vendor_capable "$VENDOR"; then
|
|
80
|
+
SESSION_MODE="live"
|
|
81
|
+
else
|
|
82
|
+
SESSION_MODE="single-shot"
|
|
83
|
+
fi
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
LIVE_SURFACE_FALLBACK=""
|
|
87
|
+
if [[ "$VENDOR" == "codex" && "$SESSION_MODE" != "single-shot" ]]; then
|
|
88
|
+
if ! codex_live_surface_available "${CODEX_BIN:-codex}"; then
|
|
89
|
+
SESSION_MODE="single-shot"
|
|
90
|
+
LIVE_SURFACE_FALLBACK="codex"
|
|
91
|
+
fi
|
|
92
|
+
elif [[ "$VENDOR" == "grok" && "$SESSION_MODE" != "single-shot" ]]; then
|
|
93
|
+
if ! grok_live_surface_available "${GROK_BIN:-grok}"; then
|
|
94
|
+
SESSION_MODE="single-shot"
|
|
95
|
+
LIVE_SURFACE_FALLBACK="grok"
|
|
96
|
+
fi
|
|
61
97
|
fi
|
|
62
98
|
|
|
63
99
|
if [[ "$SESSION_MODE" == "single-shot" ]]; then
|
|
64
100
|
set +e
|
|
65
|
-
if
|
|
101
|
+
if [[ -n "$LIVE_SURFACE_FALLBACK" ]]; then
|
|
102
|
+
run_single_shot "omnilane: $LIVE_SURFACE_FALLBACK live surface unavailable; ran in single-shot mode"
|
|
103
|
+
elif live_vendor_capable "$VENDOR"; then
|
|
66
104
|
run_single_shot "omnilane: vendor '$VENDOR' was resolved to single-shot mode"
|
|
67
105
|
else
|
|
68
106
|
run_single_shot "omnilane: vendor '$VENDOR' is not live-capable; ran in single-shot mode"
|
|
@@ -87,14 +125,24 @@ EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
|
|
|
87
125
|
EVENTS_ALIAS="$JOB_DIR/events.jsonl"
|
|
88
126
|
close_requested=0
|
|
89
127
|
close_reason=""
|
|
128
|
+
close_drain_failed=0
|
|
129
|
+
close_partial=""
|
|
130
|
+
natural_runner_exit=0
|
|
131
|
+
CLOSE_DRAIN_TIMEOUT=0.1
|
|
132
|
+
CLOSE_RUNNER_GRACE=7.5
|
|
133
|
+
CLOSE_TERM_GRACE=0.1
|
|
134
|
+
CLOSE_KILL_GRACE=0.1
|
|
90
135
|
runner_writer_open=0
|
|
91
136
|
inbox_open=0
|
|
92
137
|
events_reader_open=0
|
|
138
|
+
forward_spool_open=0
|
|
93
139
|
runner_pid=""
|
|
94
140
|
|
|
95
141
|
# Invoked by the EXIT trap below.
|
|
96
142
|
# shellcheck disable=SC2329
|
|
97
143
|
cleanup_live_mailbox() {
|
|
144
|
+
if [[ -n "${forward_spool:-}" ]]; then rm "$forward_spool" 2>/dev/null || true; fi
|
|
145
|
+
if [[ "$forward_spool_open" -eq 1 ]]; then exec 6<&-; exec 7>&-; forward_spool_open=0; fi
|
|
98
146
|
if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
|
|
99
147
|
if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
|
|
100
148
|
if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
|
|
@@ -126,15 +174,52 @@ prepare_live_mailbox() {
|
|
|
126
174
|
return "$rc"
|
|
127
175
|
}
|
|
128
176
|
|
|
177
|
+
# The Claude normalizer appends a result event of its own when it recovers a
|
|
178
|
+
# partial transcript, so that marker is what separates "the turn finished" from
|
|
179
|
+
# "we salvaged what the model had written so far".
|
|
180
|
+
live_event_is_vendor_result() {
|
|
181
|
+
local event="$1"
|
|
182
|
+
live_event_is_result "$VENDOR" "$event" || return 1
|
|
183
|
+
[[ "$event" != *'"normalized_by"'* ]]
|
|
184
|
+
}
|
|
185
|
+
|
|
129
186
|
last_result_status() {
|
|
130
|
-
local event last_result=""
|
|
187
|
+
local event last_result="" vendor_only="${1:-0}"
|
|
131
188
|
while IFS= read -r event || [[ -n "$event" ]]; do
|
|
132
|
-
if
|
|
189
|
+
if [[ "$vendor_only" -eq 1 ]]; then
|
|
190
|
+
if live_event_is_vendor_result "$event"; then last_result="$event"; fi
|
|
191
|
+
elif live_event_is_result "$VENDOR" "$event"; then
|
|
192
|
+
last_result="$event"
|
|
193
|
+
fi
|
|
133
194
|
done < "$EVENTS_FILE"
|
|
134
195
|
[[ -n "$last_result" ]] || return 1
|
|
135
196
|
live_event_is_success "$VENDOR" "$last_result"
|
|
136
197
|
}
|
|
137
198
|
|
|
199
|
+
close_had_result() {
|
|
200
|
+
local vendor_only="$1"
|
|
201
|
+
if [[ -n "$last_result_event" ]] \
|
|
202
|
+
&& { [[ "$vendor_only" -eq 0 ]] || live_event_is_vendor_result "$last_result_event"; } \
|
|
203
|
+
&& live_event_is_success "$VENDOR" "$last_result_event"; then
|
|
204
|
+
return 0
|
|
205
|
+
fi
|
|
206
|
+
last_result_status "$vendor_only"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
recover_close_result_output() {
|
|
210
|
+
# Claude's stream-json process may remain resident after stdin EOF. The
|
|
211
|
+
# worker then enforces its bounded close deadline, so the runner's signal
|
|
212
|
+
# trap is not guaranteed enough time to normalize the already-completed
|
|
213
|
+
# result. Preserve that successful vendor result before declaring the job
|
|
214
|
+
# done; never synthesize output for an incomplete turn or overwrite output
|
|
215
|
+
# the runner already committed.
|
|
216
|
+
[[ "$VENDOR" == "claude" ]] || return 0
|
|
217
|
+
[[ ! -s "$OUTPUT_FILE" ]] || return 0
|
|
218
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
219
|
+
python3 "$OMNILANE_REPO/scripts/lib/normalize-claude-stream.py" \
|
|
220
|
+
"$EVENTS_FILE" "$OUTPUT_FILE"
|
|
221
|
+
}
|
|
222
|
+
|
|
138
223
|
if ! prepare_live_mailbox; then
|
|
139
224
|
if [[ "$LIVE_REQUIRED" -eq 1 ]]; then
|
|
140
225
|
echo "omnilane: required $VENDOR live mailbox unavailable because FIFO setup failed" >&2
|
|
@@ -150,7 +235,72 @@ fi
|
|
|
150
235
|
trap 'close_requested=1' USR1
|
|
151
236
|
trap 'close_requested=1' PIPE
|
|
152
237
|
trap cleanup_live_mailbox EXIT
|
|
238
|
+
if [[ "$VENDOR" == "codex" ]]; then
|
|
239
|
+
# Allocate once before publishing the holder PID. Reader and writer offsets
|
|
240
|
+
# are independent; unlink immediately and keep this 0600 file private to us.
|
|
241
|
+
forward_spool="$(mktemp "$JOB_DIR/.forward.XXXXXX")" || exit 1
|
|
242
|
+
exec 6< "$forward_spool"
|
|
243
|
+
forward_spool_open=1
|
|
244
|
+
exec 7> "$forward_spool"
|
|
245
|
+
rm "$forward_spool"
|
|
246
|
+
forward_spool=""
|
|
247
|
+
fi
|
|
153
248
|
truncate_payload "$PROMPT_FILE" 102400
|
|
249
|
+
# Codex input must not be consumed by Bash read: an interrupted partial read
|
|
250
|
+
# can discard bytes before the close trap runs. Pump raw bytes instead. The
|
|
251
|
+
# anonymous file holds at most one 64 KiB chunk, with shared read offset on FD 6
|
|
252
|
+
# and an independent append/reset offset on FD 7. A close drains its remaining
|
|
253
|
+
# suffix first; successful sends truncate/reset it instead of growing a spool.
|
|
254
|
+
pump_codex_input() {
|
|
255
|
+
python3 - <<'PYPUMP'
|
|
256
|
+
import os
|
|
257
|
+
import select
|
|
258
|
+
import sys
|
|
259
|
+
import time
|
|
260
|
+
|
|
261
|
+
pending = b""
|
|
262
|
+
progress = False
|
|
263
|
+
|
|
264
|
+
def reset_spool():
|
|
265
|
+
os.ftruncate(7, 0)
|
|
266
|
+
os.lseek(6, 0, os.SEEK_SET)
|
|
267
|
+
os.lseek(7, 0, os.SEEK_SET)
|
|
268
|
+
|
|
269
|
+
os.set_blocking(3, False)
|
|
270
|
+
os.set_blocking(4, False)
|
|
271
|
+
try:
|
|
272
|
+
pending = os.read(6, 65536)
|
|
273
|
+
if not pending:
|
|
274
|
+
reset_spool()
|
|
275
|
+
readable, _, _ = select.select([4], [], [], 1)
|
|
276
|
+
if not readable:
|
|
277
|
+
sys.exit(3)
|
|
278
|
+
chunk = os.read(4, 65536)
|
|
279
|
+
if not chunk:
|
|
280
|
+
sys.exit(1)
|
|
281
|
+
# FD 7 is a private regular file, never the backpressured FIFO.
|
|
282
|
+
while chunk:
|
|
283
|
+
chunk = chunk[os.write(7, chunk):]
|
|
284
|
+
pending = os.read(6, 65536)
|
|
285
|
+
progress = True
|
|
286
|
+
deadline = time.monotonic() + 0.05
|
|
287
|
+
while pending and time.monotonic() < deadline:
|
|
288
|
+
_, writable, _ = select.select([], [3], [], max(0, deadline - time.monotonic()))
|
|
289
|
+
if writable:
|
|
290
|
+
pending = pending[os.write(3, pending):]
|
|
291
|
+
progress = True
|
|
292
|
+
if not pending:
|
|
293
|
+
reset_spool()
|
|
294
|
+
except OSError:
|
|
295
|
+
if pending:
|
|
296
|
+
os.lseek(6, -len(pending), os.SEEK_CUR)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
if pending:
|
|
299
|
+
os.lseek(6, -len(pending), os.SEEK_CUR)
|
|
300
|
+
sys.exit((4 if progress else 2) if pending else 0)
|
|
301
|
+
PYPUMP
|
|
302
|
+
}
|
|
303
|
+
|
|
154
304
|
INITIAL_TEXT="$(cat "$PROMPT_FILE")"
|
|
155
305
|
if [[ "${FOREMAN_SESSION+x}" == "x" ]]; then
|
|
156
306
|
FOREMAN_SESSION_VALUE="$FOREMAN_SESSION"
|
|
@@ -160,7 +310,7 @@ fi
|
|
|
160
310
|
|
|
161
311
|
write_current_pid_file "$HOLDER_PID_FILE"
|
|
162
312
|
export OMNILANE_INBOX="$RUNNER_INBOX_FIFO"
|
|
163
|
-
"$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE" &
|
|
313
|
+
"$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE" 6<&- 7>&- &
|
|
164
314
|
runner_pid=$!
|
|
165
315
|
|
|
166
316
|
# Open the runner writer only after its FIFO reader starts.
|
|
@@ -175,16 +325,109 @@ if ! printf '%s\n' "$initial_payload" >&3; then close_requested=1; fi
|
|
|
175
325
|
(umask 077; : > "$READY_FILE")
|
|
176
326
|
|
|
177
327
|
last_activity=$SECONDS
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
328
|
+
last_result_event=""
|
|
329
|
+
# A dead Codex reader can leave accepted bytes in the public FIFO even when
|
|
330
|
+
# the previous pump was idle. Always take one final bounded drain in that case.
|
|
331
|
+
while kill -0 "$runner_pid" 2>/dev/null || [[ "$VENDOR" == "codex" || "$close_requested" -ne 0 ]]; do
|
|
332
|
+
if [[ "$VENDOR" == "codex" && "$close_requested" -eq 0 ]] && ! kill -0 "$runner_pid" 2>/dev/null; then
|
|
333
|
+
natural_runner_exit=1
|
|
334
|
+
close_requested=1
|
|
335
|
+
fi
|
|
336
|
+
if [[ "$close_requested" -ne 0 ]]; then
|
|
337
|
+
rm "$READY_FILE" 2>/dev/null || true
|
|
338
|
+
if [[ "$VENDOR" == "codex" ]]; then
|
|
339
|
+
# Bash 3.2 has no fractional read timeout. Bound the *whole* drain,
|
|
340
|
+
# including backpressure, rather than granting each line another second.
|
|
341
|
+
# Retain unforwarded bytes and report failure instead of silently dropping
|
|
342
|
+
# an accepted follow-up when the runner stalls or a writer never stops.
|
|
343
|
+
if ! CLOSE_PARTIAL="$close_partial" FORWARD_PENDING="$forward_spool_open" python3 - "$JOB_DIR/close-pending.jsonl" "$CLOSE_DRAIN_TIMEOUT" <<'PY'
|
|
344
|
+
import array
|
|
345
|
+
import fcntl
|
|
346
|
+
import os
|
|
347
|
+
import select
|
|
348
|
+
import sys
|
|
349
|
+
import termios
|
|
350
|
+
import time
|
|
351
|
+
|
|
352
|
+
deadline = time.monotonic() + float(sys.argv[2])
|
|
353
|
+
pending = b""
|
|
354
|
+
if os.environ.get("FORWARD_PENDING") == "1":
|
|
355
|
+
while True:
|
|
356
|
+
chunk = os.read(6, 65536)
|
|
357
|
+
if not chunk:
|
|
358
|
+
break
|
|
359
|
+
pending += chunk
|
|
360
|
+
pending += os.environ.get("CLOSE_PARTIAL", "").encode()
|
|
361
|
+
for fd in (3, 4):
|
|
362
|
+
os.set_blocking(fd, False)
|
|
363
|
+
try:
|
|
364
|
+
while time.monotonic() < deadline:
|
|
365
|
+
readable, writable, _ = select.select([4], [3] if pending else [], [], 0)
|
|
366
|
+
if not readable and not pending:
|
|
367
|
+
sys.exit(0)
|
|
368
|
+
if readable and len(pending) < 65536:
|
|
369
|
+
pending += os.read(4, 65536 - len(pending))
|
|
370
|
+
if pending:
|
|
371
|
+
_, writable, _ = select.select([], [3], [], max(0, deadline - time.monotonic()))
|
|
372
|
+
if writable:
|
|
373
|
+
pending = pending[os.write(3, pending):]
|
|
374
|
+
except (BrokenPipeError, OSError):
|
|
375
|
+
pass
|
|
376
|
+
# Snapshot only bytes already queued at the deadline; a continuous writer
|
|
377
|
+
# cannot extend this phase. New send callers no longer see inbox.ready.
|
|
378
|
+
available = array.array("i", [0])
|
|
379
|
+
fcntl.ioctl(4, termios.FIONREAD, available, True)
|
|
380
|
+
remaining = available[0]
|
|
381
|
+
while remaining:
|
|
382
|
+
chunk = os.read(4, min(remaining, 65536))
|
|
383
|
+
if not chunk:
|
|
384
|
+
break
|
|
385
|
+
pending += chunk
|
|
386
|
+
remaining -= len(chunk)
|
|
387
|
+
if pending:
|
|
388
|
+
fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
389
|
+
with os.fdopen(fd, "wb") as saved:
|
|
390
|
+
saved.write(pending)
|
|
391
|
+
sys.exit(1)
|
|
392
|
+
PY
|
|
393
|
+
then
|
|
394
|
+
close_drain_failed=1
|
|
395
|
+
emit_mode_notice "omnilane: close drain incomplete; unforwarded input retained in $JOB_DIR/close-pending.jsonl" || true
|
|
396
|
+
fi
|
|
397
|
+
fi
|
|
398
|
+
break
|
|
399
|
+
fi
|
|
400
|
+
if [[ "$VENDOR" == "codex" ]]; then
|
|
401
|
+
if pump_codex_input; then
|
|
402
|
+
last_activity=$SECONDS
|
|
403
|
+
else
|
|
404
|
+
pump_status=$?
|
|
405
|
+
# 2/4 retain a suffix (4 made progress); 3 is an idle poll. A reader
|
|
406
|
+
# that dies with pending bytes still enters the close drain, even after
|
|
407
|
+
# its PID disappears, so those bytes are retained rather than dropped.
|
|
408
|
+
if [[ "$pump_status" -eq 2 || "$pump_status" -eq 4 ]]; then
|
|
409
|
+
[[ "$pump_status" -ne 4 ]] || last_activity=$SECONDS
|
|
410
|
+
kill -0 "$runner_pid" 2>/dev/null || close_requested=1
|
|
411
|
+
elif [[ "$pump_status" -ne 3 ]]; then
|
|
412
|
+
close_requested=1
|
|
413
|
+
fi
|
|
414
|
+
fi
|
|
415
|
+
else
|
|
416
|
+
incoming=""
|
|
417
|
+
if IFS= read -r -t 1 incoming <&4; then
|
|
418
|
+
if ! printf '%s\n' "$incoming" >&3; then close_requested=1; fi
|
|
419
|
+
last_activity=$SECONDS
|
|
420
|
+
elif [[ "$close_requested" -ne 0 && -n "$incoming" ]]; then
|
|
421
|
+
close_partial="$incoming"
|
|
422
|
+
fi
|
|
184
423
|
fi
|
|
185
424
|
while IFS= read -r event <&5; do
|
|
186
|
-
|
|
425
|
+
[[ "$close_requested" -eq 0 ]] || break
|
|
426
|
+
if live_event_is_valid "$event"; then
|
|
187
427
|
last_activity=$SECONDS
|
|
428
|
+
if live_event_is_result "$VENDOR" "$event"; then
|
|
429
|
+
last_result_event="$event"
|
|
430
|
+
fi
|
|
188
431
|
fi
|
|
189
432
|
done
|
|
190
433
|
if [[ "$IDLE_TIMEOUT" -gt 0 && $((SECONDS - last_activity)) -ge "$IDLE_TIMEOUT" ]]; then
|
|
@@ -193,16 +436,47 @@ while kill -0 "$runner_pid" 2>/dev/null; do
|
|
|
193
436
|
fi
|
|
194
437
|
done
|
|
195
438
|
|
|
439
|
+
if [[ "$forward_spool_open" -eq 1 ]]; then exec 6<&-; exec 7>&-; forward_spool_open=0; fi
|
|
196
440
|
if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
|
|
197
441
|
if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
|
|
198
442
|
if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
|
|
199
443
|
|
|
200
444
|
set +e
|
|
201
445
|
if [[ "$close_requested" -eq 1 ]]; then
|
|
202
|
-
|
|
446
|
+
# 0.1s drain + 7.5s grace + 0.1s TERM + 0.1s KILL = 7.8s.
|
|
447
|
+
# With the input pump's 1s read + 0.05s write, 8.85s also precedes
|
|
448
|
+
# the shortest ~9s interval of jobs close's integer SECONDS + 10 deadline.
|
|
449
|
+
# Codex retains its full 3+2+1+1=7s normal shutdown budget.
|
|
450
|
+
# A monotonic timer avoids accumulating 75 shell/sleep launch overheads.
|
|
451
|
+
if ! perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC,sleep -e '
|
|
452
|
+
my ($pid, $grace, $term, $kill) = @ARGV;
|
|
453
|
+
sub wait_until {
|
|
454
|
+
my ($pid, $duration) = @_;
|
|
455
|
+
my $deadline = clock_gettime(CLOCK_MONOTONIC) + $duration;
|
|
456
|
+
while (kill 0, $pid) {
|
|
457
|
+
my $left = $deadline - clock_gettime(CLOCK_MONOTONIC);
|
|
458
|
+
return 0 if $left <= 0;
|
|
459
|
+
sleep($left < 0.02 ? $left : 0.02);
|
|
460
|
+
}
|
|
461
|
+
return 1;
|
|
462
|
+
}
|
|
463
|
+
exit 0 if wait_until($pid, $grace);
|
|
464
|
+
kill "TERM", $pid;
|
|
465
|
+
exit 0 if wait_until($pid, $term);
|
|
466
|
+
kill "KILL", $pid;
|
|
467
|
+
exit(wait_until($pid, $kill) ? 0 : 1);
|
|
468
|
+
' "$runner_pid" "$CLOSE_RUNNER_GRACE" "$CLOSE_TERM_GRACE" "$CLOSE_KILL_GRACE"; then
|
|
469
|
+
close_drain_failed=1
|
|
470
|
+
fi
|
|
471
|
+
fi
|
|
472
|
+
if [[ "$close_requested" -eq 1 ]] && kill -0 "$runner_pid" 2>/dev/null; then
|
|
473
|
+
runner_rc=1
|
|
474
|
+
close_drain_failed=1
|
|
475
|
+
emit_mode_notice "omnilane: runner did not stop within close deadline" || true
|
|
476
|
+
else
|
|
477
|
+
wait "$runner_pid" 2>/dev/null
|
|
478
|
+
runner_rc=$?
|
|
203
479
|
fi
|
|
204
|
-
wait "$runner_pid" 2>/dev/null
|
|
205
|
-
runner_rc=$?
|
|
206
480
|
set -e
|
|
207
481
|
|
|
208
482
|
if [[ -n "$close_reason" ]]; then
|
|
@@ -210,9 +484,27 @@ if [[ -n "$close_reason" ]]; then
|
|
|
210
484
|
emit_mode_notice "$close_reason" || true
|
|
211
485
|
fi
|
|
212
486
|
|
|
213
|
-
if [[ "$
|
|
214
|
-
|
|
215
|
-
|
|
487
|
+
if [[ "$natural_runner_exit" -eq 1 ]]; then
|
|
488
|
+
# Draining after a natural/failed reader exit must not turn its exit status
|
|
489
|
+
# into success because an earlier turn happened to emit a result event.
|
|
490
|
+
rc="$runner_rc"
|
|
491
|
+
if [[ "$rc" -eq 0 && "$close_drain_failed" -ne 0 ]]; then rc=1; fi
|
|
492
|
+
elif [[ "$close_requested" -eq 1 ]]; then
|
|
493
|
+
# A vendor-emitted result always stands. A transcript the normalizer recovered
|
|
494
|
+
# stands only for an idle-cap close whose runner still exited on its own: that
|
|
495
|
+
# is the case the idle-cap recovery was written for, and out.txt carries the
|
|
496
|
+
# cap notice next to it. An operator close is an abort, so its recovered
|
|
497
|
+
# transcript is written but must not be reported as a finished turn.
|
|
498
|
+
recovered_counts=0
|
|
499
|
+
if [[ -n "$close_reason" && "$runner_rc" -eq 0 ]]; then recovered_counts=1; fi
|
|
500
|
+
if [[ "$close_drain_failed" -eq 0 ]] && { close_had_result 1 \
|
|
501
|
+
|| { [[ "$recovered_counts" -eq 1 ]] && close_had_result 0; }; }; then
|
|
502
|
+
if recover_close_result_output; then
|
|
503
|
+
rc=0
|
|
504
|
+
else
|
|
505
|
+
rc=1
|
|
506
|
+
emit_mode_notice "omnilane: Claude live result completed but output recovery failed" || true
|
|
507
|
+
fi
|
|
216
508
|
else
|
|
217
509
|
rc=1
|
|
218
510
|
emit_mode_notice "omnilane: $VENDOR live mailbox closed without a successful result event" || true
|
|
@@ -3,16 +3,128 @@
|
|
|
3
3
|
# Shared live-mailbox protocol differences. Callers provide json_escape().
|
|
4
4
|
|
|
5
5
|
live_capable_vendors() {
|
|
6
|
-
printf 'claude, gemini'
|
|
6
|
+
printf 'claude, gemini, codex, grok'
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
live_vendor_capable() {
|
|
10
10
|
case "$1" in
|
|
11
|
-
claude|gemini) return 0 ;;
|
|
11
|
+
claude|gemini|codex|grok) return 0 ;;
|
|
12
12
|
*) return 1 ;;
|
|
13
13
|
esac
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
codex_live_surface_available() {
|
|
17
|
+
local bin="${1:-${CODEX_BIN:-codex}}"
|
|
18
|
+
command -v "$bin" >/dev/null 2>&1 || return 1
|
|
19
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
20
|
+
python3 - "$bin" 2>/dev/null <<'PY'
|
|
21
|
+
import json
|
|
22
|
+
import select
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
process = None
|
|
27
|
+
ok = False
|
|
28
|
+
try:
|
|
29
|
+
process = subprocess.Popen(
|
|
30
|
+
[sys.argv[1], "app-server"],
|
|
31
|
+
stdin=subprocess.PIPE,
|
|
32
|
+
stdout=subprocess.PIPE,
|
|
33
|
+
stderr=subprocess.DEVNULL,
|
|
34
|
+
text=True,
|
|
35
|
+
# Stay in the caller's supervised process group. A whole-job SIGKILL
|
|
36
|
+
# bypasses this probe's finally block, so a private session would leak.
|
|
37
|
+
start_new_session=False,
|
|
38
|
+
)
|
|
39
|
+
request = {
|
|
40
|
+
"jsonrpc": "2.0",
|
|
41
|
+
"id": 1,
|
|
42
|
+
"method": "initialize",
|
|
43
|
+
"params": {"clientInfo": {"name": "omnilane-probe", "version": "1"}},
|
|
44
|
+
}
|
|
45
|
+
process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n")
|
|
46
|
+
process.stdin.flush()
|
|
47
|
+
# Keep stdin open until the reply arrives: codex 0.153.4 exits on EOF
|
|
48
|
+
# before answering, which made a healthy machine look live-unavailable.
|
|
49
|
+
if select.select([process.stdout], [], [], 3)[0]:
|
|
50
|
+
response = json.loads(process.stdout.readline())
|
|
51
|
+
ok = isinstance(response, dict) and isinstance(response.get("result"), dict)
|
|
52
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
53
|
+
ok = False
|
|
54
|
+
finally:
|
|
55
|
+
if process is not None and process.poll() is None:
|
|
56
|
+
try:
|
|
57
|
+
process.terminate()
|
|
58
|
+
process.wait(timeout=1)
|
|
59
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
60
|
+
try:
|
|
61
|
+
process.kill()
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
process.wait()
|
|
65
|
+
sys.exit(0 if ok else 1)
|
|
66
|
+
PY
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
grok_live_surface_available() {
|
|
70
|
+
local bin="${1:-${GROK_BIN:-grok}}"
|
|
71
|
+
command -v "$bin" >/dev/null 2>&1 || return 1
|
|
72
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
73
|
+
|
|
74
|
+
python3 - "$bin" 2>/dev/null <<'PY'
|
|
75
|
+
import json
|
|
76
|
+
import select
|
|
77
|
+
import subprocess
|
|
78
|
+
import sys
|
|
79
|
+
|
|
80
|
+
process = None
|
|
81
|
+
ok = False
|
|
82
|
+
try:
|
|
83
|
+
process = subprocess.Popen(
|
|
84
|
+
[sys.argv[1], "agent", "stdio"],
|
|
85
|
+
stdin=subprocess.PIPE,
|
|
86
|
+
stdout=subprocess.PIPE,
|
|
87
|
+
stderr=subprocess.DEVNULL,
|
|
88
|
+
text=True,
|
|
89
|
+
# Stay in the caller's supervised process group. A whole-job SIGKILL
|
|
90
|
+
# bypasses the probe's finally block, so a private session would leak.
|
|
91
|
+
start_new_session=False,
|
|
92
|
+
)
|
|
93
|
+
request = {
|
|
94
|
+
"jsonrpc": "2.0",
|
|
95
|
+
"id": 1,
|
|
96
|
+
"method": "initialize",
|
|
97
|
+
"params": {
|
|
98
|
+
"protocolVersion": 1,
|
|
99
|
+
"clientCapabilities": {
|
|
100
|
+
"fs": {"readTextFile": False, "writeTextFile": False}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n")
|
|
105
|
+
process.stdin.flush()
|
|
106
|
+
# Same as the codex probe: keep stdin open until the reply arrives so an
|
|
107
|
+
# agent that exits on EOF before answering is not reported unavailable.
|
|
108
|
+
if select.select([process.stdout], [], [], 3)[0]:
|
|
109
|
+
response = json.loads(process.stdout.readline())
|
|
110
|
+
ok = isinstance(response, dict) and isinstance(response.get("result"), dict)
|
|
111
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
112
|
+
ok = False
|
|
113
|
+
finally:
|
|
114
|
+
if process is not None and process.poll() is None:
|
|
115
|
+
try:
|
|
116
|
+
process.terminate()
|
|
117
|
+
process.wait(timeout=1)
|
|
118
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
119
|
+
try:
|
|
120
|
+
process.kill()
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
process.wait()
|
|
124
|
+
sys.exit(0 if ok else 1)
|
|
125
|
+
PY
|
|
126
|
+
}
|
|
127
|
+
|
|
16
128
|
live_encode_message() {
|
|
17
129
|
local vendor="$1" job_id="$2" foreman_session="$3" text="$4"
|
|
18
130
|
case "$vendor" in
|
|
@@ -25,15 +137,40 @@ live_encode_message() {
|
|
|
25
137
|
printf '{"event":"user","message":{"role":"user","content":[{"type":"text","text":"%s"}]}}' \
|
|
26
138
|
"$(json_escape "$text")"
|
|
27
139
|
;;
|
|
140
|
+
codex)
|
|
141
|
+
printf '{"type":"codex-user","text":"%s"}' "$(json_escape "$text")"
|
|
142
|
+
;;
|
|
143
|
+
grok)
|
|
144
|
+
printf '{"type":"grok-user","text":"%s"}' "$(json_escape "$text")"
|
|
145
|
+
;;
|
|
28
146
|
*) return 2 ;;
|
|
29
147
|
esac
|
|
30
148
|
}
|
|
31
149
|
|
|
150
|
+
# Cheap structural check, not a parse. Two properties matter more here than
|
|
151
|
+
# precision. It must not fork: the drain loop runs one call per stream event
|
|
152
|
+
# between inbox reads, so a per-event process would leave an operator's
|
|
153
|
+
# `jobs.sh send` waiting behind a busy stream. And it must not be able to fail
|
|
154
|
+
# because a parser is missing: a validator that cannot run would judge every
|
|
155
|
+
# event invalid, activity would never refresh, and the idle cap would kill
|
|
156
|
+
# healthy jobs. Strict parsing still guards the places where correctness
|
|
157
|
+
# depends on it (json_file_round_trip_valid).
|
|
158
|
+
live_event_is_valid() {
|
|
159
|
+
local event="$1" lead trail
|
|
160
|
+
lead="${event%%[![:space:]]*}"
|
|
161
|
+
event="${event#"$lead"}"
|
|
162
|
+
trail="${event##*[![:space:]]}"
|
|
163
|
+
event="${event%"$trail"}"
|
|
164
|
+
[[ "$event" == "{"*"}" ]]
|
|
165
|
+
}
|
|
166
|
+
|
|
32
167
|
live_event_is_result() {
|
|
33
168
|
local vendor="$1" event="$2" pattern
|
|
34
169
|
case "$vendor" in
|
|
35
170
|
claude) pattern='"type"[[:space:]]*:[[:space:]]*"result"' ;;
|
|
36
171
|
gemini) pattern='"event"[[:space:]]*:[[:space:]]*"result"' ;;
|
|
172
|
+
codex) pattern='"method"[[:space:]]*:[[:space:]]*"turn/completed"' ;;
|
|
173
|
+
grok) pattern='"method"[[:space:]]*:[[:space:]]*"_x.ai/session/prompt_complete"' ;;
|
|
37
174
|
*) return 2 ;;
|
|
38
175
|
esac
|
|
39
176
|
[[ "$event" =~ $pattern ]]
|
|
@@ -51,6 +188,14 @@ live_event_is_success() {
|
|
|
51
188
|
pattern='"status"[[:space:]]*:[[:space:]]*"SUCCESS"'
|
|
52
189
|
[[ "$event" =~ $pattern ]]
|
|
53
190
|
;;
|
|
191
|
+
codex)
|
|
192
|
+
pattern='"status"[[:space:]]*:[[:space:]]*"completed"'
|
|
193
|
+
[[ "$event" =~ $pattern ]]
|
|
194
|
+
;;
|
|
195
|
+
grok)
|
|
196
|
+
pattern='"stopReason"[[:space:]]*:[[:space:]]*"end_turn"'
|
|
197
|
+
[[ "$event" =~ $pattern ]]
|
|
198
|
+
;;
|
|
54
199
|
*) return 2 ;;
|
|
55
200
|
esac
|
|
56
201
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Normalize a successful Claude stream into Omnilane's result contract."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
parser = argparse.ArgumentParser()
|
|
13
|
+
parser.add_argument("events", type=Path)
|
|
14
|
+
parser.add_argument("output", type=Path)
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
last_result: str | None = None
|
|
18
|
+
last_result_error = False
|
|
19
|
+
last_top_level_text: str | None = None
|
|
20
|
+
with args.events.open(encoding="utf-8") as stream:
|
|
21
|
+
for raw_line in stream:
|
|
22
|
+
try:
|
|
23
|
+
event = json.loads(raw_line)
|
|
24
|
+
except json.JSONDecodeError:
|
|
25
|
+
continue
|
|
26
|
+
if not isinstance(event, dict):
|
|
27
|
+
continue
|
|
28
|
+
if event.get("type") == "result" and isinstance(event.get("result"), str):
|
|
29
|
+
last_result = event["result"]
|
|
30
|
+
last_result_error = event.get("is_error") is True
|
|
31
|
+
continue
|
|
32
|
+
if event.get("type") != "assistant" or event.get("parent_tool_use_id"):
|
|
33
|
+
continue
|
|
34
|
+
message = event.get("message")
|
|
35
|
+
if not isinstance(message, dict):
|
|
36
|
+
continue
|
|
37
|
+
content = message.get("content")
|
|
38
|
+
if not isinstance(content, list):
|
|
39
|
+
continue
|
|
40
|
+
text_blocks = [
|
|
41
|
+
block["text"]
|
|
42
|
+
for block in content
|
|
43
|
+
if isinstance(block, dict)
|
|
44
|
+
and block.get("type") == "text"
|
|
45
|
+
and isinstance(block.get("text"), str)
|
|
46
|
+
]
|
|
47
|
+
if text_blocks:
|
|
48
|
+
last_top_level_text = "\n".join(text_blocks)
|
|
49
|
+
|
|
50
|
+
if last_result is not None:
|
|
51
|
+
if last_result_error:
|
|
52
|
+
return 1
|
|
53
|
+
elif last_top_level_text is None:
|
|
54
|
+
return 1
|
|
55
|
+
else:
|
|
56
|
+
last_result = last_top_level_text
|
|
57
|
+
canonical = {
|
|
58
|
+
"type": "result",
|
|
59
|
+
"is_error": False,
|
|
60
|
+
"result": last_result,
|
|
61
|
+
"normalized_by": "omnilane",
|
|
62
|
+
}
|
|
63
|
+
with args.events.open("a", encoding="utf-8") as stream:
|
|
64
|
+
stream.write(json.dumps(canonical, ensure_ascii=False, separators=(",", ":")))
|
|
65
|
+
stream.write("\n")
|
|
66
|
+
|
|
67
|
+
args.output.write_text(last_result.rstrip("\n") + "\n", encoding="utf-8")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
raise SystemExit(main())
|