omnilane 0.14.0 → 0.20.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.
@@ -64,6 +64,94 @@ write_current_pid_file() {
64
64
  mv "$tmp" "$path"
65
65
  }
66
66
 
67
+ process_start_time() { # pid -> stable ps(1) start timestamp
68
+ local pid="$1" value
69
+ [[ "$pid" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
70
+ value="$(LC_ALL=C ps -o lstart= -p "$pid" 2>/dev/null)" || return 1
71
+ value="${value#"${value%%[![:space:]]*}"}"
72
+ value="${value%"${value##*[![:space:]]}"}"
73
+ [[ -n "$value" ]] || return 1
74
+ printf '%s\n' "$value"
75
+ }
76
+
77
+ process_parent_pid() {
78
+ local pid="$1" value
79
+ [[ "$pid" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
80
+ value="$(LC_ALL=C ps -o ppid= -p "$pid" 2>/dev/null)" || return 1
81
+ value="${value//[[:space:]]/}"
82
+ [[ "$value" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
83
+ printf '%s\n' "$value"
84
+ }
85
+
86
+ read_session_entry() { # path -> SESSION_ENTRY_{PID,START,ID}
87
+ local path="$1" fields rest
88
+ SESSION_ENTRY_PID=""
89
+ SESSION_ENTRY_START=""
90
+ SESSION_ENTRY_ID=""
91
+ [[ -f "$path" && ! -L "$path" ]] || return 1
92
+ fields="$(perl -MJSON::PP -e '
93
+ use strict;
94
+ use warnings;
95
+ my ($path) = @ARGV;
96
+ my $size = -s $path;
97
+ die "invalid size\n" unless defined($size) && $size <= 4096;
98
+ open my $fh, "<", $path or die $!;
99
+ local $/;
100
+ my $entry = decode_json(<$fh>);
101
+ close $fh or die $!;
102
+ die "invalid entry\n" unless ref($entry) eq "HASH";
103
+ my ($pid, $start, $id) = @{$entry}{qw(pid start_time session_id)};
104
+ die "invalid pid\n" if ref($pid) || !defined($pid) || $pid !~ /\A[1-9][0-9]{0,9}\z/;
105
+ die "invalid start\n" if ref($start) || !defined($start) ||
106
+ $start !~ /\A[ -~]{1,128}\z/;
107
+ die "invalid session\n" if ref($id) || !defined($id) ||
108
+ $id !~ /\A[A-Za-z0-9._:-]{1,256}\z/;
109
+ print "$pid\t$start\t$id";
110
+ ' "$path" 2>/dev/null)" || return 1
111
+ SESSION_ENTRY_PID="${fields%%$'\t'*}"
112
+ rest="${fields#*$'\t'}"
113
+ [[ "$rest" != "$fields" ]] || return 1
114
+ SESSION_ENTRY_START="${rest%%$'\t'*}"
115
+ SESSION_ENTRY_ID="${rest#*$'\t'}"
116
+ [[ "$SESSION_ENTRY_ID" != "$rest" ]] || return 1
117
+ }
118
+
119
+ prune_stale_session_entries() {
120
+ local sessions="$OMNILANE_HOME/sessions" entry live_start
121
+ [[ -d "$sessions" && ! -L "$sessions" ]] || return 0
122
+ for entry in "$sessions"/*.json; do
123
+ [[ -f "$entry" && ! -L "$entry" ]] || continue
124
+ read_session_entry "$entry" || continue
125
+ if ! live_start="$(process_start_time "$SESSION_ENTRY_PID")" ||
126
+ [[ "$live_start" != "$SESSION_ENTRY_START" ]]; then
127
+ rm "$entry" 2>/dev/null || true
128
+ fi
129
+ done
130
+ }
131
+
132
+ find_foreman_session() { # dispatch pid -> nearest live Claude foreman session
133
+ local pid="$1" sessions="$OMNILANE_HOME/sessions"
134
+ local parent start entry hops=0
135
+ [[ "$pid" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
136
+ [[ -d "$sessions" && ! -L "$sessions" ]] || return 1
137
+ prune_stale_session_entries
138
+
139
+ while [[ "$hops" -lt 128 ]]; do
140
+ parent="$(process_parent_pid "$pid")" || return 1
141
+ [[ "$parent" =~ ^[1-9][0-9]{0,9}$ && "$parent" != "$pid" ]] || return 1
142
+ start="$(process_start_time "$parent")" || return 1
143
+ entry="$sessions/$parent.json"
144
+ if read_session_entry "$entry" &&
145
+ [[ "$SESSION_ENTRY_PID" == "$parent" && "$SESSION_ENTRY_START" == "$start" ]]; then
146
+ printf '%s\n' "$SESSION_ENTRY_ID"
147
+ return 0
148
+ fi
149
+ pid="$parent"
150
+ hops=$((hops + 1))
151
+ done
152
+ return 1
153
+ }
154
+
67
155
  # ---- Vendor registry (single source of truth) ---------------------------
68
156
  # Two vendor kinds. CLI-agent vendors run a local binary (see vendor_bin).
69
157
  # Direct-API vendors are OpenAI-compatible /chat/completions endpoints driven
@@ -181,6 +269,25 @@ read_lock_owner() {
181
269
  LOCK_OWNER_VALUE="$value"
182
270
  }
183
271
 
272
+ prepare_private_store() { # path, diagnostic label
273
+ local store_root="$1" store_label="$2"
274
+ mkdir -p "$OMNILANE_HOME" || return 1
275
+ if [[ -L "$store_root" || ( -e "$store_root" && ! -d "$store_root" ) ]]; then
276
+ echo "omnilane: unsafe $store_label path (want real directory): $store_root" >&2
277
+ return 1
278
+ fi
279
+ [[ -d "$store_root" ]] || mkdir -m 700 "$store_root" || return 1
280
+ [[ -d "$store_root" && ! -L "$store_root" ]] || {
281
+ echo "omnilane: $store_label changed while preparing it" >&2
282
+ return 1
283
+ }
284
+ chmod 700 "$store_root" || return 1
285
+ }
286
+
287
+ prepare_inbox_store() {
288
+ prepare_private_store "$OMNILANE_HOME/inbox" "inbox store"
289
+ }
290
+
184
291
  prepare_lock_store() {
185
292
  local lock_root="$OMNILANE_HOME/locks"
186
293
  mkdir -p "$OMNILANE_HOME"
@@ -28,6 +28,14 @@ msg() { # key -> localized string ({} = placeholder, see msgf)
28
28
  case "$L" in zh-TW) echo "已偵測到——請自行執行:";; zh-CN) echo "已检测到——请自行执行:";; ja) echo "検出——次を手動で実行:";; ko) echo "감지됨——직접 실행하세요:";; *) echo "found — run this yourself:";; esac ;;
29
29
  plugin_hint)
30
30
  case "$L" in zh-TW) echo "(選配:把本 repo 裝成 Claude Code 外掛,可用 /route 指令)";; zh-CN) echo "(可选:把本仓库装成 Claude Code 插件,获得 /route 命令)";; ja) echo "(任意:このリポジトリを Claude Code プラグインとして追加すると /route コマンドが使えます)";; ko) echo "(선택: 이 저장소를 Claude Code 플러그인으로 추가하면 /route 명령 사용 가능)";; *) echo "(optional: add this repo as a Claude Code plugin for /route commands)";; esac ;;
31
+ completion_notice_active)
32
+ case "$L" in zh-TW) echo "Claude Code 完成通知:已啟用(omnilane@omnilane 外掛已啟用)。";; zh-CN) echo "Claude Code 完成通知:已启用(omnilane@omnilane 插件已启用)。";; ja) echo "Claude Code 完了通知: 有効です(omnilane@omnilane プラグインが有効)。";; ko) echo "Claude Code 완료 알림: 활성 상태입니다(omnilane@omnilane 플러그인 활성화됨).";; *) echo "Claude Code completion notice: active (omnilane@omnilane is enabled).";; esac ;;
33
+ completion_notice_unknown)
34
+ case "$L" in zh-TW) echo "Claude Code 完成通知:狀態未知(查不到外掛狀態)。";; zh-CN) echo "Claude Code 完成通知:状态未知(无法查询插件状态)。";; ja) echo "Claude Code 完了通知: 状態不明(プラグイン状態を確認できません)。";; ko) echo "Claude Code 완료 알림: 상태를 알 수 없습니다(플러그인 상태 조회 실패).";; *) echo "Claude Code completion notice: unknown (plugin state could not be queried).";; esac ;;
35
+ completion_notice_inactive)
36
+ case "$L" in zh-TW) echo "Claude Code 完成通知:未啟用。這項功能需要安裝外掛;只有 skill symlink 不會送達通知。";; zh-CN) echo "Claude Code 完成通知:未启用。此功能需要安装插件;只有 skill symlink 不会送达通知。";; ja) echo "Claude Code 完了通知: 無効です。この機能にはプラグインのインストールが必要で、skill symlink だけでは通知されません。";; ko) echo "Claude Code 완료 알림: 비활성 상태입니다. 이 기능은 플러그인 설치가 필요하며 skill symlink만으로는 알림이 전달되지 않습니다.";; *) echo "Claude Code completion notice: inactive. This feature requires the plugin install; the skill symlink alone does not deliver notices.";; esac ;;
37
+ completion_notice_install)
38
+ case "$L" in zh-TW) echo "請執行:";; zh-CN) echo "请执行:";; ja) echo "次を実行してください:";; ko) echo "다음을 실행하세요:";; *) echo "Run:";; esac ;;
31
39
  linked)
32
40
  case "$L" in zh-TW) echo "已連結";; zh-CN) echo "已链接";; ja) echo "リンクしました";; ko) echo "링크됨";; *) echo "linked";; esac ;;
33
41
  removed)
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
- # Internal worker boundary for one dispatch. The optional whole-job supervisor
3
+
4
+ # Internal worker boundary for one dispatch. An optional whole-job supervisor
4
5
  # wraps this process so lock wait, retries, and vote rounds share one budget.
5
6
 
7
+ # Runtime-relative shared library.
8
+ # shellcheck disable=SC1091
6
9
  source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
7
10
 
8
11
  [[ $# -eq 7 ]] || { echo "omnilane: internal job worker received invalid arguments" >&2; exit 2; }
@@ -13,11 +16,177 @@ PROMPT_FILE="$6"; OUTPUT_FILE="$7"
13
16
  RUNNER="$OMNILANE_REPO/scripts/runners/run-$VENDOR.sh"
14
17
  [[ -x "$RUNNER" ]] || { echo "omnilane: no runner for vendor '$VENDOR'" >&2; exit 2; }
15
18
 
16
- # Two concurrent codex exec in one target dir corrupt its job index — serialize.
19
+ # Two concurrent codex execs in one target dir corrupt its job index — serialize.
17
20
  [[ "$VENDOR" == "codex" ]] && acquire_cwd_lock codex "$WORKDIR"
18
21
 
22
+ # The backslash case pattern is intentional.
23
+ # shellcheck disable=SC1003
24
+ json_escape() {
25
+ local s="$1" out="" ch escaped code i
26
+ for ((i = 0; i < ${#s}; i++)); do
27
+ ch="${s:i:1}"
28
+ case "$ch" in
29
+ '"') out="$out\\\"" ;;
30
+ '\\') out="$out\\\\" ;;
31
+ $'\b') out="$out\\b" ;;
32
+ $'\f') out="$out\\f" ;;
33
+ $'\n') out="$out\\n" ;;
34
+ $'\r') out="$out\\r" ;;
35
+ $'\t') out="$out\\t" ;;
36
+ *)
37
+ LC_CTYPE=C printf -v code '%d' "'$ch"
38
+ if [[ "$code" -ge 0 && "$code" -lt 32 ]]; then
39
+ printf -v escaped '\\u%04x' "$code"
40
+ out="$out$escaped"
41
+ else
42
+ out="$out$ch"
43
+ fi
44
+ ;;
45
+ esac
46
+ done
47
+ printf '%s' "$out"
48
+ }
49
+
50
+ emit_mode_notice() {
51
+ local notice="$1" notice_file="${OUTPUT_FILE%/*}/mode-notice.txt"
52
+ printf '%s\n' "$notice" >&2
53
+ if [[ -L "$notice_file" || ( -e "$notice_file" && ! -f "$notice_file" ) ]]; then
54
+ echo "omnilane: unsafe mode notice path" >&2
55
+ return 1
56
+ fi
57
+ (umask 077; printf '%s\n' "$notice" > "$notice_file")
58
+ }
59
+
60
+ run_single_shot() {
61
+ local notice="$1" rc
62
+ set +e
63
+ (
64
+ unset OMNILANE_INBOX
65
+ "$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE"
66
+ )
67
+ rc=$?
68
+ set -e
69
+ emit_mode_notice "$notice"
70
+ return "$rc"
71
+ }
72
+
73
+ if [[ "$VENDOR" != "claude" ]]; then
74
+ set +e
75
+ run_single_shot "omnilane: vendor '$VENDOR' is not live-capable; ran in single-shot mode"
76
+ rc=$?
77
+ set -e
78
+ exit "$rc"
79
+ fi
80
+
81
+ JOB_DIR="${OUTPUT_FILE%/*}"
82
+ JOB_ID="${JOB_DIR##*/}"
83
+ INBOX_FIFO="$JOB_DIR/inbox.fifo"
84
+ HOLDER_PID_FILE="$JOB_DIR/inbox.holder.pid"
85
+ READY_FILE="$JOB_DIR/inbox.ready"
86
+ EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
87
+ EVENTS_ALIAS="$JOB_DIR/events.jsonl"
88
+ close_requested=0
89
+ inbox_holder_open=0
90
+ runner_pid=""
91
+
92
+ # Invoked by the EXIT trap below.
93
+ # shellcheck disable=SC2329
94
+ 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
100
+ }
101
+
102
+ prepare_live_mailbox() {
103
+ local path old_umask rc=0
104
+ for path in "$INBOX_FIFO" "$HOLDER_PID_FILE" "$READY_FILE" "$EVENTS_FILE" "$EVENTS_ALIAS"; do
105
+ [[ ! -e "$path" && ! -L "$path" ]] || return 1
106
+ done
107
+ old_umask="$(umask)"
108
+ umask 077
109
+ mkfifo "$INBOX_FIFO" || rc=$?
110
+ if [[ "$rc" -eq 0 ]]; then
111
+ : > "$EVENTS_FILE" || rc=$?
112
+ fi
113
+ if [[ "$rc" -eq 0 ]]; then
114
+ ln "$EVENTS_FILE" "$EVENTS_ALIAS" || rc=$?
115
+ fi
116
+ if [[ "$rc" -eq 0 ]]; then
117
+ chmod 600 "$INBOX_FIFO" "$EVENTS_FILE" "$EVENTS_ALIAS" || rc=$?
118
+ fi
119
+ umask "$old_umask"
120
+ if [[ "$rc" -ne 0 ]]; then
121
+ rm "$EVENTS_ALIAS" "$EVENTS_FILE" "$INBOX_FIFO" 2>/dev/null || true
122
+ fi
123
+ return "$rc"
124
+ }
125
+
126
+ 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)"
129
+ [[ -n "$last_result" ]] || return 1
130
+ [[ ! "$last_result" =~ "is_error"[[:space:]]*:[[:space:]]*true ]]
131
+ }
132
+
133
+ if ! prepare_live_mailbox; then
134
+ set +e
135
+ run_single_shot "omnilane: Claude live mailbox unavailable because FIFO setup failed; ran in single-shot mode"
136
+ rc=$?
137
+ set -e
138
+ exit "$rc"
139
+ fi
140
+
141
+ trap 'close_requested=1' USR1
142
+ trap cleanup_live_mailbox EXIT
143
+ truncate_payload "$PROMPT_FILE" 102400
144
+ INITIAL_TEXT="$(cat "$PROMPT_FILE")"
145
+ if [[ "${FOREMAN_SESSION+x}" == "x" ]]; then
146
+ FOREMAN_SESSION_VALUE="$FOREMAN_SESSION"
147
+ else
148
+ FOREMAN_SESSION_VALUE="${foreman_session-}"
149
+ fi
150
+
151
+ write_current_pid_file "$HOLDER_PID_FILE"
152
+ export OMNILANE_INBOX="$INBOX_FIFO"
153
+ "$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE" &
154
+ runner_pid=$!
155
+
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
165
+ (umask 077; : > "$READY_FILE")
166
+
19
167
  set +e
20
- "$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE"
21
- rc=$?
168
+ wait "$runner_pid"
169
+ runner_rc=$?
22
170
  set -e
171
+
172
+ 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
+ if last_result_status; then
180
+ rc=0
181
+ else
182
+ rc=1
183
+ emit_mode_notice "omnilane: Claude live mailbox closed without a successful result event"
184
+ fi
185
+ else
186
+ exec 3>&-
187
+ inbox_holder_open=0
188
+ rc="$runner_rc"
189
+ fi
190
+
191
+ trap - USR1
23
192
  exit "$rc"
@@ -3,6 +3,8 @@ set -euo pipefail
3
3
  # omnilane runner: Claude Code CLI
4
4
  # Usage: run-claude.sh MODE WORKDIR MODEL EFFORT PROMPT_FILE OUTPUT_FILE
5
5
 
6
+ # Runtime-relative shared library.
7
+ # shellcheck disable=SC1091
6
8
  source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh"
7
9
 
8
10
  MODE="$1"; WORKDIR="$2"; MODEL="$3"; EFFORT="$4"; PROMPT_FILE="$5"; OUTPUT_FILE="$6"
@@ -12,6 +14,110 @@ RUN_TIMEOUT="${OMNILANE_TIMEOUT:-600}"
12
14
 
13
15
  truncate_payload "$PROMPT_FILE" 102400
14
16
 
17
+ LIVE_INBOX="${OMNILANE_INBOX:-}"
18
+ if [[ -n "$LIVE_INBOX" && -p "$LIVE_INBOX" ]]; then
19
+ EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
20
+ STDERR_FILE="${OUTPUT_FILE}.stderr.log"
21
+ LIVE_CHILD_PID=""
22
+
23
+ if [[ -L "$EVENTS_FILE" || ( -e "$EVENTS_FILE" && ! -f "$EVENTS_FILE" ) ]]; then
24
+ echo "omnilane: unsafe Claude live event path" >&2
25
+ exit 125
26
+ fi
27
+ (umask 077; : > "$EVENTS_FILE"; : > "$STDERR_FILE")
28
+
29
+ LIVE_ARGS=(--disable-slash-commands --model "$MODEL")
30
+ [[ -n "$EFFORT" && "$EFFORT" != "-" ]] && LIVE_ARGS+=(--effort "$EFFORT")
31
+ if [[ "$MODE" == "advise" ]]; then
32
+ LIVE_ARGS+=(--tools Read Glob Grep)
33
+ else
34
+ LIVE_ARGS+=(--permission-mode acceptEdits)
35
+ fi
36
+ LIVE_ARGS+=(-p --verbose --input-format stream-json --output-format stream-json)
37
+
38
+ finalize_live_output() {
39
+ local tmp="${OUTPUT_FILE}.tmp"
40
+ if ! command -v python3 >/dev/null 2>&1; then
41
+ echo "omnilane: cannot extract Claude live result: python3 not found" >> "$STDERR_FILE"
42
+ return 1
43
+ fi
44
+ if ! python3 - "$EVENTS_FILE" "$tmp" <<'PY'
45
+ import json
46
+ import pathlib
47
+ import sys
48
+
49
+ events_path = pathlib.Path(sys.argv[1])
50
+ output_path = pathlib.Path(sys.argv[2])
51
+ last_result = None
52
+
53
+ with events_path.open(encoding="utf-8") as events:
54
+ for raw_line in events:
55
+ try:
56
+ event = json.loads(raw_line)
57
+ except json.JSONDecodeError:
58
+ continue
59
+ if event.get("type") == "result" and isinstance(event.get("result"), str):
60
+ last_result = event["result"]
61
+
62
+ if last_result is None:
63
+ raise SystemExit(1)
64
+
65
+ output_path.write_text(last_result.rstrip("\n") + "\n", encoding="utf-8")
66
+ PY
67
+ then
68
+ echo "omnilane: Claude live stream ended without a readable result event" >> "$STDERR_FILE"
69
+ return 1
70
+ fi
71
+ mv "$tmp" "$OUTPUT_FILE"
72
+ }
73
+
74
+ # Invoked by signal traps below.
75
+ # shellcheck disable=SC2329
76
+ stop_live_child() {
77
+ local signal_rc="$1" waited=0
78
+ trap - TERM HUP INT
79
+ set +e
80
+ if [[ -n "$LIVE_CHILD_PID" ]] && kill -0 "$LIVE_CHILD_PID" 2>/dev/null; then
81
+ kill -TERM "-$LIVE_CHILD_PID" 2>/dev/null || kill -TERM "$LIVE_CHILD_PID" 2>/dev/null || true
82
+ while kill -0 "$LIVE_CHILD_PID" 2>/dev/null && [[ "$waited" -lt 50 ]]; do
83
+ sleep 0.1
84
+ waited=$((waited + 1))
85
+ done
86
+ if kill -0 "$LIVE_CHILD_PID" 2>/dev/null; then
87
+ kill -KILL "-$LIVE_CHILD_PID" 2>/dev/null || kill -KILL "$LIVE_CHILD_PID" 2>/dev/null || true
88
+ fi
89
+ wait "$LIVE_CHILD_PID" 2>/dev/null
90
+ fi
91
+ finalize_live_output || true
92
+ [[ -s "$STDERR_FILE" ]] || rm "$STDERR_FILE" 2>/dev/null || true
93
+ exit "$signal_rc"
94
+ }
95
+
96
+ set -m
97
+ (
98
+ cd "$WORKDIR" || exit 127
99
+ run_with_timeout "$RUN_TIMEOUT" env \
100
+ OMNILANE_DEPTH=1 \
101
+ "$CLAUDE_BIN" "${LIVE_ARGS[@]}" < "$LIVE_INBOX" > "$EVENTS_FILE" 2> "$STDERR_FILE"
102
+ ) &
103
+ LIVE_CHILD_PID=$!
104
+ set +m
105
+ trap 'stop_live_child 143' TERM
106
+ trap 'stop_live_child 129' HUP
107
+ trap 'stop_live_child 130' INT
108
+
109
+ set +e
110
+ wait "$LIVE_CHILD_PID"
111
+ RC=$?
112
+ set -e
113
+ trap - TERM HUP INT
114
+ if ! finalize_live_output && [[ "$RC" -eq 0 ]]; then
115
+ RC=1
116
+ fi
117
+ [[ -s "$STDERR_FILE" ]] || rm "$STDERR_FILE" 2>/dev/null || true
118
+ exit "$RC"
119
+ fi
120
+
15
121
  ARGS=(--disable-slash-commands --model "$MODEL" --output-format text)
16
122
  [[ -n "$EFFORT" && "$EFFORT" != "-" ]] && ARGS+=(--effort "$EFFORT")
17
123
  if [[ "$MODE" == "advise" ]]; then
@@ -16,7 +16,9 @@ RUN_TIMEOUT="${OMNILANE_TIMEOUT:-600}"
16
16
 
17
17
  # --skip-git-repo-check: the operator chose WORKDIR explicitly; codex would
18
18
  # otherwise refuse any directory that is not a trusted git repo.
19
- ARGS=(exec -m "$MODEL" -o "${OUTPUT_FILE}.tmp" --skip-git-repo-check)
19
+ # --json: without it codex writes nothing until it exits, so a watchdog kill
20
+ # leaves an empty progress log that looks identical to a run that never started.
21
+ ARGS=(exec --json -m "$MODEL" -o "${OUTPUT_FILE}.tmp" --skip-git-repo-check)
20
22
  [[ -n "$EFFORT" && "$EFFORT" != "-" ]] && ARGS+=(-c "model_reasoning_effort=\"$EFFORT\"")
21
23
  if [[ "$MODE" == "advise" ]]; then
22
24
  ARGS+=(--ephemeral -s read-only)
@@ -41,10 +43,25 @@ set +e
41
43
  RC=$?
42
44
  set -e
43
45
 
44
- # Watchdog kill with an empty progress log = codex produced nothing at all;
45
- # the usual cause is a quota-window 429 retry loop, which codex retries silently.
46
- if [[ ("$RC" -eq 142 || "$RC" -eq 124) && ! -s "${OUTPUT_FILE}.progress.log" ]]; then
47
- echo "omnilane: codex timed out after ${RUN_TIMEOUT}s with no output — likely a usage-limit retry loop; check your codex quota window and retry" >> "${OUTPUT_FILE}.stderr.log"
46
+ if [[ "$RC" -eq 142 || "$RC" -eq 124 ]]; then
47
+ {
48
+ echo "omnilane: codex timed out after ${RUN_TIMEOUT}s. This does NOT identify the cause."
49
+ echo "omnilane: an empty progress log is NOT evidence of a stall check, in order:"
50
+ echo " 1. the streamed events in ${OUTPUT_FILE}.progress.log — the last event says how far the run got"
51
+ echo " 2. the rollout file below — if it stops at task_started, the request never left the CLI"
52
+ echo " 3. your provider proxy's log for the run window: rate-limit responses, and whether a"
53
+ echo " request was ever sent upstream at all"
54
+ # The rollout holds the full turn history a killed run never got to report;
55
+ # thread_id is the only handle onto it once the process is gone.
56
+ thread_id="$(head -1 "${OUTPUT_FILE}.progress.log" 2>/dev/null |
57
+ sed -n 's/.*"thread_id":"\([^"]*\)".*/\1/p')"
58
+ if [[ -n "$thread_id" ]]; then
59
+ rollout="$(find "${CODEX_HOME:-$HOME/.codex}/sessions" -name "rollout-*-${thread_id}.jsonl" 2>/dev/null | head -1)"
60
+ echo "omnilane: rollout: ${rollout:-not found (thread ${thread_id})}"
61
+ else
62
+ echo "omnilane: rollout: unknown — no thread_id in the progress log, so codex died before its first event"
63
+ fi
64
+ } >> "${OUTPUT_FILE}.stderr.log"
48
65
  fi
49
66
 
50
67
  if [[ -f "${OUTPUT_FILE}.tmp" ]]; then
@@ -0,0 +1,172 @@
1
+ ---
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.'
4
+ ---
5
+
6
+ # omnilane — one routing table, every harness
7
+
8
+ You (the main loop) may be Claude, GPT, Grok, or Gemini. The procedure is identical:
9
+
10
+ 1. **Identify your main model.** You know which model you are running as.
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:
13
+ `<repo>/scripts/dispatch.sh [--vendor V] [--mode work] [--workdir DIR] <lane> "<task>"`
14
+ Add `--background` for long tasks; poll with `scripts/jobs.sh status|result <id>`.
15
+ Before changing lane order from anecdotal outcomes, run
16
+ `scripts/jobs.sh recommend [--last N] [--lane L] [--min-samples N]` and report
17
+ its evidence threshold. The command is read-only and never changes routing.
18
+ Preview old completed-job cleanup with `scripts/jobs.sh prune --keep <N>`;
19
+ deletion requires the explicit `--apply` flag and never targets running jobs.
20
+ A deep task whose CLI call may outrun the 600s per-call watchdog can raise its
21
+ cap with `--timeout <seconds>` (e.g. `--timeout 1200` for hard-judgment /
22
+ long-context). It bounds each CLI call, not the whole dispatch.
23
+ For one aggregate fuse across lock wait, retries, voters, and rounds, add
24
+ `--job-timeout <seconds>`. It is disabled by default; deep full-repository
25
+ audits typically need 7200–14400 seconds, and expiry returns 124. The one
26
+ automatic exception is non-Git Codex `work`: without an explicit, lane, or
27
+ global job timeout, its resolved per-call timeout becomes the whole-job fuse,
28
+ capped at the supervisor's 999999999-second maximum. If the bundled Perl
29
+ supervisor is unavailable, it warns and continues through the existing
30
+ per-call watchdog path.
31
+
32
+ Run `scripts/dispatch.sh --list` to see the effective table (local overrides win).
33
+ When routing is unexpectedly unavailable, run `bin/omnilane doctor` before
34
+ changing configuration; it reports state and dependencies without repairing them.
35
+ Doctor remains offline unless the operator explicitly adds `--probe V`; that
36
+ bounded probe returns metadata only. Use `bin/omnilane benchmark` for a fixed
37
+ no-call route plan, and add `--run` only when actual advise-mode comparison calls
38
+ were explicitly requested. Neither command changes routing.
39
+ Lanes are fallback chains — dispatch uses the first vendor CLI actually installed,
40
+ so the same table works with any subset of subscriptions.
41
+
42
+ ## Lanes (defaults; see routing.yaml for the live values)
43
+
44
+ Each lane's **backup** is the next candidate in its `routing.yaml` chain —
45
+ what dispatch picks when the first-choice vendor CLI is not installed.
46
+
47
+ | Lane | First choice | Backup | When |
48
+ |---|---|---|---|
49
+ | hardest-coding | GPT-5.6 Sol (xhigh) | Claude Opus 5 (xhigh) | Hardest implementation, deep root-cause debug, correctness-critical edits |
50
+ | bulk-mechanical | GPT-5.6 Terra (max) | Claude Sonnet 5 (high) | Refactors, migrations, tests, review sweeps — mechanical endurance |
51
+ | triage | GPT-5.6 Luna (medium) | Gemini 3.6 Flash (Low) | High-volume scans, first-pass filtering |
52
+ | hard-judgment | Claude Opus 5 (xhigh) | GPT-5.6 Sol (max) | Architecture arbitration, deep reasoning, second opinions |
53
+ | taste-final | Claude Opus 5 (high) | GPT-5.6 Sol (max) | User-facing prose, prompt/doc polish, Chinese phrasing, style arbitration |
54
+ | consult | Explicit named vendor/model | — (no fallback) | Direct natural-language consultation; always keep `--vendor` |
55
+ | ui-draft | GPT-5.6 Sol (xhigh) | Claude Opus 5 (high) | UI drafts only WITH a design system / reference images; open-ended visual taste goes to taste-final |
56
+ | long-context | Gemini 3.1 Pro (High) | GPT-5.6 Sol (high) | 1M-token synthesis; Pro is agentic-capable, while fast repeated loops prefer Flash on speed/cost |
57
+ | fast-agentic | GPT-5.6 Luna (max) | Gemini 3.6 Flash (High) | Fast multi-step agentic loops, multimodal checks |
58
+ | live-search | Grok 4.5 | — (off) | Realtime X/web search and social context |
59
+ | coding-overflow | Grok 4.5 | Kimi K3 → Qwen3 Coder Plus → OpenCode | Codex-quota relief valve for mid-tier coding; verify factual claims |
60
+ | arbitrate | off (opt-in vote panel) | — | Disabled by default. Enable with `arbitrate: vote codex,claude,grok -` in routing.local.yaml or via the configurator (any 1-4 voters). One quota hit PER VOTER PER ROUND; you chair: read the opinions and own the decision. Effort field 2 = debate round (voters rebut each other) |
61
+
62
+ Claude Fable 5 (`claude-fable-5`) is absent from the defaults on purpose: the
63
+ top Claude tier is usually the main loop itself, not a dispatched worker, and
64
+ it prices at twice Opus 5. This is a cost / guardrail / main-loop policy choice,
65
+ not a capability verdict — Artificial Analysis calls Opus 5 (61) and Fable 5 (60)
66
+ "effectively tied" on the Intelligence Index, but Opus 5 leads AA-Briefcase by
67
+ 146 Elo at 20% lower cost per task. Fable 5 keeps the lead on factual breadth
68
+ (AA-Omniscience), so name it explicitly for recall-heavy consults. To route to
69
+ it anyway, select it in the configurator or override a lane in
70
+ `~/.omnilane/routing.local.yaml` (e.g. `taste-final: claude claude-fable-5 high`).
71
+
72
+ ## Natural-language consultation
73
+
74
+ Users may speak normally; they do not need lane names.
75
+
76
+ 1. Capability-only question (`which model`, `what can Claude do`, `哪個模型`,
77
+ `誰適合`) → classify the need, then answer with the first available model
78
+ shown for that lane by `dispatch.sh --list`; do not dispatch unless execution
79
+ is also requested.
80
+ 2. Generic vendor name (`Claude`, `Codex`, `Grok`, `Gemini`, `OpenCode`) → run
81
+ `dispatch.sh --vendor <vendor> consult "<task>"`.
82
+ 3. Canonical model alias → pass its vendor, model, and effort from the table
83
+ below. Never silently substitute another model family.
84
+ 4. No named target → classify into an existing lane and dispatch normally.
85
+ 5. Unknown or ambiguous nickname → ask for clarification; do not guess or run.
86
+
87
+ | Alias | Vendor | Model | Effort |
88
+ |---|---|---|---|
89
+ | Opus | claude | claude-opus-5 | high |
90
+ | Fable | claude | claude-fable-5 | high |
91
+ | Sonnet | claude | claude-sonnet-5 | high |
92
+ | Haiku | claude | claude-haiku-4-5 | - |
93
+ | Sol | codex | gpt-5.6-sol | max |
94
+ | Terra | codex | gpt-5.6-terra | max |
95
+ | Luna | codex | gpt-5.6-luna | medium |
96
+ | Grok 4.5 | grok | grok-4.5 | - |
97
+ | Gemini Pro | gemini | Gemini 3.1 Pro (High) | - |
98
+ | Gemini Flash | gemini | Gemini 3.6 Flash (High) | - |
99
+ | Kimi | kimi | kimi-k3 | - |
100
+ | Qwen | qwen | qwen3-coder-plus | - |
101
+ | OpenCode | opencode | provider/model form, or `-` for its own default | - |
102
+ | OpenRouter | openrouter | explicit OpenRouter slug (e.g. anthropic/claude-sonnet-5) | - |
103
+
104
+ OpenCode is the multi-provider aggregator CLI (75+ providers): work-capable,
105
+ last resort in coding-overflow. OpenRouter is direct-API — no CLI needed, only
106
+ `OPENROUTER_API_KEY` — and is **advise/consult only** (it cannot edit files);
107
+ its model slug is mandatory. "Ask <any hosted model> via OpenRouter" →
108
+ `dispatch.sh --vendor openrouter --model <slug> consult "<task>"`.
109
+
110
+ Examples:
111
+
112
+ - Ask Opus to challenge this architecture →
113
+ `dispatch.sh --vendor claude --model claude-opus-5 --effort high consult "challenge this architecture"`
114
+ - 請 Grok 查最新公開資訊 →
115
+ `dispatch.sh --vendor grok consult "查最新公開資訊"`
116
+ - 哪個模型適合檢查大型 repo? → answer only; do not dispatch.
117
+
118
+ Consultation defaults to `advise`. Use `--mode work --workdir <dir>` only for
119
+ an explicit edit request. Missing explicit targets fail clearly; never remove
120
+ `--vendor` to obtain a fallback.
121
+
122
+ ## Live UI is observation only
123
+
124
+ The optional Live UI is a read-only observer, not a prompt or dispatch path.
125
+ It displays existing jobs' `task.txt` and public `out.txt`, but never raw logs;
126
+ its history search and state filters can export only the currently visible public
127
+ metadata as local JSON; tokens and task/result bodies are excluded from export.
128
+ it cannot interpret natural language, choose routes, dispatch, retry, cancel,
129
+ delete jobs, or edit configuration. Natural-language interpretation and
130
+ dispatch stay in this skill and the CLI. Manage the local board with
131
+ `omnilane ui start|status|url|stop`, and stop it when monitoring is finished.
132
+
133
+ ## Rules
134
+
135
+ - **Dispatch in `advise` mode by default** (read-only worker). Use `--mode work`
136
+ only when the worker must edit files, and give it an explicit `--workdir`.
137
+ - **`--mode sysops`** is `work` minus the vendor sandbox, for service
138
+ operations the sandbox denies (launchctl, system daemons). Codex runs with
139
+ `-s danger-full-access`; other vendors treat it as `work`. Explicit
140
+ per-dispatch opt-in only — never a lane default, and the task text must
141
+ name the exact service commands the worker is authorized to run.
142
+ Codex `work`/`sysops` still needs a git-repo `--workdir` (non-git
143
+ directories trip the whole-job fuse).
144
+ - **Every dispatched task states acceptance criteria and the exact verification
145
+ command.** Do not accept "done" without evidence.
146
+ - **No nested dispatch**: workers must not fan out again (enforced via
147
+ `OMNILANE_DEPTH`). Escalate back to the main loop instead.
148
+ - **Same-directory codex dispatches are serialized automatically** (lock);
149
+ do not try to parallelize them yourself.
150
+ - Escalate without asking: two failed attempts on a lane → move one lane up
151
+ (triage → bulk-mechanical → hardest-coding).
152
+ - Vendor quota exhausted (429 / "stream disconnected" / usage-limit message):
153
+ send mid-tier coding through coding-overflow instead; never silently downgrade
154
+ hardest-coding — wait or escalate to the user.
155
+
156
+ ## Per-model notes (apply the row matching YOUR main model)
157
+
158
+ - **Claude (Fable/Opus main)**: top judgment and taste are yours — self-execute;
159
+ push mechanical coding volume out to the codex lanes.
160
+ - **Claude Sonnet main**: coordination/tools/mid-tier coding only; never
161
+ self-assign top judgment or hardest implementation.
162
+ - **GPT Sol main**: hardest coding + hard judgment are yours (use max for
163
+ judgment turns, xhigh for coding); cross to taste-final for style calls.
164
+ - **GPT Terra main**: bulk work is yours at max; escalate the genuinely hardest
165
+ pieces to Sol instead of grinding.
166
+ - **Grok 4.5 main**: mid-tier coding + live-search are yours; verify every API
167
+ signature and cited fact before shipping (measured high hallucination rate).
168
+ - **Gemini Flash main**: fast agentic/multimodal loops are yours; never
169
+ self-assign top judgment.
170
+ - **Gemini 3.1 Pro main**: 1M-context synthesis and context-heavy agentic work
171
+ are yours. Prefer Gemini Flash for fast repeated tool loops on speed/cost;
172
+ route hardest coding and judgment to the stronger codex lanes.