omnilane 0.20.0 → 0.31.0

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.
@@ -7,6 +7,8 @@ set -euo pipefail
7
7
  # Runtime-relative shared library.
8
8
  # shellcheck disable=SC1091
9
9
  source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
10
+ # shellcheck disable=SC1091
11
+ source "$OMNILANE_REPO/scripts/lib/live-protocol.sh"
10
12
 
11
13
  [[ $# -eq 7 ]] || { echo "omnilane: internal job worker received invalid arguments" >&2; exit 2; }
12
14
  VENDOR="$1"; MODE="$2"; WORKDIR="$3"; MODEL="$4"; EFFORT="$5"
@@ -70,43 +72,72 @@ run_single_shot() {
70
72
  return "$rc"
71
73
  }
72
74
 
73
- if [[ "$VENDOR" != "claude" ]]; then
75
+ SESSION_MODE="${OMNILANE_SESSION_MODE:-auto}"
76
+ IDLE_TIMEOUT="${OMNILANE_IDLE_TIMEOUT:-900}"
77
+ LIVE_REQUIRED="${OMNILANE_LIVE_REQUIRED:-0}"
78
+ [[ "$SESSION_MODE" == "auto" || "$SESSION_MODE" == "live" || "$SESSION_MODE" == "single-shot" ]] || {
79
+ echo "omnilane: invalid worker session mode" >&2; exit 2
80
+ }
81
+ [[ "$IDLE_TIMEOUT" =~ ^(0|[1-9][0-9]*)$ ]] || {
82
+ echo "omnilane: invalid worker idle timeout" >&2; exit 2
83
+ }
84
+ [[ "$LIVE_REQUIRED" == "0" || "$LIVE_REQUIRED" == "1" ]] || {
85
+ echo "omnilane: invalid worker live requirement" >&2; exit 2
86
+ }
87
+ if [[ "$SESSION_MODE" == "auto" ]]; then
88
+ if live_vendor_capable "$VENDOR"; then SESSION_MODE="live"; else SESSION_MODE="single-shot"; fi
89
+ fi
90
+
91
+ if [[ "$SESSION_MODE" == "single-shot" ]]; then
74
92
  set +e
75
- run_single_shot "omnilane: vendor '$VENDOR' is not live-capable; ran in single-shot mode"
93
+ if live_vendor_capable "$VENDOR"; then
94
+ run_single_shot "omnilane: vendor '$VENDOR' was resolved to single-shot mode"
95
+ else
96
+ run_single_shot "omnilane: vendor '$VENDOR' is not live-capable; ran in single-shot mode"
97
+ fi
76
98
  rc=$?
77
99
  set -e
78
100
  exit "$rc"
79
101
  fi
80
102
 
103
+ if ! live_vendor_capable "$VENDOR"; then
104
+ echo "omnilane: vendor '$VENDOR' cannot run required live session; live-capable vendors: $(live_capable_vendors)" >&2
105
+ exit 2
106
+ fi
107
+
81
108
  JOB_DIR="${OUTPUT_FILE%/*}"
82
109
  JOB_ID="${JOB_DIR##*/}"
83
110
  INBOX_FIFO="$JOB_DIR/inbox.fifo"
111
+ RUNNER_INBOX_FIFO="$JOB_DIR/runner-inbox.fifo"
84
112
  HOLDER_PID_FILE="$JOB_DIR/inbox.holder.pid"
85
113
  READY_FILE="$JOB_DIR/inbox.ready"
86
114
  EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
87
115
  EVENTS_ALIAS="$JOB_DIR/events.jsonl"
88
116
  close_requested=0
89
- inbox_holder_open=0
117
+ close_reason=""
118
+ runner_writer_open=0
119
+ inbox_open=0
120
+ events_reader_open=0
90
121
  runner_pid=""
91
122
 
92
123
  # Invoked by the EXIT trap below.
93
124
  # shellcheck disable=SC2329
94
125
  cleanup_live_mailbox() {
95
- if [[ "$inbox_holder_open" -eq 1 ]]; then
96
- exec 3>&-
97
- inbox_holder_open=0
98
- fi
99
- rm "$READY_FILE" "$HOLDER_PID_FILE" "$INBOX_FIFO" 2>/dev/null || true
126
+ if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
127
+ if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
128
+ if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
129
+ rm "$READY_FILE" "$HOLDER_PID_FILE" "$INBOX_FIFO" "$RUNNER_INBOX_FIFO" 2>/dev/null || true
100
130
  }
101
131
 
102
132
  prepare_live_mailbox() {
103
133
  local path old_umask rc=0
104
- for path in "$INBOX_FIFO" "$HOLDER_PID_FILE" "$READY_FILE" "$EVENTS_FILE" "$EVENTS_ALIAS"; do
134
+ for path in "$INBOX_FIFO" "$RUNNER_INBOX_FIFO" "$HOLDER_PID_FILE" \
135
+ "$READY_FILE" "$EVENTS_FILE" "$EVENTS_ALIAS"; do
105
136
  [[ ! -e "$path" && ! -L "$path" ]] || return 1
106
137
  done
107
138
  old_umask="$(umask)"
108
139
  umask 077
109
- mkfifo "$INBOX_FIFO" || rc=$?
140
+ mkfifo "$INBOX_FIFO" "$RUNNER_INBOX_FIFO" || rc=$?
110
141
  if [[ "$rc" -eq 0 ]]; then
111
142
  : > "$EVENTS_FILE" || rc=$?
112
143
  fi
@@ -114,31 +145,38 @@ prepare_live_mailbox() {
114
145
  ln "$EVENTS_FILE" "$EVENTS_ALIAS" || rc=$?
115
146
  fi
116
147
  if [[ "$rc" -eq 0 ]]; then
117
- chmod 600 "$INBOX_FIFO" "$EVENTS_FILE" "$EVENTS_ALIAS" || rc=$?
148
+ chmod 600 "$INBOX_FIFO" "$RUNNER_INBOX_FIFO" "$EVENTS_FILE" "$EVENTS_ALIAS" || rc=$?
118
149
  fi
119
150
  umask "$old_umask"
120
151
  if [[ "$rc" -ne 0 ]]; then
121
- rm "$EVENTS_ALIAS" "$EVENTS_FILE" "$INBOX_FIFO" 2>/dev/null || true
152
+ rm "$EVENTS_ALIAS" "$EVENTS_FILE" "$INBOX_FIFO" "$RUNNER_INBOX_FIFO" 2>/dev/null || true
122
153
  fi
123
154
  return "$rc"
124
155
  }
125
156
 
126
157
  last_result_status() {
127
- local last_result
128
- last_result="$(grep -E '"type"[[:space:]]*:[[:space:]]*"result"' "$EVENTS_FILE" 2>/dev/null | tail -n 1 || true)"
158
+ local event last_result=""
159
+ while IFS= read -r event || [[ -n "$event" ]]; do
160
+ if live_event_is_result "$VENDOR" "$event"; then last_result="$event"; fi
161
+ done < "$EVENTS_FILE"
129
162
  [[ -n "$last_result" ]] || return 1
130
- [[ ! "$last_result" =~ "is_error"[[:space:]]*:[[:space:]]*true ]]
163
+ live_event_is_success "$VENDOR" "$last_result"
131
164
  }
132
165
 
133
166
  if ! prepare_live_mailbox; then
167
+ if [[ "$LIVE_REQUIRED" -eq 1 ]]; then
168
+ echo "omnilane: required $VENDOR live mailbox unavailable because FIFO setup failed" >&2
169
+ exit 1
170
+ fi
134
171
  set +e
135
- run_single_shot "omnilane: Claude live mailbox unavailable because FIFO setup failed; ran in single-shot mode"
172
+ run_single_shot "omnilane: $VENDOR live mailbox unavailable because FIFO setup failed; ran in single-shot mode"
136
173
  rc=$?
137
174
  set -e
138
175
  exit "$rc"
139
176
  fi
140
177
 
141
178
  trap 'close_requested=1' USR1
179
+ trap 'close_requested=1' PIPE
142
180
  trap cleanup_live_mailbox EXIT
143
181
  truncate_payload "$PROMPT_FILE" 102400
144
182
  INITIAL_TEXT="$(cat "$PROMPT_FILE")"
@@ -149,44 +187,67 @@ else
149
187
  fi
150
188
 
151
189
  write_current_pid_file "$HOLDER_PID_FILE"
152
- export OMNILANE_INBOX="$INBOX_FIFO"
190
+ export OMNILANE_INBOX="$RUNNER_INBOX_FIFO"
153
191
  "$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE" &
154
192
  runner_pid=$!
155
193
 
156
- # Open only after the reader starts. The runner was launched first, so it cannot
157
- # inherit this write descriptor. FD 3 is the named holder for the conversation.
158
- exec 3> "$INBOX_FIFO"
159
- inbox_holder_open=1
160
- if [[ "$close_requested" -eq 0 ]]; then
161
- printf '{"type":"user","omnilane_job_id":"%s","foreman_session":"%s","message":{"role":"user","content":[{"type":"text","text":"%s"}]}}\n' \
162
- "$(json_escape "$JOB_ID")" "$(json_escape "$FOREMAN_SESSION_VALUE")" \
163
- "$(json_escape "$INITIAL_TEXT")" >&3
164
- fi
194
+ # Open the runner writer only after its FIFO reader starts.
195
+ exec 3> "$RUNNER_INBOX_FIFO"
196
+ runner_writer_open=1
197
+ exec 4<> "$INBOX_FIFO"
198
+ inbox_open=1
199
+ exec 5< "$EVENTS_FILE"
200
+ events_reader_open=1
201
+ initial_payload="$(live_encode_message "$VENDOR" "$JOB_ID" "$FOREMAN_SESSION_VALUE" "$INITIAL_TEXT")"
202
+ if ! printf '%s\n' "$initial_payload" >&3; then close_requested=1; fi
165
203
  (umask 077; : > "$READY_FILE")
166
204
 
205
+ last_activity=$SECONDS
206
+ while kill -0 "$runner_pid" 2>/dev/null; do
207
+ [[ "$close_requested" -eq 0 ]] || break
208
+ incoming=""
209
+ if IFS= read -r -t 1 incoming <&4; then
210
+ if ! printf '%s\n' "$incoming" >&3; then close_requested=1; fi
211
+ last_activity=$SECONDS
212
+ fi
213
+ while IFS= read -r event <&5; do
214
+ if live_event_is_result "$VENDOR" "$event"; then
215
+ last_activity=$SECONDS
216
+ fi
217
+ done
218
+ if [[ "$IDLE_TIMEOUT" -gt 0 && $((SECONDS - last_activity)) -ge "$IDLE_TIMEOUT" ]]; then
219
+ close_reason="closed by idle cap after ${IDLE_TIMEOUT}s"
220
+ close_requested=1
221
+ fi
222
+ done
223
+
224
+ if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
225
+ if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
226
+ if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
227
+
167
228
  set +e
168
- wait "$runner_pid"
229
+ if [[ "$close_requested" -eq 1 ]]; then
230
+ kill -TERM "$runner_pid" 2>/dev/null || true
231
+ fi
232
+ wait "$runner_pid" 2>/dev/null
169
233
  runner_rc=$?
170
234
  set -e
171
235
 
236
+ if [[ -n "$close_reason" ]]; then
237
+ printf '\n%s\n' "$close_reason" >> "$OUTPUT_FILE"
238
+ emit_mode_notice "$close_reason" || true
239
+ fi
240
+
172
241
  if [[ "$close_requested" -eq 1 ]]; then
173
- exec 3>&-
174
- inbox_holder_open=0
175
- set +e
176
- kill -TERM "$runner_pid" 2>/dev/null || true
177
- wait "$runner_pid" 2>/dev/null
178
- set -e
179
242
  if last_result_status; then
180
243
  rc=0
181
244
  else
182
245
  rc=1
183
- emit_mode_notice "omnilane: Claude live mailbox closed without a successful result event"
246
+ emit_mode_notice "omnilane: $VENDOR live mailbox closed without a successful result event" || true
184
247
  fi
185
248
  else
186
- exec 3>&-
187
- inbox_holder_open=0
188
249
  rc="$runner_rc"
189
250
  fi
190
251
 
191
- trap - USR1
252
+ trap - USR1 PIPE
192
253
  exit "$rc"
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Shared live-mailbox protocol differences. Callers provide json_escape().
4
+
5
+ live_capable_vendors() {
6
+ printf 'claude, gemini'
7
+ }
8
+
9
+ live_vendor_capable() {
10
+ case "$1" in
11
+ claude|gemini) return 0 ;;
12
+ *) return 1 ;;
13
+ esac
14
+ }
15
+
16
+ live_encode_message() {
17
+ local vendor="$1" job_id="$2" foreman_session="$3" text="$4"
18
+ case "$vendor" in
19
+ claude)
20
+ printf '{"type":"user","omnilane_job_id":"%s","foreman_session":"%s","message":{"role":"user","content":[{"type":"text","text":"%s"}]}}' \
21
+ "$(json_escape "$job_id")" "$(json_escape "$foreman_session")" \
22
+ "$(json_escape "$text")"
23
+ ;;
24
+ gemini)
25
+ printf '{"event":"user","message":{"role":"user","content":[{"type":"text","text":"%s"}]}}' \
26
+ "$(json_escape "$text")"
27
+ ;;
28
+ *) return 2 ;;
29
+ esac
30
+ }
31
+
32
+ live_event_is_result() {
33
+ local vendor="$1" event="$2" pattern
34
+ case "$vendor" in
35
+ claude) pattern='"type"[[:space:]]*:[[:space:]]*"result"' ;;
36
+ gemini) pattern='"event"[[:space:]]*:[[:space:]]*"result"' ;;
37
+ *) return 2 ;;
38
+ esac
39
+ [[ "$event" =~ $pattern ]]
40
+ }
41
+
42
+ live_event_is_success() {
43
+ local vendor="$1" event="$2" pattern
44
+ live_event_is_result "$vendor" "$event" || return 1
45
+ case "$vendor" in
46
+ claude)
47
+ pattern='"is_error"[[:space:]]*:[[:space:]]*true'
48
+ [[ ! "$event" =~ $pattern ]]
49
+ ;;
50
+ gemini)
51
+ pattern='"status"[[:space:]]*:[[:space:]]*"SUCCESS"'
52
+ [[ "$event" =~ $pattern ]]
53
+ ;;
54
+ *) return 2 ;;
55
+ esac
56
+ }
57
+
58
+ live_count_result_events() {
59
+ local vendor="$1" path="$2" event count=0
60
+ [[ -f "$path" && ! -L "$path" ]] || {
61
+ printf '0'
62
+ return 0
63
+ }
64
+ while IFS= read -r event || [[ -n "$event" ]]; do
65
+ if live_event_is_result "$vendor" "$event"; then
66
+ count=$((count + 1))
67
+ fi
68
+ done < "$path"
69
+ printf '%s' "$count"
70
+ }
@@ -33,6 +33,115 @@ MODEL_ARGS=()
33
33
  # plan = read-only tools (advise), accept-edits = file edits allowed (work).
