claude-comms 1.0.45 → 2.0.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +12 -13
  3. package/bin/claude-setup.js +3 -4
  4. package/package.json +6 -10
  5. package/src/orchestrator/installer.js +5 -63
  6. package/.agents/AGENTS.md +0 -61
  7. package/.agents/repo.md +0 -18
  8. package/.agents/tools/README.md +0 -45
  9. package/.agents/tools/agent-job/README.md +0 -208
  10. package/.agents/tools/agent-job/agent_job.py +0 -866
  11. package/.agents/tools/agent-job/agent_monitor.py +0 -575
  12. package/.agents/tools/agent-job/consultant_template.txt +0 -73
  13. package/.agents/tools/chrome-devtools/README.md +0 -101
  14. package/.agents/tools/chrome-devtools/browsertools.py +0 -839
  15. package/.agents/tools/chrome-devtools/config.json +0 -24
  16. package/.claude/agents/core/architect.md +0 -316
  17. package/.claude/agents/core/consultants/codex.md +0 -39
  18. package/.claude/agents/core/consultants/gemini.md +0 -39
  19. package/.claude/agents/core/designer.md +0 -181
  20. package/.claude/agents/core/engineer.md +0 -157
  21. package/.claude/agents/core/planner.md +0 -251
  22. package/.claude/agents/core/uat.md +0 -147
  23. package/.claude/agents/util/feedback-executor.md +0 -29
  24. package/.claude/agents/util/meta-agent-builder.md +0 -309
  25. package/.claude/agents/util/session-review.md +0 -228
  26. package/.claude/commands/action-feedback-item.md +0 -22
  27. package/.claude/commands/analyse-document.md +0 -10
  28. package/.claude/commands/await-feedback-orchestrator.md +0 -12
  29. package/.claude/commands/await-feedback.md +0 -21
  30. package/.claude/commands/cook.md +0 -119
  31. package/.claude/commands/feedback.md +0 -140
  32. package/.claude/commands/hello-world.md +0 -1
  33. package/.claude/commands/install-feedback-widget.md +0 -599
  34. package/.claude/commands/learn.md +0 -32
  35. package/.claude/commands/uat-refine.md +0 -121
  36. package/.claude/hooks/comms/get_unread_messages.py +0 -71
  37. package/.claude/hooks/comms/register_subagent.py +0 -74
  38. package/.claude/hooks/comms/send_message.py +0 -87
  39. package/.claude/hooks/comms/update_subagent_completion.py +0 -126
  40. package/.claude/hooks/context/session_id.py +0 -35
  41. package/.claude/hooks/observability/send_event.py +0 -110
  42. package/.claude/hooks/session-data/get_session_data.py +0 -261
  43. package/.claude/hooks/session-data/intercept_session_id.py +0 -69
  44. package/.claude/hooks/utils/server_config.py +0 -17
  45. package/.claude/settings.json +0 -108
  46. package/CLAUDE.md +0 -240
