@oneciel-ai/ciel-runtime 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -207,14 +207,19 @@ CTL = str(Path.home() / ".local/bin/ciel-runtimectl")
207
207
  CONFIG = Path.home() / ".config/ciel-runtime/config.json"
208
208
  NCP_ENV = Path.home() / ".config/nvd-claude-proxy/.env"
209
209
  PROVIDERS = [
210
- ("anthropic", "Anthropic"),
211
- ("ollama", "Ollama"),
212
- ("ollama-cloud", "Ollama Cloud"),
210
+ ("agy:native", "AGY"),
211
+ ("agy:routed", "AGY Routed"),
212
+ ("anthropic:routed", "Anthropic routed"),
213
+ ("anthropic:native", "Claude Native"),
214
+ ("codex:native", "Codex Native"),
215
+ ("codex:routed", "Codex routed"),
213
216
  ("deepseek", "DeepSeek.com"),
214
- ("vllm", "vLLM"),
215
217
  ("lm-studio", "LM Studio"),
216
218
  ("nvidia-hosted", "Nvidia Hosted"),
219
+ ("ollama", "Ollama"),
220
+ ("ollama-cloud", "Ollama Cloud"),
217
221
  ("self-hosted-nim", "Self Hosted NIM"),
222
+ ("vllm", "vLLM"),
218
223
  ]
219
224
  APP_NAME = "Ciel Runtime"
220
225
 
@@ -233,6 +238,10 @@ def app_title() -> str:
233
238
 
234
239
 
235
240
  CREDITS = "Credits: One Ciel LLC"