34
34
  if [[ "$MODE" == "advise" ]]; then MODE_ARGS=(--mode plan); else MODE_ARGS=(--mode accept-edits); fi
35
35
 
36
+ LIVE_INBOX="${OMNILANE_INBOX:-}"
37
+ if [[ -n "$LIVE_INBOX" && -p "$LIVE_INBOX" ]]; then
38
+ EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
39
+ STDERR_FILE="${OUTPUT_FILE}.stderr.log"
40
+ LIVE_CHILD_PID=""
41
+
42
+ if [[ -L "$EVENTS_FILE" || ( -e "$EVENTS_FILE" && ! -f "$EVENTS_FILE" ) ]]; then
43
+ echo "omnilane: unsafe Gemini live event path" >&2
44
+ exit 125
45
+ fi
46
+ (umask 077; : > "$EVENTS_FILE"; : > "$STDERR_FILE")
47
+
48
+ finalize_live_output() {
49
+ local tmp="${OUTPUT_FILE}.tmp"
50
+ if ! command -v python3 >/dev/null 2>&1; then
51
+ echo "omnilane: cannot extract Gemini live result: python3 not found" >> "$STDERR_FILE"
52
+ return 1
53
+ fi
54
+ if ! python3 - "$EVENTS_FILE" "$tmp" <<'PY'
55
+ import json
56
+ import pathlib
57
+ import sys
58
+
59
+ events_path = pathlib.Path(sys.argv[1])
60
+ output_path = pathlib.Path(sys.argv[2])
61
+ last_response = None
62
+
63
+ with events_path.open(encoding="utf-8") as events:
64
+ for raw_line in events:
65
+ try:
66
+ event = json.loads(raw_line)
67
+ except json.JSONDecodeError:
68
+ continue
69
+ result = event.get("result")
70
+ if (
71
+ event.get("event") == "result"
72
+ and isinstance(result, dict)
73
+ and result.get("status") == "SUCCESS"
74
+ and isinstance(result.get("response"), str)
75
+ ):
76
+ last_response = result["response"]
77
+
78
+ if last_response is None:
79
+ raise SystemExit(1)
80
+
81
+ output_path.write_text(last_response.rstrip("\n") + "\n", encoding="utf-8")
82
+ PY
83
+ then
84
+ echo "omnilane: Gemini live stream ended without a readable SUCCESS result" >> "$STDERR_FILE"
85
+ return 1
86
+ fi
87
+ mv "$tmp" "$OUTPUT_FILE"
88
+ }
89
+
90
+ # Invoked by signal traps below.
91
+ # shellcheck disable=SC2329
92
+ stop_live_child() {
93
+ local signal_rc="$1" waited=0
94
+ trap - TERM HUP INT
95
+ set +e
96
+ if [[ -n "$LIVE_CHILD_PID" ]] && kill -0 "$LIVE_CHILD_PID" 2>/dev/null; then
97
+ kill -TERM "-$LIVE_CHILD_PID" 2>/dev/null || kill -TERM "$LIVE_CHILD_PID" 2>/dev/null || true
98
+ while kill -0 "$LIVE_CHILD_PID" 2>/dev/null && [[ "$waited" -lt 50 ]]; do
99
+ sleep 0.1
100
+ waited=$((waited + 1))
101
+ done
102
+ if kill -0 "$LIVE_CHILD_PID" 2>/dev/null; then
103
+ kill -KILL "-$LIVE_CHILD_PID" 2>/dev/null || kill -KILL "$LIVE_CHILD_PID" 2>/dev/null || true
104
+ fi
105
+ wait "$LIVE_CHILD_PID" 2>/dev/null
106
+ fi
107
+ finalize_live_output || true
108
+ [[ -s "$STDERR_FILE" ]] || rm "$STDERR_FILE" 2>/dev/null || true
109
+ exit "$signal_rc"
110
+ }
111
+
112
+ set -m
113
+ (
114
+ cd "$RUN_DIR" || exit 127
115
+ run_with_timeout "$RUN_TIMEOUT" env \
116
+ -u GEMINI_API_KEY -u GOOGLE_API_KEY -u GOOGLE_AI_API_KEY \
117
+ NO_BROWSER=1 OMNILANE_DEPTH=1 \
118
+ "$AGY_BIN" --dangerously-skip-permissions --add-dir "$RUN_DIR" \
119
+ "${MODE_ARGS[@]}" ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
120
+ --input-format stream-json --output-format stream-json -p "" \
121
+ < "$LIVE_INBOX" > "$EVENTS_FILE" 2> "$STDERR_FILE"
122
+ ) &
123
+ LIVE_CHILD_PID=$!
124
+ set +m
125
+ trap 'stop_live_child 143' TERM
126
+ trap 'stop_live_child 129' HUP
127
+ trap 'stop_live_child 130' INT
128
+
129
+ set +e
130
+ wait "$LIVE_CHILD_PID"
131
+ RC=$?
132
+ set -e
133
+ trap - TERM HUP INT
134
+ if ! finalize_live_output && [[ "$RC" -eq 0 ]]; then
135
+ RC=1
136
+ fi
137
+ if grep -Eiq "$CAPACITY_PATTERN" "$EVENTS_FILE" "$STDERR_FILE" 2>/dev/null; then
138
+ echo "omnilane: gemini capacity exhausted" >> "$STDERR_FILE"
139
+ RC=126
140
+ fi
141
+ [[ -s "$STDERR_FILE" ]] || rm "$STDERR_FILE" 2>/dev/null || true
142
+ exit "$RC"
143
+ fi
144
+
36
145
  set +e
