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.
@@ -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
 
@@ -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
- if live_vendor_capable "$VENDOR"; then SESSION_MODE="live"; else SESSION_MODE="single-shot"; fi
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 live_vendor_capable "$VENDOR"; then
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 live_event_is_result "$VENDOR" "$event"; then last_result="$event"; fi
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
- 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
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
- if live_event_is_result "$VENDOR" "$event"; then
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
- kill -TERM "$runner_pid" 2>/dev/null || true
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 [[ "$close_requested" -eq 1 ]]; then
214
- if last_result_status; then
215
- rc=0
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())