claude-comms 1.0.34 → 1.0.36

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,858 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = []
5
+ # ///
6
+ """
7
+ Agent Job Adapter - Turns verbose CLI agent runs into file-backed jobs.
8
+
9
+ Supports Codex and Gemini CLI harnesses with:
10
+ - Stable job IDs (harness-native)
11
+ - File-backed logs and status
12
+ - Idle timeout enforcement
13
+ - Simple CLI API: spawn, status, list
14
+
15
+ Usage:
16
+ uv run agent_job.py spawn --harness <gemini|codex> -- "<assignment>"
17
+ uv run agent_job.py status <job_id>
18
+ uv run agent_job.py list
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import signal
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ from dataclasses import dataclass, field, asdict
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+
35
+ # =============================================================================
36
+ # Configuration
37
+ # =============================================================================
38
+
39
+ IDLE_TIMEOUT_SEC = int(os.environ.get("IDLE_TIMEOUT_SEC", "300"))
40
+
41
+
42
+ def get_jobs_root() -> Path:
43
+ """Get the jobs root directory from environment or default."""
44
+ if root := os.environ.get("AGENT_JOBS_ROOT"):
45
+ return Path(root)
46
+
47
+ tmpdir = os.environ.get("TMPDIR", "/tmp")
48
+ user = os.environ.get("USER", "unknown")
49
+ return Path(tmpdir) / "agent_jobs" / user
50
+
51
+
52
+ def get_consultant_template() -> str:
53
+ """Load the consultant template from the template file."""
54
+ template_path = Path(__file__).parent / "consultant_template.txt"
55
+ if template_path.exists():
56
+ return template_path.read_text()
57
+ return ""
58
+
59
+
60
+ # =============================================================================
61
+ # Data Models
62
+ # =============================================================================
63
+
64
+ @dataclass
65
+ class TokenStats:
66
+ input: Optional[int] = None
67
+ output: Optional[int] = None
68
+ total: Optional[int] = None
69
+
70
+
71
+ @dataclass
72
+ class Completion:
73
+ messages: list[str] = field(default_factory=list)
74
+ final_message: Optional[str] = None
75
+ tokens: TokenStats = field(default_factory=TokenStats)
76
+ duration_ms: Optional[int] = None
77
+
78
+
79
+ @dataclass
80
+ class JobStatus:
81
+ job_id: str
82
+ harness: str
83
+ agent_id: Optional[str] = None
84
+ pid: Optional[int] = None
85
+ logs: Optional[str] = None
86
+
87
+ status: str = "running" # running | complete | error | timeout
88
+ status_reason: Optional[str] = None # current activity or error reason
89
+
90
+ start_time: Optional[str] = None
91
+ last_event_time: Optional[str] = None
92
+ end_time: Optional[str] = None
93
+
94
+ operations: int = 0
95
+
96
+ completion: Completion = field(default_factory=Completion)
97
+
98
+ def to_dict(self) -> dict:
99
+ """Convert to dictionary for JSON serialization."""
100
+ return {
101
+ "job_id": self.job_id,
102
+ "harness": self.harness,
103
+ "agent_id": self.agent_id,
104
+ "pid": self.pid,
105
+ "logs": self.logs,
106
+ "status": self.status,
107
+ "status_reason": self.status_reason,
108
+ "start_time": self.start_time,
109
+ "last_event_time": self.last_event_time,
110
+ "end_time": self.end_time,
111
+ "operations": self.operations,
112
+ "completion": {
113
+ "messages": self.completion.messages,
114
+ "final_message": self.completion.final_message,
115
+ "tokens": {
116
+ "input": self.completion.tokens.input,
117
+ "output": self.completion.tokens.output,
118
+ "total": self.completion.tokens.total,
119
+ },
120
+ "duration_ms": self.completion.duration_ms,
121
+ }
122
+ }
123
+
124
+ @classmethod
125
+ def from_dict(cls, data: dict) -> "JobStatus":
126
+ """Create from dictionary."""
127
+ completion_data = data.get("completion", {})
128
+ tokens_data = completion_data.get("tokens", {})
129
+
130
+ return cls(
131
+ job_id=data["job_id"],
132
+ harness=data["harness"],
133
+ agent_id=data.get("agent_id"),
134
+ pid=data.get("pid"),
135
+ logs=data.get("logs"),
136
+ status=data.get("status", "running"),
137
+ status_reason=data.get("status_reason"),
138
+ start_time=data.get("start_time"),
139
+ last_event_time=data.get("last_event_time"),
140
+ end_time=data.get("end_time"),
141
+ operations=data.get("operations", 0),
142
+ completion=Completion(
143
+ messages=completion_data.get("messages", []),
144
+ final_message=completion_data.get("final_message"),
145
+ tokens=TokenStats(
146
+ input=tokens_data.get("input"),
147
+ output=tokens_data.get("output"),
148
+ total=tokens_data.get("total"),
149
+ ),
150
+ duration_ms=completion_data.get("duration_ms"),
151
+ )
152
+ )
153
+
154
+
155
+ # =============================================================================
156
+ # Utilities
157
+ # =============================================================================
158
+
159
+ def utc_now_iso() -> str:
160
+ """Get current UTC time in ISO format."""
161
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
162
+
163
+
164
+ def parse_iso_time(iso_str: str) -> datetime:
165
+ """Parse ISO time string to datetime."""
166
+ # Handle 'Z' suffix
167
+ if iso_str.endswith("Z"):
168
+ iso_str = iso_str[:-1] + "+00:00"
169
+ return datetime.fromisoformat(iso_str)
170
+
171
+
172
+ def atomic_write_json(path: Path, data: dict) -> None:
173
+ """Write JSON atomically using temp file and rename."""
174
+ tmp_path = path.with_suffix(".tmp")
175
+ with open(tmp_path, "w") as f:
176
+ json.dump(data, f, indent=2)
177
+ f.flush()
178
+ os.fsync(f.fileno())
179
+ tmp_path.rename(path)
180
+
181
+
182
+ def try_parse_json(line: str) -> Optional[dict]:
183
+ """Try to parse a line as JSON, return None if not valid JSON."""
184
+ line = line.strip()
185
+ if not line:
186
+ return None
187
+ try:
188
+ return json.loads(line)
189
+ except json.JSONDecodeError:
190
+ return None
191
+
192
+
193
+ # =============================================================================
194
+ # Harness Event Handlers
195
+ # =============================================================================
196
+
197
+ class CodexEventHandler:
198
+ """Handle Codex JSON stream events."""
199
+
200
+ @staticmethod
201
+ def extract_job_id(event: dict) -> Optional[str]:
202
+ """Extract job ID from initial event."""
203
+ if event.get("type") == "thread.started":
204
+ return event.get("thread_id")
205
+ return None
206
+
207
+ @staticmethod
208
+ def process_event(event: dict, status: JobStatus) -> None:
209
+ """Process a Codex event and update status."""
210
+ event_type = event.get("type", "")
211
+
212
+ # Update status_reason based on current activity
213
+ if event_type == "turn.started":
214
+ status.status_reason = "thinking"
215
+
216
+ elif event_type == "item.started":
217
+ item = event.get("item", {})
218
+ item_type = item.get("type")
219
+ if item_type:
220
+ status.status_reason = item_type # reasoning, command_execution, todo_list
221
+
222
+ elif event_type == "item.completed":
223
+ item = event.get("item", {})
224
+ item_type = item.get("type")
225
+ if item_type:
226
+ status.status_reason = item_type
227
+
228
+ # Capture agent_message text
229
+ if item_type == "agent_message":
230
+ text = item.get("text", "")
231
+ if text:
232
+ status.completion.messages.append(text)
233
+ status.completion.final_message = text
234
+
235
+ # Handle turn completion with usage stats
236
+ elif event_type == "turn.completed":
237
+ status.status_reason = "completed"
238
+ usage = event.get("usage", {})
239
+ if usage:
240
+ status.completion.tokens.input = usage.get("input_tokens")
241
+ status.completion.tokens.output = usage.get("output_tokens")
242
+ if status.completion.tokens.input and status.completion.tokens.output:
243
+ status.completion.tokens.total = (
244
+ status.completion.tokens.input + status.completion.tokens.output
245
+ )
246
+
247
+ @staticmethod
248
+ def is_completion_event(event: dict) -> bool:
249
+ """Check if this event indicates completion."""
250
+ return event.get("type") == "turn.completed"
251
+
252
+
253
+ class GeminiEventHandler:
254
+ """Handle Gemini CLI JSON stream events."""
255
+
256
+ def __init__(self):
257
+ self._assistant_buffer = ""
258
+
259
+ def extract_job_id(self, event: dict) -> Optional[str]:
260
+ """Extract job ID from initial event."""
261
+ if event.get("type") == "init":
262
+ return event.get("session_id")
263
+ return None
264
+
265
+ def process_event(self, event: dict, status: JobStatus) -> None:
266
+ """Process a Gemini event and update status."""
267
+ event_type = event.get("type", "")
268
+
269
+ # Update status_reason based on current activity
270
+ if event_type == "tool_use":
271
+ tool_name = event.get("tool_name")
272
+ if tool_name:
273
+ status.status_reason = tool_name # run_shell_command, write_file, read_file, write_todos
274
+
275
+ # Handle assistant messages (accumulate deltas)
276
+ elif event_type == "message" and event.get("role") == "assistant":
277
+ status.status_reason = "responding"
278
+ content = event.get("content", "")
279
+ if content:
280
+ self._assistant_buffer += content
281
+
282
+ # Handle result with stats
283
+ elif event_type == "result":
284
+ status.status_reason = "completed"
285
+ stats = event.get("stats", {})
286
+ if stats:
287
+ status.completion.tokens.input = stats.get("input_tokens")
288
+ status.completion.tokens.output = stats.get("output_tokens")
289
+ status.completion.tokens.total = stats.get("total_tokens")
290
+ status.completion.duration_ms = stats.get("duration_ms")
291
+
292
+ # Finalize assistant message buffer
293
+ if self._assistant_buffer:
294
+ status.completion.final_message = self._assistant_buffer
295
+ status.completion.messages = [self._assistant_buffer]
296
+
297
+ def is_completion_event(self, event: dict) -> bool:
298
+ """Check if this event indicates completion."""
299
+ return event.get("type") == "result" and event.get("status") == "success"
300
+
301
+
302
+ # =============================================================================
303
+ # Job Manager
304
+ # =============================================================================
305
+
306
+ class JobManager:
307
+ """Manage agent job lifecycle."""
308
+
309
+ def __init__(self, jobs_root: Optional[Path] = None):
310
+ self.jobs_root = jobs_root or get_jobs_root()
311
+ self.jobs_root.mkdir(parents=True, exist_ok=True)
312
+
313
+ def get_job_dir(self, job_id: str) -> Path:
314
+ """Get the directory for a job."""
315
+ return self.jobs_root / job_id
316
+
317
+ def get_status_path(self, job_id: str) -> Path:
318
+ """Get the status.json path for a job."""
319
+ return self.get_job_dir(job_id) / "status.json"
320
+
321
+ def get_log_path(self, job_id: str) -> Path:
322
+ """Get the agent.log path for a job."""
323
+ return self.get_job_dir(job_id) / "agent.log"
324
+
325
+ def create_job_dir(self, job_id: str) -> Path:
326
+ """Create job directory structure."""
327
+ job_dir = self.get_job_dir(job_id)
328
+ job_dir.mkdir(parents=True, exist_ok=True)
329
+ return job_dir
330
+
331
+ def write_status(self, status: JobStatus) -> None:
332
+ """Write status.json atomically."""
333
+ status_path = self.get_status_path(status.job_id)
334
+ atomic_write_json(status_path, status.to_dict())
335
+
336
+ def read_status(self, job_id: str) -> Optional[JobStatus]:
337
+ """Read status.json for a job."""
338
+ status_path = self.get_status_path(job_id)
339
+ if not status_path.exists():
340
+ return None
341
+
342
+ with open(status_path) as f:
343
+ data = json.load(f)
344
+ return JobStatus.from_dict(data)
345
+
346
+ def list_jobs(self) -> list[dict]:
347
+ """List all jobs with summary info."""
348
+ jobs = []
349
+
350
+ if not self.jobs_root.exists():
351
+ return jobs
352
+
353
+ for job_dir in self.jobs_root.iterdir():
354
+ if not job_dir.is_dir():
355
+ continue
356
+
357
+ status = self.read_status(job_dir.name)
358
+ if not status:
359
+ continue
360
+
361
+ # Compute runtime and idle time
362
+ now = datetime.now(timezone.utc)
363
+ runtime_sec = 0.0
364
+ idle_sec = 0.0
365
+ if status.start_time:
366
+ start = parse_iso_time(status.start_time)
367
+ if status.end_time:
368
+ end = parse_iso_time(status.end_time)
369
+ else:
370
+ end = now
371
+ runtime_sec = (end - start).total_seconds()
372
+ if status.last_event_time:
373
+ last = parse_iso_time(status.last_event_time)
374
+ idle_sec = (now - last).total_seconds()
375
+
376
+ job_data = {
377
+ "job_id": status.job_id,
378
+ "harness": status.harness,
379
+ "pid": status.pid,
380
+ "logs": status.logs,
381
+ "status": status.status,
382
+ "status_reason": status.status_reason,
383
+ "start_time": status.start_time,
384
+ "last_event_time": status.last_event_time,
385
+ "runtime_sec": round(runtime_sec, 1),
386
+ "operations": status.operations,
387
+ }
388
+
389
+ # Only include idle_sec for running jobs
390
+ if status.status == "running":
391
+ job_data["idle_sec"] = round(idle_sec, 1)
392
+
393
+ # Include end_time for finished jobs
394
+ if status.end_time:
395
+ job_data["end_time"] = status.end_time
396
+
397
+ jobs.append(job_data)
398
+
399
+ # Sort by start_time (newest first)
400
+ jobs.sort(key=lambda x: x.get("start_time") or "", reverse=True)
401
+ return jobs
402
+
403
+
404
+ # =============================================================================
405
+ # Spawn Command
406
+ # =============================================================================
407
+
408
+ def build_harness_command(harness: str, assignment: str) -> list[str]:
409
+ """Build the command to run the harness."""
410
+ if harness == "gemini":
411
+ return [
412
+ "gemini",
413
+ "--yolo",
414
+ "-s",
415
+ "-m", "gemini-3-pro-preview",
416
+ "--output-format", "stream-json",
417
+ assignment,
418
+ ]
419
+ elif harness == "codex":
420
+ return [
421
+ "codex",
422
+ "--yolo",
423
+ "e",
424
+ assignment,
425
+ "--json",
426
+ ]
427
+ else:
428
+ raise ValueError(f"Unknown harness: {harness}")
429
+
430
+
431
+ def _run_monitor_loop(
432
+ process: subprocess.Popen,
433
+ handler,
434
+ manager: JobManager,
435
+ harness: str,
436
+ job_id: str,
437
+ status: JobStatus,
438
+ log_file,
439
+ initial_lines: list[str],
440
+ ) -> None:
441
+ """
442
+ Background monitor loop - runs in forked child process.
443
+
444
+ Continues reading harness output, updating status.json, and enforcing idle timeout.
445
+ """
446
+ seen_completion = False
447
+ last_event_time = time.time()
448
+
449
+ try:
450
+ # First, process any lines we already buffered before forking
451
+ for line in initial_lines:
452
+ event = try_parse_json(line)
453
+ if event:
454
+ last_event_time = time.time()
455
+ status.operations += 1
456
+ status.last_event_time = utc_now_iso()
457
+ handler.process_event(event, status)
458
+ if handler.is_completion_event(event):
459
+ seen_completion = True
460
+ manager.write_status(status)
461
+
462
+ if log_file:
463
+ log_file.write(line)
464
+ log_file.flush()
465
+
466
+ # Continue reading from harness
467
+ for line in iter(process.stdout.readline, ""):
468
+ if not line:
469
+ if process.poll() is not None:
470
+ break
471
+ continue
472
+
473
+ current_time = time.time()
474
+
475
+ # Check idle timeout
476
+ if (current_time - last_event_time) > IDLE_TIMEOUT_SEC:
477
+ process.terminate()
478
+ try:
479
+ process.wait(timeout=5)
480
+ except subprocess.TimeoutExpired:
481
+ process.kill()
482
+
483
+ status.status = "timeout"
484
+ status.status_reason = "idle_timeout"
485
+ status.end_time = utc_now_iso()
486
+ manager.write_status(status)
487
+
488
+ if log_file:
489
+ log_file.close()
490
+ return
491
+
492
+ # Try to parse as JSON
493
+ event = try_parse_json(line)
494
+
495
+ if event:
496
+ last_event_time = current_time
497
+ status.operations += 1
498
+ status.last_event_time = utc_now_iso()
499
+
500
+ handler.process_event(event, status)
501
+
502
+ if handler.is_completion_event(event):
503
+ seen_completion = True
504
+
505
+ manager.write_status(status)
506
+
507
+ # Write to log file
508
+ if log_file:
509
+ log_file.write(line)
510
+ log_file.flush()
511
+
512
+ # Wait for process to complete
513
+ exit_code = process.wait()
514
+
515
+ # Finalize status
516
+ status.end_time = utc_now_iso()
517
+
518
+ if exit_code == 0 and seen_completion:
519
+ status.status = "complete"
520
+ else:
521
+ status.status = "error"
522
+ status.status_reason = f"process_exit_{exit_code}"
523
+
524
+ manager.write_status(status)
525
+
526
+ except Exception as e:
527
+ if process.poll() is None:
528
+ process.terminate()
529
+
530
+ status.status = "error"
531
+ status.status_reason = str(e)
532
+ status.end_time = utc_now_iso()
533
+ manager.write_status(status)
534
+
535
+ finally:
536
+ if log_file:
537
+ log_file.close()
538
+
539
+
540
+ def spawn_job(harness: str, assignment: str) -> dict:
541
+ """
542
+ Spawn a new agent job.
543
+
544
+ Starts the harness, waits for job ID, then forks to background.
545
+ Parent returns immediately with job info; child continues monitoring.
546
+
547
+ Returns dict with job_id, status_path, log_path on success.
548
+ """
549
+ manager = JobManager()
550
+
551
+ # Build and start harness command
552
+ cmd = build_harness_command(harness, assignment)
553
+
554
+ process = subprocess.Popen(
555
+ cmd,
556
+ stdin=subprocess.DEVNULL, # Detach from TTY to survive fork
557
+ stdout=subprocess.PIPE,
558
+ stderr=subprocess.STDOUT,
559
+ text=True,
560
+ bufsize=1, # Line buffered
561
+ )
562
+
563
+ # Create appropriate event handler
564
+ if harness == "gemini":
565
+ handler = GeminiEventHandler()
566
+ else:
567
+ handler = CodexEventHandler()
568
+
569
+ job_id: Optional[str] = None
570
+ status: Optional[JobStatus] = None
571
+ log_file = None
572
+ buffered_lines: list[str] = [] # Lines read after job_id but before fork
573
+
574
+ try:
575
+ # Read stdout until we get the job ID
576
+ for line in iter(process.stdout.readline, ""):
577
+ if not line:
578
+ if process.poll() is not None:
579
+ break
580
+ continue
581
+
582
+ # Try to parse as JSON
583
+ event = try_parse_json(line)
584
+
585
+ if event and not job_id:
586
+ job_id = handler.extract_job_id(event)
587
+
588
+ if job_id:
589
+ # Initialize job
590
+ manager.create_job_dir(job_id)
591
+ log_file = open(manager.get_log_path(job_id), "w")
592
+
593
+ # Write the init line to log
594
+ log_file.write(line)
595
+ log_file.flush()
596
+
597
+ status = JobStatus(
598
+ job_id=job_id,
599
+ harness=harness,
600
+ agent_id=job_id,
601
+ pid=process.pid,
602
+ logs=str(manager.get_log_path(job_id)),
603
+ status="running",
604
+ status_reason="initializing",
605
+ start_time=utc_now_iso(),
606
+ last_event_time=utc_now_iso(),
607
+ operations=1, # Count the init event
608
+ )
609
+
610
+ # Write initial status
611
+ manager.write_status(status)
612
+
613
+ # Build result for parent to return
614
+ result = {
615
+ "job_id": job_id,
616
+ "status_path": str(manager.get_status_path(job_id)),
617
+ "log_path": str(manager.get_log_path(job_id)),
618
+ }
619
+
620
+ # Fork: parent returns, child continues monitoring
621
+ pid = os.fork()
622
+
623
+ if pid > 0:
624
+ # Parent process - return immediately
625
+ # Don't close log_file or process handles here;
626
+ # the child owns them now
627
+ return result
628
+ else:
629
+ # Child process - detach and continue monitoring
630
+ # Create new session to detach from terminal
631
+ os.setsid()
632
+
633
+ # Redirect stdin/stdout/stderr to /dev/null
634
+ # (we communicate via status.json now)
635
+ devnull = os.open(os.devnull, os.O_RDWR)
636
+ os.dup2(devnull, sys.stdin.fileno())
637
+ os.dup2(devnull, sys.stdout.fileno())
638
+ os.dup2(devnull, sys.stderr.fileno())
639
+ os.close(devnull)
640
+
641
+ # Run the monitor loop
642
+ _run_monitor_loop(
643
+ process=process,
644
+ handler=handler,
645
+ manager=manager,
646
+ harness=harness,
647
+ job_id=job_id,
648
+ status=status,
649
+ log_file=log_file,
650
+ initial_lines=buffered_lines,
651
+ )
652
+ # Exit child process
653
+ os._exit(0)
654
+
655
+ elif job_id:
656
+ # We have a job_id but haven't forked yet - buffer the line
657
+ # (This shouldn't normally happen as we fork immediately after job_id)
658
+ buffered_lines.append(line)
659
+
660
+ # If we get here without a job_id, the harness exited early
661
+ if log_file:
662
+ log_file.close()
663
+
664
+ return {"error": "Failed to extract job ID from harness output"}
665
+
666
+ except Exception as e:
667
+ # Clean up on error
668
+ if process.poll() is None:
669
+ process.terminate()
670
+ if log_file:
671
+ log_file.close()
672
+
673
+ if status:
674
+ status.status = "error"
675
+ status.status_reason = str(e)
676
+ status.end_time = utc_now_iso()
677
+ manager.write_status(status)
678
+
679
+ raise
680
+
681
+
682
+ # =============================================================================
683
+ # Status Command
684
+ # =============================================================================
685
+
686
+ def get_status(job_id: str) -> dict:
687
+ """Get status for a job with computed fields."""
688
+ manager = JobManager()
689
+ status = manager.read_status(job_id)
690
+
691
+ if not status:
692
+ return {"error": f"Job not found: {job_id}"}
693
+
694
+ now = datetime.now(timezone.utc)
695
+
696
+ # Compute runtime_sec
697
+ runtime_sec = 0.0
698
+ if status.start_time:
699
+ start = parse_iso_time(status.start_time)
700
+ if status.end_time:
701
+ end = parse_iso_time(status.end_time)
702
+ else:
703
+ end = now
704
+ runtime_sec = (end - start).total_seconds()
705
+
706
+ # Compute idle_sec
707
+ idle_sec = 0.0
708
+ if status.last_event_time:
709
+ last = parse_iso_time(status.last_event_time)
710
+ idle_sec = (now - last).total_seconds()
711
+
712
+ # Build result
713
+ result = {
714
+ "job_id": status.job_id,
715
+ "harness": status.harness,
716
+ "pid": status.pid,
717
+ "logs": status.logs,
718
+ "status": status.status,
719
+ "status_reason": status.status_reason,
720
+ "start_time": status.start_time,
721
+ "last_event_time": status.last_event_time,
722
+ "runtime_sec": round(runtime_sec, 1),
723
+ "operations": status.operations,
724
+ }
725
+
726
+ # Only include idle_sec when job is still running
727
+ if status.status == "running":
728
+ result["idle_sec"] = round(idle_sec, 1)
729
+
730
+ # Only include end_time and completion when job has finished
731
+ if status.end_time:
732
+ result["end_time"] = status.end_time
733
+ result["completion"] = {
734
+ "messages": status.completion.messages,
735
+ "tokens": {
736
+ "input": status.completion.tokens.input,
737
+ "output": status.completion.tokens.output,
738
+ "total": status.completion.tokens.total,
739
+ },
740
+ "duration_ms": status.completion.duration_ms,
741
+ }
742
+
743
+ return result
744
+
745
+
746
+ # =============================================================================
747
+ # List Command
748
+ # =============================================================================
749
+
750
+ def list_jobs() -> list[dict]:
751
+ """List all jobs."""
752
+ manager = JobManager()
753
+ return manager.list_jobs()
754
+
755
+
756
+ # =============================================================================
757
+ # CLI
758
+ # =============================================================================
759
+
760
+ def main():
761
+ parser = argparse.ArgumentParser(
762
+ description="Agent Job Adapter - Manage file-backed agent jobs"
763
+ )
764
+ subparsers = parser.add_subparsers(dest="command", required=True)
765
+
766
+ # spawn command
767
+ spawn_parser = subparsers.add_parser(
768
+ "spawn",
769
+ help="Spawn a new agent job"
770
+ )
771
+ spawn_parser.add_argument(
772
+ "--harness",
773
+ required=True,
774
+ choices=["gemini", "codex"],
775
+ help="Agent harness to use"
776
+ )
777
+ spawn_parser.add_argument(
778
+ "--consultant",
779
+ action="store_true",
780
+ help="Append consultant template (WORKFLOW + TEAMWORK) to assignment"
781
+ )
782
+ spawn_parser.add_argument(
783
+ "assignment",
784
+ nargs=argparse.REMAINDER,
785
+ help="Assignment text (after --)"
786
+ )
787
+
788
+ # status command
789
+ status_parser = subparsers.add_parser(
790
+ "status",
791
+ help="Get status of a job"
792
+ )
793
+ status_parser.add_argument(
794
+ "job_id",
795
+ help="Job ID to query"
796
+ )
797
+
798
+ # list command
799
+ subparsers.add_parser(
800
+ "list",
801
+ help="List all jobs"
802
+ )
803
+
804
+ args = parser.parse_args()
805
+
806
+ try:
807
+ if args.command == "spawn":
808
+ # Handle assignment parsing (skip leading --)
809
+ assignment_parts = args.assignment
810
+ if assignment_parts and assignment_parts[0] == "--":
811
+ assignment_parts = assignment_parts[1:]
812
+
813
+ if not assignment_parts:
814
+ print(json.dumps({"error": "No assignment provided"}))
815
+ sys.exit(1)
816
+
817
+ assignment = " ".join(assignment_parts)
818
+
819
+ # Append consultant template if --consultant flag is set
820
+ if args.consultant:
821
+ template = get_consultant_template()
822
+ if template:
823
+ assignment = assignment + "\n\n" + template
824
+
825
+ result = spawn_job(args.harness, assignment)
826
+
827
+ if "error" in result:
828
+ print(json.dumps(result))
829
+ sys.exit(1)
830
+
831
+ # Print user-friendly message with status command
832
+ job_id = result.get("job_id", "")
833
+ script_path = sys.argv[0]
834
+ print(f'Agent running. Check status with: `uv run {script_path} status {job_id}`')
835
+ sys.stdout.flush()
836
+
837
+ elif args.command == "status":
838
+ result = get_status(args.job_id)
839
+ print(json.dumps(result, indent=2))
840
+
841
+ if "error" in result:
842
+ sys.exit(1)
843
+
844
+ elif args.command == "list":
845
+ result = list_jobs()
846
+ print(json.dumps(result, indent=2))
847
+
848
+ except KeyboardInterrupt:
849
+ print(json.dumps({"error": "Interrupted"}))
850
+ sys.exit(130)
851
+
852
+ except Exception as e:
853
+ print(json.dumps({"error": str(e)}))
854
+ sys.exit(1)
855
+
856
+
857
+ if __name__ == "__main__":
858
+ main()