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,462 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Long-lived Codex app-server 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 codex_app_server_argv(codex_bin: str, sandbox: str) -> list[str]:
|
|
25
|
+
"""Pin every mode-relevant control before app-server starts."""
|
|
26
|
+
config = ['approval_policy="never"', f'sandbox_mode="{sandbox}"']
|
|
27
|
+
if sandbox == "workspace-write":
|
|
28
|
+
config.extend(
|
|
29
|
+
[
|
|
30
|
+
'web_search="disabled"',
|
|
31
|
+
"sandbox_workspace_write.network_access=false",
|
|
32
|
+
"sandbox_workspace_write.exclude_slash_tmp=true",
|
|
33
|
+
"sandbox_workspace_write.exclude_tmpdir_env_var=true",
|
|
34
|
+
"sandbox_workspace_write.writable_roots=[]",
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
elif sandbox in {"read-only", "danger-full-access"}:
|
|
38
|
+
config.append('web_search="live"')
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(f"unsupported Codex sandbox: {sandbox}")
|
|
41
|
+
|
|
42
|
+
argv = [codex_bin]
|
|
43
|
+
for value in config:
|
|
44
|
+
argv.extend(["-c", value])
|
|
45
|
+
argv.append("app-server")
|
|
46
|
+
return argv
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CodexLiveClient:
|
|
50
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
51
|
+
self.args = args
|
|
52
|
+
self.process: subprocess.Popen[str] | None = None
|
|
53
|
+
self.events: Any = None
|
|
54
|
+
self.inbox: Any = None
|
|
55
|
+
self.messages: queue.Queue[tuple[str, str | None]] = queue.Queue()
|
|
56
|
+
self.next_request_id = 1
|
|
57
|
+
self.thread_id: str | None = None
|
|
58
|
+
self.turn_id: str | None = None
|
|
59
|
+
self.last_turn_succeeded = False
|
|
60
|
+
self.stop_requested = False
|
|
61
|
+
self.server_stdout_eof = threading.Event()
|
|
62
|
+
|
|
63
|
+
def request_stop(self, _signum: int, _frame: Any) -> None:
|
|
64
|
+
self.stop_requested = True
|
|
65
|
+
|
|
66
|
+
def start_server(self) -> None:
|
|
67
|
+
self.process = subprocess.Popen(
|
|
68
|
+
codex_app_server_argv(self.args.codex_bin, getattr(self.args, "sandbox", "workspace-write")),
|
|
69
|
+
cwd=self.args.cwd,
|
|
70
|
+
stdin=subprocess.PIPE,
|
|
71
|
+
stdout=subprocess.PIPE,
|
|
72
|
+
stderr=None,
|
|
73
|
+
text=True,
|
|
74
|
+
bufsize=1,
|
|
75
|
+
# The outer watchdog owns this process group. Do not split the
|
|
76
|
+
# app-server into a session that survives if this client is
|
|
77
|
+
# SIGKILLed before its finally block can run.
|
|
78
|
+
start_new_session=False,
|
|
79
|
+
)
|
|
80
|
+
threading.Thread(target=self.read_server, name="codex-app-server", daemon=True).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.server_stdout_eof.set()
|
|
89
|
+
self.messages.put(("eof", None))
|
|
90
|
+
|
|
91
|
+
def send(self, method: str, params: dict[str, Any]) -> int:
|
|
92
|
+
assert self.process is not None and self.process.stdin is not None
|
|
93
|
+
request_id = self.next_request_id
|
|
94
|
+
self.next_request_id += 1
|
|
95
|
+
request = {
|
|
96
|
+
"jsonrpc": "2.0",
|
|
97
|
+
"id": request_id,
|
|
98
|
+
"method": method,
|
|
99
|
+
"params": params,
|
|
100
|
+
}
|
|
101
|
+
try:
|
|
102
|
+
self.process.stdin.write(json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
103
|
+
self.process.stdin.flush()
|
|
104
|
+
except (BrokenPipeError, OSError) as exc:
|
|
105
|
+
raise ProtocolError(f"Codex app-server stdin closed during {method}") from exc
|
|
106
|
+
return request_id
|
|
107
|
+
|
|
108
|
+
def record_event(self, raw: str) -> dict[str, Any] | None:
|
|
109
|
+
assert self.events is not None
|
|
110
|
+
self.events.write(raw if raw.endswith("\n") else raw + "\n")
|
|
111
|
+
self.events.flush()
|
|
112
|
+
try:
|
|
113
|
+
message = json.loads(raw)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return None
|
|
116
|
+
if not isinstance(message, dict):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
if message.get("method") == "item/completed":
|
|
120
|
+
params = message.get("params")
|
|
121
|
+
item = params.get("item") if isinstance(params, dict) else None
|
|
122
|
+
if isinstance(item, dict) and item.get("type") == "agentMessage":
|
|
123
|
+
text = item.get("text")
|
|
124
|
+
if isinstance(text, str):
|
|
125
|
+
self.append_agent_text(text)
|
|
126
|
+
|
|
127
|
+
if message.get("method") == "turn/completed":
|
|
128
|
+
params = message.get("params")
|
|
129
|
+
turn = params.get("turn") if isinstance(params, dict) else None
|
|
130
|
+
if isinstance(turn, dict) and turn.get("id") == self.turn_id:
|
|
131
|
+
self.last_turn_succeeded = turn.get("status") == "completed" and turn.get("error") is None
|
|
132
|
+
self.turn_id = None
|
|
133
|
+
return message
|
|
134
|
+
|
|
135
|
+
def append_agent_text(self, text: str) -> None:
|
|
136
|
+
with open(self.args.output, "a", encoding="utf-8") as output:
|
|
137
|
+
output.write(text)
|
|
138
|
+
if not text.endswith("\n"):
|
|
139
|
+
output.write("\n")
|
|
140
|
+
output.flush()
|
|
141
|
+
os.fsync(output.fileno())
|
|
142
|
+
|
|
143
|
+
def receive(self, timeout: float) -> dict[str, Any] | None:
|
|
144
|
+
try:
|
|
145
|
+
kind, raw = self.messages.get(timeout=timeout)
|
|
146
|
+
except queue.Empty:
|
|
147
|
+
return None
|
|
148
|
+
if kind == "eof":
|
|
149
|
+
raise ProtocolError("Codex app-server stdout closed")
|
|
150
|
+
assert raw is not None
|
|
151
|
+
return self.record_event(raw)
|
|
152
|
+
|
|
153
|
+
def wait_response(self, request_id: int, method: str) -> dict[str, Any]:
|
|
154
|
+
deadline = time.monotonic() + self.args.rpc_timeout
|
|
155
|
+
while not self.stop_requested and time.monotonic() < deadline:
|
|
156
|
+
message = self.receive(min(0.2, max(0.0, deadline - time.monotonic())))
|
|
157
|
+
if message is None or message.get("id") != request_id:
|
|
158
|
+
continue
|
|
159
|
+
result = message.get("result")
|
|
160
|
+
if not isinstance(result, dict):
|
|
161
|
+
error = message.get("error")
|
|
162
|
+
raise ProtocolError(f"Codex app-server rejected {method}: {error!r}")
|
|
163
|
+
return result
|
|
164
|
+
raise ProtocolError(f"Codex app-server timed out during {method}")
|
|
165
|
+
|
|
166
|
+
def initialize(self) -> None:
|
|
167
|
+
request_id = self.send(
|
|
168
|
+
"initialize",
|
|
169
|
+
{"clientInfo": {"name": "omnilane", "version": self.args.version}},
|
|
170
|
+
)
|
|
171
|
+
self.wait_response(request_id, "initialize")
|
|
172
|
+
|
|
173
|
+
params: dict[str, Any] = {
|
|
174
|
+
"cwd": self.args.cwd,
|
|
175
|
+
"model": self.args.model,
|
|
176
|
+
"sandbox": self.args.sandbox,
|
|
177
|
+
}
|
|
178
|
+
request_id = self.send("thread/start", params)
|
|
179
|
+
result = self.wait_response(request_id, "thread/start")
|
|
180
|
+
thread = result.get("thread")
|
|
181
|
+
thread_id = thread.get("id") if isinstance(thread, dict) else None
|
|
182
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
183
|
+
raise ProtocolError("Codex thread/start response omitted thread.id")
|
|
184
|
+
self.thread_id = thread_id
|
|
185
|
+
self.record_thread_id(thread_id)
|
|
186
|
+
|
|
187
|
+
def record_thread_id(self, thread_id: str) -> None:
|
|
188
|
+
session_path = pathlib.Path(self.args.session_id_file)
|
|
189
|
+
session_path.write_text(thread_id + "\n", encoding="utf-8")
|
|
190
|
+
session_path.chmod(0o600)
|
|
191
|
+
progress = {
|
|
192
|
+
"type": "thread.started",
|
|
193
|
+
"thread_id": thread_id,
|
|
194
|
+
}
|
|
195
|
+
pathlib.Path(self.args.progress).write_text(
|
|
196
|
+
json.dumps(progress, separators=(",", ":")) + "\n",
|
|
197
|
+
encoding="utf-8",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def decode_mailbox(raw: str) -> str:
|
|
202
|
+
try:
|
|
203
|
+
payload = json.loads(raw)
|
|
204
|
+
except json.JSONDecodeError as exc:
|
|
205
|
+
raise ProtocolError("Codex live mailbox received invalid JSON") from exc
|
|
206
|
+
if not isinstance(payload, dict) or payload.get("type") != "codex-user":
|
|
207
|
+
raise ProtocolError("Codex live mailbox received an invalid payload type")
|
|
208
|
+
text = payload.get("text")
|
|
209
|
+
if not isinstance(text, str):
|
|
210
|
+
raise ProtocolError("Codex live mailbox payload omitted text")
|
|
211
|
+
return text
|
|
212
|
+
|
|
213
|
+
def start_turn(self, text: str) -> None:
|
|
214
|
+
assert self.thread_id is not None
|
|
215
|
+
params: dict[str, Any] = {
|
|
216
|
+
"threadId": self.thread_id,
|
|
217
|
+
"input": [{"type": "text", "text": text}],
|
|
218
|
+
}
|
|
219
|
+
if self.args.effort and self.args.effort != "-":
|
|
220
|
+
params["effort"] = self.args.effort
|
|
221
|
+
request_id = self.send("turn/start", params)
|
|
222
|
+
result = self.wait_response(request_id, "turn/start")
|
|
223
|
+
turn = result.get("turn")
|
|
224
|
+
turn_id = turn.get("id") if isinstance(turn, dict) else None
|
|
225
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
226
|
+
raise ProtocolError("Codex turn/start response omitted turn.id")
|
|
227
|
+
self.turn_id = turn_id
|
|
228
|
+
self.last_turn_succeeded = False
|
|
229
|
+
|
|
230
|
+
def steer_turn(self, text: str) -> None:
|
|
231
|
+
assert self.thread_id is not None and self.turn_id is not None
|
|
232
|
+
expected_turn_id = self.turn_id
|
|
233
|
+
params = {
|
|
234
|
+
"threadId": self.thread_id,
|
|
235
|
+
"expectedTurnId": expected_turn_id,
|
|
236
|
+
"input": [{"type": "text", "text": text}],
|
|
237
|
+
}
|
|
238
|
+
request_id = self.send("turn/steer", params)
|
|
239
|
+
result = self.wait_response(request_id, "turn/steer")
|
|
240
|
+
returned_turn_id = result.get("turnId")
|
|
241
|
+
if returned_turn_id != expected_turn_id:
|
|
242
|
+
raise ProtocolError("Codex turn/steer response changed the active turn id")
|
|
243
|
+
|
|
244
|
+
def run_mailbox(self) -> None:
|
|
245
|
+
self.inbox = open(self.args.inbox, "r", encoding="utf-8")
|
|
246
|
+
eof_deadline: float | None = None
|
|
247
|
+
while not self.stop_requested:
|
|
248
|
+
while True:
|
|
249
|
+
try:
|
|
250
|
+
kind, raw = self.messages.get_nowait()
|
|
251
|
+
except queue.Empty:
|
|
252
|
+
break
|
|
253
|
+
if kind == "eof":
|
|
254
|
+
raise ProtocolError("Codex app-server stdout closed")
|
|
255
|
+
assert raw is not None
|
|
256
|
+
self.record_event(raw)
|
|
257
|
+
|
|
258
|
+
if eof_deadline is not None:
|
|
259
|
+
if self.turn_id is None:
|
|
260
|
+
return
|
|
261
|
+
if time.monotonic() >= eof_deadline:
|
|
262
|
+
raise ProtocolError("Codex turn did not finish before live close deadline")
|
|
263
|
+
self.receive(0.1)
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
ready, _, _ = select.select([self.inbox], [], [], 0.1)
|
|
267
|
+
if not ready:
|
|
268
|
+
if self.process is not None and self.process.poll() is not None:
|
|
269
|
+
raise ProtocolError(f"Codex app-server exited {self.process.returncode}")
|
|
270
|
+
continue
|
|
271
|
+
raw = self.inbox.readline()
|
|
272
|
+
if raw == "":
|
|
273
|
+
eof_deadline = time.monotonic() + self.args.close_grace
|
|
274
|
+
continue
|
|
275
|
+
text = self.decode_mailbox(raw)
|
|
276
|
+
if self.turn_id is None:
|
|
277
|
+
self.start_turn(text)
|
|
278
|
+
else:
|
|
279
|
+
self.steer_turn(text)
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def process_identity(pid: int) -> str | None:
|
|
283
|
+
"""Return a stable start-time identity so a recycled PID is never signalled."""
|
|
284
|
+
try:
|
|
285
|
+
result = subprocess.run(
|
|
286
|
+
["ps", "-o", "state=,lstart=", "-p", str(pid)],
|
|
287
|
+
text=True,
|
|
288
|
+
capture_output=True,
|
|
289
|
+
check=False,
|
|
290
|
+
timeout=1,
|
|
291
|
+
)
|
|
292
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
293
|
+
return None
|
|
294
|
+
value = result.stdout.strip()
|
|
295
|
+
if result.returncode != 0 or not value:
|
|
296
|
+
return None
|
|
297
|
+
state, _, identity = value.partition(" ")
|
|
298
|
+
if state.startswith("Z") or not identity.strip():
|
|
299
|
+
return None
|
|
300
|
+
return " ".join(identity.split())
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def snapshot_descendants(cls, root_pid: int) -> dict[int, str]:
|
|
304
|
+
"""Snapshot descendants before app-server EOF can reparent its helpers."""
|
|
305
|
+
try:
|
|
306
|
+
result = subprocess.run(
|
|
307
|
+
["ps", "-axo", "pid=,ppid=,lstart="],
|
|
308
|
+
text=True,
|
|
309
|
+
capture_output=True,
|
|
310
|
+
check=False,
|
|
311
|
+
timeout=1,
|
|
312
|
+
)
|
|
313
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
314
|
+
return {}
|
|
315
|
+
if result.returncode != 0:
|
|
316
|
+
return {}
|
|
317
|
+
children: dict[int, list[int]] = {}
|
|
318
|
+
identities: dict[int, str] = {}
|
|
319
|
+
for line in result.stdout.splitlines():
|
|
320
|
+
parts = line.split(None, 7)
|
|
321
|
+
if len(parts) < 7:
|
|
322
|
+
continue
|
|
323
|
+
try:
|
|
324
|
+
pid, ppid = int(parts[0]), int(parts[1])
|
|
325
|
+
except ValueError:
|
|
326
|
+
continue
|
|
327
|
+
children.setdefault(ppid, []).append(pid)
|
|
328
|
+
identities[pid] = " ".join(parts[2:7])
|
|
329
|
+
found: dict[int, str] = {}
|
|
330
|
+
pending = [root_pid]
|
|
331
|
+
while pending:
|
|
332
|
+
parent = pending.pop()
|
|
333
|
+
for pid in children.get(parent, []):
|
|
334
|
+
if pid in found:
|
|
335
|
+
continue
|
|
336
|
+
identity = identities.get(pid)
|
|
337
|
+
if identity:
|
|
338
|
+
found[pid] = identity
|
|
339
|
+
pending.append(pid)
|
|
340
|
+
return found
|
|
341
|
+
|
|
342
|
+
@classmethod
|
|
343
|
+
def terminate_tracked_descendants(cls, tracked: dict[int, str]) -> None:
|
|
344
|
+
"""Reap app-server helpers that remain after their parent exits."""
|
|
345
|
+
alive: list[int] = []
|
|
346
|
+
for pid, identity in tracked.items():
|
|
347
|
+
if cls.process_identity(pid) != identity:
|
|
348
|
+
continue
|
|
349
|
+
try:
|
|
350
|
+
os.kill(pid, signal.SIGTERM)
|
|
351
|
+
alive.append(pid)
|
|
352
|
+
except (ProcessLookupError, PermissionError):
|
|
353
|
+
pass
|
|
354
|
+
if alive:
|
|
355
|
+
time.sleep(0.05)
|
|
356
|
+
survivors: list[int] = []
|
|
357
|
+
for pid in alive:
|
|
358
|
+
identity = tracked[pid]
|
|
359
|
+
if cls.process_identity(pid) != identity:
|
|
360
|
+
continue
|
|
361
|
+
try:
|
|
362
|
+
os.kill(pid, signal.SIGKILL)
|
|
363
|
+
survivors.append(pid)
|
|
364
|
+
except (ProcessLookupError, PermissionError):
|
|
365
|
+
pass
|
|
366
|
+
if survivors:
|
|
367
|
+
# Give init a bounded chance to reap detached grandchildren.
|
|
368
|
+
time.sleep(0.05)
|
|
369
|
+
|
|
370
|
+
def wait_for_server_exit(self, timeout: float) -> bool:
|
|
371
|
+
assert self.process is not None
|
|
372
|
+
try:
|
|
373
|
+
self.process.wait(timeout=timeout)
|
|
374
|
+
return True
|
|
375
|
+
except subprocess.TimeoutExpired:
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def close(self) -> None:
|
|
379
|
+
if self.inbox is not None:
|
|
380
|
+
self.inbox.close()
|
|
381
|
+
if self.process is None:
|
|
382
|
+
return
|
|
383
|
+
tracked_descendants = self.snapshot_descendants(self.process.pid)
|
|
384
|
+
if self.process.stdin is not None:
|
|
385
|
+
try:
|
|
386
|
+
self.process.stdin.close()
|
|
387
|
+
except OSError:
|
|
388
|
+
pass
|
|
389
|
+
if self.process.poll() is None and not self.wait_for_server_exit(
|
|
390
|
+
self.args.app_server_eof_grace
|
|
391
|
+
):
|
|
392
|
+
try:
|
|
393
|
+
self.process.terminate()
|
|
394
|
+
except OSError:
|
|
395
|
+
pass
|
|
396
|
+
if self.process.poll() is None and not self.wait_for_server_exit(
|
|
397
|
+
self.args.app_server_term_grace
|
|
398
|
+
):
|
|
399
|
+
try:
|
|
400
|
+
self.process.kill()
|
|
401
|
+
except OSError:
|
|
402
|
+
pass
|
|
403
|
+
if self.process.poll() is None:
|
|
404
|
+
self.wait_for_server_exit(self.args.app_server_kill_grace)
|
|
405
|
+
# The reader thread owns stdout. A sidecar may inherit that pipe even
|
|
406
|
+
# after app-server exits, so closing it here can block on the reader's
|
|
407
|
+
# file-object lock and defeat the bounded shutdown contract.
|
|
408
|
+
if self.process.stdout is not None and self.server_stdout_eof.is_set():
|
|
409
|
+
self.process.stdout.close()
|
|
410
|
+
self.terminate_tracked_descendants(tracked_descendants)
|
|
411
|
+
|
|
412
|
+
def run(self) -> int:
|
|
413
|
+
pathlib.Path(self.args.output).write_text("", encoding="utf-8")
|
|
414
|
+
pathlib.Path(self.args.progress).write_text("", encoding="utf-8")
|
|
415
|
+
self.events = open(self.args.events, "a", encoding="utf-8", buffering=1)
|
|
416
|
+
try:
|
|
417
|
+
self.start_server()
|
|
418
|
+
self.initialize()
|
|
419
|
+
self.run_mailbox()
|
|
420
|
+
return 0 if self.last_turn_succeeded else 1
|
|
421
|
+
except ProtocolError as exc:
|
|
422
|
+
print(f"omnilane: {exc}", file=sys.stderr)
|
|
423
|
+
return 1
|
|
424
|
+
finally:
|
|
425
|
+
self.close()
|
|
426
|
+
self.events.close()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def parse_args() -> argparse.Namespace:
|
|
430
|
+
parser = argparse.ArgumentParser()
|
|
431
|
+
parser.add_argument("--codex-bin", required=True)
|
|
432
|
+
parser.add_argument("--cwd", required=True)
|
|
433
|
+
parser.add_argument("--model", required=True)
|
|
434
|
+
parser.add_argument("--effort", required=True)
|
|
435
|
+
parser.add_argument("--sandbox", required=True)
|
|
436
|
+
parser.add_argument("--inbox", required=True)
|
|
437
|
+
parser.add_argument("--events", required=True)
|
|
438
|
+
parser.add_argument("--output", required=True)
|
|
439
|
+
parser.add_argument("--progress", required=True)
|
|
440
|
+
parser.add_argument("--session-id-file", required=True)
|
|
441
|
+
parser.add_argument("--version", default="0.41.1")
|
|
442
|
+
parser.add_argument("--rpc-timeout", type=float, default=10.0)
|
|
443
|
+
# Coupled to job-worker.sh's 100 * 0.1s outer grace. The default turn,
|
|
444
|
+
# EOF, TERM, and KILL waits total seven seconds below that boundary.
|
|
445
|
+
parser.add_argument("--close-grace", type=float, default=3.0)
|
|
446
|
+
parser.add_argument("--app-server-eof-grace", type=float, default=2.0)
|
|
447
|
+
parser.add_argument("--app-server-term-grace", type=float, default=1.0)
|
|
448
|
+
parser.add_argument("--app-server-kill-grace", type=float, default=1.0)
|
|
449
|
+
return parser.parse_args()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main() -> int:
|
|
453
|
+
args = parse_args()
|
|
454
|
+
client = CodexLiveClient(args)
|
|
455
|
+
signal.signal(signal.SIGTERM, client.request_stop)
|
|
456
|
+
signal.signal(signal.SIGHUP, client.request_stop)
|
|
457
|
+
signal.signal(signal.SIGINT, client.request_stop)
|
|
458
|
+
return client.run()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__":
|
|
462
|
+
raise SystemExit(main())
|
|
@@ -38,19 +38,78 @@ else
|
|
|
38
38
|
ARGS=(exec --json -m "$MODEL" -o "${OUTPUT_FILE}.tmp" --skip-git-repo-check)
|
|
39
39
|
fi
|
|
40
40
|
[[ -n "$EFFORT" && "$EFFORT" != "-" ]] && ARGS+=(-c "model_reasoning_effort=\"$EFFORT\"")
|
|
41
|
+
ARGS+=(-c 'approval_policy="never"')
|
|
41
42
|
if [[ "$MODE" == "advise" ]]; then
|
|
42
43
|
[[ -z "$THREAD_MODE" ]] && ARGS+=(--ephemeral)
|
|
43
44
|
SANDBOX=read-only
|
|
45
|
+
ARGS+=(-c 'web_search="live"')
|
|
44
46
|
elif [[ "$MODE" == "sysops" ]]; then
|
|
45
47
|
SANDBOX=danger-full-access
|
|
48
|
+
ARGS+=(-c 'web_search="live"')
|
|
46
49
|
else
|
|
47
50
|
SANDBOX=workspace-write
|
|
51
|
+
ARGS+=(
|
|
52
|
+
-c 'web_search="disabled"'
|
|
53
|
+
-c 'sandbox_workspace_write.network_access=false'
|
|
54
|
+
-c 'sandbox_workspace_write.exclude_slash_tmp=true'
|
|
55
|
+
-c 'sandbox_workspace_write.exclude_tmpdir_env_var=true'
|
|
56
|
+
-c 'sandbox_workspace_write.writable_roots=[]'
|
|
57
|
+
)
|
|
58
|
+
fi
|
|
59
|
+
ARGS+=(-c "sandbox_mode=\"$SANDBOX\"")
|
|
60
|
+
|
|
61
|
+
LIVE_INBOX="${OMNILANE_INBOX:-}"
|
|
62
|
+
if [[ -n "$LIVE_INBOX" && -p "$LIVE_INBOX" ]]; then
|
|
63
|
+
EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
|
|
64
|
+
STDERR_FILE="${OUTPUT_FILE}.stderr.log"
|
|
65
|
+
PROGRESS_FILE="${OUTPUT_FILE}.progress.log"
|
|
66
|
+
SESSION_ID_FILE="${OUTPUT_FILE}.session-id"
|
|
67
|
+
CODEX_LIVE_RUNNER="$(dirname "${BASH_SOURCE[0]}")/run-codex-live.py"
|
|
68
|
+
|
|
69
|
+
for path in "$EVENTS_FILE" "$STDERR_FILE" "$PROGRESS_FILE" "$SESSION_ID_FILE"; do
|
|
70
|
+
if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
|
|
71
|
+
echo "omnilane: unsafe Codex live artifact path" >&2
|
|
72
|
+
exit 125
|
|
73
|
+
fi
|
|
74
|
+
done
|
|
75
|
+
command -v python3 >/dev/null 2>&1 || {
|
|
76
|
+
echo "omnilane: Codex live mode requires python3" >&2
|
|
77
|
+
exit 127
|
|
78
|
+
}
|
|
79
|
+
[[ -f "$CODEX_LIVE_RUNNER" ]] || {
|
|
80
|
+
echo "omnilane: Codex live runner is missing" >&2
|
|
81
|
+
exit 127
|
|
82
|
+
}
|
|
83
|
+
truncate_payload "$PROMPT_FILE" 140000
|
|
84
|
+
(umask 077; : > "$EVENTS_FILE"; : > "$STDERR_FILE")
|
|
85
|
+
|
|
86
|
+
set +e
|
|
87
|
+
(
|
|
88
|
+
cd "$WORKDIR" || exit 127
|
|
89
|
+
run_with_timeout "$RUN_TIMEOUT" env \
|
|
90
|
+
-u OPENAI_API_KEY -u OPENAI_ORG_ID -u OPENAI_ORGANIZATION -u OPENAI_PROJECT -u OPENAI_API_BASE \
|
|
91
|
+
OMNILANE_DEPTH=1 \
|
|
92
|
+
python3 "$CODEX_LIVE_RUNNER" \
|
|
93
|
+
--codex-bin "$CODEX_BIN" \
|
|
94
|
+
--cwd "$WORKDIR" \
|
|
95
|
+
--model "$MODEL" \
|
|
96
|
+
--effort "$EFFORT" \
|
|
97
|
+
--sandbox "$SANDBOX" \
|
|
98
|
+
--inbox "$LIVE_INBOX" \
|
|
99
|
+
--events "$EVENTS_FILE" \
|
|
100
|
+
--output "$OUTPUT_FILE" \
|
|
101
|
+
--progress "$PROGRESS_FILE" \
|
|
102
|
+
--session-id-file "$SESSION_ID_FILE" \
|
|
103
|
+
2> "$STDERR_FILE"
|
|
104
|
+
)
|
|
105
|
+
RC=$?
|
|
106
|
+
set -e
|
|
107
|
+
[[ -s "$STDERR_FILE" ]] || rm "$STDERR_FILE" 2>/dev/null || true
|
|
108
|
+
exit "$RC"
|
|
48
109
|
fi
|
|
49
110
|
# `codex exec resume` has no -s/--sandbox flag (rejects it with exit 2); the
|
|
50
111
|
# same policy is only reachable there through the sandbox_mode config override.
|
|
51
|
-
if [[ "$THREAD_MODE"
|
|
52
|
-
ARGS+=(-c "sandbox_mode=\"$SANDBOX\"")
|
|
53
|
-
else
|
|
112
|
+
if [[ "$THREAD_MODE" != "resume" ]]; then
|
|
54
113
|
ARGS+=(-s "$SANDBOX")
|
|
55
114
|
fi
|
|
56
115
|
[[ "$THREAD_MODE" == "resume" ]] && ARGS+=("$THREAD_ID" -)
|
|
@@ -31,6 +31,58 @@ fi
|
|
|
31
31
|
|
|
32
32
|
truncate_payload "$PROMPT_FILE" 140000
|
|
33
33
|
|
|
34
|
+
WORKDIR="$(cd -- "$WORKDIR" && pwd -P)" || {
|
|
35
|
+
echo "omnilane: Gemini workdir is not accessible" >&2
|
|
36
|
+
exit 2
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# agy 1.1.27 loads per-run CLI settings from the hidden app_data_dir
|
|
40
|
+
# interface while leaving GeminiDir (and subscription auth) unchanged. Keep
|
|
41
|
+
# one stable root for a named thread; anonymous jobs use their output path.
|
|
42
|
+
AGY_PREPARE="$OMNILANE_REPO/scripts/lib/prepare-agy-mode.py"
|
|
43
|
+
[[ -f "$AGY_PREPARE" ]] || {
|
|
44
|
+
echo "omnilane: Gemini mode policy helper missing" >&2
|
|
45
|
+
exit 127
|
|
46
|
+
}
|
|
47
|
+
if [[ -n "${OMNILANE_THREAD_NAME:-}" ]]; then
|
|
48
|
+
AGY_APP_KEY="thread-v1-${MODE}-$(printf '%s' "$OMNILANE_THREAD_NAME" | hash_str)"
|
|
49
|
+
else
|
|
50
|
+
AGY_APP_KEY="job-v1-${MODE}-$(printf '%s' "$OUTPUT_FILE" | hash_str)"
|
|
51
|
+
fi
|
|
52
|
+
AGY_APP_ROOT="$OMNILANE_HOME/agy-app/$AGY_APP_KEY"
|
|
53
|
+
AGY_APP_DATA_REL="$(python3 "$AGY_PREPARE" \
|
|
54
|
+
--mode "$MODE" --workdir "$WORKDIR" --app-root "$AGY_APP_ROOT" \
|
|
55
|
+
--gemini-dir "$HOME/.gemini")" || {
|
|
56
|
+
echo "omnilane: Gemini isolated mode settings could not be prepared" >&2
|
|
57
|
+
exit 2
|
|
58
|
+
}
|
|
59
|
+
APP_DATA_ARGS=("--app_data_dir=$AGY_APP_DATA_REL")
|
|
60
|
+
AGY_WORK_ENV=()
|
|
61
|
+
if [[ "$MODE" == "work" ]]; then
|
|
62
|
+
agy_cleanup_workspace_policy() {
|
|
63
|
+
local previous_rc=$?
|
|
64
|
+
trap - EXIT
|
|
65
|
+
if ! python3 "$AGY_PREPARE" --cleanup --mode "$MODE" --workdir "$WORKDIR" \
|
|
66
|
+
--app-root "$AGY_APP_ROOT" --gemini-dir "$HOME/.gemini"; then
|
|
67
|
+
echo "omnilane: agy workspace policy cleanup/integrity check failed; inspect owned leaf" >&2
|
|
68
|
+
[[ "$previous_rc" -ne 0 ]] || previous_rc=125
|
|
69
|
+
fi
|
|
70
|
+
exit "$previous_rc"
|
|
71
|
+
}
|
|
72
|
+
trap agy_cleanup_workspace_policy EXIT
|
|
73
|
+
AGY_WORK_META="$(python3 - "$AGY_APP_ROOT/workspace-agent.json" <<'PY'
|
|
74
|
+
import json, pathlib, sys
|
|
75
|
+
state = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
76
|
+
print(state["agent"])
|
|
77
|
+
print(state["cache"])
|
|
78
|
+
PY
|
|
79
|
+
)"
|
|
80
|
+
AGY_AGENT_NAME="${AGY_WORK_META%%$'\n'*}"
|
|
81
|
+
AGY_CACHE_ROOT="${AGY_WORK_META#*$'\n'}"
|
|
82
|
+
AGY_WORK_ENV=("TMPDIR=$AGY_CACHE_ROOT/tmp" "XDG_CACHE_HOME=$AGY_CACHE_ROOT/cache"
|
|
83
|
+
"CLANG_MODULE_CACHE_PATH=$AGY_CACHE_ROOT/clang" "SWIFT_MODULECACHE_PATH=$AGY_CACHE_ROOT/swift")
|
|
84
|
+
fi
|
|
85
|
+
|
|
34
86
|
# Both modes run inside the target WORKDIR so the worker can actually see the
|
|
35
87
|
# repo it is asked about. Tradeoff: repo-level agent personas may color advise
|
|
36
88
|
# answers; set OMNILANE_GEMINI_SCRATCH=1 to run advise in a neutral scratch dir.
|
|
@@ -44,9 +96,26 @@ fi
|
|
|
44
96
|
MODEL_ARGS=()
|
|
45
97
|
[[ -n "$MODEL" && "$MODEL" != "-" ]] && MODEL_ARGS=(--model "$MODEL")
|
|
46
98
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
MODE_ARGS=()
|
|
100
|
+
case "$MODE" in
|
|
101
|
+
advise)
|
|
102
|
+
# A private per-job settings file carries the permission policy; --sandbox
|
|
103
|
+
# supplies the native terminal sandbox for restricted modes.
|
|
104
|
+
MODE_ARGS=(--sandbox)
|
|
105
|
+
;;
|
|
106
|
+
work)
|
|
107
|
+
MODE_ARGS=(--sandbox --agent "$AGY_AGENT_NAME")
|
|
108
|
+
;;
|
|
109
|
+
sysops)
|
|
110
|
+
# Explicit opt-in: bypass approval prompts. The private settings file also
|
|
111
|
+
# selects always-proceed and disables the terminal sandbox for this job.
|
|
112
|
+
MODE_ARGS=(--mode accept-edits --dangerously-skip-permissions)
|
|
113
|
+
;;
|
|
114
|
+
*)
|
|
115
|
+
echo "omnilane: invalid Gemini mode (advise|work|sysops)" >&2
|
|
116
|
+
exit 2
|
|
117
|
+
;;
|
|
118
|
+
esac
|
|
50
119
|
|
|
51
120
|
LIVE_INBOX="${OMNILANE_INBOX:-}"
|
|
52
121
|
if [[ -n "$LIVE_INBOX" && -p "$LIVE_INBOX" ]]; then
|
|
@@ -129,8 +198,8 @@ PY
|
|
|
129
198
|
cd "$RUN_DIR" || exit 127
|
|
130
199
|
run_with_timeout "$RUN_TIMEOUT" env \
|
|
131
200
|
-u GEMINI_API_KEY -u GOOGLE_API_KEY -u GOOGLE_AI_API_KEY \
|
|
132
|
-
NO_BROWSER=1 OMNILANE_DEPTH=1 \
|
|
133
|
-
"$AGY_BIN"
|
|
201
|
+
NO_BROWSER=1 OMNILANE_DEPTH=1 ${AGY_WORK_ENV[@]+"${AGY_WORK_ENV[@]}"} \
|
|
202
|
+
"$AGY_BIN" "${APP_DATA_ARGS[@]}" --add-dir "$RUN_DIR" \
|
|
134
203
|
"${MODE_ARGS[@]}" ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
|
|
135
204
|
--input-format stream-json --output-format stream-json -p "" \
|
|
136
205
|
< "$LIVE_INBOX" > "$EVENTS_FILE" 2> "$STDERR_FILE"
|
|
@@ -162,11 +231,11 @@ if [[ -n "$THREAD_MODE" ]]; then
|
|
|
162
231
|
(
|
|
163
232
|
cd "$RUN_DIR" || exit 127
|
|
164
233
|
env -u GEMINI_API_KEY -u GOOGLE_API_KEY -u GOOGLE_AI_API_KEY \
|
|
165
|
-
NO_BROWSER=1 OMNILANE_DEPTH=1 \
|
|
166
|
-
"$AGY_BIN"
|
|
234
|
+
NO_BROWSER=1 OMNILANE_DEPTH=1 ${AGY_WORK_ENV[@]+"${AGY_WORK_ENV[@]}"} \
|
|
235
|
+
"$AGY_BIN" "${APP_DATA_ARGS[@]}" --add-dir "$RUN_DIR" \
|
|
167
236
|
"${MODE_ARGS[@]}" ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
|
|
168
237
|
--print-timeout "${RUN_TIMEOUT}s" --output-format json \
|
|
169
|
-
"${THREAD_ARGS[@]}" -p "$(cat "$PROMPT_FILE")" \
|
|
238
|
+
${THREAD_ARGS[@]+"${THREAD_ARGS[@]}"} -p "$(cat "$PROMPT_FILE")" \
|
|
170
239
|
> "${OUTPUT_FILE}.result.json" 2> "${OUTPUT_FILE}.stderr.log"
|
|
171
240
|
)
|
|
172
241
|
RC=$?
|
|
@@ -196,8 +265,8 @@ set +e
|
|
|
196
265
|
# --add-dir registers RUN_DIR as the active workspace; without it agy's
|
|
197
266
|
# sandbox denies every tool call (run_command/view_file) in print mode.
|
|
198
267
|
env -u GEMINI_API_KEY -u GOOGLE_API_KEY -u GOOGLE_AI_API_KEY \
|
|
199
|
-
NO_BROWSER=1 OMNILANE_DEPTH=1 \
|
|
200
|
-
"$AGY_BIN"
|
|
268
|
+
NO_BROWSER=1 OMNILANE_DEPTH=1 ${AGY_WORK_ENV[@]+"${AGY_WORK_ENV[@]}"} \
|
|
269
|
+
"$AGY_BIN" "${APP_DATA_ARGS[@]}" --add-dir "$RUN_DIR" \
|
|
201
270
|
"${MODE_ARGS[@]}" ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
|
|
202
271
|
--print-timeout "${RUN_TIMEOUT}s" \
|
|
203
272
|
--print "$(cat "$PROMPT_FILE")" \
|
|
@@ -207,6 +276,12 @@ RC=$?
|
|
|
207
276
|
set -e
|
|
208
277
|
fi
|
|
209
278
|
|
|
279
|
+
|
|
280
|
+
if [[ "$RC" -eq 0 && -f "${OUTPUT_FILE}.tmp" && ! -s "${OUTPUT_FILE}.tmp" ]]; then
|
|
281
|
+
echo "omnilane: Gemini completed without a readable response" >> "${OUTPUT_FILE}.stderr.log"
|
|
282
|
+
RC=1
|
|
283
|
+
fi
|
|
284
|
+
|
|
210
285
|
if grep -Eiq "$CAPACITY_PATTERN" "${OUTPUT_FILE}.tmp" "${OUTPUT_FILE}.result.json" "${OUTPUT_FILE}.stderr.log" 2>/dev/null; then
|
|
211
286
|
echo "omnilane: gemini capacity exhausted" >> "${OUTPUT_FILE}.stderr.log"
|
|
212
287
|
RC=126
|