omnilane 0.34.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env python3
2
+ """Long-lived Grok ACP bridge for omnilane's live mailbox."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import queue
11
+ import select
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from typing import Any
18
+
19
+
20
+ class ProtocolError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def grok_acp_argv(grok_bin: str, model: str, mode: str) -> list[str]:
25
+ """Build the only supported ACP mode: explicit unrestricted sysops."""
26
+ if mode != "sysops":
27
+ raise ValueError(
28
+ f"Grok {mode} live is unavailable: ACP has no enforceable restricted-mode policy"
29
+ )
30
+ return [
31
+ grok_bin,
32
+ "--no-memory",
33
+ "--no-subagents",
34
+ "--no-plan",
35
+ "--verbatim",
36
+ "--sandbox",
37
+ "off",
38
+ "agent",
39
+ "--always-approve",
40
+ "--model",
41
+ model,
42
+ "stdio",
43
+ ]
44
+
45
+
46
+ class GrokLiveClient:
47
+ def __init__(self, args: argparse.Namespace) -> None:
48
+ self.args = args
49
+ self.process: subprocess.Popen[str] | None = None
50
+ self.events: Any = None
51
+ self.inbox: Any = None
52
+ self.messages: queue.Queue[tuple[str, str | None]] = queue.Queue()
53
+ self.next_request_id = 1
54
+ self.active_prompt_request_id: int | None = None
55
+ self.session_id: str | None = None
56
+ self.turn_running = False
57
+ self.last_turn_succeeded = False
58
+ self.stop_requested = False
59
+ self.session_close_sent = False
60
+
61
+ def request_stop(self, _signum: int, _frame: Any) -> None:
62
+ self.stop_requested = True
63
+
64
+ def start_server(self) -> None:
65
+ self.process = subprocess.Popen(
66
+ grok_acp_argv(self.args.grok_bin, self.args.model, self.args.mode),
67
+ cwd=self.args.cwd,
68
+ stdin=subprocess.PIPE,
69
+ stdout=subprocess.PIPE,
70
+ stderr=None,
71
+ text=True,
72
+ bufsize=1,
73
+ # The outer watchdog owns this process group. Do not split the
74
+ # ACP agent into a session that survives if this client is
75
+ # SIGKILLed before its finally block can run.
76
+ start_new_session=False,
77
+ )
78
+ threading.Thread(
79
+ target=self.read_server, name="grok-acp-agent", daemon=True
80
+ ).start()
81
+
82
+ def read_server(self) -> None:
83
+ assert self.process is not None and self.process.stdout is not None
84
+ try:
85
+ for raw in self.process.stdout:
86
+ self.messages.put(("message", raw))
87
+ finally:
88
+ self.messages.put(("eof", None))
89
+
90
+ def write_message(self, message: dict[str, Any]) -> None:
91
+ assert self.process is not None and self.process.stdin is not None
92
+ try:
93
+ self.process.stdin.write(
94
+ json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n"
95
+ )
96
+ self.process.stdin.flush()
97
+ except (BrokenPipeError, OSError) as exc:
98
+ raise ProtocolError(f"Grok ACP stdin closed: {exc}") from exc
99
+
100
+ def send_request(self, method: str, params: dict[str, Any]) -> int:
101
+ request_id = self.next_request_id
102
+ self.next_request_id += 1
103
+ self.write_message(
104
+ {
105
+ "jsonrpc": "2.0",
106
+ "id": request_id,
107
+ "method": method,
108
+ "params": params,
109
+ }
110
+ )
111
+ return request_id
112
+
113
+ def send_notification(self, method: str, params: dict[str, Any]) -> None:
114
+ # session/cancel must be an ACP notification. Adding an id changes it
115
+ # into a request, which Grok rejects with -32601 Method not found.
116
+ self.write_message({"jsonrpc": "2.0", "method": method, "params": params})
117
+
118
+ def record_event(self, raw: str) -> dict[str, Any] | None:
119
+ assert self.events is not None
120
+ self.events.write(raw if raw.endswith("\n") else raw + "\n")
121
+ self.events.flush()
122
+ try:
123
+ message = json.loads(raw)
124
+ except json.JSONDecodeError:
125
+ return None
126
+ if not isinstance(message, dict):
127
+ return None
128
+
129
+ if (
130
+ self.active_prompt_request_id is not None
131
+ and message.get("id") == self.active_prompt_request_id
132
+ ):
133
+ if "error" in message:
134
+ error = message.get("error")
135
+ if isinstance(error, dict):
136
+ detail = (
137
+ f"code={error.get('code')!r} "
138
+ f"message={error.get('message')!r}"
139
+ )
140
+ else:
141
+ detail = repr(error)
142
+ self.active_prompt_request_id = None
143
+ self.turn_running = False
144
+ self.last_turn_succeeded = False
145
+ raise ProtocolError(
146
+ f"Grok ACP rejected session/prompt: {detail}"
147
+ )
148
+ self.active_prompt_request_id = None
149
+
150
+ if message.get("method") == "session/update":
151
+ params = message.get("params")
152
+ update = params.get("update") if isinstance(params, dict) else None
153
+ if isinstance(update, dict):
154
+ update_type = update.get("sessionUpdate")
155
+ if update_type == "agent_message_chunk":
156
+ content = update.get("content")
157
+ text = content.get("text") if isinstance(content, dict) else None
158
+ if isinstance(text, str):
159
+ self.append_agent_text(text)
160
+ # agent_thought_chunk is private reasoning and is deliberately
161
+ # journaled only; it must never reach OUTPUT_FILE.
162
+
163
+ if message.get("method") == "_x.ai/session/prompt_complete":
164
+ params = message.get("params")
165
+ stop_reason = params.get("stopReason") if isinstance(params, dict) else None
166
+ self.active_prompt_request_id = None
167
+ self.turn_running = False
168
+ self.last_turn_succeeded = stop_reason == "end_turn"
169
+
170
+ return message
171
+
172
+ def append_agent_text(self, text: str) -> None:
173
+ with open(self.args.output, "a", encoding="utf-8") as output:
174
+ output.write(text)
175
+ output.flush()
176
+ os.fsync(output.fileno())
177
+
178
+ def receive(self, timeout: float) -> dict[str, Any] | None:
179
+ try:
180
+ kind, raw = self.messages.get(timeout=timeout)
181
+ except queue.Empty:
182
+ return None
183
+ if kind == "eof":
184
+ raise ProtocolError("Grok ACP stdout closed")
185
+ assert raw is not None
186
+ return self.record_event(raw)
187
+
188
+ def wait_response(self, request_id: int, method: str) -> dict[str, Any]:
189
+ deadline = time.monotonic() + self.args.rpc_timeout
190
+ while not self.stop_requested and time.monotonic() < deadline:
191
+ message = self.receive(min(0.2, max(0.0, deadline - time.monotonic())))
192
+ if message is None or message.get("id") != request_id:
193
+ continue
194
+ result = message.get("result")
195
+ if not isinstance(result, dict):
196
+ error = message.get("error")
197
+ raise ProtocolError(f"Grok ACP rejected {method}: {error!r}")
198
+ return result
199
+ raise ProtocolError(f"Grok ACP timed out waiting for {method}")
200
+
201
+ def initialize(self) -> None:
202
+ request_id = self.send_request(
203
+ "initialize",
204
+ {
205
+ "protocolVersion": 1,
206
+ "clientCapabilities": {
207
+ "fs": {"readTextFile": False, "writeTextFile": False}
208
+ },
209
+ },
210
+ )
211
+ self.wait_response(request_id, "initialize")
212
+
213
+ request_id = self.send_request(
214
+ "session/new", {"cwd": self.args.cwd, "mcpServers": []}
215
+ )
216
+ result = self.wait_response(request_id, "session/new")
217
+ session_id = result.get("sessionId")
218
+ if not isinstance(session_id, str) or not session_id:
219
+ raise ProtocolError("Grok session/new response omitted sessionId")
220
+ self.session_id = session_id
221
+ self.record_session_id(session_id)
222
+
223
+ # The verified ACP surface has no permission-mode or model-selection
224
+ # field equivalent to single-shot --permission-mode/--model. Do not
225
+ # invent one. run-grok.sh keeps its single-shot mapping unchanged.
226
+ print(
227
+ "omnilane: Grok ACP live exposes no permission-mode field; "
228
+ f"MODE={self.args.mode} uses the ACP default. jobs.sh send cancels "
229
+ "an active Grok turn before starting the new prompt; conversation "
230
+ "context survives, but in-progress work is lost.",
231
+ file=sys.stderr,
232
+ )
233
+
234
+ def record_session_id(self, session_id: str) -> None:
235
+ session_path = pathlib.Path(self.args.session_id_file)
236
+ session_path.write_text(session_id + "\n", encoding="utf-8")
237
+ session_path.chmod(0o600)
238
+ progress = {
239
+ "event": "session.started",
240
+ "session_id": session_id,
241
+ "vendor": "grok",
242
+ }
243
+ pathlib.Path(self.args.progress).write_text(
244
+ json.dumps(progress, ensure_ascii=False, separators=(",", ":")) + "\n",
245
+ encoding="utf-8",
246
+ )
247
+
248
+ def decode_mailbox(self, raw: str) -> str:
249
+ try:
250
+ payload = json.loads(raw)
251
+ except json.JSONDecodeError as exc:
252
+ raise ProtocolError("Grok mailbox line is not JSON") from exc
253
+ if not isinstance(payload, dict) or payload.get("type") != "grok-user":
254
+ raise ProtocolError("Grok mailbox line has the wrong type")
255
+ text = payload.get("text")
256
+ if not isinstance(text, str):
257
+ raise ProtocolError("Grok mailbox line omitted text")
258
+ return text
259
+
260
+ def start_prompt(self, text: str) -> None:
261
+ assert self.session_id is not None
262
+ self.active_prompt_request_id = self.send_request(
263
+ "session/prompt",
264
+ {
265
+ "sessionId": self.session_id,
266
+ "prompt": [{"type": "text", "text": text}],
267
+ },
268
+ )
269
+ self.turn_running = True
270
+ self.last_turn_succeeded = False
271
+
272
+ def cancel_active_turn(self) -> None:
273
+ assert self.session_id is not None
274
+ if not self.turn_running:
275
+ return
276
+ self.send_notification("session/cancel", {"sessionId": self.session_id})
277
+ # A cancelled request may answer late after the replacement prompt has
278
+ # started. Only an error for the current active request is fatal.
279
+ self.active_prompt_request_id = None
280
+ deadline = time.monotonic() + self.args.rpc_timeout
281
+ while self.turn_running and not self.stop_requested:
282
+ if time.monotonic() >= deadline:
283
+ raise ProtocolError("Grok ACP timed out waiting for cancelled turn")
284
+ self.receive(min(0.1, max(0.0, deadline - time.monotonic())))
285
+
286
+ def close_session(self, deadline: float) -> None:
287
+ if self.session_id is None or self.session_close_sent:
288
+ return
289
+ if self.turn_running:
290
+ self.send_notification("session/cancel", {"sessionId": self.session_id})
291
+ while self.turn_running and time.monotonic() < deadline:
292
+ self.receive(min(0.1, max(0.0, deadline - time.monotonic())))
293
+ request_id = self.send_request("session/close", {"sessionId": self.session_id})
294
+ self.session_close_sent = True
295
+ while time.monotonic() < deadline:
296
+ try:
297
+ message = self.receive(
298
+ min(0.1, max(0.0, deadline - time.monotonic()))
299
+ )
300
+ except ProtocolError:
301
+ if self.process is not None and self.process.poll() is not None:
302
+ return
303
+ raise
304
+ if message is not None and message.get("id") == request_id:
305
+ if "error" in message:
306
+ raise ProtocolError(
307
+ f"Grok ACP rejected session/close: {message['error']!r}"
308
+ )
309
+ return
310
+
311
+ def drain_server_messages(self) -> None:
312
+ while True:
313
+ try:
314
+ kind, raw = self.messages.get_nowait()
315
+ except queue.Empty:
316
+ return
317
+ if kind == "eof":
318
+ raise ProtocolError("Grok ACP stdout closed")
319
+ assert raw is not None
320
+ self.record_event(raw)
321
+
322
+ def run_mailbox(self) -> None:
323
+ self.inbox = open(self.args.inbox, "r", encoding="utf-8")
324
+ while not self.stop_requested:
325
+ self.drain_server_messages()
326
+ if self.process is not None and self.process.poll() is not None:
327
+ raise ProtocolError(f"Grok ACP exited with {self.process.returncode}")
328
+
329
+ readable, _, _ = select.select([self.inbox], [], [], 0.1)
330
+ if not readable:
331
+ continue
332
+ raw = self.inbox.readline()
333
+ if raw == "":
334
+ # Coupled to job-worker.sh's 50 * 0.1s outer grace. The entire
335
+ # ACP cancel/close grace plus close()'s one-second TERM wait
336
+ # must expire before the worker escalates at five seconds.
337
+ self.close_session(time.monotonic() + self.args.close_grace)
338
+ return
339
+
340
+ text = self.decode_mailbox(raw)
341
+ if self.turn_running:
342
+ self.cancel_active_turn()
343
+ self.start_prompt(text)
344
+
345
+ def close(self) -> None:
346
+ if self.inbox is not None:
347
+ self.inbox.close()
348
+ if self.process is None:
349
+ return
350
+
351
+ if self.process.poll() is None and self.session_id is not None:
352
+ try:
353
+ self.close_session(time.monotonic() + min(0.2, self.args.close_grace))
354
+ except (ProtocolError, OSError):
355
+ pass
356
+ if self.process.stdin is not None:
357
+ try:
358
+ self.process.stdin.close()
359
+ except OSError:
360
+ pass
361
+ if self.process.poll() is None:
362
+ try:
363
+ self.process.terminate()
364
+ self.process.wait(timeout=1)
365
+ except (OSError, subprocess.TimeoutExpired):
366
+ try:
367
+ self.process.kill()
368
+ except OSError:
369
+ pass
370
+ self.process.wait()
371
+ if self.process.stdout is not None:
372
+ self.process.stdout.close()
373
+
374
+ def run(self) -> int:
375
+ pathlib.Path(self.args.output).write_text("", encoding="utf-8")
376
+ pathlib.Path(self.args.progress).write_text("", encoding="utf-8")
377
+ self.events = open(self.args.events, "a", encoding="utf-8", buffering=1)
378
+ try:
379
+ self.start_server()
380
+ self.initialize()
381
+ self.run_mailbox()
382
+ return 0 if self.last_turn_succeeded else 1
383
+ except ProtocolError as exc:
384
+ print(f"omnilane: {exc}", file=sys.stderr)
385
+ return 1
386
+ finally:
387
+ self.close()
388
+ self.events.close()
389
+
390
+
391
+ def parse_args() -> argparse.Namespace:
392
+ parser = argparse.ArgumentParser()
393
+ parser.add_argument("--grok-bin", required=True)
394
+ parser.add_argument("--cwd", required=True)
395
+ parser.add_argument("--model", required=True)
396
+ parser.add_argument("--mode", choices=("advise", "work", "sysops"), required=True)
397
+ parser.add_argument("--inbox", required=True)
398
+ parser.add_argument("--events", required=True)
399
+ parser.add_argument("--output", required=True)
400
+ parser.add_argument("--progress", required=True)
401
+ parser.add_argument("--session-id-file", required=True)
402
+ parser.add_argument("--rpc-timeout", type=float, default=10.0)
403
+ # Coupled to job-worker.sh's 50 * 0.1s outer grace: leave time for the
404
+ # one-second ACP process TERM wait before the worker escalates.
405
+ parser.add_argument("--close-grace", type=float, default=3.0)
406
+ return parser.parse_args()
407
+
408
+
409
+ def main() -> int:
410
+ args = parse_args()
411
+ if args.mode != "sysops":
412
+ print(
413
+ f"omnilane: Grok {args.mode} live is unavailable because ACP exposes no "
414
+ "enforceable restricted-mode policy",
415
+ file=sys.stderr,
416
+ )
417
+ return 2
418
+ client = GrokLiveClient(args)
419
+ signal.signal(signal.SIGTERM, client.request_stop)
420
+ signal.signal(signal.SIGHUP, client.request_stop)
421
+ signal.signal(signal.SIGINT, client.request_stop)
422
+ return client.run()
423
+
424
+
425
+ if __name__ == "__main__":
426
+ raise SystemExit(main())
@@ -37,13 +37,120 @@ unset XAI_API_KEY 2>/dev/null || true
37
37
 
38
38
  truncate_payload "$PROMPT_FILE" 140000
39
39
 
40
+ MODE_ARGS=()
41
+ case "$MODE" in
42
+ advise)
43
+ # Native tool allowlisting plus deny rules form the read-only boundary.
44
+ # Keep Bash visible so a requested mutation produces an actual policy
45
+ # denial rather than mere model avoidance; deny wins over allow. Avoid the
46
+ # filesystem sandbox here because it rejects a legitimate symlinked user
47
+ # hooks root before provider startup.
48
+ MODE_ARGS=(
49
+ --permission-mode dontAsk
50
+ # Hosted search uses strict internal IDs, unlike client-tool aliases.
51
+ # Keep permission-rule class names below separate from tool selection.
52
+ --tools 'Bash,Read,Glob,Grep,web_search,web_fetch'
53
+ --allow Read --allow Glob --allow Grep --allow WebSearch --allow WebFetch
54
+ --deny Bash --deny Edit --deny MCPTool
55
+ )
56
+ ;;
57
+ work)
58
+ # Grok child-network restriction is Linux-only. macOS work therefore
59
+ # fails before provider startup instead of silently weakening policy.
60
+ if [[ "$(uname -s)" == "Darwin" ]]; then
61
+ echo "omnilane: Grok work requires enforced agent-tool network isolation; Grok sandbox network isolation is unavailable on macOS (use explicit --mode sysops or another work-capable vendor)" >&2
62
+ exit 2
63
+ fi
64
+ MODE_ARGS=(--permission-mode acceptEdits --sandbox strict --disable-web-search)
65
+ ;;
66
+ sysops)
67
+ MODE_ARGS=(--always-approve --sandbox off)
68
+ ;;
69
+ *)
70
+ echo "omnilane: invalid Grok mode '$MODE'" >&2
71
+ exit 2
72
+ ;;
73
+ esac
74
+
75
+ LIVE_INBOX="${OMNILANE_INBOX:-}"
76
+ if [[ "$MODE" != "sysops" && -n "$LIVE_INBOX" ]]; then
77
+ echo "omnilane: Grok $MODE live is unavailable because ACP exposes no enforceable restricted-mode policy; use single-shot $MODE or explicit --mode sysops" >&2
78
+ exit 2
79
+ fi
80
+ if [[ -n "$LIVE_INBOX" && -p "$LIVE_INBOX" ]]; then
81
+ EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
82
+ STDERR_FILE="${OUTPUT_FILE}.stderr.log"
83
+ PROGRESS_FILE="${OUTPUT_FILE}.progress.log"
84
+ SESSION_ID_FILE="${OUTPUT_FILE}.session-id"
85
+ GROK_LIVE_RUNNER="$(dirname "${BASH_SOURCE[0]}")/run-grok-live.py"
86
+
87
+ for path in "$EVENTS_FILE" "$STDERR_FILE" "$PROGRESS_FILE" "$SESSION_ID_FILE"; do
88
+ if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
89
+ echo "omnilane: unsafe Grok live artifact path" >&2
90
+ exit 125
91
+ fi
92
+ done
93
+ command -v python3 >/dev/null 2>&1 || {
94
+ echo "omnilane: Grok live mode requires python3" >&2
95
+ exit 127
96
+ }
97
+ [[ -f "$GROK_LIVE_RUNNER" ]] || {
98
+ echo "omnilane: Grok live runner missing" >&2
99
+ exit 127
100
+ }
101
+ (umask 077; : > "$EVENTS_FILE"; : > "$STDERR_FILE")
102
+
103
+ set +e
104
+ (
105
+ cd "$WORKDIR" || exit 127
106
+ run_with_timeout "$RUN_TIMEOUT" env -u XAI_API_KEY \
107
+ OMNILANE_DEPTH=1 \
108
+ python3 "$GROK_LIVE_RUNNER" \
109
+ --grok-bin "$GROK_BIN" \
110
+ --cwd "$WORKDIR" \
111
+ --model "$MODEL" \
112
+ --mode "$MODE" \
113
+ --inbox "$LIVE_INBOX" \
114
+ --events "$EVENTS_FILE" \
115
+ --output "$OUTPUT_FILE" \
116
+ --progress "$PROGRESS_FILE" \
117
+ --session-id-file "$SESSION_ID_FILE" \
118
+ 2> "$STDERR_FILE"
119
+ )
120
+ RC=$?
121
+ set -e
122
+ [[ -s "$STDERR_FILE" ]] || rm "$STDERR_FILE" 2>/dev/null || true
123
+ exit "$RC"
124
+ fi
125
+
126
+ # Advise denies MCPTool, so another session's live context-mode MCP is not
127
+ # available to this agent. Give its readiness hook a private job scope instead
128
+ # of letting the hook redirect native WebFetch to an inaccessible MCP tool.
129
+ # This does not disable hooks: their security decisions still run normally.
130
+ if [[ "$MODE" == "advise" ]]; then
131
+ if [[ -n "${CONTEXT_MODE_MCP_SENTINEL_DIR:-}" ]]; then
132
+ echo "omnilane: Grok advise needs its own MCP readiness scope; conflicting CONTEXT_MODE_MCP_SENTINEL_DIR was left unchanged" >&2
133
+ exit 2
134
+ fi
135
+ GROK_MCP_READINESS_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/omnilane-grok-mcp.XXXXXX")" || {
136
+ echo "omnilane: could not create private Grok MCP readiness scope" >&2
137
+ exit 125
138
+ }
139
+ export CONTEXT_MODE_MCP_SENTINEL_DIR="$GROK_MCP_READINESS_DIR"
140
+ # Remove only our empty leaf. Never remove foreign markers or a live server's
141
+ # newly created state if a future Grok version starts an MCP there.
142
+ trap 'rmdir "$GROK_MCP_READINESS_DIR" 2>/dev/null || echo "omnilane: retained nonempty Grok MCP readiness scope: $GROK_MCP_READINESS_DIR" >&2' EXIT
143
+ fi
144
+
40
145
  ARGS=(--cwd "$WORKDIR" --model "$MODEL"
41
- --no-memory --no-subagents --no-plan --no-alt-screen
42
- --output-format plain --verbatim --prompt-file "$PROMPT_FILE")
43
- ARGS+=("${THREAD_ARGS[@]}")
44
- [[ "$MODE" == "advise" ]] && ARGS+=(--permission-mode plan)
45
- # Web/X search stays ON by default — it is this vendor's signature lane.
46
- [[ "${OMNILANE_GROK_NO_WEB:-0}" == "1" ]] && ARGS+=(--disable-web-search)
146
+ --no-memory --no-subagents --no-plan --no-alt-screen
147
+ --output-format plain --verbatim --prompt-file "$PROMPT_FILE")
148
+ [[ ${#THREAD_ARGS[@]} -eq 0 ]] || ARGS+=("${THREAD_ARGS[@]}")
149
+ ARGS+=("${MODE_ARGS[@]}")
150
+ # Web/X search stays ON by default for advise and sysops.
151
+ if [[ "$MODE" != "work" && "${OMNILANE_GROK_NO_WEB:-0}" == "1" ]]; then
152
+ ARGS+=(--disable-web-search)
153
+ fi
47
154
 
48
155
  # Grok intermittently emits empty output on large inputs; retry until it speaks.
49
156
  RC=0; attempt=1
@@ -30,9 +30,9 @@ trap cleanup_temp_files EXIT
30
30
 
31
31
  voter_spec() { # vendor -> "model<TAB>effort"
32
32
  case "$1" in
33
- codex) printf 'gpt-5.6-sol\thigh' ;;
34
- claude) printf 'claude-fable-5-1\thigh' ;;
35
- gemini) printf 'Gemini 3.7 Flash (High)\t-' ;;
33
+ codex) printf 'gpt-6-astra\txhigh' ;;
34
+ claude) printf 'claude-fable-5-1\txhigh' ;;
35
+ gemini) printf 'gemini-3.8-flash-medium\t-' ;;
36
36
  grok) printf 'grok-4.6\t-' ;;
37
37
  *) return 1 ;;
38
38
  esac