omnilane 0.42.6 → 0.42.8

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "One routing table, native-first or CLI delegation, supervised completion workflows.",
9
- "version": "0.42.6"
9
+ "version": "0.42.8"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "omnilane",
14
14
  "source": "./",
15
15
  "description": "Route each subtask to the model that fits, then delegate through a compatible caller-owned native agent or vendor CLI.",
16
- "version": "0.42.6",
16
+ "version": "0.42.8",
17
17
  "category": "development",
18
18
  "keywords": [
19
19
  "routing",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnilane",
3
- "version": "0.42.6",
3
+ "version": "0.42.8",
4
4
  "description": "One routing table, every harness: classify subtasks into lanes and delegate through compatible caller-owned native agents or vendor CLIs with exact-AA downward policy and supervised jobs.",
5
5
  "author": {
6
6
  "name": "Seraphim0916"
package/CHANGELOG.md CHANGED
@@ -6,6 +6,83 @@ semantic version tags.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.42.8] - 2026-09-12
10
+
11
+ ### Fixed
12
+
13
+ - When ancestor inspection fails under `CODEX_SANDBOX=seatbelt`, `whoami` now explains that Codex blocked process inspection and that dispatch must be rerun outside the sandbox because omnilane also needs `~/.omnilane` writes and network access; non-sandbox refusals are unchanged.
14
+
15
+ - Codex `app-server` caller identity always comes from the latest host-written
16
+ `turn_context`, never its startup defaults. Other Codex launches use rollout
17
+ evidence only when no explicit model was selected (profiles are not selectors).
18
+ - Bind `CODEX_THREAD_ID` in the current process to the codex direct child's
19
+ initial environment. macOS reads argv and environment separately through
20
+ `KERN_PROCARGS2`; Linux reads `/proc`. Look the rollout up as
21
+ `rollout-*-<thread>.jsonl` and, for a resumed thread,
22
+ `rollout-*-<thread>_<session>.jsonl`, read the most recently written match, and
23
+ require a matching `session_meta.id`, non-empty model/effort/turn id, and no
24
+ later `task_complete`, its `turn_complete` read alias, or `turn_aborted`
25
+ carrying that same turn id. A refusal names both turn ids and how long ago the
26
+ rollout was last written. Missing, malformed or stale evidence refuses.
27
+ - Stream JSONL records and retain only identity metadata and event types;
28
+ diagnostics never include message content. `whoami` reports thread and turn ids.
29
+ - Accept Codex `-c model=...` / `--config` overrides, including attached/equals
30
+ forms and TOML strings (Python 3.11+); explicit `-m` / `--model` still wins
31
+ outside app-server. Other vendors and caller-context/inherited/human priority
32
+ remain unchanged. `OMNILANE_AA_CALLER_FROM_PROCESS=0` disables both readers.
33
+ - The test suite no longer inherits the dispatcher's AA environment. Run inside
34
+ an omnilane worker it kept the exported `OMNILANE_AA_*`, so the authorizer
35
+ identity and the transport overlay hash pin outranked each fixture's own human
36
+ exemption and six tests failed for that reason alone.
37
+
38
+ ### Verification
39
+
40
+ - Accepted on real Codex desktop threads on 2026-09-12: `whoami` and
41
+ `dispatch.sh --dry-run` both exited 0 in a fresh thread and in a resumed thread
42
+ whose original rollout had stopped the previous day, reporting
43
+ `codex/gpt-6-astra-xhigh (score 54)` and `"allowed":true` with no job created.
44
+ - Source contract verified earlier in five real turns with bundled app-server
45
+ 0.153.4. 較新版本若延後寫檔,過期保護會讓它拒絕,而不是讀到舊的一輪。
46
+ - Metadata-only check of the two 0.154.0 worker rollouts: thread
47
+ `01a08f9e-6450-7f82-85e9-af534954b4ac` was absent from both active and archived
48
+ directories; `01a08fb4-fd1a-7811-a457-c21a4a6ff4d8` existed in active sessions,
49
+ created 2026-09-11 17:03:18 Asia/Taipei. Neither file's contents were opened.
50
+
51
+ ## [0.42.7] - 2026-09-10
52
+
53
+ ### Added
54
+
55
+ - `omnilane whoami` prints a caller-context file for the CLI it runs under. It
56
+ walks up the process tree to the nearest vendor CLI, reads the model and
57
+ effort that CLI was launched with, and maps them onto the one scored
58
+ configuration they select, or exits 3 with the reason: a missing `--effort`,
59
+ a model alias, or a Claude effort whose only scored row is non-reasoning. It
60
+ never guesses.
61
+
62
+ ### Changed
63
+
64
+ - A dispatch that carries no `--caller-context` and no human assertion reads
65
+ the caller's identity from its launching CLI. Model sessions outside the
66
+ omnilane checkout used to stop on `missing-caller-context` and hand the
67
+ question back to the operator — three did on 2026-09-08 and 2026-09-10 —
68
+ because the refusal offered a model two options it could not take, and the
69
+ instructions for a third lived where those sessions never looked.
70
+ - Launch flags are set by the harness rather than the model, so an identity
71
+ read from them is harder to overstate than a hand-written file, which the
72
+ gate checks for shape but not against what is running. Each session is held
73
+ to its own flags: the same model at `high` and at `max` gets 52 and 54.
74
+ - An explicit `--caller-context`, the context a worker inherits, and
75
+ `--operator-asserted-human` all take precedence.
76
+ `OMNILANE_AA_CALLER_FROM_PROCESS=0` restores the file-only contract.
77
+ - The `missing-caller-context` and retry refusals name `omnilane whoami`.
78
+ Retries still require an explicit current identity; they are not read
79
+ automatically.
80
+
81
+ ### Fixed
82
+
83
+ - `omnilane --version` reported 0.42.5 in the 0.42.6 release, whose `VERSION`
84
+ file was not bumped with the package manifests.
85
+
9
86
  ## [0.42.6] - 2026-09-10
10
87
 
11
88
  ### Added
@@ -981,7 +1058,10 @@ work to the wrong model, and records the evidence behind the shipped defaults.
981
1058
  - Initial shared routing table, cross-vendor dispatcher, runners, installer,
982
1059
  and baseline lint fixes.
983
1060
 
984
- [Unreleased]: https://github.com/Seraphim0916/omnilane/compare/v0.42.5...HEAD
1061
+ [Unreleased]: https://github.com/Seraphim0916/omnilane/compare/v0.42.8...HEAD
1062
+ [0.42.8]: https://github.com/Seraphim0916/omnilane/compare/v0.42.7...v0.42.8
1063
+ [0.42.7]: https://github.com/Seraphim0916/omnilane/compare/v0.42.6...v0.42.7
1064
+ [0.42.6]: https://github.com/Seraphim0916/omnilane/compare/v0.42.5...v0.42.6
985
1065
  [0.42.5]: https://github.com/Seraphim0916/omnilane/compare/v0.42.4...v0.42.5
986
1066
  [0.42.4]: https://github.com/Seraphim0916/omnilane/compare/v0.42.3...v0.42.4
987
1067
  [0.42.3]: https://github.com/Seraphim0916/omnilane/compare/v0.42.2...v0.42.3
package/README.ja.md CHANGED
@@ -71,8 +71,10 @@ omnilane route hardest-coding "auth トークン更新テストの不安定さ
71
71
  > ゲートするため、「誰が依頼しているか」を必ず示す必要があります。端末の前にいる人間は
72
72
  > `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` を一度設定するか、呼び出しごとに
73
73
  > `--operator-asserted-human` を付けます。omnilane を動かすモデルは**自分でこれを主張
74
- > できません**。代わりに正確なベンダー・モデル・effort を持つ `--caller-context FILE` を
75
- > 渡します。どちらも無い場合、ジョブ生成前に `missing-caller-context` で拒否されます。
74
+ > できません**。モデルの識別情報は、それを起動した CLI のモデル・effort フラグから自動で
75
+ > 読み取られるため、通常のセッションは何も渡す必要がありません。`omnilane whoami` はその
76
+ > 識別情報を `--caller-context FILE` として出力します。主張も読み取れる識別情報も無い場合、
77
+ > ジョブ生成前に `missing-caller-context` で拒否されます。
76
78
 
77
79
  > はじめての方は、まず `omnilane doctor` を実行してください。omnilane が今どのモデル CLI と
78
80
  > API キーに接続できるかがわかり、実際に何が動くか把握できます。
@@ -530,10 +532,12 @@ work の別名ではありません。サービス管理など、work の境界
530
532
  ください。その `transport-overlay` チェックが、問題はこのマシンの設定なのか
531
533
  リクエストなのかをすぐに示します。
532
534
 
533
- `missing-caller-context` — 呼び出し元の識別情報がありません。人間は
535
+ `missing-caller-context` — 識別情報がゲートに届いていません。人間は
534
536
  `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` または `--operator-asserted-human` を
535
- 使います。omnilane を動かすモデルは、正確なベンダー・モデル・effort を含む
536
- `--caller-context FILE` を渡し、人間向けの免除を自称してはいけません。
537
+ 使います。モデルは通常何もしなくてよく、dispatch が起動元の CLI から識別情報を
538
+ 読み取ります。読み取れない場合は `omnilane whoami` を実行してください。渡すべき
539
+ `--caller-context FILE` を出力するか、読み取れない理由(`--effort` 未指定、モデル
540
+ エイリアス、スコア行なし)を示します。モデルは人間向けの免除を自称してはいけません。
537
541
 
538
542
  `runtime-mapping-unverified` — 識別情報は正しく、**ターゲット**にホストローカルの
539
543
  リクエストセレクタの証明がありません。未プローブか、プローブが失敗しています。
@@ -573,6 +577,45 @@ work の別名ではありません。サービス管理など、work の境界
573
577
 
574
578
  ## 📜 リリース履歴
575
579
 
580
+ ## v0.42.8 の新機能
581
+
582
+ - **Codex の現在ターンの身元。** `app-server` は起動時のモデル・強度を常に無視します。
583
+ それ以外の Codex は明示的な `-m` / `--model` / `-c model=...` / `--config` を維持し、
584
+ モデル指定がない場合(プロファイルのみを含む)は現在ターンの記録を使います。
585
+ TOML のモデル指定には Python 3.11 以降が必要です。
586
+ - **曖昧なら拒否。** 現在のプロセス環境と Codex 直下の子プロセスの初期環境に、同じ UUID 形式の
587
+ `CODEX_THREAD_ID` が必要です。記録は `$CODEX_HOME/sessions`(既定 `~/.codex`)から
588
+ `rollout-*-<スレッド>.jsonl`、再開後は `rollout-*-<スレッド>_<セッション>.jsonl` として探し、
589
+ 最後に書き込まれたものを読み、`session_meta.id` が一致する必要があります。最後の `turn_context`
590
+ にはモデル・強度・ターン ID が必要です。その後に**同じターン ID** の `task_complete` /
591
+ `turn_complete`(読み取り時の別名)/ `turn_aborted` があれば古い身元として拒否し、
592
+ 両方のターン ID と、その記録が最後に書かれてからの経過時間を示します。
593
+ - **Codex サンドボックスでの拒否。** 祖先検索は従来どおり先に実行します。`CODEX_SANDBOX=seatbelt`
594
+ で失敗した場合、`whoami` はプロセス検査、`~/.omnilane` への書き込み、ネットワーク接続に
595
+ サンドボックス外での再実行が必要だと案内します。
596
+ - **追跡可能性と互換性。** JSONL は逐次読み込み、メッセージ本文を診断に含めません。
597
+ `whoami` はスレッドとターン ID を表示します。設定既定値・モデル一覧・アーカイブ・他の会話は
598
+ 代用しません。他社 CLI、明示・継承した身元、人間の宣言の優先順位は変更しません。
599
+ `OMNILANE_AA_CALLER_FROM_PROCESS=0` は両方の読み取りを無効にします。
600
+ - **テストの環境分離。** テストはディスパッチ元のワーカーから `OMNILANE_AA_*` を
601
+ 引き継がなくなりました。ワーカー内で実行すると、承認者の身元と転送オーバーレイの
602
+ ハッシュ固定が各フィクスチャ自身の人間宣言を上書きし、6 件が失敗していました。
603
+ - **実機での受け入れ。** 2026-09-12 に Codex デスクトップで実測:新規スレッドと、
604
+ 元の記録が前日で止まっていた再開スレッドの両方で `whoami` と
605
+ `dispatch.sh --dry-run` が終了コード 0、`codex/gpt-6-astra-xhigh (score 54)` と
606
+ `"allowed":true` を返し、ジョブは作成されませんでした。
607
+ 更新は `npm i -g omnilane@0.42.8`。
608
+
609
+ ## v0.42.7 の新機能
610
+
611
+ - **モデルのセッションは識別ファイルなしでディスパッチできます。** `--caller-context` が無い場合、dispatch はプロセスツリーを遡って最も近いベンダー CLI を見つけ、その起動時のモデルと effort を読み取ります。これまで omnilane のチェックアウト外のセッションは `missing-caller-context` で止まり、判断をオペレーターに差し戻していました(2026-09-08 と 2026-09-10 に 3 件)。
612
+ - **`omnilane whoami`** はその識別情報を caller-context ファイルとして出力し、読み取れない場合は理由(`--effort` 未指定、モデルエイリアス、Claude のその effort に non-reasoning 行しか無い)を示します。推測はしません。
613
+ - **手書きファイルより偽りにくい。** ゲートは caller-context ファイルの形式しか確認せず、実際に動いているモデルと一致するかは見ていません。起動フラグはモデルではなくハーネスが設定し、セッションごとに判定されます。同じモデルでも `high` と `max` なら上限は 52 と 54 です。
614
+ - **明示指定が優先。** `--caller-context` ファイル、ワーカーが継承する環境、`--operator-asserted-human` はいずれも自動読み取りより優先します。`OMNILANE_AA_CALLER_FROM_PROCESS=0` でファイルのみの契約に戻せます。
615
+ - **拒否メッセージが出口を示します。** `missing-caller-context` と再試行の拒否は `omnilane whoami` を案内します。
616
+ - **`omnilane --version` が正しくなりました。** 0.42.6 では `VERSION` が 0.42.5 のままでした。
617
+ - **アップグレード。** npm 公開後は `npm i -g omnilane@0.42.7` を実行してください。
618
+
576
619
  ## v0.42.6 の新機能
577
620
 
578
621
  - **「検証済み」が、どう検証されたかを示すようになりました。** 各 overlay マッピングは `evidence_tier` を持ちます。`billed-model` はプロバイダー自身が課金対象のモデル名を返した場合(claude、grok)、`client-echo` は CLI が自ら送信したモデルを記録した場合(codex、agy)、`selector-only` は CLI がセレクターを受け付けただけの場合です。`client-echo` は CLI が控えた注文書、`billed-model` はプロバイダーが発行した領収書です。
package/README.ko.md CHANGED
@@ -70,8 +70,10 @@ omnilane route hardest-coding "간헐적으로 실패하는 auth 토큰 갱신
70
70
  > 게이트하므로, 디스패치는 «누가 요청하는지»를 반드시 밝혀야 합니다. 터미널 앞의 사람은
71
71
  > `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` 을 한 번 설정하거나 호출마다
72
72
  > `--operator-asserted-human` 을 붙입니다. omnilane 을 구동하는 모델은 **스스로 이를 주장할
73
- > 수 없으며**, 대신 정확한 벤더·모델·effort 를 담은 `--caller-context FILE` 을 전달합니다.
74
- > 둘 다 없으면 잡 생성 전에 `missing-caller-context` 로 거부됩니다.
73
+ > 수 없습니다**. 모델의 신원은 그것을 실행한 CLI 의 모델·effort 플래그에서 자동으로 읽히므로,
74
+ > 일반 세션은 아무것도 전달할 필요가 없습니다. `omnilane whoami` 는 그 신원을
75
+ > `--caller-context FILE` 로 출력합니다. 주장도 읽을 수 있는 신원도 없으면 잡 생성 전에
76
+ > `missing-caller-context` 로 거부됩니다.
75
77
 
76
78
  > 처음이신가요? 먼저 `omnilane doctor` 를 실행하세요. omnilane 이 지금 어떤 모델 CLI 와
77
79
  > API 키에 접근할 수 있는지 알려 주어, 실제로 무엇이 실행될지 파악할 수 있습니다.
@@ -516,10 +518,12 @@ work 는 지정한 디렉터리 안의 변경만 허용하며 모델 연결은
516
518
  `transport-overlay` 검사가 문제의 원인이 이 머신의 설정인지 요청인지 바로
517
519
  알려줍니다.
518
520
 
519
- `missing-caller-context` — 호출자 신원을 전달하지 않았습니다. 사람은
521
+ `missing-caller-context` — 게이트에 신원이 전달되지 않았습니다. 사람은
520
522
  `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` 또는 `--operator-asserted-human`을
521
- 사용합니다. omnilane을 구동하는 모델은 정확한 벤더·모델·effort가 담긴
522
- `--caller-context FILE`을 전달해야 하며, 사람용 면제를 스스로 주장해서는 안 됩니다.
523
+ 사용합니다. 모델은 보통 아무것도 할 필요가 없으며, dispatch 가 실행한 CLI 에서 신원을
524
+ 읽습니다. 읽지 못하면 `omnilane whoami` 를 실행하세요. 전달할 `--caller-context FILE`
525
+ 을 출력하거나, 읽지 못한 정확한 이유(`--effort` 누락, 모델 별칭, 점수 행 없음)를 알려줍니다.
526
+ 모델은 사람용 면제를 스스로 주장해서는 안 됩니다.
523
527
 
524
528
  `runtime-mapping-unverified` — 신원은 정상이지만 **대상**에 검증된 호스트 로컬
525
529
  요청 셀렉터가 없습니다. 프로브를 한 적이 없거나 프로브가 실패한 경우입니다.
@@ -558,6 +562,44 @@ doctor가 파일과 벤더를 지목하며, 재서명 절차는 디스패치 스
558
562
 
559
563
  ## 📜 릴리스 기록
560
564
 
565
+ ## v0.42.8 새 기능
566
+
567
+ - **Codex 현재 턴 신원.** `app-server`는 시작 시 모델·강도 기본값을 항상 무시합니다.
568
+ 다른 Codex 실행은 명시적인 `-m` / `--model` / `-c model=...` / `--config`를 유지하며,
569
+ 모델 지정이 없으면(프로필만 지정한 경우 포함) 현재 턴 기록을 사용합니다.
570
+ TOML 모델 지정에는 Python 3.11 이상이 필요합니다.
571
+ - **불확실하면 거부.** 현재 프로세스 환경과 Codex의 직계 자식 프로세스 초기 환경에 같은 UUID 형식의
572
+ `CODEX_THREAD_ID`가 있어야 합니다. 기록은 `$CODEX_HOME/sessions`(기본 `~/.codex`)에서
573
+ `rollout-*-<스레드>.jsonl`로, 재개한 뒤에는 `rollout-*-<스레드>_<세션>.jsonl`로 찾으며,
574
+ 가장 최근에 기록된 파일을 읽고 `session_meta.id`가 일치해야 합니다. 마지막 `turn_context`에는
575
+ 모델·강도·턴 ID가 필요하며, 그 뒤에 **같은 턴 ID**의 `task_complete` / `turn_complete`(읽기 별칭) /
576
+ `turn_aborted`가 있으면 오래된 신원으로 거부하고, 두 턴 ID와 그 기록이 마지막으로 기록된 뒤
577
+ 지난 시간을 함께 알려 줍니다.
578
+ - **Codex 샌드박스 거부.** 조상 조회는 기존처럼 먼저 실행합니다. `CODEX_SANDBOX=seatbelt`에서
579
+ 실패하면 `whoami`는 프로세스 조회, `~/.omnilane` 쓰기 및 네트워크 연결을 위해 샌드박스
580
+ 밖에서 명령을 다시 실행해야 한다고 안내합니다.
581
+ - **추적성과 호환성.** JSONL을 순차적으로 읽고 메시지 본문은 진단에 포함하지 않습니다.
582
+ `whoami`는 스레드와 턴 ID를 표시합니다. 설정 기본값·모델 목록·보관 기록·다른 대화로
583
+ 추정하지 않습니다. 다른 공급자, 명시적·상속된 신원과 사람 선언의 우선순위는 유지합니다.
584
+ `OMNILANE_AA_CALLER_FROM_PROCESS=0`은 두 읽기 경로를 모두 끕니다.
585
+ - **테스트 환경 격리.** 테스트는 더 이상 디스패치 워커가 내보낸 `OMNILANE_AA_*`를
586
+ 물려받지 않습니다. 워커 안에서 실행하면 승인자 신원과 전송 오버레이 해시 고정이 각
587
+ 픽스처의 사람 선언을 덮어써 6개가 실패했습니다.
588
+ - **실제 환경 검증.** 2026-09-12 Codex 데스크톱에서 확인: 새 스레드와, 원래 기록이 전날
589
+ 멈춰 있던 재개 스레드 모두에서 `whoami`와 `dispatch.sh --dry-run`이 종료 코드 0으로
590
+ `codex/gpt-6-astra-xhigh (score 54)`와 `"allowed":true`를 보고했고 작업은 생성되지
591
+ 않았습니다. 업데이트: `npm i -g omnilane@0.42.8`.
592
+
593
+ ## v0.42.7 새 기능
594
+
595
+ - **모델 세션은 신원 파일 없이 디스패치할 수 있습니다.** `--caller-context` 가 없으면 dispatch 가 프로세스 트리를 거슬러 올라가 가장 가까운 벤더 CLI 를 찾고, 그 CLI 가 실행될 때의 모델과 effort 를 읽습니다. 지금까지 omnilane 체크아웃 밖의 세션은 `missing-caller-context` 에서 멈추고 판단을 운영자에게 되돌렸습니다(2026-09-08, 2026-09-10 에 3건).
596
+ - **`omnilane whoami`** 는 그 신원을 caller-context 파일로 출력하고, 읽지 못하면 정확한 이유(`--effort` 누락, 모델 별칭, 해당 effort 에 Claude 의 non-reasoning 행만 있음)를 알려줍니다. 추측하지 않습니다.
597
+ - **손으로 쓴 파일보다 속이기 어렵습니다.** 게이트는 caller-context 파일의 형식만 확인하고 실제로 실행 중인 모델과 일치하는지는 보지 않습니다. 실행 플래그는 모델이 아니라 하네스가 설정하며, 세션마다 따로 판정됩니다. 같은 모델이라도 `high` 와 `max` 면 상한은 52 와 54 입니다.
598
+ - **명시 지정이 우선합니다.** `--caller-context` 파일, 워커가 물려받는 환경, `--operator-asserted-human` 은 모두 자동 읽기보다 우선합니다. `OMNILANE_AA_CALLER_FROM_PROCESS=0` 으로 파일 전용 계약으로 되돌릴 수 있습니다.
599
+ - **거부 메시지가 해결책을 알려줍니다.** `missing-caller-context` 와 재시도 거부는 `omnilane whoami` 를 안내합니다.
600
+ - **`omnilane --version` 이 다시 정확해졌습니다.** 0.42.6 에서는 `VERSION` 이 0.42.5 로 남아 있었습니다.
601
+ - **업그레이드.** npm 게시 후 `npm i -g omnilane@0.42.7`를 실행하세요.
602
+
561
603
  ## v0.42.6 새 기능
562
604
 
563
605
  - **"검증됨"이 어떻게 검증했는지도 알려줍니다.** 각 overlay 매핑은 `evidence_tier`를 가집니다. `billed-model`은 공급자가 과금한 모델을 직접 밝힌 경우(claude, grok), `client-echo`는 CLI가 자신이 보낸 모델을 기록한 경우(codex, agy), `selector-only`는 CLI가 셀렉터를 받아들이기만 한 경우입니다. `client-echo`는 CLI가 적어 둔 주문서이고, `billed-model`은 공급자가 발행한 영수증입니다.
package/README.md CHANGED
@@ -71,8 +71,11 @@ omnilane route hardest-coding "fix the flaky auth token refresh"
71
71
  > capability score, so a dispatch has to say who is asking. A human at a terminal
72
72
  > asserts that once with `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1`, or per call with
73
73
  > `--operator-asserted-human`. A model driving omnilane cannot assert it for
74
- > itself — it passes `--caller-context FILE` carrying its exact vendor, model and
75
- > effort instead. With neither, the dispatch is refused with
74
+ > itself. Its identity is read from the nearest launching CLI: explicit model and
75
+ > effort flags, or for Codex app-server/no-model launches, a bound current-turn
76
+ > rollout (never app-server startup defaults). An ordinary session passes nothing,
77
+ > and `omnilane whoami` prints that identity as a `--caller-context FILE`. With
78
+ > neither an assertion nor a readable identity, the dispatch is refused with
76
79
  > `missing-caller-context` before any job is created.
77
80
 
78
81
  > New to this? Run `omnilane doctor` first — it tells you which model CLIs and
@@ -625,10 +628,14 @@ Three codes, three different fixes. Run `omnilane doctor` first — its
625
628
  `transport-overlay` check tells you immediately whether the problem is your
626
629
  machine's configuration or your request.
627
630
 
628
- `missing-caller-context` — you passed no identity. A human adds
629
- `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` or `--operator-asserted-human`; a model
630
- driving omnilane writes a `--caller-context FILE` with its exact vendor, model,
631
- and effort, and must not assert the human exemption for itself.
631
+ `missing-caller-context` — no identity reached the gate. A human adds
632
+ `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` or `--operator-asserted-human`. A model
633
+ normally needs nothing: dispatch reads its identity from the nearest launching
634
+ CLI, using a bound current-turn rollout for Codex app-server/no-model launches.
635
+ When that fails, run `omnilane whoami` — it either prints a
636
+ `--caller-context FILE` to pass, or says exactly why it cannot (a missing
637
+ `--effort`, a model alias, no scored row). A model must not assert the human
638
+ exemption for itself.
632
639
 
633
640
  `runtime-mapping-unverified` — your identity is fine, but the *target* has no
634
641
  proven host-local request selector. Either it was never probed, or its probe
@@ -699,6 +706,48 @@ working notes, including per-benchmark caveats, live in
699
706
 
700
707
  ## 📜 Release history
701
708
 
709
+ ## What's new in v0.42.8
710
+
711
+ - **Codex current-turn identity.** `app-server` always ignores startup model and
712
+ effort defaults. Other Codex launches keep explicit `-m` / `--model` or
713
+ `-c model=...` / `--config` selectors (TOML model overrides require Python 3.11+);
714
+ no model, including profile-only launches, uses the rollout path.
715
+ - **Bound, fail-closed evidence.** The current process environment and the codex
716
+ direct-child initial environment must carry the same UUID-shaped
717
+ `CODEX_THREAD_ID`. The rollout is looked up under `$CODEX_HOME/sessions`
718
+ (default `~/.codex`) as `rollout-*-<thread>.jsonl` and, after a resume,
719
+ `rollout-*-<thread>_<session>.jsonl`; the most recently written match is read
720
+ and its `session_meta.id` must match. Its latest `turn_context` needs model,
721
+ effort and turn id; a later `task_complete`, `turn_complete` (read alias) or
722
+ `turn_aborted` carrying that same turn id refuses stale identity, and the
723
+ refusal names both turn ids and how long ago the rollout was written. No config,
724
+ model-list, archive or other-session fallback; JSONL is streamed and message
725
+ content is never included in diagnostics. `whoami` reports thread and turn ids.
726
+ - **Codex sandbox refusal.** Ancestor lookup still runs first. If it fails under
727
+ `CODEX_SANDBOX=seatbelt`, `whoami` explains that process inspection,
728
+ `~/.omnilane` writes and networking require rerunning outside the sandbox.
729
+ - **Hermetic tests.** The suite no longer inherits `OMNILANE_AA_*` from a
730
+ dispatching worker, where the authorizer identity and the overlay hash pin used
731
+ to outrank each fixture's own human exemption and fail six tests.
732
+ - **Compatibility.** Other vendors, explicit/inherited caller identity and human
733
+ assertion precedence are unchanged. `OMNILANE_AA_CALLER_FROM_PROCESS=0` disables
734
+ both argv and rollout reading.
735
+ - **Accepted on a real desktop thread.** On 2026-09-12 `whoami` and
736
+ `dispatch.sh --dry-run` both exited 0 from the Codex desktop app — in a fresh
737
+ thread and in a resumed one whose original rollout had stopped the day before,
738
+ reporting `codex/gpt-6-astra-xhigh (score 54)` and `"allowed":true`.
739
+ - **Upgrade.** Run `npm i -g omnilane@0.42.8`.
740
+
741
+ ## What's new in v0.42.7
742
+
743
+ - **A model session no longer needs an identity file to dispatch.** When no `--caller-context` is given, dispatch walks up the process tree to the nearest vendor CLI and reads the model and effort it was launched with. Sessions outside the omnilane checkout used to stop on `missing-caller-context` and hand the question back to the operator; three did so on 2026-09-08 and 2026-09-10.
744
+ - **`omnilane whoami`** prints that identity as a caller-context file, or says exactly why it cannot — a missing `--effort`, a model alias, or a Claude effort whose only scored row is non-reasoning. It never guesses.
745
+ - **Harder to overstate than a hand-written file.** The gate checks a caller-context file's shape, not whether it matches the model actually running. Launch flags are set by the harness, not the model, and each session is held to its own: the same model at `high` and at `max` gets ceilings 52 and 54.
746
+ - **Explicit still wins.** A `--caller-context` file, the context a worker inherits, and `--operator-asserted-human` all take precedence. `OMNILANE_AA_CALLER_FROM_PROCESS=0` restores the file-only contract.
747
+ - **The refusal names the way out.** `missing-caller-context` and the retry refusal now point to `omnilane whoami` instead of offering a model two options it cannot take.
748
+ - **`omnilane --version` is right again.** The 0.42.6 release left `VERSION` at 0.42.5.
749
+ - **Upgrade.** After npm publication, run `npm i -g omnilane@0.42.7`. Existing repo-symlink installations can update their checkout and verify `omnilane --version` without rerunning installation.
750
+
702
751
  ## What's new in v0.42.6
703
752
 
704
753
  - **A verified mapping now says how it was verified.** Each overlay mapping carries an `evidence_tier`: `billed-model` when the provider named the model it charged for (Claude, grok), `client-echo` when the CLI recorded the model it asked for (codex, agy), `selector-only` when the CLI accepted the selector and said nothing more. `client-echo` is the CLI's copy of your order; `billed-model` is the provider's receipt.
package/README.zh-CN.md CHANGED
@@ -65,8 +65,9 @@ omnilane route hardest-coding "修掉会间歇失败的 auth token 更新测试"
65
65
  > **那个 export 是做什么的?** omnilane 会用调用者自己的能力分数来把关每一次派工,
66
66
  > 所以派工必须表明「是谁在问」。人类在终端前只要设一次
67
67
  > `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1`,或每次带 `--operator-asserted-human`。
68
- > 模型驱动 omnilane 时**不能替自己主张**这个标志,它要改用 `--caller-context FILE`
69
- > 提供确切的厂商、模型与强度。两者都没有的话,派工会在创建作业前就被
68
+ > 模型驱动 omnilane 时**不能替自己主张**这个标志。它的身份会从启动它的 CLI 标志
69
+ > (模型与强度)自动读取,一般 session 什么都不用带;`omnilane whoami` 会把这个身份
70
+ > 打印成 `--caller-context FILE`。既没主张、又读不到身份的话,派工会在创建作业前就被
70
71
  > `missing-caller-context` 拒绝。
71
72
 
72
73
  > 第一次用?先跑 `omnilane doctor`——它会告诉你 omnilane 现在能接到哪些模型 CLI 与
@@ -489,10 +490,11 @@ scripts/dispatch.sh --dry-run hardest-coding "…" # 完整解析后的计划,
489
490
  三个代码,三种不同的修法。先运行 `omnilane doctor`——它的 `transport-overlay`
490
491
  检查会直接告诉你问题出在本机配置还是你的请求。
491
492
 
492
- `missing-caller-context`——你没带身份。真人加上
493
- `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` 或 `--operator-asserted-human`;
494
- 模型驱动 omnilane 时要写一份 `--caller-context FILE`,包含它精确的厂商、模型与
495
- 强度,且不得替自己主张真人豁免。
493
+ `missing-caller-context`——没有身份送到闸门。真人加上
494
+ `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` 或 `--operator-asserted-human`。
495
+ 模型通常什么都不用做:dispatch 会从启动它的 CLI 读取身份。读不到时运行
496
+ `omnilane whoami`,它会打印可以带的 `--caller-context FILE`,或说明读不到的确切原因
497
+ (缺 `--effort`、模型别名、查无评分行)。模型不得替自己主张真人豁免。
496
498
 
497
499
  `runtime-mapping-unverified`——你的身份没问题,但**目标**没有已验证的本机请求
498
500
  选择器。可能从未探测过,也可能探测失败;`omnilane doctor` 会报告这类配置的数量,
@@ -554,6 +556,39 @@ codex 记在 session rollout,agy 写进 `cli.log`。这是 CLI 自己抄的订
554
556
 
555
557
  ## 📜 版本历程
556
558
 
559
+ ## v0.42.8 新功能
560
+
561
+ - **Codex 当前轮次身份。** `app-server` 始终忽略启动参数中的模型和强度默认值。
562
+ 其他 Codex 启动保留明确的 `-m` / `--model` / `-c model=...` / `--config`;
563
+ 未指定模型(包括只指定 profile)时读取当前轮次记录。TOML 模型覆盖需要 Python 3.11+。
564
+ - **证据不明就拒绝。** 本进程的当前环境与 Codex 直属子进程的初始环境必须有相同、符合 UUID 格式的
565
+ `CODEX_THREAD_ID`。记录文件在 `$CODEX_HOME/sessions`(默认 `~/.codex`)下按
566
+ `rollout-*-<对话串>.jsonl` 查找,对话串续用后还会有 `rollout-*-<对话串>_<会话>.jsonl`;
567
+ 取最后写入的那一个,`session_meta.id` 必须一致。最后一个 `turn_context` 必须包含模型、强度和轮次 ID;
568
+ 后面若出现**同一个轮次 ID** 的 `task_complete` / `turn_complete`(读取别名)/ `turn_aborted`,
569
+ 就拒绝使用过期身份,并在消息里写出两个轮次 ID,以及该记录多久没有被写入。
570
+ - **Codex 沙箱拒绝。** 仍会先查询祖先进程;若在 `CODEX_SANDBOX=seatbelt` 下查询失败,
571
+ `whoami` 会说明进程查询、写入 `~/.omnilane` 和联网都需要在沙箱外重新执行命令。
572
+ - **隐私与兼容。** 逐行读取 JSONL,诊断不含消息正文;`whoami` 会报告对话串和轮次 ID。
573
+ 不从配置默认值、模型列表、归档或其他对话推断。其他供应商、明确或继承的身份、人类声明
574
+ 的优先级不变;`OMNILANE_AA_CALLER_FROM_PROCESS=0` 同时关闭两种读取路径。
575
+ - **测试自己清环境。** 测试不再沿用派工工人带进来的 `OMNILANE_AA_*`;在工人里运行时,
576
+ 授权者身份与传输对照表的哈希锁定会盖掉各夹具自己的人类豁免,导致 6 个测试失败。
577
+ - **实机验收。** 2026-09-12 在 Codex 桌面版实测:新建对话串与续用对话串(原始记录文件
578
+ 前一天就停止写入)各跑一次,`whoami` 与 `dispatch.sh --dry-run` 都退出 0,读出
579
+ `codex/gpt-6-astra-xhigh (score 54)`、`"allowed":true`,且没有创建任何任务。
580
+ 升级:`npm i -g omnilane@0.42.8`。
581
+
582
+ ## v0.42.7 新功能
583
+
584
+ - **模型 session 派工不再需要身份文件。** 没给 `--caller-context` 时,dispatch 会沿进程树往上找到最近的厂商 CLI,读取它启动时带的模型与强度。以前不在 omnilane 目录下的 session 会卡在 `missing-caller-context`,把问题丢回给操作者——2026-09-08 与 2026-09-10 就有三个 session 这样停下来。
585
+ - **`omnilane whoami`** 会把这个身份打印成 caller-context 文件,读不到时说明确切原因(缺 `--effort`、模型别名、claude 该强度只剩 non-reasoning 行),绝不猜测。
586
+ - **比手写的文件更难作假。** 闸门只检查身份文件的格式,不核对它与实际运行的模型是否一致。启动标志由 harness 设置,模型改不了,而且每个 session 各算各的:同一模型分别开 `high` 和 `max`,上限就是 52 和 54。
587
+ - **明确指定仍然优先。** `--caller-context` 文件、worker 继承的环境、`--operator-asserted-human` 都优先于自动读取。`OMNILANE_AA_CALLER_FROM_PROCESS=0` 可恢复「只认文件」的规则。
588
+ - **被拒时会告诉你出路。** `missing-caller-context` 和重试被拒的消息都改为指向 `omnilane whoami`。
589
+ - **`omnilane --version` 恢复正确。** 0.42.6 发版时漏改了 `VERSION`,会报告 0.42.5。
590
+ - **升级。** npm 发布后运行 `npm i -g omnilane@0.42.7`。既有的 repo symlink 安装更新检出后确认 `omnilane --version` 即可。
591
+
557
592
  ## v0.42.6 新功能
558
593
 
559
594
  - **「已验证」现在会说明是怎么验的。** 每条 overlay 映射带一个 `evidence_tier`:`billed-model` 是供应商自己说出计费的模型(claude、grok),`client-echo` 是 CLI 记下自己送出的模型(codex、agy),`selector-only` 是 CLI 收下选择器、其余不表态。`client-echo` 是 CLI 自己抄的订单,`billed-model` 是供应商开的收据。
package/README.zh-TW.md CHANGED
@@ -65,8 +65,9 @@ omnilane route hardest-coding "修掉會間歇失敗的 auth token 更新測試"
65
65
  > **那個 export 是做什麼的?** omnilane 會用呼叫者自己的能力分數來把關每一次派工,
66
66
  > 所以派工必須表明「是誰在問」。人類在終端機前只要設一次
67
67
  > `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1`,或每次帶 `--operator-asserted-human`。
68
- > 模型驅動 omnilane 時**不能替自己主張**這個旗標,它要改用 `--caller-context FILE`
69
- > 提供確切的廠商、模型與強度。兩者都沒有的話,派工會在建立工作前就被
68
+ > 模型驅動 omnilane 時**不能替自己主張**這個旗標。它的身分會從啟動它的 CLI 旗標
69
+ > (模型與強度)自動讀取,一般 session 什麼都不用帶;`omnilane whoami` 會把這個身分
70
+ > 印成 `--caller-context FILE`。既沒主張、又讀不到身分的話,派工會在建立工作前就被
70
71
  > `missing-caller-context` 拒絕。
71
72
 
72
73
  > 第一次用?先跑 `omnilane doctor`——它會告訴你 omnilane 現在能接到哪些模型 CLI 與
@@ -533,10 +534,11 @@ scripts/dispatch.sh --dry-run hardest-coding "…" # 完整解析後的計畫,
533
534
  三個代碼,三種不同的修法。先跑 `omnilane doctor`——它的 `transport-overlay`
534
535
  檢查會直接告訴你問題出在本機設定還是你的請求。
535
536
 
536
- `missing-caller-context`——你沒帶身分。真人加上
537
- `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` 或 `--operator-asserted-human`;
538
- 模型驅動 omnilane 時要寫一份 `--caller-context FILE`,內含它精確的廠商、模型與
539
- 強度,且不得替自己主張真人豁免。
537
+ `missing-caller-context`——沒有身分送到閘門。真人加上
538
+ `OMNILANE_AA_OPERATOR_ASSERTED_HUMAN=1` 或 `--operator-asserted-human`。
539
+ 模型通常什麼都不用做:dispatch 會從啟動它的 CLI 讀取身分。讀不到時跑
540
+ `omnilane whoami`,它會印出可以帶的 `--caller-context FILE`,或說明讀不到的確切原因
541
+ (缺 `--effort`、模型別名、查無評分列)。模型不得替自己主張真人豁免。
540
542
 
541
543
  `runtime-mapping-unverified`——你的身分沒問題,但**目標**沒有已驗證的本機請求
542
544
  選擇器。可能從未探測過,也可能探測失敗;`omnilane doctor` 會回報這類設定的數量,
@@ -595,6 +597,39 @@ codex 記在 session rollout,agy 寫進 `cli.log`。這是 CLI 自己抄的訂
595
597
 
596
598
  ## 📜 版本歷程
597
599
 
600
+ ## v0.42.8 新功能
601
+
602
+ - **Codex 本輪身分。** `app-server` 一律忽略啟動參數的模型與強度預設值。
603
+ 其他 Codex 啟動保留明確的 `-m` / `--model` / `-c model=...` / `--config`;
604
+ 沒有指定模型(包括只指定 profile)時,改讀本輪記錄。TOML 模型覆寫需要 Python 3.11+。
605
+ - **證據不明就拒絕。** 本程序的現行環境與 Codex 直屬子程序的初始環境,必須有相同且符合 UUID 形狀的
606
+ `CODEX_THREAD_ID`。記錄檔在 `$CODEX_HOME/sessions`(預設 `~/.codex`)底下找
607
+ `rollout-*-<對話串>.jsonl`,對話串續用之後還會有 `rollout-*-<對話串>_<工作階段>.jsonl`;
608
+ 取最後寫入的那一份,`session_meta.id` 必須一致。最後一筆 `turn_context` 必須有模型、強度與回合 ID;
609
+ 後面若出現**同一個回合 ID** 的 `task_complete` / `turn_complete`(讀取別名)/ `turn_aborted`,
610
+ 就拒絕沿用過期身分,並在訊息裡寫出兩個回合 ID,以及那份記錄多久沒有被寫入。
611
+ - **Codex 沙箱拒絕。** 仍會先查詢祖先程序;若在 `CODEX_SANDBOX=seatbelt` 下查詢失敗,
612
+ `whoami` 會說明程序查詢、寫入 `~/.omnilane` 與連網都必須把指令移到沙箱外重新執行。
613
+ - **隱私與相容。** 逐行讀取 JSONL,不把訊息內容放進診斷;`whoami` 會回報對話串與回合 ID。
614
+ 不從設定預設值、模型清單、封存或其他對話推斷。其他廠商、明確或繼承的身分、真人聲明
615
+ 的優先順序不變;`OMNILANE_AA_CALLER_FROM_PROCESS=0` 同時關閉兩條讀取路徑。
616
+ - **測試自己清環境。** 測試不再沿用派工工人帶進來的 `OMNILANE_AA_*`;在工人裡跑時,
617
+ 授權者身分與傳輸對照表的雜湊釘選會蓋掉各夾具自己的真人豁免,害 6 個測試失敗。
618
+ - **實機驗收。** 2026-09-12 在 Codex 桌面版實測:新開的對話串與續用的對話串(原始記錄檔
619
+ 前一天就停筆)各跑一次,`whoami` 與 `dispatch.sh --dry-run` 都退出 0,讀出
620
+ `codex/gpt-6-astra-xhigh (score 54)`、`"allowed":true`,而且沒有建立任何工作。
621
+ 升級:`npm i -g omnilane@0.42.8`。
622
+
623
+ ## v0.42.7 新功能
624
+
625
+ - **模型 session 派工不再需要身分檔。** 沒給 `--caller-context` 時,dispatch 會沿行程樹往上找到最近的廠商 CLI,讀它啟動時帶的模型與強度。以前不在 omnilane 目錄下的 session 會卡在 `missing-caller-context`,把問題丟回給操作者——2026-09-08 與 2026-09-10 就有三個 session 這樣停下來。
626
+ - **`omnilane whoami`** 會把這個身分印成 caller-context 檔,讀不到時說明確切原因(缺 `--effort`、模型別名、claude 那個強度只剩 non-reasoning 列),絕不猜。
627
+ - **比手寫的檔案更難作假。** 閘門只檢查身分檔的格式,不核對它和實際在跑的模型是否一致。啟動旗標是 harness 設的,模型改不了,而且每個 session 各算各的:同一個模型分別開 `high` 和 `max`,上限就是 52 和 54。
628
+ - **明確指定仍然優先。** `--caller-context` 檔、worker 繼承的環境、`--operator-asserted-human` 都優先於自動讀取。`OMNILANE_AA_CALLER_FROM_PROCESS=0` 可以恢復「只認檔案」的規則。
629
+ - **被拒時會告訴你出路。** `missing-caller-context` 和重試被拒的訊息都改成指向 `omnilane whoami`,不再只給模型兩條走不通的路。
630
+ - **`omnilane --version` 恢復正確。** 0.42.6 發版時漏改了 `VERSION`,會回報 0.42.5。
631
+ - **升級。** npm 發布後執行 `npm i -g omnilane@0.42.7`。既有的 repo symlink 安裝更新檢出後確認 `omnilane --version` 即可,不需重跑安裝。
632
+
598
633
  ## v0.42.6 新功能
599
634
 
600
635
  - **「已驗證」現在會說明是怎麼驗的。** 每條 overlay 映射帶一個 `evidence_tier`:`billed-model` 是供應商自己說出計費的模型(claude、grok),`client-echo` 是 CLI 記下自己送出的模型(codex、agy),`selector-only` 是 CLI 收下選擇器、其餘不表態。白話說:`client-echo` 是 CLI 自己抄的訂單,`billed-model` 是供應商開的收據。
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.42.5
1
+ 0.42.8
package/bin/omnilane CHANGED
@@ -53,6 +53,13 @@ case "$cmd" in
53
53
  ;;
54
54
  release-audit) exec bash "$REPO/scripts/release-audit.sh" "$@" ;;
