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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +47 -1
- package/README.ja.md +44 -33
- package/README.ko.md +44 -32
- package/README.md +71 -77
- package/README.zh-CN.md +42 -30
- package/README.zh-TW.md +65 -68
- package/VERSION +1 -1
- package/docs/aa-model-coverage-2026-09-05.json +29204 -0
- package/docs/model-capabilities-2026-09.md +380 -0
- package/package.json +3 -1
- package/plugin.json +1 -1
- package/routing.local.yaml.example +8 -3
- package/routing.yaml +16 -16
- package/scripts/configure.sh +4 -4
- package/scripts/dispatch.sh +83 -14
- package/scripts/doctor.sh +55 -1
- package/scripts/jobs.sh +6 -2
- package/scripts/lib/common.sh +47 -1
- package/scripts/lib/job-worker.sh +312 -20
- package/scripts/lib/live-protocol.sh +147 -2
- package/scripts/lib/normalize-claude-stream.py +72 -0
- package/scripts/lib/prepare-agy-mode.py +374 -0
- package/scripts/release-audit.sh +103 -0
- package/scripts/runners/run-claude.sh +81 -47
- package/scripts/runners/run-codex-live.py +462 -0
- package/scripts/runners/run-codex.sh +62 -3
- package/scripts/runners/run-gemini.sh +85 -10
- package/scripts/runners/run-grok-live.py +426 -0
- package/scripts/runners/run-grok.sh +113 -6
- package/scripts/runners/run-vote.sh +3 -3
- package/skills/omnilane/SKILL.md +106 -59
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
ARGS+=("${THREAD_ARGS[@]}")
|
|
44
|
-
|
|
45
|
-
# Web/X search stays ON by default
|
|
46
|
-
[[ "${OMNILANE_GROK_NO_WEB:-0}" == "1" ]]
|
|
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-
|
|
34
|
-
claude) printf 'claude-fable-5-1\
|
|
35
|
-
gemini) printf '
|
|
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
|