@@ -1,866 +0,0 @@
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
- "-m", "gemini-3-pro-preview",
415
- "--output-format", "stream-json",
416
- assignment,
417
- ]
418
- elif harness == "codex":
419
- return [
420
- "codex",
421
- "--yolo",
422
- "e",
423
- assignment,
424
- "--json",
425
- ]
426
- else:
427
- raise ValueError(f"Unknown harness: {harness}")
428
-
429
-
430
- def _run_monitor_loop(
431
- process: subprocess.Popen,
432
- handler,
433
- manager: JobManager,
434
- harness: str,
435
- job_id: str,
436
- status: JobStatus,
437
- log_file,
438
- initial_lines: list[str],
439
- ) -> None:
440
- """
441
- Background monitor loop - runs in forked child process.
442
-
443
- Continues reading harness output, updating status.json, and enforcing idle timeout.
444
- """
445
- seen_completion = False
446
- last_event_time = time.time()
447
-
448
- try:
449
- # First, process any lines we already buffered before forking
450
- for line in initial_lines:
451
- event = try_parse_json(line)
452
- if event:
453
- last_event_time = time.time()
454
- status.operations += 1
455
- status.last_event_time = utc_now_iso()
456
- handler.process_event(event, status)
457
- if handler.is_completion_event(event):
458
- seen_completion = True
459
- manager.write_status(status)
460
-
461
- if log_file:
462
- log_file.write(line)
463
- log_file.flush()
464
-
465
- # Continue reading from harness
466
- for line in iter(process.stdout.readline, ""):
467
- if not line:
468
- if process.poll() is not None:
469
- break
470
- continue
471
-
472
- current_time = time.time()
473
-
474
- # Check idle timeout
475
- if (current_time - last_event_time) > IDLE_TIMEOUT_SEC:
476
- process.terminate()
477
- try:
478
- process.wait(timeout=5)
479
- except subprocess.TimeoutExpired:
480
- process.kill()
481
-
482
- status.status = "timeout"
483
- status.status_reason = "idle_timeout"
484
- status.end_time = utc_now_iso()
485
- manager.write_status(status)
486
-
487
- if log_file:
488
- log_file.close()
489
- return
490
-
491
- # Try to parse as JSON
492
- event = try_parse_json(line)
493
-
494
- if event:
495
- last_event_time = current_time
496
- status.operations += 1
497
- status.last_event_time = utc_now_iso()
498
-
499
- handler.process_event(event, status)
500
-
501
- if handler.is_completion_event(event):
502
- seen_completion = True
503
-
504
- manager.write_status(status)
505
-
506
- # Write to log file
507
- if log_file:
508
- log_file.write(line)
509
- log_file.flush()
510
-
511
- # Wait for process to complete
512
- exit_code = process.wait()
513
-
514
- # Finalize status
515
- status.end_time = utc_now_iso()
516
-
517
- if exit_code == 0 and seen_completion:
518
- status.status = "complete"
519
- else:
520
- status.status = "error"
521
- status.status_reason = f"process_exit_{exit_code}"
522
-
523
- manager.write_status(status)
524
-
525
- except Exception as e:
526
- if process.poll() is None:
527
- process.terminate()
528
-
529
- status.status = "error"
530
- status.status_reason = str(e)
531
- status.end_time = utc_now_iso()
532
- manager.write_status(status)
533
-
534
- finally:
535
- if log_file:
536
- log_file.close()
537
-
538
-
539
- def spawn_job(harness: str, assignment: str) -> dict:
540
- """
541
- Spawn a new agent job.
542
-
543
- Starts the harness, waits for job ID, then forks to background.
544
- Parent returns immediately with job info; child continues monitoring.
545
-
546
- Returns dict with job_id, status_path, log_path on success.
547
- """
548
- manager = JobManager()
549
-
550
- # Build and start harness command
551
- cmd = build_harness_command(harness, assignment)
552
-
553
- process = subprocess.Popen(
554
- cmd,
555
- stdin=subprocess.DEVNULL, # Detach from TTY to survive fork
556
- stdout=subprocess.PIPE,
557
- stderr=subprocess.STDOUT,
558
- text=True,
559
- bufsize=1, # Line buffered
560
- )
561
-
562
- # Create appropriate event handler
563
- if harness == "gemini":
564
- handler = GeminiEventHandler()
565
- else:
566
- handler = CodexEventHandler()
567
-
568
- job_id: Optional[str] = None
569
- status: Optional[JobStatus] = None
570
- log_file = None
571
- buffered_lines: list[str] = [] # Lines read after job_id but before fork
572
- first_lines: list[str] = [] # Capture first few lines for error reporting
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
- # Capture first few lines for debugging if we fail
583
- if len(first_lines) < 10:
584
- first_lines.append(line.rstrip())
585
-
586
- # Try to parse as JSON
587
- event = try_parse_json(line)
588
-
589
- if event and not job_id:
590
- job_id = handler.extract_job_id(event)
591
-
592
- if job_id:
593
- # Initialize job
594
- manager.create_job_dir(job_id)
595
- log_file = open(manager.get_log_path(job_id), "w")
596
-
597
- # Write the init line to log
598
- log_file.write(line)
599
- log_file.flush()
600
-
601
- status = JobStatus(
602
- job_id=job_id,
603
- harness=harness,
604
- agent_id=job_id,
605
- pid=process.pid,
606
- logs=str(manager.get_log_path(job_id)),
607
- status="running",
608
- status_reason="initializing",
609
- start_time=utc_now_iso(),
610
- last_event_time=utc_now_iso(),
611
- operations=1, # Count the init event
612
- )
613
-
614
- # Write initial status
615
- manager.write_status(status)
616
-
617
- # Build result for parent to return
618
- result = {
619
- "job_id": job_id,
620
- "status_path": str(manager.get_status_path(job_id)),
621
- "log_path": str(manager.get_log_path(job_id)),
622
- }
623
-
624
- # Fork: parent returns, child continues monitoring
625
- pid = os.fork()
626
-
627
- if pid > 0:
628
- # Parent process - return immediately
629
- # Don't close log_file or process handles here;
630
- # the child owns them now
631
- return result
632
- else:
633
- # Child process - detach and continue monitoring
634
- # Create new session to detach from terminal
635
- os.setsid()
636
-
637
- # Redirect stdin/stdout/stderr to /dev/null
638
- # (we communicate via status.json now)
639
- devnull = os.open(os.devnull, os.O_RDWR)
640
- os.dup2(devnull, sys.stdin.fileno())
641
- os.dup2(devnull, sys.stdout.fileno())
642
- os.dup2(devnull, sys.stderr.fileno())
643
- os.close(devnull)
644
-
645
- # Run the monitor loop
646
- _run_monitor_loop(
647
- process=process,
648
- handler=handler,
649
- manager=manager,
650
- harness=harness,
651
- job_id=job_id,
652
- status=status,
653
- log_file=log_file,
654
- initial_lines=buffered_lines,
655
- )
656
- # Exit child process
657
- os._exit(0)
658
-
659
- elif job_id:
660
- # We have a job_id but haven't forked yet - buffer the line
661
- # (This shouldn't normally happen as we fork immediately after job_id)
662
- buffered_lines.append(line)
663
-
664
- # If we get here without a job_id, the harness exited early
665
- if log_file:
666
- log_file.close()
667
-
668
- return {
669
- "error": "Failed to extract job ID from harness output",
670
- "exit_code": process.poll(),
671
- "output": first_lines,
672
- }
673
-
674
- except Exception as e:
675
- # Clean up on error
676
- if process.poll() is None:
677
- process.terminate()
678
- if log_file:
679
- log_file.close()
680
-
681
- if status:
682
- status.status = "error"
683
- status.status_reason = str(e)
684
- status.end_time = utc_now_iso()
685
- manager.write_status(status)
686
-
687
- raise
688
-
689
-
690
- # =============================================================================
691
- # Status Command
692
- # =============================================================================
693
-
694
- def get_status(job_id: str) -> dict:
695
- """Get status for a job with computed fields."""
696
- manager = JobManager()
697
- status = manager.read_status(job_id)
698
-
699
- if not status:
700
- return {"error": f"Job not found: {job_id}"}
701
-
702
- now = datetime.now(timezone.utc)
703
-
704
- # Compute runtime_sec
705
- runtime_sec = 0.0
706
- if status.start_time:
707
- start = parse_iso_time(status.start_time)
708
- if status.end_time:
709
- end = parse_iso_time(status.end_time)
710
- else:
711
- end = now
712
- runtime_sec = (end - start).total_seconds()
713
-
714
- # Compute idle_sec
715
- idle_sec = 0.0
716
- if status.last_event_time:
717
- last = parse_iso_time(status.last_event_time)
718
- idle_sec = (now - last).total_seconds()
719
-
720
- # Build result
721
- result = {
722
- "job_id": status.job_id,
723
- "harness": status.harness,
724
- "pid": status.pid,
725
- "logs": status.logs,
726
- "status": status.status,
727
- "status_reason": status.status_reason,
728
- "start_time": status.start_time,
729
- "last_event_time": status.last_event_time,
730
- "runtime_sec": round(runtime_sec, 1),
731
- "operations": status.operations,
732
- }
733
-
734
- # Only include idle_sec when job is still running
735
- if status.status == "running":
736
- result["idle_sec"] = round(idle_sec, 1)
737
-
738
- # Only include end_time and completion when job has finished
739
- if status.end_time:
740
- result["end_time"] = status.end_time
741
- result["completion"] = {
742
- "messages": status.completion.messages,
743
- "tokens": {
744
- "input": status.completion.tokens.input,
745
- "output": status.completion.tokens.output,
746
- "total": status.completion.tokens.total,
747
- },
748
- "duration_ms": status.completion.duration_ms,
749
- }
750
-
751
- return result
752
-
753
-
754
- # =============================================================================
755
- # List Command
756
- # =============================================================================
757
-
758
- def list_jobs() -> list[dict]:
759
- """List all jobs."""
760
- manager = JobManager()
761
- return manager.list_jobs()
762
-
763
-
764
- # =============================================================================
765
- # CLI
766
- # =============================================================================
767
-
768
- def main():
769
- parser = argparse.ArgumentParser(
770
- description="Agent Job Adapter - Manage file-backed agent jobs"
771
- )
772
- subparsers = parser.add_subparsers(dest="command", required=True)
773
-
774
- # spawn command
775
- spawn_parser = subparsers.add_parser(
776
- "spawn",
777
- help="Spawn a new agent job"
778
- )
779
- spawn_parser.add_argument(
780
- "--harness",
781
- required=True,
782
- choices=["gemini", "codex"],
783
- help="Agent harness to use"
784
- )
785
- spawn_parser.add_argument(
786
- "--consultant",
787
- action="store_true",
788
- help="Append consultant template (WORKFLOW + TEAMWORK) to assignment"
789
- )
790
- spawn_parser.add_argument(
791
- "assignment",
792
- nargs=argparse.REMAINDER,
793
- help="Assignment text (after --)"
794
- )
795
-
796
- # status command
797
- status_parser = subparsers.add_parser(
798
- "status",
799
- help="Get status of a job"
800
- )
801
- status_parser.add_argument(
802
- "job_id",
803
- help="Job ID to query"
804
- )
805
-
806
- # list command
807
- subparsers.add_parser(
808
- "list",
809
- help="List all jobs"
810
- )
811
-
812
- args = parser.parse_args()
813
-
814
- try:
815
- if args.command == "spawn":
816
- # Handle assignment parsing (skip leading --)
817
- assignment_parts = args.assignment
818
- if assignment_parts and assignment_parts[0] == "--":
819
- assignment_parts = assignment_parts[1:]
820
-
821
- if not assignment_parts:
822
- print(json.dumps({"error": "No assignment provided"}))
823
- sys.exit(1)
824
-
825
- assignment = " ".join(assignment_parts)
826
-
827
- # Append consultant template if --consultant flag is set
828
- if args.consultant:
829
- template = get_consultant_template()
830
- if template:
831
- assignment = assignment + "\n\n" + template
832
-
833
- result = spawn_job(args.harness, assignment)
834
-
835
- if "error" in result:
836
- print(json.dumps(result, indent=2))
837
- sys.exit(1)
838
-
839
- # Print user-friendly message with status command
840
- job_id = result.get("job_id", "")
841
- script_path = sys.argv[0]
842
- print(f'Agent running. Check status with: `uv run {script_path} status {job_id}`')
843
- sys.stdout.flush()
844
-
845
- elif args.command == "status":
846
- result = get_status(args.job_id)
847
- print(json.dumps(result, indent=2))
848
-
849
- if "error" in result:
850
- sys.exit(1)
851
-
852
- elif args.command == "list":
853
- result = list_jobs()
854
- print(json.dumps(result, indent=2))
855
-
856
- except KeyboardInterrupt:
857
- print(json.dumps({"error": "Interrupted"}))
858
- sys.exit(130)
859
-
860
- except Exception as e:
861
- print(json.dumps({"error": str(e)}))
862
- sys.exit(1)
863
-
864
-
865
- if __name__ == "__main__":
866
- main()