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,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" == "resume" ]]; then
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
- # Without an execution mode, print mode denies tool calls outright:
48
- # plan = read-only tools (advise), accept-edits = file edits allowed (work).
49
- if [[ "$MODE" == "advise" ]]; then MODE_ARGS=(--mode plan); else MODE_ARGS=(--mode accept-edits); fi
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" --dangerously-skip-permissions --add-dir "$RUN_DIR" \
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" --dangerously-skip-permissions --add-dir "$RUN_DIR" \
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" --dangerously-skip-permissions --add-dir "$RUN_DIR" \
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