relay-codex 0.4.1 → 0.5.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,551 @@
1
+ """Noninteractive Codex tasks using Relay's existing account/recovery controller."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+
10
+ from launch import (
11
+ arguments_only,
12
+ tokens,
13
+ translate,
14
+ backend_arguments,
15
+ working_directory,
16
+ additional_roots,
17
+ prepare_worktree,
18
+ )
19
+
20
+
21
+ def command(arguments):
22
+ for name, value, raw in tokens(arguments_only(arguments)):
23
+ if name is None and len(raw) == 1:
24
+ return value if value in {"exec", "e", "review"} else None
25
+ return None
26
+
27
+
28
+ def options(arguments, cwd):
29
+ parsed = list(tokens(arguments_only(arguments)))
30
+ positional = []
31
+ values, flags = {}, set()
32
+ allowed = {
33
+ "--config",
34
+ "--profile",
35
+ "--enable",
36
+ "--disable",
37
+ "--model",
38
+ "--image",
39
+ "--cd",
40
+ "--sandbox",
41
+ "--ask-for-approval",
42
+ "--permission-profile",
43
+ "--add-dir",
44
+ "--thread-source",
45
+ "--cyber-access-program",
46
+ "--output-schema",
47
+ "--color",
48
+ "--output-last-message",
49
+ "--base",
50
+ "--commit",
51
+ "--title",
52
+ "--search",
53
+ "--strict-config",
54
+ "--approve-for-me",
55
+ "--yolo",
56
+ "--dangerously-bypass-approvals-and-sandbox",
57
+ "--skip-git-repo-check",
58
+ "--ephemeral",
59
+ "--json",
60
+ "--last",
61
+ "--all",
62
+ "--include-non-interactive",
63
+ "--uncommitted",
64
+ "--no-daemon",
65
+ "--no-alt-screen",
66
+ }
67
+ for name, value, raw in parsed:
68
+ if name is None:
69
+ positional.extend(raw[1:] if raw[:1] == ["--"] else raw)
70
+ else:
71
+ if name not in allowed:
72
+ raise ValueError("This exec option needs native Codex: " + name)
73
+ flags.add(name)
74
+ if name in {"--image", "--add-dir"}:
75
+ values.setdefault(name, []).append(value)
76
+ else:
77
+ values[name] = value
78
+ top = positional.pop(0)
79
+ mode = "review" if top == "review" else "start"
80
+ if top != "review" and positional[:1] in (["resume"], ["fork"], ["review"]):
81
+ mode = positional.pop(0)
82
+ target = None
83
+ if mode in {"resume", "fork"} and "--last" not in flags:
84
+ if not positional:
85
+ raise ValueError("Choose a session ID/name or use --last")
86
+ target = positional.pop(0)
87
+ if len(positional) > 1:
88
+ raise ValueError("Provide one quoted prompt, as with native Codex")
89
+ prompt = positional[0] if positional else None
90
+ return {
91
+ "mode": mode,
92
+ "target": target,
93
+ "prompt": prompt,
94
+ "values": values,
95
+ "flags": flags,
96
+ "cwd": working_directory(arguments, cwd),
97
+ }
98
+
99
+
100
+ class Output:
101
+ def __init__(self, json_output=False):
102
+ self.json_output = json_output
103
+ self.done = asyncio.Event()
104
+ self.status = 0
105
+ self.last_message = ""
106
+ self.controller = None
107
+ self.usage = {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0}
108
+ self.thread = None
109
+
110
+ def write(self, value):
111
+ if self.json_output:
112
+ print(json.dumps(value), flush=True)
113
+
114
+ async def send(self, raw):
115
+ value = json.loads(raw)
116
+ method, params = value.get("method"), value.get("params", {})
117
+ if value.get("id") == "exec-start" and value.get("error"):
118
+ if value["error"].get("code") != -32001:
119
+ self.status = 1
120
+ self.write(
121
+ {
122
+ "type": "error",
123
+ "message": "Codex rejected the task. Check the saved conversation and options.",
124
+ }
125
+ )
126
+ self.done.set()
127
+ return
128
+ if self.thread and params.get("threadId") not in {None, self.thread}:
129
+ return
130
+ if "id" in value and method:
131
+ # Noninteractive execution cannot invent an approval or tool response.
132
+ self.status = 1
133
+ print(
134
+ "Task requires input. Resume the saved conversation in an interactive terminal.",
135
+ file=sys.stderr,
136
+ )
137
+ self.done.set()
138
+ return
139
+ if method == "turn/started":
140
+ self.write({"type": "turn.started"})
141
+ elif method == "thread/tokenUsage/updated":
142
+ usage = params.get("tokenUsage", {}).get("last", {})
143
+ self.usage = {
144
+ "input_tokens": usage.get("inputTokens", 0),
145
+ "cached_input_tokens": usage.get("cachedInputTokens", 0),
146
+ "output_tokens": usage.get("outputTokens", 0),
147
+ }
148
+ elif method in {"item/started", "item/completed"}:
149
+ native = params.get("item", {})
150
+ kind = native.get("type")
151
+ translated = {"id": native.get("id")}
152
+ if kind == "agentMessage":
153
+ translated.update(type="agent_message", text=native.get("text", ""))
154
+ if method == "item/completed":
155
+ self.last_message = native.get("text", "")
156
+ if not self.json_output:
157
+ print(self.last_message, flush=True)
158
+ elif kind == "reasoning":
159
+ translated.update(
160
+ type="reasoning", text="\n".join(native.get("summary", []))
161
+ )
162
+ elif kind == "commandExecution":
163
+ translated.update(
164
+ type="command_execution",
165
+ command=native.get("command", ""),
166
+ aggregated_output=native.get("aggregatedOutput", ""),
167
+ exit_code=native.get("exitCode"),
168
+ status="completed" if method == "item/completed" else "in_progress",
169
+ )
170
+ elif kind == "fileChange":
171
+ translated.update(
172
+ type="file_change",
173
+ changes=native.get("changes", []),
174
+ status="completed" if method == "item/completed" else "in_progress",
175
+ )
176
+ elif kind == "mcpToolCall":
177
+ translated.update(
178
+ type="mcp_tool_call",
179
+ server=native.get("server", ""),
180
+ tool=native.get("tool", ""),
181
+ arguments=native.get("arguments", {}),
182
+ result=native.get("result"),
183
+ error=native.get("error"),
184
+ status="completed" if method == "item/completed" else "in_progress",
185
+ )
186
+ elif kind == "webSearch":
187
+ action = native.get("action") or {}
188
+ translated.update(
189
+ type="web_search",
190
+ query=native.get("query")
191
+ or action.get("query")
192
+ or action.get("queries", []),
193
+ action=action,
194
+ )
195
+ elif kind == "plan":
196
+ translated.update(
197
+ type="todo_list", items=native.get("plan", native.get("items", []))
198
+ )
199
+ elif kind == "collabAgentToolCall":
200
+ translated.update(
201
+ type="collab_tool_call",
202
+ tool=native.get("tool"),
203
+ sender_thread_id=native.get("senderThreadId"),
204
+ receiver_thread_ids=native.get("receiverThreadIds", []),
205
+ prompt=native.get("prompt"),
206
+ agents_states=native.get("agentsStates", {}),
207
+ status=native.get("status"),
208
+ )
209
+ elif kind in {"imageView", "imageGeneration"}:
210
+ translated.update(
211
+ type="image_view" if kind == "imageView" else "image_generation",
212
+ path=native.get("path"),
213
+ status=native.get("status"),
214
+ )
215
+ else:
216
+ return
217
+ self.write(
218
+ {
219
+ "type": "item.completed"
220
+ if method == "item/completed"
221
+ else "item.started",
222
+ "item": translated,
223
+ }
224
+ )
225
+ elif method == "turn/completed":
226
+ turn = params.get("turn", {})
227
+ if turn.get("status") == "completed":
228
+ self.write({"type": "turn.completed", "usage": self.usage})
229
+ self.done.set()
230
+ elif turn.get("status") in {"failed", "interrupted"}:
231
+ from pool import failure_kind
232
+
233
+ error = turn.get("error") or {}
234
+ recoverable = failure_kind(
235
+ error.get("codexErrorInfo"), error.get("message")
236
+ )
237
+ if not recoverable:
238
+ self.status = 1
239
+ self.write(
240
+ {
241
+ "type": "turn.failed",
242
+ "error": {
243
+ "message": "Task stopped. Review the saved conversation."
244
+ },
245
+ }
246
+ )
247
+ self.done.set()
248
+
249
+
250
+ async def run(args, state, binary):
251
+ from pool import file_lock
252
+
253
+ async with file_lock(state / "metadata.lock", shared=True):
254
+ return await _run(args, state, binary)
255
+
256
+
257
+ async def _run(args, state, binary):
258
+ from router import (
259
+ Controller,
260
+ RPC,
261
+ RouterError,
262
+ labels,
263
+ account_home,
264
+ resolve_resume_target,
265
+ )
266
+ from broker import ClientAccount
267
+ from pool import Pool
268
+ import settings
269
+
270
+ invocation_cwd = os.getcwd()
271
+ arguments = arguments_only(args.codex_args)
272
+ cwd = working_directory(arguments, invocation_cwd)
273
+ arguments, cwd = prepare_worktree(arguments, state, cwd)
274
+ config = options(arguments, invocation_cwd)
275
+ flags, values = config["flags"], config["values"]
276
+ if not Path(cwd).is_dir():
277
+ raise ValueError("Working directory does not exist")
278
+ if "--skip-git-repo-check" not in flags and config["mode"] != "review":
279
+ checked = subprocess.run(
280
+ ["git", "-C", cwd, "rev-parse", "--show-toplevel"], capture_output=True
281
+ )
282
+ if checked.returncode:
283
+ raise ValueError(
284
+ "This directory is not a Git repository; pass --skip-git-repo-check to continue"
285
+ )
286
+ prompt = config["prompt"]
287
+ stdin = ""
288
+ if (
289
+ prompt == "-"
290
+ or prompt is None
291
+ and config["mode"] not in {"fork", "review"}
292
+ or not sys.stdin.isatty()
293
+ ):
294
+ stdin = await asyncio.to_thread(sys.stdin.read)
295
+ if prompt == "-" or prompt is None:
296
+ prompt = stdin
297
+ elif stdin:
298
+ prompt += "\n<stdin>\n" + stdin + "\n</stdin>"
299
+ if not prompt and config["mode"] not in {"fork", "review"}:
300
+ raise ValueError("Provide a prompt or pipe instructions to stdin")
301
+ _, overrides = translate(arguments)
302
+ from launch import profile_config
303
+
304
+ private_config = profile_config(arguments)
305
+ if private_config:
306
+ overrides["config"] = private_config
307
+ overrides.setdefault("approvalPolicy", "never")
308
+ home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).resolve()
309
+ pool = Pool(state)
310
+ pool.assign("")
311
+ pool.touch(
312
+ name=args.name or "Codex " + config["mode"],
313
+ cwd=cwd,
314
+ mode="managed",
315
+ recovery_enabled=True,
316
+ )
317
+ enrolled = labels(state)
318
+ if not enrolled:
319
+ pool.close()
320
+ raise RouterError("Enroll an account first with relay setup")
321
+ output = Output("--json" in flags)
322
+ backend = None
323
+ controller = None
324
+ heartbeat = None
325
+ try:
326
+
327
+ async def factory():
328
+ return await RPC.start(
329
+ binary, home, extra_args=backend_arguments(arguments)
330
+ )
331
+
332
+ backend = await factory()
333
+ controller = Controller(
334
+ [
335
+ ClientAccount(label, account_home(state, label), binary, pool)
336
+ for label in enrolled
337
+ ],
338
+ backend,
339
+ pool=pool,
340
+ report=lambda message: print(message, file=sys.stderr),
341
+ )
342
+ controller.background = True
343
+ controller.project = cwd
344
+ controller.profile = args.profile
345
+ controller.preferences = settings.load(state, cwd, args.profile)
346
+ controller.retry_interval = controller.preferences["retry_interval"]
347
+ controller.retry_attempts = controller.preferences["retry_attempts"]
348
+ controller.interval = controller.preferences["unknown_reset_interval"]
349
+ controller.backend_factory = factory
350
+ controller.launch_overrides = overrides
351
+ controller.client = output
352
+ output.controller = controller
353
+ backend.handler = controller.event
354
+ init = await backend.initialize()
355
+ heartbeat = asyncio.create_task(controller.maintain())
356
+ model = values.get("--model")
357
+ first = enrolled.index(args.first) if args.first in enrolled else None
358
+ while True:
359
+ index, deadline = await controller.choose(model, preferred=first)
360
+ if index is None:
361
+ await controller.wait_until(deadline, controller.user_generation)
362
+ continue
363
+ try:
364
+ await controller.login(index)
365
+ break
366
+ except (RouterError, asyncio.TimeoutError):
367
+ await controller.mark(index, model, "unavailable")
368
+ roots = additional_roots(arguments, cwd)
369
+ thread_params = {"cwd": cwd, **overrides}
370
+ if model:
371
+ thread_params["model"] = model
372
+ if roots:
373
+ effective = await backend.call(
374
+ "config/read", {"cwd": cwd, "includeLayers": False}
375
+ )
376
+ configured = (
377
+ effective.get("config", {}).get("sandbox_workspace_write") or {}
378
+ ).get("writable_roots", [])
379
+ thread_params["runtimeWorkspaceRoots"] = list(
380
+ dict.fromkeys([cwd, *configured, *roots])
381
+ )
382
+ if config["mode"] == "fork":
383
+ if not prompt and (
384
+ flags
385
+ & {
386
+ "--ephemeral",
387
+ "--image",
388
+ "--output-schema",
389
+ "--output-last-message",
390
+ "--cyber-access-program",
391
+ }
392
+ ):
393
+ raise ValueError(
394
+ "A fork needs a prompt when images, output options, ephemeral mode, or an access program are requested"
395
+ )
396
+ if "--ephemeral" in flags:
397
+ thread_params["ephemeral"] = True
398
+ if "--thread-source" in values:
399
+ thread_params["threadSource"] = values["--thread-source"]
400
+ if config["mode"] in {"resume", "fork"}:
401
+ selection = [config["mode"]]
402
+ if config["target"]:
403
+ selection.append(config["target"])
404
+ selection.extend(
405
+ flag
406
+ for flag in ("--last", "--all", "--include-non-interactive")
407
+ if flag in flags
408
+ )
409
+ ident = await resolve_resume_target(selection, home, cwd, backend=backend)
410
+ if not ident:
411
+ raise RouterError("No matching saved conversation")
412
+ if config["mode"] == "resume" and pool.owner(ident) not in {
413
+ None,
414
+ pool.session,
415
+ }:
416
+ raise RouterError(
417
+ "Conversation is already running in another Relay session"
418
+ )
419
+ result = await backend.call(
420
+ "thread/" + config["mode"], {"threadId": ident, **thread_params}
421
+ )
422
+ else:
423
+ if "--ephemeral" in flags:
424
+ thread_params["ephemeral"] = True
425
+ if "--thread-source" in values:
426
+ thread_params["threadSource"] = values["--thread-source"]
427
+ else:
428
+ thread_params["threadSource"] = "exec"
429
+ result = await backend.call("thread/start", thread_params)
430
+ thread = result["thread"]["id"]
431
+ if result["thread"].get("canAcceptDirectInput") is False or not pool.claim(
432
+ thread
433
+ ):
434
+ raise RouterError(
435
+ "Conversation is open in another backend. Close it there or fork it."
436
+ )
437
+ controller.last_turn[thread] = {"threadId": thread}
438
+ controller.merge_settings(thread, result)
439
+ controller.merge_settings(thread, thread_params)
440
+ controller.persist(thread, role="foreground")
441
+ pool.touch(
442
+ thread=thread,
443
+ threads=[thread],
444
+ cwd=cwd,
445
+ model=controller.model_for(thread),
446
+ state="idle",
447
+ )
448
+ output.thread = thread
449
+ output.write({"type": "thread.started", "thread_id": thread})
450
+ if config["mode"] == "fork" and not prompt:
451
+ return 0
452
+ if config["mode"] == "review":
453
+ choices = [
454
+ key for key in ("--uncommitted", "--base", "--commit") if key in flags
455
+ ]
456
+ if len(choices) > 1 or choices and prompt:
457
+ raise ValueError(
458
+ "Choose one review target, or provide custom review instructions"
459
+ )
460
+ if "--base" in values:
461
+ target = {"type": "baseBranch", "branch": values["--base"]}
462
+ elif "--commit" in values:
463
+ target = {
464
+ "type": "commit",
465
+ "sha": values["--commit"],
466
+ "title": values.get("--title"),
467
+ }
468
+ elif prompt:
469
+ target = {"type": "custom", "instructions": prompt}
470
+ else:
471
+ target = {"type": "uncommittedChanges"}
472
+ reviewed = await backend.call(
473
+ "review/start",
474
+ {"threadId": thread, "target": target, "delivery": "inline"},
475
+ )
476
+ review_thread = reviewed.get("reviewThreadId", thread)
477
+ if review_thread != thread:
478
+ if not pool.claim(review_thread):
479
+ raise RouterError("Review conversation is already owned")
480
+ controller.last_turn[review_thread] = {"threadId": review_thread}
481
+ controller.merge_settings(
482
+ review_thread, controller.thread_settings.get(thread, {})
483
+ )
484
+ thread = review_thread
485
+ output.thread = thread
486
+ else:
487
+ inputs = [{"type": "text", "text": prompt, "text_elements": []}]
488
+ for image_group in values.get("--image", []):
489
+ for image in image_group.split(","):
490
+ path = Path(image).expanduser()
491
+ inputs.append(
492
+ {
493
+ "type": "localImage",
494
+ "path": str(
495
+ (
496
+ path
497
+ if path.is_absolute()
498
+ else Path(invocation_cwd) / path
499
+ ).resolve()
500
+ ),
501
+ }
502
+ )
503
+ turn = {"threadId": thread, "input": inputs}
504
+ if "--output-schema" in values:
505
+ turn["outputSchema"] = json.loads(
506
+ Path(values["--output-schema"]).read_text()
507
+ )
508
+ if "--cyber-access-program" in values:
509
+ turn["cyberAccessProgram"] = values["--cyber-access-program"]
510
+ await controller.forward(
511
+ {"id": "exec-start", "method": "turn/start", "params": turn}, init
512
+ )
513
+ while not output.done.is_set():
514
+ try:
515
+ await asyncio.wait_for(output.done.wait(), 0.5)
516
+ except asyncio.TimeoutError:
517
+ saved = pool.intent(thread) or {}
518
+ if saved.get("review_required"):
519
+ raise RouterError(
520
+ "Task needs review. Resume the saved conversation before retrying."
521
+ )
522
+ if controller.stopping.is_set():
523
+ return 130
524
+ if "--output-last-message" in values:
525
+ Path(values["--output-last-message"]).write_text(output.last_message + "\n")
526
+ return output.status
527
+ finally:
528
+ if controller:
529
+ controller.stopping.set()
530
+ for task in (controller.recovery, controller.restart_task, heartbeat):
531
+ if task:
532
+ task.cancel()
533
+ await asyncio.gather(
534
+ *[
535
+ task
536
+ for task in (
537
+ controller.recovery,
538
+ controller.restart_task,
539
+ heartbeat,
540
+ )
541
+ if task
542
+ ],
543
+ return_exceptions=True,
544
+ )
545
+ if controller.awake_process and controller.awake_process.returncode is None:
546
+ controller.awake_process.terminate()
547
+ await controller.awake_process.wait()
548
+ await controller.backend.close()
549
+ elif backend:
550
+ await backend.close()
551
+ pool.close()