241
+ PRELAUNCH_CANCEL = 10
242
+ PRELAUNCH_LAUNCH_CODEX = 11
243
+ PRELAUNCH_LAUNCH_CLAUDE = 12
244
+ PRELAUNCH_LAUNCH_AGY = 13
236
245
  LANGUAGES = {
237
246
  "en": "English",
238
247
  "ko": "한국어",
@@ -251,6 +260,8 @@ UI_TEXT = {
251
260
  "provider_options": "Provider options",
252
261
  "test": "Test compatibility",
253
262
  "launch": "Launch Claude Code",
263
+ "launch_agy": "Launch AGY",
264
+ "launch_codex": "Launch Codex",
254
265
  "quit": "Quit",
255
266
  "title": "ciel-runtime pre-launch",
256
267
  "select_language": "Enter selects language. Up/Down moves inside submenu. Esc closes submenu.",
@@ -261,6 +272,8 @@ UI_TEXT = {
261
272
  "select_provider_options": "Enter applies this provider option. Custom input accepts KEY=VALUE or unset:KEY.",
262
273
  "test_result": "Compatibility result is shown inline. Esc closes the result. Enter runs the test again.",
263
274
  "help_launch": "Enter launches Claude Code with the selected provider and model.",
275
+ "help_launch_agy": "Enter launches AGY with the selected provider mode.",
276
+ "help_launch_codex": "Enter launches Codex with the selected provider mode.",
264
277
  "help_test": "Enter tests current provider/model with a minimal Claude Code tool request.",
265
278
  "help_language": "Enter expands language submenu inline.",
266
279
  "help_provider": "Enter expands provider submenu inline.",
@@ -288,6 +301,8 @@ UI_TEXT = {
288
301
  "provider_options": "프로바이더 옵션",
289
302
  "test": "호환성 테스트",
290
303
  "launch": "Claude Code 실행",
304
+ "launch_agy": "AGY 실행",
305
+ "launch_codex": "Codex 실행",
291
306
  "quit": "종료",
292
307
  "title": "ciel-runtime 실행 전 설정",
293
308
  "select_language": "Enter로 언어를 선택합니다. 위/아래로 이동, Esc로 닫기.",
@@ -298,6 +313,8 @@ UI_TEXT = {
298
313
  "select_provider_options": "Enter로 프로바이더 옵션을 적용합니다. 직접 입력은 KEY=VALUE 또는 unset:KEY를 받습니다.",
299
314
  "test_result": "호환성 결과가 메뉴 안에 표시됩니다. Esc로 닫고 Enter로 다시 테스트합니다.",
300
315
  "help_launch": "선택한 프로바이더와 모델로 Claude Code를 실행합니다.",
316
+ "help_launch_agy": "선택한 프로바이더 모드로 AGY를 실행합니다.",
317
+ "help_launch_codex": "선택한 프로바이더 모드로 Codex를 실행합니다.",
301
318
  "help_test": "현재 프로바이더/모델에 최소 Claude Code 도구 요청을 보내 호환성을 확인합니다.",
302
319
  "help_language": "언어 선택 메뉴를 펼칩니다.",
303
320
  "help_provider": "프로바이더 선택 메뉴를 펼칩니다.",
@@ -325,6 +342,8 @@ UI_TEXT = {
325
342
  "provider_options": "プロバイダーオプション",
326
343
  "test": "互換性テスト",
327
344
  "launch": "Claude Codeを起動",
345
+ "launch_agy": "AGYを起動",
346
+ "launch_codex": "Codexを起動",
328
347
  "quit": "終了",
329
348
  "title": "ciel-runtime 起動前設定",
330
349
  "select_language": "Enterで言語を選択します。上下で移動、Escで閉じます。",
@@ -335,6 +354,8 @@ UI_TEXT = {
335
354
  "select_provider_options": "Enterでプロバイダーオプションを適用します。手入力はKEY=VALUEまたはunset:KEYです。",
336
355
  "test_result": "互換性結果はメニュー内に表示されます。Escで閉じ、Enterで再テストします。",
337
356
  "help_launch": "選択したプロバイダーとモデルでClaude Codeを起動します。",
357
+ "help_launch_agy": "選択したプロバイダーモードでAGYを起動します。",
358
+ "help_launch_codex": "選択したプロバイダーモードでCodexを起動します。",
338
359
  "help_test": "現在のプロバイダー/モデルへ最小のClaude Codeツール要求を送り互換性を確認します。",
339
360
  "help_language": "言語選択メニューを展開します。",
340
361
  "help_provider": "プロバイダー選択メニューを展開します。",
@@ -362,6 +383,8 @@ UI_TEXT = {
362
383
  "provider_options": "提供商选项",
363
384
  "test": "兼容性测试",
364
385
  "launch": "启动 Claude Code",
386
+ "launch_agy": "启动 AGY",
387
+ "launch_codex": "启动 Codex",
365
388
  "quit": "退出",
366
389
  "title": "ciel-runtime 启动前设置",
367
390
  "select_language": "按 Enter 选择语言。上下移动,Esc 关闭。",
@@ -372,6 +395,8 @@ UI_TEXT = {
372
395
  "select_provider_options": "按 Enter 应用提供商选项。手动输入支持 KEY=VALUE 或 unset:KEY。",
373
396
  "test_result": "兼容性结果会在菜单内显示。Esc 关闭,Enter 重新测试。",
374
397
  "help_launch": "使用所选提供商和模型启动 Claude Code。",
398
+ "help_launch_agy": "使用所选提供商模式启动 AGY。",
399
+ "help_launch_codex": "使用所选提供商模式启动 Codex。",
375
400
  "help_test": "向当前提供商/模型发送最小 Claude Code 工具请求以检查兼容性。",
376
401
  "help_language": "展开语言选择菜单。",
377
402
  "help_provider": "展开提供商选择菜单。",
@@ -681,6 +706,14 @@ def api_key_status(provider: str, pcfg: dict) -> str:
681
706
  return "API key: set (NVIDIA)" if meaningful_key(read_env_file(NCP_ENV).get("NVIDIA_API_KEY")) else "API key: missing (NVIDIA required)"
682
707
  if provider == "anthropic":
683
708
  return "API key: set (Anthropic)" if meaningful_key(pcfg.get("api_key")) else "API key: not set (use API key or Claude login)"
709
+ if provider == "agy":
710
+ if pcfg.get("route_through_router"):
711
+ return "API key: not set (uses native AGY Google sign-in/keyring)"
712
+ return "API key: not set (uses native AGY Google sign-in/config)"
713
+ if provider == "codex":
714
+ if pcfg.get("route_through_router"):
715
+ return "API key: not set (uses native Codex login/auth headers)"
716
+ return "API key: optional fallback (uses native Codex login/config)"
684
717
  if provider == "ollama-cloud":
685
718
  return "API key: set (Ollama Cloud)" if meaningful_key(pcfg.get("api_key")) else "API key: missing (Ollama Cloud required)"
686
719
  if provider == "deepseek":
@@ -712,6 +745,14 @@ def probe_base_url(provider: str, pcfg: dict) -> str:
712
745
  return f"Base URL: placeholder ({base})"
713
746
  if provider == "nvidia-hosted":
714
747
  return f"Base URL: NVIDIA hosted ({base}); local router http://127.0.0.1:8799 starts on launch"
748
+ if provider == "codex":
749
+ if pcfg.get("route_through_router"):
750
+ return "Base URL: Codex routed through local router (/backend-api/codex)"
751
+ return "Base URL: native Codex config (not overridden)"
752
+ if provider == "agy":
753
+ if pcfg.get("route_through_router"):
754
+ return "Base URL: AGY routed uses native upstream; Ciel routes channel/PTY wake only"
755
+ return "Base URL: native AGY config (not overridden)"
715
756
  if provider == "deepseek":
716
757
  return f"Base URL: DeepSeek Anthropic API configured ({base})"
717
758
  path = "/api/tags" if provider in ("ollama", "ollama-cloud") else "/v1/models"
@@ -759,7 +800,36 @@ def preflight_checks() -> list[str]:
759
800
  return lines
760
801
 
761
802
 
803
+ def provider_from_choice(value: str) -> str:
804
+ return str(value or "").split(":", 1)[0]
805
+
806
+
807
+ def provider_choice_for_cfg(cfg: dict) -> str:
808
+ provider = cfg.get("current_provider", "nvidia-hosted")
809
+ pcfg = cfg.get("providers", {}).get(provider, {})
810
+ if provider == "anthropic":
811
+ return "anthropic:routed" if bool(pcfg.get("route_through_router", False)) else "anthropic:native"
812
+ if provider == "agy":
813
+ return "agy:routed" if bool(pcfg.get("route_through_router", False)) else "agy:native"
814
+ if provider == "codex":
815
+ return "codex:routed" if bool(pcfg.get("route_through_router", False)) else "codex:native"
816
+ return provider
817
+
818
+
819
+ def claude_launch_enabled(provider: str) -> bool:
820
+ return provider not in ("agy", "codex")
821
+
822
+
823
+ def agy_launch_enabled(provider: str) -> bool:
824
+ return provider == "agy"
825
+
826
+
827
+ def codex_launch_enabled(provider: str) -> bool:
828
+ return provider == "codex"
829
+
830
+
762
831
  def provider_preview_checks(provider: str) -> list[str]:
832
+ provider = provider_from_choice(provider)
763
833
  cfg = load_cfg()
764
834
  pcfg = cfg.get("providers", {}).get(provider, {})
765
835
  lang = current_language()
@@ -800,7 +870,7 @@ def is_ollama_provider(provider: str) -> bool:
800
870
 
801
871
 
802
872
  def has_provider_options(provider: str) -> bool:
803
- return provider in ("vllm", "lm-studio", "nvidia-hosted", "self-hosted-nim", "ollama", "ollama-cloud", "deepseek")
873
+ return provider in ("anthropic", "agy", "codex", "vllm", "lm-studio", "nvidia-hosted", "self-hosted-nim", "ollama", "ollama-cloud", "deepseek")
804
874
 
805
875
 
806
876
  def ollama_ctx_text(pcfg: dict) -> str:
@@ -852,11 +922,21 @@ def provider_options_summary(provider: str, pcfg: dict) -> str:
852
922
  return "; ".join(parts)
853
923
 
854
924
 
925
+ def provider_menu_label(provider: str, pcfg: dict) -> str:
926
+ if provider == "anthropic":
927
+ return "Anthropic routed" if bool(pcfg.get("route_through_router", False)) else "Claude Native"
928
+ if provider == "agy":
929
+ return "AGY Routed" if bool(pcfg.get("route_through_router", False)) else "AGY"
930
+ if provider == "codex":
931
+ return "Codex routed" if bool(pcfg.get("route_through_router", False)) else "Codex Native"
932
+ return provider
933
+
934
+
855
935
  def main_items() -> list[tuple[str, str]]:
856
936
  provider, pcfg = current_provider_cfg()
857
937
  lang = current_language()
858
- model = pcfg.get("current_model", "unset")
859
- advisor_model = "Claude Code native /advisor" if provider == "anthropic" else (pcfg.get("advisor_model") or "off")
938
+ model = "AGY default" if provider == "agy" and not pcfg.get("current_model") else ("Codex default" if provider == "codex" and not pcfg.get("current_model") else pcfg.get("current_model", "unset"))
939
+ advisor_model = "Claude Code native /advisor" if provider == "anthropic" else ("AGY native" if provider == "agy" else ("Codex native" if provider == "codex" else (pcfg.get("advisor_model") or "off")))
860
940
  base = pcfg.get("base_url", "unset")
861
941
  rows: list[tuple[str, str]] = []
862
942
 
@@ -864,7 +944,7 @@ def main_items() -> list[tuple[str, str]]:
864
944
  rows.append((key, f"{len(rows)}. {label}"))
865
945
 
866
946
  add("language", f"{t('language')} [{LANGUAGES.get(lang, lang)}]")
867
- add("provider", f"{t('provider')} [{provider}]")
947
+ add("provider", f"{t('provider')} [{provider_menu_label(provider, pcfg)}]")
868
948
  add("api-key", t("api_key"))
869
949
  add("base-url", f"{t('base_url')} [{base}]")
870
950
  add("model", f"{t('model')} [{model}]")
@@ -874,13 +954,29 @@ def main_items() -> list[tuple[str, str]]:
874
954
  if has_provider_options(provider):
875
955
  add("provider-options", f"{t('provider_options')} [{provider_options_summary(provider, pcfg)}]")
876
956
  add("test", t("test"))
877
- add("launch", t("launch"))
957
+ launch_label = t("launch")
958
+ if not claude_launch_enabled(provider):
959
+ family = "AGY" if provider == "agy" else "Codex" if provider == "codex" else provider_menu_label(provider, pcfg)
960
+ launch_label += f" [disabled: {family} provider selected]"
961
+ add("launch", launch_label)
962
+ launch_codex_label = t("launch_codex")
963
+ if not codex_launch_enabled(provider):
964
+ launch_codex_label += " [disabled: select Codex provider]"
965
+ add("launch-codex", launch_codex_label)
966
+ launch_agy_label = t("launch_agy")
967
+ if not agy_launch_enabled(provider):
968
+ launch_agy_label += " [disabled: select AGY provider]"
969
+ add("launch-agy", launch_agy_label)
878
970
  rows.append(("quit", t("quit")))
879
971
  return rows
880
972
 
881
973
 
882
974
  def settings_ready_except_api_key() -> bool:
883
975
  provider, pcfg = current_provider_cfg()
976
+ if provider == "codex":
977
+ return True
978
+ if provider == "agy":
979
+ return True
884
980
  base = pcfg.get("base_url", "")
885
981
  model = pcfg.get("current_model", "")
886
982
  return bool(provider and base and model and "your-" not in base)
@@ -888,6 +984,8 @@ def settings_ready_except_api_key() -> bool:
888
984
  def default_base_url(provider: str) -> str:
889
985
  return {
890
986
  "anthropic": "https://api.anthropic.com",
987
+ "codex": "https://api.openai.com",
988
+ "agy": "https://antigravity.google",
891
989
  "ollama": "http://your-ollama:11434",
892
990
  "ollama-cloud": "https://ollama.com",
893
991
  "deepseek": "https://api.deepseek.com/anthropic",
@@ -915,6 +1013,8 @@ def help_for_action(action: str, sub_kind: str | None = None) -> str:
915
1013
  return t("test_result")
916
1014
  return {
917
1015
  "launch": t("help_launch"),
1016
+ "launch-agy": t("help_launch_agy"),
1017
+ "launch-codex": t("help_launch_codex"),
918
1018
  "test": t("help_test"),
919
1019
  "language": t("help_language"),
920
1020
  "provider": t("help_provider"),
@@ -942,14 +1042,15 @@ def get_models_for_current_provider() -> tuple[list[tuple[str, str]], str]:
942
1042
 
943
1043
  def build_provider_submenu() -> dict:
944
1044
  cfg = load_cfg()
945
- current = cfg.get("current_provider", "nvidia-hosted")
1045
+ current = provider_choice_for_cfg(cfg)
946
1046
  items = []
947
1047
  idx = 0
948
1048
  for i, (key, label) in enumerate(PROVIDERS):
949
1049
  if key == current:
950
1050
  idx = i
951
- base = cfg.get("providers", {}).get(key, {}).get("base_url", "")
952
- items.append({"value": key, "label": f"{label:<16} {key:<15} {base}", "current": key == current})
1051
+ provider = provider_from_choice(key)
1052
+ base = cfg.get("providers", {}).get(provider, {}).get("base_url", "")
1053
+ items.append({"value": key, "label": f"{label:<16} {key:<17} {base}", "current": key == current})
953
1054
  return {"kind": "provider", "parent": "provider", "items": items, "idx": idx, "offset": 0}
954
1055
 
955
1056
 
@@ -969,9 +1070,10 @@ def build_api_key_submenu() -> dict:
969
1070
  items = []
970
1071
  idx = 0
971
1072
  for i, (key, label) in enumerate(PROVIDERS):
972
- if key == current:
1073
+ provider = provider_from_choice(key)
1074
+ if provider == current:
973
1075
  idx = i
974
- items.append({"value": key, "label": f"{label:<16} {key:<15}", "current": key == current})
1076
+ items.append({"value": provider, "label": f"{label:<16} {provider:<15}", "current": provider == current})
975
1077
  return {"kind": "api-key", "parent": "api-key", "items": items, "idx": idx, "offset": 0}
976
1078
 
977
1079
  def build_model_submenu() -> tuple[dict | None, list[str]]:
@@ -1301,6 +1403,10 @@ def build_provider_options_submenu() -> dict:
1301
1403
  ("__edit_max_output__", f"Edit max_output_tokens [{max_output}]", False),
1302
1404
  ("__edit_timeout__", f"Edit timeout ms [{timeout}]", False),
1303
1405
  ]
1406
+ if provider in ("anthropic", "codex"):
1407
+ routed = bool(pcfg.get("route_through_router", False))
1408
+ choices.insert(0, ("route_through_router=true", "routed through ciel-runtime router", routed))
1409
+ choices.insert(1, ("route_through_router=false", "native direct mode", not routed))
1304
1410
  if provider in ("lm-studio", "nvidia-hosted", "self-hosted-nim", "ollama", "ollama-cloud"):
1305
1411
  choices.append(("__edit_rate_limit__", f"Edit rate_limit_rpm [{pcfg.get('rate_limit_rpm', 40)}]", False))
1306
1412
  choices.append(("rate_limit_status=true", "rate_limit_status on", bool(pcfg.get("rate_limit_status", True))))
@@ -1542,6 +1648,12 @@ def index_for_action(action: str) -> int:
1542
1648
  return next((i for i, (key, _) in enumerate(items) if key == action), 0)
1543
1649
 
1544
1650
 
1651
+ def default_launch_action() -> str:
1652
+ if agy_launch_enabled(current_provider()):
1653
+ return "launch-agy"
1654
+ return "launch-codex" if codex_launch_enabled(current_provider()) else "launch"
1655
+
1656
+
1545
1657
  def add(stdscr, y: int, x: int, text: str, style: str = "") -> None:
1546
1658
  _write_safe(y, max(0, x), text, style)
1547
1659
 
@@ -1615,7 +1727,9 @@ def render(stdscr, idx: int, sub: dict | None, notice: list[str], checks: list[s
1615
1727
  break
1616
1728
  if i == idx and (sub is None or sub.get("readonly")):
1617
1729
  style = _style(reverse=True, bold=True)
1618
- elif key == "launch":
1730
+ elif key in ("launch", "launch-codex", "launch-agy") and "disabled:" in label:
1731
+ style = _style(dim=True)
1732
+ elif key in ("launch", "launch-codex", "launch-agy"):
1619
1733
  style = cp(2) + _style(bold=True)
1620
1734
  elif key == "test":
1621
1735
  style = cp(3) + _style(bold=True)
@@ -1701,7 +1815,7 @@ def render(stdscr, idx: int, sub: dict | None, notice: list[str], checks: list[s
1701
1815
 
1702
1816
  def main() -> int:
1703
1817
  init_colors()
1704
- idx = index_for_action("launch") if settings_ready_except_api_key() else 0
1818
+ idx = index_for_action(default_launch_action()) if settings_ready_except_api_key() else 0
1705
1819
  sub: dict | None = None
1706
1820
  notice: list[str] = []
1707
1821
  checks = preflight_checks()
@@ -1718,7 +1832,7 @@ def main() -> int:
1718
1832
  else:
1719
1833
  notice = [t("test_failed")]
1720
1834
  checks = preflight_checks()
1721
- idx = index_for_action("launch" if ok else "model")
1835
+ idx = index_for_action(default_launch_action() if ok else "model")
1722
1836
 
1723
1837
  while True:
1724
1838
  items = main_items()
@@ -1743,6 +1857,10 @@ def main() -> int:
1743
1857
  action = items[idx][0]
1744
1858
  if action == "launch":
1745
1859
  provider, pcfg = current_provider_cfg()
1860
+ if not claude_launch_enabled(provider):
1861
+ notice = [f"Launch Claude Code is disabled while {provider_menu_label(provider, pcfg)} provider is selected."]
1862
+ sub = None
1863
+ continue
1746
1864
  if launch_requires_api_key(provider, pcfg):
1747
1865
  label = dict(PROVIDERS).get(provider, provider)
1748
1866
  notice = [
@@ -1752,7 +1870,39 @@ def main() -> int:
1752
1870
  idx = index_for_action("api-key")
1753
1871
  sub = None
1754
1872
  continue
1755
- return 0
1873
+ return PRELAUNCH_LAUNCH_CLAUDE
1874
+ if action == "launch-agy":
1875
+ provider, pcfg = current_provider_cfg()
1876
+ if not agy_launch_enabled(provider):
1877
+ notice = ["Launch AGY is disabled until you select AGY or AGY Routed as the provider."]
1878
+ sub = None
1879
+ continue
1880
+ if launch_requires_api_key(provider, pcfg):
1881
+ label = dict(PROVIDERS).get(provider, provider)
1882
+ notice = [
1883
+ f"Launch blocked: {label} requires an API key.",
1884
+ "Opening API key setup.",
1885
+ ]
1886
+ idx = index_for_action("api-key")
1887
+ sub = None
1888
+ continue
1889
+ return PRELAUNCH_LAUNCH_AGY
1890
+ if action == "launch-codex":
1891
+ provider, pcfg = current_provider_cfg()
1892
+ if not codex_launch_enabled(provider):
1893
+ notice = ["Launch Codex is disabled until you select Codex or Codex routed as the provider."]
1894
+ sub = None
1895
+ continue
1896
+ if launch_requires_api_key(provider, pcfg):
1897
+ label = dict(PROVIDERS).get(provider, provider)
1898
+ notice = [
1899
+ f"Launch blocked: {label} requires an API key.",
1900
+ "Opening API key setup.",
1901
+ ]
1902
+ idx = index_for_action("api-key")
1903
+ sub = None
1904
+ continue
1905
+ return PRELAUNCH_LAUNCH_CODEX
1756
1906
  if action == "test":
1757
1907
  code, out = run_test_with_animation(idx, checks)
1758
1908
  apply_test_result(code, out)
@@ -1806,7 +1956,8 @@ def main() -> int:
1806
1956
  notice = (out.strip().splitlines() or [item["value"]])[:2]
1807
1957
  checks = preflight_checks()
1808
1958
  sub = None
1809
- idx = index_for_action("model")
1959
+ provider = current_provider()
1960
+ idx = index_for_action(default_launch_action() if provider == "codex" else "model")
1810
1961
  elif sub["kind"] == "api-key":
1811
1962
  row = row_by_action.get("__sub_selected__", row_by_action.get("api-key", 10))
1812
1963
  key = inline_secret_prompt(None, f"API key for {item['value']}: ", row)
@@ -1944,7 +2095,7 @@ def main() -> int:
1944
2095
  continue
1945
2096
 
1946
2097
  if ch in ("KEY_ESC", "q"):
1947
- return 10
2098
+ return PRELAUNCH_CANCEL
1948
2099
  if ch in ("KEY_UP", "k"):
1949
2100
  notice = []
1950
2101
  idx = (idx - 1) % len(items)
@@ -1959,6 +2110,37 @@ def main() -> int:
1959
2110
  action = items[idx][0]
1960
2111
  if action == "launch":
1961
2112
  provider, pcfg = current_provider_cfg()
2113
+ if not claude_launch_enabled(provider):
2114
+ notice = [f"Launch Claude Code is disabled while {provider_menu_label(provider, pcfg)} provider is selected."]
2115
+ continue
2116
+ if launch_requires_api_key(provider, pcfg):
2117
+ label = dict(PROVIDERS).get(provider, provider)
2118
+ notice = [
2119
+ f"Launch blocked: {label} requires an API key.",
2120
+ "Opening API key setup.",
2121
+ ]
2122
+ idx = index_for_action("api-key")
2123
+ continue
2124
+ return PRELAUNCH_LAUNCH_CLAUDE
2125
+ if action == "launch-agy":
2126
+ provider, pcfg = current_provider_cfg()
2127
+ if not agy_launch_enabled(provider):
2128
+ notice = ["Launch AGY is disabled until you select AGY or AGY Routed as the provider."]
2129
+ continue
2130
+ if launch_requires_api_key(provider, pcfg):
2131
+ label = dict(PROVIDERS).get(provider, provider)
2132
+ notice = [
2133
+ f"Launch blocked: {label} requires an API key.",
2134
+ "Opening API key setup.",
2135
+ ]
2136
+ idx = index_for_action("api-key")
2137
+ continue
2138
+ return PRELAUNCH_LAUNCH_AGY
2139
+ if action == "launch-codex":
2140
+ provider, pcfg = current_provider_cfg()
2141
+ if not codex_launch_enabled(provider):
2142
+ notice = ["Launch Codex is disabled until you select Codex or Codex routed as the provider."]
2143
+ continue
1962
2144
  if launch_requires_api_key(provider, pcfg):
1963
2145
  label = dict(PROVIDERS).get(provider, provider)
1964
2146
  notice = [
@@ -1967,13 +2149,13 @@ def main() -> int:
1967
2149
  ]
1968
2150
  idx = index_for_action("api-key")
1969
2151
  continue
1970
- return 0
2152
+ return PRELAUNCH_LAUNCH_CODEX
1971
2153
  if action == "test":
1972
2154
  code, out = run_test_with_animation(idx, checks)
1973
2155
  apply_test_result(code, out)
1974
2156
  continue
1975
2157
  if action == "quit":
1976
- return 10
2158
+ return PRELAUNCH_CANCEL
1977
2159
  if action == "language":
1978
2160
  sub = build_language_submenu()
1979
2161
  notice = []
@@ -59,6 +59,7 @@ GLOB_KEYS = {"pattern", "path"}
59
59
  GREP_KEYS = {"pattern", "path", "glob", "type", "output_mode", "-A", "-B", "-C", "head_limit", "multiline"}
60
60
  LS_KEYS = {"path", "ignore"}
61
61
  TASKLIST_KEYS: set[str] = set()
62
+ TRANSCRIPT_TAIL_BYTES = 1024 * 1024
62
63
  TASKUPDATE_KEYS = {
63
64
  "taskId",
64
65
  "subject",
@@ -313,16 +314,34 @@ def has_in_progress_task(session_id: str | None) -> bool:
313
314
  return False
314
315
 
315
316
 
316
- def transcript_latest_user_text(transcript_path: str | None) -> str:
317
- if not transcript_path:
318
- return ""
317
+ def transcript_tail_lines(transcript_path: str | None, max_lines: int) -> list[str]:
318
+ if not transcript_path or max_lines <= 0:
319
+ return []
319
320
  path = Path(transcript_path)
320
321
  if not path.exists():
321
- return ""
322
+ return []
322
323
  try:
323
- lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()[-80:]
324
+ size = path.stat().st_size
325
+ offset = max(0, size - TRANSCRIPT_TAIL_BYTES)
326
+ with path.open("rb") as f:
327
+ if offset:
328
+ f.seek(offset)
329
+ data = f.read(TRANSCRIPT_TAIL_BYTES)
324
330
  except Exception:
325
- return ""
331
+ return []
332
+ if offset:
333
+ newline = data.find(b"\n")
334
+ if newline >= 0:
335
+ data = data[newline + 1 :]
336
+ try:
337
+ text = data.decode("utf-8", errors="ignore")
338
+ except Exception:
339
+ return []
340
+ return text.splitlines()[-max_lines:]
341
+
342
+
343
+ def transcript_latest_user_text(transcript_path: str | None) -> str:
344
+ lines = transcript_tail_lines(transcript_path, 80)
326
345
  for line in reversed(lines):
327
346
  try:
328
347
  data = json.loads(line)
@@ -352,19 +371,13 @@ def latest_user_is_channel_wake(transcript_path: str | None) -> bool:
352
371
  return (
353
372
  "[ciel-runtime external channel message" in text
354
373
  or "[ciel-runtime channel inbox]" in text
374
+ or "[external channel input]" in text
375
+ or text.startswith("[external input pending]")
355
376
  )
356
377
 
357
378
 
358
379
  def transcript_plan_mode_active(transcript_path: str | None) -> bool:
359
- if not transcript_path:
360
- return False
361
- path = Path(transcript_path)
362
- if not path.exists():
363
- return False
364
- try:
365
- lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()[-240:]
366
- except Exception:
367
- return False
380
+ lines = transcript_tail_lines(transcript_path, 240)
368
381
  active = False
369
382
  tool_names_by_id: dict[str, str] = {}
370
383
  for line in lines:
@@ -429,15 +442,7 @@ def message_has_tool_use(message: dict[str, Any]) -> bool:
429
442
 
430
443
 
431
444
  def transcript_latest_turn(transcript_path: str | None) -> dict[str, Any]:
432
- if not transcript_path:
433
- return {}
434
- path = Path(transcript_path)
435
- if not path.exists():
436
- return {}
437
- try:
438
- lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()[-160:]
439
- except Exception:
440
- return {}
445
+ lines = transcript_tail_lines(transcript_path, 160)
441
446
 
442
447
  latest_assistant: dict[str, Any] | None = None
443
448
  latest_assistant_index = -1