55
55
  doctor) exec bash "$REPO/scripts/doctor.sh" "$@" ;;
56
+ whoami)
57
+ command -v python3 >/dev/null 2>&1 || {
58
+ echo "omnilane: whoami requires Python 3.9 or newer" >&2
59
+ exit 1
60
+ }
61
+ exec python3 "$REPO/scripts/lib/caller_identity.py" "$@"
62
+ ;;
56
63
  benchmark)
57
64
  command -v python3 >/dev/null 2>&1 || {
58
65
  echo "omnilane: benchmark requires Python 3.9 or newer" >&2
@@ -89,6 +96,7 @@ omnilane — one routing table, every harness
89
96
  omnilane completion bash|zsh|fish print a shell completion script
90
97
  omnilane release-audit [flags] offline, read-only release gate
91
98
  omnilane doctor [--json] [--strict] [--probe V] local health / opt-in live probe
99
+ omnilane whoami caller-context file for the CLI this runs under
92
100
  omnilane benchmark [--json] [--run] [--vendor V] fixed quality/cost comparison
93
101
  omnilane ui start|status|url|stop
94
102
  omnilane configure interactive lane menu
@@ -53,7 +53,7 @@ _omnilane() {
53
53
  local command="${words[2]:-}" sub sub_index=3
54
54
  local -a lanes ids
55
55
  if (( CURRENT == 2 )); then
56
- _values 'command' version list route dispatch jobs doctor benchmark release-audit ui configure completion help
56
+ _values 'command' version list route dispatch jobs doctor whoami benchmark release-audit ui configure completion help
57
57
  return
58
58
  fi
59
59
  case "$command" in
@@ -58,7 +58,7 @@ _omnilane() {
58
58
  prev="${COMP_WORDS[COMP_CWORD-1]:-}"
59
59
  command="${COMP_WORDS[1]:-}"
60
60
  if [[ "$COMP_CWORD" -eq 1 ]]; then
61
- words="version list route dispatch jobs doctor benchmark release-audit ui configure completion help"
61
+ words="version list route dispatch jobs doctor whoami benchmark release-audit ui configure completion help"
62
62
  else
63
63
  case "$command" in
64
64
  route|dispatch)
@@ -10,6 +10,7 @@ complete -c omnilane -f -n __fish_use_subcommand -a dispatch -d 'dispatch o
10
10
  complete -c omnilane -f -n __fish_use_subcommand -a jobs -d 'inspect background jobs'
11
11
  complete -c omnilane -f -n __fish_use_subcommand -a mcp -d 'MCP stdio server'
12
12
  complete -c omnilane -f -n __fish_use_subcommand -a doctor -d 'read-only health report'
13
+ complete -c omnilane -f -n __fish_use_subcommand -a whoami -d 'caller-context file for the launching CLI'
13
14
  complete -c omnilane -f -n __fish_use_subcommand -a benchmark -d 'fixed quality/cost comparison'
14
15
  complete -c omnilane -f -n __fish_use_subcommand -a release-audit -d 'offline release gate'
15
16
  complete -c omnilane -f -n __fish_use_subcommand -a ui -d 'Live Board server'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnilane",
3
- "version": "0.42.6",
3
+ "version": "0.42.8",
4
4
  "description": "One routing table, every harness — classify subtasks into lanes and delegate each lane through a compatible caller-owned native agent or vendor CLI.",
5
5
  "bin": {
6
6
  "omnilane": "bin/omnilane"
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://antigravity.google/schemas/v1/plugin.json",
3
3
  "name": "omnilane",
4
- "version": "0.42.6",
4
+ "version": "0.42.8",
5
5
  "description": "One routing table, every harness: classify subtasks into lanes and delegate through compatible caller-owned native agents or vendor CLIs with exact-AA downward policy and supervised jobs."
6
6
  }
@@ -796,6 +796,17 @@ if [[ -n "${OMNILANE_AA_TRANSPORT_OVERLAY:-}" ]]; then
796
796
  OMNILANE_AA_OVERLAY_SHA256="$(file_sha256 "$OMNILANE_AA_TRANSPORT_OVERLAY")"
797
797
  export OMNILANE_AA_OVERLAY_SHA256
798
798
  fi
799
+ # A model caller that passed no identity is read from the CLI that launched it:
800
+ # the harness sets those flags and the model cannot. An explicit file or the
801
+ # human assertion still wins; OMNILANE_AA_CALLER_FROM_PROCESS=0 keeps the
802
+ # file-only contract.
803
+ if [[ "$AA_OPERATOR_ASSERTED_HUMAN" == "0" && -z "$AA_CALLER_CONTEXT" \
804
+ && "${OMNILANE_AA_CALLER_FROM_PROCESS:-1}" != "0" ]]; then
805
+ if derived_caller="$(python3 "$OMNILANE_REPO/scripts/lib/caller_identity.py" \
806
+ --registry "$AA_POLICY_FILE")"; then
807
+ AA_CALLER_CONTEXT="$derived_caller"
808
+ fi
809
+ fi
799
810
  AA_POLICY_ACTIVE=1
800
811
  if [[ -n "$OVERRIDE_VENDOR" || -n "$OVERRIDE_MODEL" || -n "$OVERRIDE_EFFORT" || -n "$AA_TARGET_CONFIG" ]]; then
801
812
  AA_EXPLICIT_TARGET=1
@@ -341,7 +341,10 @@ def decide(registry: dict[str, Any], registry_sha256: str, *,
341
341
  if caller is None:
342
342
  base.update(
343
343
  code="missing-caller-context",
344
- message="provide --caller-context FILE or explicitly assert --operator-asserted-human",
344
+ message=("no caller identity: a model caller runs `omnilane whoami` and passes the "
345
+ "file it prints as --caller-context, which dispatch does itself when the "
346
+ "launching CLI names its model and effort; a human operator passes "
347
+ "--operator-asserted-human"),
345
348
  )
346
349
  return base
347
350
  base["caller_context_sha256"] = caller_sha256
@@ -27,7 +27,9 @@ def retry_args(directory):
27
27
  original_limit = min(rows[0]["score"], original["inherited_ceiling"])
28
28
  current_path = os.environ.get("OMNILANE_AA_CALLER_CONTEXT")
29
29
  current_human = os.environ.get("OMNILANE_AA_OPERATOR_ASSERTED_HUMAN") == "1"
30
- aa_policy._check(bool(current_path) != current_human, "retry requires one current caller context or explicit current human assertion")
30
+ aa_policy._check(bool(current_path) != current_human,
31
+ "retry requires one current caller context or explicit current human "
32
+ "assertion; a model caller gets one from `omnilane whoami`")
31
33
  if current_path:
32
34
  current, _ = aa_policy.load_caller(current_path, registry)
33
35
  rows = aa_policy._matching_rows(registry, current["caller"])
@@ -0,0 +1,521 @@
1
+ #!/usr/bin/env python3
2
+ """Read the exact AA caller identity of the CLI this process runs under.
3
+
4
+ Walks up the process tree to the nearest vendor CLI and maps its selector onto
5
+ one scored configuration. Codex app-server always uses the host-written current
6
+ turn; other Codex launches do so only without an explicit model. The rollout is
7
+ bound to the codex direct child's initial environment, never another session.
8
+
9
+ Prints the path of a caller-context file for that identity. Exits 3 with the
10
+ reason on stderr when it cannot decide. It never guesses: missing selector
11
+ evidence, an alias, or an identity not scored exactly once is a
12
+ refusal, not a fallback.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import ctypes
18
+ import json
19
+ import os
20
+ import re
21
+ import struct
22
+ import subprocess
23
+ import sys
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Callable, Mapping, Optional
27
+
28
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
29
+ import aa_policy # noqa: E402
30
+
31
+ try:
32
+ import tomllib
33
+ except ImportError: # Explicit-model callers still work on Python 3.9/3.10.
34
+ tomllib = None
35
+
36
+ REPO = Path(__file__).resolve().parents[2]
37
+ MAX_DEPTH = 64
38
+ ENCODED_EFFORTS = ("xhigh", "high", "medium", "low")
39
+ Selector = tuple[str, Optional[str], Optional[str]]
40
+ Lookup = Callable[[int], Optional[tuple[int, list[str]]]]
41
+ EnvironmentLookup = Callable[[int], dict[str, str]]
42
+ UUID_PATTERN = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\Z")
43
+ TURN_END_EVENTS = frozenset(("task_complete", "turn_complete", "turn_aborted"))
44
+ CODEX_SANDBOX_REFUSAL = (
45
+ "Codex sandbox (CODEX_SANDBOX=seatbelt) blocked process inspection; omnilane also "
46
+ "needs to write ~/.omnilane and access the network, which this sandbox does not allow; "
47
+ "rerun this command outside the sandbox (approve running outside the sandbox when Codex "
48
+ "asks, or use a conversation with full access)"
49
+ )
50
+
51
+
52
+ class _ProcessQueryUnavailable(Exception):
53
+ """Keep a swallowed platform process-query failure available to read_caller()."""
54
+
55
+
56
+ def _vendor(executable: str) -> str | None:
57
+ path = Path(executable)
58
+ name = path.name
59
+ if name == "claude" or (path.parent.name == "versions" and path.parent.parent.name == "claude"):
60
+ return "claude"
61
+ if name == "codex":
62
+ return "codex"
63
+ if name == "grok" or (name.startswith("grok-") and path.parent.name == "downloads"):
64
+ return "grok"
65
+ if name == "agy":
66
+ return "gemini"
67
+ return None
68
+
69
+
70
+ def _flag(argv: list[str], *names: str) -> str | None:
71
+ """The last value given for any of names, as `--name value` or `--name=value`."""
72
+ value = None
73
+ for index, token in enumerate(argv):
74
+ for name in names:
75
+ if token == name and index + 1 < len(argv):
76
+ value = argv[index + 1]
77
+ elif token.startswith(name + "="):
78
+ value = token.split("=", 1)[1]
79
+ return value
80
+
81
+
82
+ def _codex_config(argv: list[str], name: str) -> str | None:
83
+ selected = None
84
+ tokens = iter(argv)
85
+ for token in tokens:
86
+ if token == "--":
87
+ break
88
+ if token in ("-c", "--config"):
89
+ override = next(tokens, "")
90
+ elif token.startswith("--config="):
91
+ override = token.partition("=")[2]
92
+ elif token.startswith("-c"):
93
+ override = token[2:].removeprefix("=")
94
+ else:
95
+ continue
96
+ key, sep, value = override.partition("=")
97
+ if sep and key.strip() == name:
98
+ selected = value.strip()
99
+ if selected is None:
100
+ return None
101
+ if tomllib is None:
102
+ if name == "model_reasoning_effort":
103
+ return selected.strip("'\"")
104
+ raise ValueError("model config decoding requires Python 3.11+ (tomllib)")
105
+ try:
106
+ value = tomllib.loads("value = " + selected)["value"]
107
+ except ValueError:
108
+ value = selected # Codex treats invalid TOML as a literal string.
109
+ if not isinstance(value, str) or not value.strip():
110
+ raise ValueError(f"{name} config must be a non-empty string")
111
+ return value
112
+
113
+
114
+ def _codex_subcommand(argv: list[str]) -> str | None:
115
+ takes_value = {"-c", "--config", "-m", "--model", "-p", "--profile", "-C", "--cd",
116
+ "-s", "--sandbox", "-a", "--ask-for-approval", "-i", "--image",
117
+ "--enable", "--disable", "--add-dir", "--local-provider"}
118
+ tokens = iter(argv[1:])
119
+ for token in tokens:
120
+ if token == "--":
121
+ return None
122
+ if token in takes_value:
123
+ next(tokens, None)
124
+ elif not token.startswith("-"):
125
+ return token
126
+ return None
127
+
128
+ def read_selector(argv: list[str]) -> Selector | None:
129
+ """(vendor, model, effort) when argv launches a vendor CLI, otherwise None."""
130
+ if not argv:
131
+ return None
132
+ vendor = _vendor(argv[0])
133
+ rest = argv[1:]
134
+ if vendor == "claude":
135
+ return vendor, _flag(rest, "--model"), _flag(rest, "--effort")
136
+ if vendor == "codex":
137
+ if _codex_subcommand(argv) == "app-server":
138
+ return vendor, None, None
139
+ model = _flag(rest, "-m", "--model")
140
+ if model is None:
141
+ model = _codex_config(rest, "model")
142
+ if model is None:
143
+ return vendor, None, None
144
+ return vendor, model, _codex_config(rest, "model_reasoning_effort")
145
+ if vendor == "grok":
146
+ return vendor, _flag(rest, "-m", "--model"), _flag(rest, "--reasoning-effort")
147
+ if vendor == "gemini":
148
+ return vendor, _flag(rest, "--model", "-m"), None
149
+ return None
150
+
151
+
152
+ def resolve(registry: dict, vendor: str, model: str | None,
153
+ effort: str | None) -> tuple[dict | None, str]:
154
+ """The one scored row a launch selector lands on, or None and the reason."""
155
+ if not model:
156
+ return None, f"{vendor} was launched without a model flag"
157
+ if vendor == "gemini" and effort is None:
158
+ base, _, suffix = model.rpartition("-")
159
+ if suffix in ENCODED_EFFORTS:
160
+ model, effort = base, suffix
161
+ if vendor == "codex":
162
+ if effort is None:
163
+ return None, (f"codex was launched without model_reasoning_effort; its configured "
164
+ f"default is not read, so the effort of {model} is unknown")
165
+ if effort == "none":
166
+ effort = None
167
+ rows = [row for row in registry["scored_configs"]
168
+ if row["vendor"] == vendor and row["model"] == model and row["effort"] == effort]
169
+ excluded = []
170
+ if vendor == "claude":
171
+ # ADR-0046: --effort has no reasoning-off value, so a non-reasoning row
172
+ # can never be what a Claude launch selected.
173
+ excluded = [row for row in rows if row["reasoning"] == "non-reasoning"]
174
+ rows = [row for row in rows if row["reasoning"] != "non-reasoning"]
175
+ if len(rows) == 1:
176
+ return rows[0], ""
177
+ if rows:
178
+ return None, f"{vendor} {model} at effort {effort} matches {len(rows)} scored configurations"
179
+ if excluded:
180
+ return None, (f"the only scored {model} row at effort {effort} is non-reasoning, which a "
181
+ f"Claude launch cannot select")
182
+ if vendor == "claude" and effort is None:
183
+ return None, f"{model} was launched without --effort and no default is scored for it"
184
+ return None, f"no scored configuration for {vendor} {model} at effort {effort}"
185
+
186
+
187
+ def _thread_environment(entries: list[bytes]) -> dict[str, str]:
188
+ values = [entry.partition(b"=")[2] for entry in entries
189
+ if entry.partition(b"=")[0] == b"CODEX_THREAD_ID"]
190
+ if len(values) > 1:
191
+ raise ValueError("duplicate CODEX_THREAD_ID in initial environment")
192
+ return {"CODEX_THREAD_ID": values[0].decode(errors="replace")} if values else {}
193
+
194
+
195
+ def _parse_procargs2(data: bytes) -> tuple[list[str], dict[str, str]]:
196
+ if len(data) < 4:
197
+ raise ValueError("truncated KERN_PROCARGS2 header")
198
+ argc = struct.unpack_from("=i", data)[0]
199
+ offset = data.find(b"\0", 4)
200
+ if argc <= 0 or offset < 0:
201
+ raise ValueError("invalid KERN_PROCARGS2 header")
202
+ while offset < len(data) and data[offset] == 0:
203
+ offset += 1
204
+ argv = []
205
+ for _ in range(argc):
206
+ end = data.find(b"\0", offset)
207
+ if end < 0:
208
+ raise ValueError("truncated KERN_PROCARGS2 argv")
209
+ argv.append(data[offset:end].decode(errors="replace"))
210
+ offset = end + 1
211
+ # argc, not a KEY=value search, separates argv from the initial environment.
212
+ entries = []
213
+ while offset < len(data) and data[offset] != 0:
214
+ end = data.find(b"\0", offset)
215
+ if end < 0:
216
+ raise ValueError("truncated KERN_PROCARGS2 environment")
217
+ entries.append(data[offset:end])
218
+ offset = end + 1
219
+ return argv, _thread_environment(entries)
220
+
221
+
222
+ def _darwin_procargs(pid: int) -> tuple[list[str], dict[str, str]]:
223
+ libc = ctypes.CDLL(None, use_errno=True)
224
+ argmax = ctypes.c_int()
225
+ size = ctypes.c_size_t(ctypes.sizeof(argmax))
226
+ if libc.sysctlbyname(b"kern.argmax", ctypes.byref(argmax), ctypes.byref(size), None, 0):
227
+ raise OSError(ctypes.get_errno(), "cannot read kern.argmax")
228
+ buffer = ctypes.create_string_buffer(argmax.value)
229
+ size = ctypes.c_size_t(len(buffer))
230
+ mib = (ctypes.c_int * 3)(1, 49, pid) # CTL_KERN, KERN_PROCARGS2.
231
+ if libc.sysctl(mib, 3, buffer, ctypes.byref(size), None, 0):
232
+ raise OSError(ctypes.get_errno(), "cannot read KERN_PROCARGS2")
233
+ return _parse_procargs2(buffer.raw[:size.value])
234
+
235
+
236
+ def _initial_environment(pid: int) -> dict[str, str]:
237
+ if sys.platform == "darwin":
238
+ return _darwin_procargs(pid)[1]
239
+ return _thread_environment(Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"))
240
+
241
+
242
+ def _process(pid: int) -> tuple[int, list[str]] | None:
243
+ """(ppid, argv) for pid. /proc keeps argv exact; ps joins it with spaces, so
244
+ argv[0] comes from `comm`, which keeps a path like `Application Support` whole."""
245
+ proc = Path(f"/proc/{pid}")
246
+ if proc.is_dir():
247
+ try:
248
+ argv = [part.decode(errors="replace")
249
+ for part in (proc / "cmdline").read_bytes().split(b"\0") if part]
250
+ ppid = int((proc / "stat").read_text().rsplit(")", 1)[1].split()[1])
251
+ except (OSError, ValueError, IndexError):
252
+ return None
253
+ return ppid, argv
254
+ try:
255
+ head_result = subprocess.run(
256
+ ["ps", "-o", "ppid=", "-o", "comm=", "-p", str(pid)],
257
+ capture_output=True, text=True, timeout=5)
258
+ args_result = subprocess.run(
259
+ ["ps", "-o", "args=", "-p", str(pid)],
260
+ capture_output=True, text=True, timeout=5)
261
+ except (OSError, subprocess.SubprocessError):
262
+ raise _ProcessQueryUnavailable from None
263
+ head = head_result.stdout.strip()
264
+ args = args_result.stdout.strip()
265
+ parts = head.split(None, 1)
266
+ if len(parts) != 2 or not parts[0].isdigit():
267
+ return None
268
+ comm = parts[1].strip()
269
+ if sys.platform == "darwin" and _vendor(comm) == "codex":
270
+ argv, _ = _darwin_procargs(pid)
271
+ return int(parts[0]), argv
272
+ rest = args[len(comm):] if args.startswith(comm) else args.partition(" ")[2]
273
+ return int(parts[0]), [comm, *rest.split()]
274
+
275
+
276
+ def _launcher(pid: int, lookup: Lookup) -> tuple[int, Selector, int | None] | None:
277
+ """The nearest process at or above pid that is a vendor CLI, with its selector.
278
+
279
+ Nearest wins: a codex worker started by a Claude session is a codex caller.
280
+ """
281
+ seen: set[int] = set()
282
+ child = None
283
+ for _ in range(MAX_DEPTH):
284
+ if pid <= 0 or pid in seen:
285
+ return None
286
+ seen.add(pid)
287
+ entry = lookup(pid)
288
+ if entry is None:
289
+ return None
290
+ ppid, argv = entry
291
+ selector = read_selector(argv)
292
+ if selector is not None:
293
+ return pid, selector, child
294
+ child = pid
295
+ pid = ppid
296
+ return None
297
+
298
+
299
+ def find_launcher(pid: int, lookup: Lookup = _process) -> tuple[int, Selector] | None:
300
+ try:
301
+ found = _launcher(pid, lookup)
302
+ except _ProcessQueryUnavailable:
303
+ return None
304
+ return found[:2] if found else None
305
+
306
+
307
+ def _rollout_age_hint(timestamp: object, now: Callable[[], datetime] | None) -> str:
308
+ """Describe the last record without letting unusable time data change a refusal."""
309
+ if not isinstance(timestamp, str):
310
+ return ""
311
+ try:
312
+ recorded = datetime.fromisoformat(timestamp[:-1] + "+00:00" if timestamp.endswith("Z") else timestamp)
313
+ current = now() if now is not None else datetime.now(timezone.utc)
314
+ if recorded.tzinfo is None or current.tzinfo is None:
315
+ return ""
316
+ seconds = (current - recorded).total_seconds()
317
+ except (AttributeError, TypeError, ValueError, OverflowError):
318
+ return ""
319
+ if seconds < 0:
320
+ return ""
321
+ # Floor display units; compare the unrounded duration against the hint threshold.
322
+ if seconds < 3600:
323
+ age = f"{int(seconds // 60)}m"
324
+ elif seconds < 86400:
325
+ age = f"{int(seconds // 3600)}h"
326
+ else:
327
+ age = f"{int(seconds // 86400)}d"
328
+ hint = f"; that rollout's last record is {timestamp}, {age} before now"
329
+ if seconds > 600:
330
+ hint += ", so CODEX_THREAD_ID may name an earlier conversation than the one running this command"
331
+ return hint
332
+
333
+
334
+ def _rollout_candidates(home: Path, thread: str) -> list[Path]:
335
+ """Resuming a thread opens a second rollout named <thread>_<session>, so match both forms."""
336
+ sessions = home / "sessions"
337
+ found = set(sessions.glob(f"*/*/*/rollout-*-{thread}.jsonl"))
338
+ found |= set(sessions.glob(f"*/*/*/rollout-*-{thread}_*.jsonl"))
339
+ return sorted(found, key=lambda path: (path.stat().st_mtime, path.name))
340
+
341
+
342
+ def _rollout_selector(thread: str,
343
+ current_environment: Mapping[str, str] = os.environ,
344
+ now: Callable[[], datetime] | None = None) -> tuple[Selector, str]:
345
+ home = Path(current_environment.get("CODEX_HOME") or Path.home() / ".codex")
346
+ try:
347
+ paths = _rollout_candidates(home, thread)
348
+ except OSError as error:
349
+ raise ValueError(f"cannot read rollout (errno {error.errno})") from None
350
+ if not paths:
351
+ raise ValueError("expected an active rollout for this thread; found 0")
352
+ # The most recently written file is the live one; older files belong to earlier sessions.
353
+ path = paths[-1]
354
+ metadata = 0
355
+ latest = None
356
+ ended = None
357
+ last_timestamp = None
358
+ try:
359
+ with path.open(encoding="utf-8") as stream:
360
+ for line in stream:
361
+ record = json.loads(line)
362
+ if not isinstance(record, dict):
363
+ raise ValueError("invalid rollout record")
364
+ last_timestamp = record.get("timestamp")
365
+ kind = record.get("type")
366
+ if kind not in ("session_meta", "turn_context", "event_msg"):
367
+ continue
368
+ payload = record.get("payload")
369
+ if not isinstance(payload, dict):
370
+ raise ValueError("invalid rollout payload")
371
+ if kind == "session_meta":
372
+ metadata += 1
373
+ if payload.get("id") != thread:
374
+ raise ValueError("session_meta id does not match CODEX_THREAD_ID")
375
+ elif kind == "turn_context":
376
+ latest = {key: payload.get(key) for key in ("turn_id", "model", "effort")}
377
+ ended = None
378
+ elif kind == "event_msg":
379
+ event = payload.get("type")
380
+ if not isinstance(event, str):
381
+ raise ValueError("invalid rollout event type")
382
+ if latest is not None and event in TURN_END_EVENTS:
383
+ event_turn = payload.get("turn_id")
384
+ if not isinstance(event_turn, str) or not event_turn.strip():
385
+ event_turn = None
386
+ context_turn = latest["turn_id"]
387
+ if not isinstance(context_turn, str) or not context_turn.strip():
388
+ context_turn = None
389
+ if event_turn is None or context_turn is None or event_turn == context_turn:
390
+ ended = (event, context_turn or "unknown", event_turn or "unknown")
391
+ except (json.JSONDecodeError, UnicodeError):
392
+ raise ValueError("rollout contains incomplete or invalid JSON") from None
393
+ except OSError as error:
394
+ raise ValueError(f"cannot read rollout (errno {error.errno})") from None
395
+ if metadata != 1:
396
+ raise ValueError("expected exactly one matching session_meta")
397
+ if latest is None:
398
+ raise ValueError("rollout has no turn_context")
399
+ if ended:
400
+ event, context_turn, event_turn = ended
401
+ raise ValueError(f"latest turn_context turn {context_turn} has already ended "
402
+ f"({event}, turn {event_turn})" + _rollout_age_hint(last_timestamp, now))
403
+ for key, value in latest.items():
404
+ if not isinstance(value, str) or not value.strip():
405
+ detail = " (turn unknown)" if key == "turn_id" else ""
406
+ raise ValueError(f"latest turn_context {key} must be a non-empty string{detail}")
407
+ return ("codex", latest["model"], latest["effort"]), f"thread {thread}, turn {latest['turn_id']}"
408
+
409
+
410
+ def read_caller(pid: int, lookup: Lookup = _process,
411
+ environment: EnvironmentLookup = _initial_environment,
412
+ current_environment: Mapping[str, str] = os.environ,
413
+ now: Callable[[], datetime] | None = None) -> tuple[int, Selector, str]:
414
+ if current_environment.get("OMNILANE_AA_CALLER_FROM_PROCESS") == "0":
415
+ raise ValueError("caller identity from process is disabled")
416
+ try:
417
+ found = _launcher(pid, lookup)
418
+ except _ProcessQueryUnavailable:
419
+ if current_environment.get("CODEX_SANDBOX") == "seatbelt":
420
+ raise ValueError(CODEX_SANDBOX_REFUSAL) from None
421
+ found = None
422
+ except OSError as error:
423
+ if current_environment.get("CODEX_SANDBOX") == "seatbelt":
424
+ raise ValueError(CODEX_SANDBOX_REFUSAL) from None
425
+ raise ValueError(f"cannot query caller process (errno {error.errno})") from None
426
+ if found is None:
427
+ if current_environment.get("CODEX_SANDBOX") == "seatbelt":
428
+ raise ValueError(CODEX_SANDBOX_REFUSAL)
429
+ raise ValueError("no vendor CLI among this process's ancestors; a model caller passes "
430
+ "--caller-context FILE and a human operator --operator-asserted-human")
431
+ launcher, selector, child = found
432
+ if selector[0] != "codex" or selector[1] is not None:
433
+ return launcher, selector, ""
434
+ thread = current_environment.get("CODEX_THREAD_ID")
435
+ if not thread or not UUID_PATTERN.fullmatch(thread):
436
+ raise ValueError("current process CODEX_THREAD_ID is missing or not a UUID")
437
+ if child is None:
438
+ raise ValueError("cannot find the codex direct child in the ancestor chain")
439
+ try:
440
+ inherited = environment(child).get("CODEX_THREAD_ID")
441
+ except (OSError, ValueError) as error:
442
+ detail = f"errno {error.errno}" if isinstance(error, OSError) else "invalid environment block"
443
+ raise ValueError(f"cannot read codex direct child initial environment ({detail})") from None
444
+ if not inherited or not UUID_PATTERN.fullmatch(inherited):
445
+ raise ValueError("codex direct child CODEX_THREAD_ID is missing or not a UUID")
446
+ if inherited != thread:
447
+ raise ValueError("CODEX_THREAD_ID mismatch between current process and codex direct child")
448
+ selector, source = _rollout_selector(thread, current_environment, now)
449
+ return launcher, selector, source
450
+
451
+
452
+ def load_registry(path: str | Path) -> tuple[dict, str]:
453
+ """The approved registry without the transport overlay, which says nothing
454
+ about the caller and must not stop one from learning who it is."""
455
+ saved = {key: os.environ.pop(key)
456
+ for key in ("OMNILANE_AA_TRANSPORT_OVERLAY", "OMNILANE_AA_OVERLAY_SHA256")
457
+ if key in os.environ}
458
+ try:
459
+ return aa_policy.load_registry(path)
460
+ finally:
461
+ os.environ.update(saved)
462
+
463
+
464
+ def write_context(row: dict, registry: dict, home: Path) -> Path:
465
+ """One file per identity: every session launched the same way is the same caller."""
466
+ directory = Path(home) / "caller-context"
467
+ directory.mkdir(parents=True, exist_ok=True)
468
+ path = directory / (row["id"].replace("/", "--") + ".json")
469
+ text = json.dumps({
470
+ "schema_version": 1,
471
+ "snapshot_id": registry["snapshot"]["id"],
472
+ "kind": "model",
473
+ "caller": {key: row[key] for key in aa_policy.IDENTITY_FIELDS},
474
+ "inherited_ceiling": row["score"],
475
+ }, indent=2, sort_keys=True) + "\n"
476
+ try:
477
+ if path.read_text() == text:
478
+ return path
479
+ except OSError:
480
+ pass
481
+ staging = path.with_name(f".{path.name}.{os.getpid()}.tmp")
482
+ staging.write_text(text)
483
+ os.replace(staging, path)
484
+ return path
485
+
486
+
487
+ def main(argv: list[str] | None = None, environment: Mapping[str, str] = os.environ,
488
+ lookup: Lookup = _process,
489
+ process_environment: EnvironmentLookup = _initial_environment) -> int:
490
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
491
+ parser.add_argument("--registry",
492
+ default=os.environ.get("OMNILANE_AA_POLICY_FILE")
493
+ or str(REPO / "config" / "aa-model-policy.json"),
494
+ help="frozen AA registry (default: the repository copy)")
495
+ args = parser.parse_args(argv)
496
+ try:
497
+ registry, _ = load_registry(args.registry)
498
+ except (aa_policy.PolicyError, OSError, ValueError) as error:
499
+ print(f"omnilane: cannot read the AA registry: {error}", file=sys.stderr)
500
+ return 3
501
+ try:
502
+ pid, (vendor, model, effort), source = read_caller(
503
+ os.getpid(), lookup, process_environment, environment)
504
+ except ValueError as error:
505
+ print(f"omnilane: cannot read the caller identity: {error}", file=sys.stderr)
506
+ return 3
507
+ row, reason = resolve(registry, vendor, model, effort)
508
+ if row is None:
509
+ print(f"omnilane: cannot read the caller identity from pid {pid}: {reason}", file=sys.stderr)
510
+ return 3
511
+ home = Path(os.environ.get("OMNILANE_HOME") or Path.home() / ".omnilane")
512
+ path = write_context(row, registry, home)
513
+ provenance = f", {source}" if source else ""
514
+ print(f"omnilane: caller is {row['id']} (score {row['score']}), read from pid {pid}{provenance}",
515
+ file=sys.stderr)
516
+ print(path)
517
+ return 0
518
+
519
+
520
+ if __name__ == "__main__":
521
+ raise SystemExit(main())
@@ -142,7 +142,13 @@ if [[ "$RC" -eq 142 || "$RC" -eq 124 ]]; then
142
142
  thread_id="$(head -1 "${OUTPUT_FILE}.progress.log" 2>/dev/null |
143
143
  sed -n 's/.*"thread_id":"\([^"]*\)".*/\1/p')"
144
144
  if [[ -n "$thread_id" ]]; then
145
- rollout="$(find "${CODEX_HOME:-$HOME/.codex}/sessions" -name "rollout-*-${thread_id}.jsonl" 2>/dev/null | head -1)"
145
+ # A resumed thread writes rollout-*-<thread>_<session>.jsonl, so match both names and keep the newest.
146
+ rollout=""
147
+ while IFS= read -r candidate; do
148
+ [[ -z "$candidate" ]] && continue
149
+ if [[ -z "$rollout" || "$candidate" -nt "$rollout" ]]; then rollout="$candidate"; fi
150
+ done < <(find "${CODEX_HOME:-$HOME/.codex}/sessions" \
151
+ \( -name "rollout-*-${thread_id}.jsonl" -o -name "rollout-*-${thread_id}_*.jsonl" \) 2>/dev/null)
146
152
  echo "omnilane: rollout: ${rollout:-not found (thread ${thread_id})}"
147
153
  else
148
154
  echo "omnilane: rollout: unknown — no thread_id in the progress log, so codex died before its first event"
@@ -19,10 +19,14 @@ You (the main loop) may be Claude, GPT, Grok, or Gemini. The procedure is identi
19
19
  Read-only work uses advise; edits require `--mode work --workdir <repo>`.
20
20
  `<repo>/scripts/dispatch.sh --caller-context FILE [--executor auto|native|cli] [--native-context FILE] [--vendor V] [--mode work] [--workdir DIR] <lane> "<task>"`
21
21
 
22
- A model caller MUST pass `--caller-context`; without it every dispatch is
23
- refused with `missing-caller-context` before a job exists. Build that file
24
- before the first dispatch — see **Frozen exact-AA downward gate** for the
25
- schema, a worked example, and what to do when your own effort is unverifiable.
22
+ A model caller needs a verifiable caller identity. Codex app-server reads the
23
+ current turn from its rollout; other launches read the model and effort from
24
+ the CLI that launched you, so an ordinary session passes nothing. When that
25
+ identity cannot be read, dispatch is refused with `missing-caller-context`
26
+ before a job exists: run `omnilane whoami` (or `<repo>/bin/omnilane whoami`)
27
+ and pass the file it prints as `--caller-context`. See **Frozen exact-AA
28
+ downward gate** for the schema and what to do when effort is genuinely
29
+ unverifiable.
26
30
 
27
31
  Add `--background` for long tasks; poll with `scripts/jobs.sh status|result <id>`.
28
32
  Use `--thread NAME` when later claude, codex, grok or gemini dispatches
@@ -355,15 +359,37 @@ and `snapshot_id` must equal the registry's own `snapshot.id`.
355
359
  Set `inherited_ceiling` to your own row's score when you are the root caller, or
356
360
  to the ceiling you were handed when you are a child.
357
361
 
358
- **When your harness names a model but no effort, look before you guess.** The
359
- launching process usually carries the exact flags. Walk your own ancestor chain
360
- (`ps -o ppid=,comm= -p <pid>` upward, then `ps -o args= -p <ancestor>`) and read
361
- its `--model` / `--effort`. Match the ancestor chain rather than the first
362
- matching process on the host — a second session of the same CLI is common and
363
- its flags are not yours. This is request-selector evidence, the same class the
364
- transport overlay carries, and it does not certify upstream identity.
365
-
366
- Only when that genuinely yields nothing: ask the operator, or declare the
362
+ **Your identity is read from the CLI that launched you.** Without explicit or
363
+ inherited caller-context, dispatch finds the nearest vendor CLI. Claude, Grok
364
+ and Agy retain their launch selectors. Codex outside app-server retains explicit
365
+ `-m` / `--model` or `-c model=...` / `--config` selectors; TOML model overrides
366
+ require Python 3.11+. A profile is not an explicit model selector.
367
+
368
+ Codex app-server always ignores startup selectors, even explicit model flags;
369
+ other Codex launches without a model use the same current-turn rollout reader.
370
+ It requires matching UUID-shaped `CODEX_THREAD_ID` values in this process and the
371
+ codex direct child's initial environment (not text embedded in argv). Exactly
372
+ one active rollout under `$CODEX_HOME/sessions` (default `~/.codex`) must have
373
+ matching `session_meta.id`. The last `turn_context` must provide non-empty model,
374
+ effort and turn id, with no later `task_complete`, `turn_complete` or
375
+ `turn_aborted` event.
376
+ Missing, ambiguous, malformed or stale evidence refuses: no config defaults,
377
+ model-list, archive or other-thread fallback. JSONL is streamed; only identity
378
+ metadata and event types reach diagnostics, never message content.
379
+
380
+ `omnilane whoami` prints the resulting caller-context path and reports thread
381
+ and turn ids for rollout evidence. The existing scored-row resolver still makes
382
+ the decision; this is host request-selector evidence, not proof of upstream
383
+ provider identity. Nearest wins, including workers launched by another vendor.
384
+ Explicit `--caller-context`, inherited identity and `--operator-asserted-human`
385
+ retain their precedence; `OMNILANE_AA_CALLER_FROM_PROCESS=0` disables both readers.
386
+
387
+ Ancestor lookup still runs first. If it fails under `CODEX_SANDBOX=seatbelt`,
388
+ `whoami` explains that process inspection, `~/.omnilane` writes and networking
389
+ require rerunning the command outside the Codex sandbox; do not retry with a
390
+ caller-context inside that sandbox.
391
+
392
+ Only when `omnilane whoami` refuses: ask the operator, or declare the
367
393
  lowest-scoring row of your model and say so in your report. Understating only
368
394
  narrows what you may dispatch to, so it fails in the safe direction — but it is
369
395
  the fallback, not the first move, and an unnecessarily low ceiling silently
@@ -372,7 +398,9 @@ declared effort to unblock a refused target, and never assert
372
398
  `--operator-asserted-human` on your own behalf.
373
399
 
374
400
  Three refusal codes mean different things and need different fixes.
375
- `missing-caller-context` means you passed no file — write one.
401
+ `missing-caller-context` means no identity reached the gate: you passed no file
402
+ and dispatch could not read one from your launching CLI. Run `omnilane whoami`;
403
+ its refusal says exactly why, and that reason is what to fix or report.
376
404
  `runtime-mapping-unverified` means the file is fine but the *target* has no proven
377
405
  host-local request selector; that is fixed by a `--transport-overlay` entry backed
378
406
  by real evidence, never by editing the frozen registry (its sha256 is pinned in