37
146
  (
38
147
  cd "$RUN_DIR" || exit 127
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: omnilane
3
- description: 'Universal model-routing table + cross-vendor dispatch for ANY harness (Claude Code, Codex, Grok Build, Antigravity). Use when delegating subtasks, choosing a model for work, planning multi-part tasks, or when asked about model routing, delegate, dispatch, which model, tier selection, escalate, 派工, 模型路由. One routing table; the main loop self-executes its own lane and shells out to every other vendor via dispatch.sh.'
3
+ description: 'Universal model-routing table + cross-vendor dispatch for ANY harness (Claude Code, Codex, Grok Build, Antigravity). Use when delegating subtasks, choosing a model for work, planning multi-part tasks, or when asked about model routing, delegate, dispatch, which model, tier selection, escalate, 派工, 模型路由. One routing table; implementation work dispatches by default via dispatch.sh the main loop self-executes only reserved commander items.'
4
4
  ---
5
5
 
6
6
  # omnilane — one routing table, every harness
@@ -9,7 +9,11 @@ You (the main loop) may be Claude, GPT, Grok, or Gemini. The procedure is identi
9
9
 
10
10
  1. **Identify your main model.** You know which model you are running as.
11
11
  2. **Split the work into subtasks and classify each into a lane** (table below).
12
- 3. **If the lane's model is you, self-execute.** Otherwise dispatch:
12
+ 3. **Dispatch implementation work by default — even when the lane's model is
13
+ you.** Self-execute only reserved commander items: planning and
14
+ decomposition, writing task briefs, reviewing reports, acceptance checks,
15
+ replies to the operator, git commit/push, read-only verification, and
16
+ fixes of one line or less. Dispatch:
13
17
  `<repo>/scripts/dispatch.sh [--vendor V] [--mode work] [--workdir DIR] <lane> "<task>"`
14
18
  Add `--background` for long tasks; poll with `scripts/jobs.sh status|result <id>`.
15
19
  Before changing lane order from anecdotal outcomes, run
@@ -155,8 +159,9 @@ dispatch stay in this skill and the CLI. Manage the local board with
155
159
 
156
160
  ## Per-model notes (apply the row matching YOUR main model)
157
161
 
158
- - **Claude (Fable/Opus main)**: top judgment and taste are yours — self-execute;
159
- push mechanical coding volume out to the codex lanes.
162
+ - **Claude (Fable/Opus main)**: top judgment and taste are yours, but
163
+ implementation still dispatches by default self-execute only reserved
164
+ commander items; push all coding volume out to the lanes.
160
165
  - **Claude Sonnet main**: coordination/tools/mid-tier coding only; never
161
166
  self-assign top judgment or hardest implementation.
162
167
  - **GPT Sol main**: hardest coding + hard judgment are yours (use max for