claude-comms 1.0.46 → 2.0.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +12 -13
  3. package/package.json +6 -10
  4. package/.agents/AGENTS.md +0 -61
  5. package/.agents/repo.md +0 -18
  6. package/.agents/tools/README.md +0 -45
  7. package/.agents/tools/agent-job/README.md +0 -208
  8. package/.agents/tools/agent-job/__pycache__/agent_monitor.cpython-312.pyc +0 -0
  9. package/.agents/tools/agent-job/agent_job.py +0 -936
  10. package/.agents/tools/agent-job/agent_monitor.py +0 -575
  11. package/.agents/tools/agent-job/consultant_template.txt +0 -73
  12. package/.agents/tools/chrome-devtools/README.md +0 -101
  13. package/.agents/tools/chrome-devtools/browsertools.py +0 -839
  14. package/.agents/tools/chrome-devtools/config.json +0 -24
  15. package/.agents/tools/workflow/CONSUMER_SETUP.md +0 -114
  16. package/.agents/tools/workflow/ONBOARD.md +0 -167
  17. package/.agents/tools/workflow/README.md +0 -169
  18. package/.agents/tools/workflow/cli.ts +0 -726
  19. package/.agents/tools/workflow/config.example.json +0 -16
  20. package/.agents/tools/workflow/init.ts +0 -76
  21. package/.agents/tools/workflow/lib/file-tracker.test.ts +0 -81
  22. package/.agents/tools/workflow/lib/file-tracker.ts +0 -387
  23. package/.agents/tools/workflow/lib/harness-executor.ts +0 -649
  24. package/.agents/tools/workflow/lib/prompts.scenario.test.ts +0 -262
  25. package/.agents/tools/workflow/lib/prompts.ts +0 -548
  26. package/.agents/tools/workflow/lib/streams.test.ts +0 -140
  27. package/.agents/tools/workflow/lib/streams.ts +0 -254
  28. package/.agents/tools/workflow/runner.ts +0 -994
  29. package/.agents/tools/workflow/templates/document.md +0 -42
  30. package/.agents/tools/workflow/templates/implement.md +0 -117
  31. package/.agents/tools/workflow/templates/plan.md +0 -54
  32. package/.agents/tools/workflow/templates/pm.md +0 -84
  33. package/.agents/tools/workflow/templates/product-owner.md +0 -211
  34. package/.agents/tools/workflow/templates/review.md +0 -56
  35. package/.agents/tools/workflow/templates/uat.md +0 -71
  36. package/.claude/agents/core/engineer.md +0 -146
  37. package/.claude/commands/action-feedback-item.md +0 -27
  38. package/.claude/commands/install-feedback-widget.md +0 -599
  39. package/.claude/commands/learn.md +0 -32
  40. package/.claude/hooks/comms/__pycache__/register_subagent.cpython-312.pyc +0 -0
  41. package/.claude/hooks/comms/__pycache__/update_subagent_completion.cpython-312.pyc +0 -0
  42. package/.claude/hooks/comms/get_unread_messages.py +0 -71
  43. package/.claude/hooks/comms/register_subagent.py +0 -74
  44. package/.claude/hooks/comms/send_message.py +0 -87
  45. package/.claude/hooks/comms/update_subagent_completion.py +0 -126
  46. package/.claude/hooks/context/session_id.py +0 -35
  47. package/.claude/hooks/observability/send_event.py +0 -110
  48. package/.claude/hooks/session-data/get_session_data.py +0 -261
  49. package/.claude/hooks/session-data/intercept_session_id.py +0 -69
  50. package/.claude/hooks/utils/__pycache__/server_config.cpython-312.pyc +0 -0
  51. package/.claude/hooks/utils/server_config.py +0 -17
  52. package/.claude/settings.json +0 -108
  53. package/.claude/settings.local.json +0 -46
  54. package/CLAUDE.md +0 -240
@@ -1,936 +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
- class ClaudeEventHandler:
303
- """Handle Claude CLI JSON stream events."""
304
-
305
- def __init__(self):
306
- self._text_chunks: list[str] = []
307
- self._final_result: Optional[str] = None
308
- self._complete = False
309
- self._success = False
310
-
311
- def extract_job_id(self, event: dict) -> Optional[str]:
312
- """Extract job ID from initial event."""
313
- if event.get("type") == "system" and event.get("subtype") == "init":
314
- return event.get("session_id")
315
- return None
316
-
317
- def process_event(self, event: dict, status: JobStatus) -> None:
318
- """Process a Claude event and update status."""
319
- event_type = event.get("type", "")
320
-
321
- # Capture assistant text messages
322
- if event_type == "assistant":
323
- status.status_reason = "responding"
324
- msg = event.get("message", {})
325
- content = msg.get("content", [])
326
- if isinstance(content, list):
327
- for block in content:
328
- if isinstance(block, dict):
329
- if block.get("type") == "text" and block.get("text"):
330
- self._text_chunks.append(block["text"])
331
- elif block.get("type") == "tool_use":
332
- tool_name = block.get("name", "tool")
333
- status.status_reason = tool_name
334
-
335
- # Handle tool results
336
- elif event_type == "user":
337
- status.status_reason = "tool_result"
338
-
339
- # Capture final result
340
- elif event_type == "result":
341
- self._complete = True
342
- self._success = event.get("subtype") == "success"
343
- status.status_reason = "completed" if self._success else "failed"
344
-
345
- if event.get("result"):
346
- self._final_result = str(event["result"])
347
-
348
- # Extract duration if available
349
- if event.get("duration_ms"):
350
- status.completion.duration_ms = event["duration_ms"]
351
-
352
- def is_completion_event(self, event: dict) -> bool:
353
- """Check if this event indicates completion."""
354
- return event.get("type") == "result" and event.get("subtype") == "success"
355
-
356
- def get_result(self) -> str:
357
- """Get the accumulated result."""
358
- return self._final_result or "\n\n".join(self._text_chunks)
359
-
360
-
361
- # =============================================================================
362
- # Job Manager
363
- # =============================================================================
364
-
365
- class JobManager:
366
- """Manage agent job lifecycle."""
367
-
368
- def __init__(self, jobs_root: Optional[Path] = None):
369
- self.jobs_root = jobs_root or get_jobs_root()
370
- self.jobs_root.mkdir(parents=True, exist_ok=True)
371
-
372
- def get_job_dir(self, job_id: str) -> Path:
373
- """Get the directory for a job."""
374
- return self.jobs_root / job_id
375
-
376
- def get_status_path(self, job_id: str) -> Path:
377
- """Get the status.json path for a job."""
378
- return self.get_job_dir(job_id) / "status.json"
379
-
380
- def get_log_path(self, job_id: str) -> Path:
381
- """Get the agent.log path for a job."""
382
- return self.get_job_dir(job_id) / "agent.log"
383
-
384
- def create_job_dir(self, job_id: str) -> Path:
385
- """Create job directory structure."""
386
- job_dir = self.get_job_dir(job_id)
387
- job_dir.mkdir(parents=True, exist_ok=True)
388
- return job_dir
389
-
390
- def write_status(self, status: JobStatus) -> None:
391
- """Write status.json atomically."""
392
- status_path = self.get_status_path(status.job_id)
393
- atomic_write_json(status_path, status.to_dict())
394
-
395
- def read_status(self, job_id: str) -> Optional[JobStatus]:
396
- """Read status.json for a job."""
397
- status_path = self.get_status_path(job_id)
398
- if not status_path.exists():
399
- return None
400
-
401
- with open(status_path) as f:
402
- data = json.load(f)
403
- return JobStatus.from_dict(data)
404
-
405
- def list_jobs(self) -> list[dict]:
406
- """List all jobs with summary info."""
407
- jobs = []
408
-
409
- if not self.jobs_root.exists():
410
- return jobs
411
-
412
- for job_dir in self.jobs_root.iterdir():
413
- if not job_dir.is_dir():
414
- continue
415
-
416
- status = self.read_status(job_dir.name)
417
- if not status:
418
- continue
419
-
420
- # Compute runtime and idle time
421
- now = datetime.now(timezone.utc)
422
- runtime_sec = 0.0
423
- idle_sec = 0.0
424
- if status.start_time:
425
- start = parse_iso_time(status.start_time)
426
- if status.end_time:
427
- end = parse_iso_time(status.end_time)
428
- else:
429
- end = now
430
- runtime_sec = (end - start).total_seconds()
431
- if status.last_event_time:
432
- last = parse_iso_time(status.last_event_time)
433
- idle_sec = (now - last).total_seconds()
434
-
435
- job_data = {
436
- "job_id": status.job_id,
437
- "harness": status.harness,
438
- "pid": status.pid,
439
- "logs": status.logs,
440
- "status": status.status,
441
- "status_reason": status.status_reason,
442
- "start_time": status.start_time,
443
- "last_event_time": status.last_event_time,
444
- "runtime_sec": round(runtime_sec, 1),
445
- "operations": status.operations,
446
- }
447
-
448
- # Only include idle_sec for running jobs
449
- if status.status == "running":
450
- job_data["idle_sec"] = round(idle_sec, 1)
451
-
452
- # Include end_time for finished jobs
453
- if status.end_time:
454
- job_data["end_time"] = status.end_time
455
-
456
- jobs.append(job_data)
457
-
458
- # Sort by start_time (newest first)
459
- jobs.sort(key=lambda x: x.get("start_time") or "", reverse=True)
460
- return jobs
461
-
462
-
463
- # =============================================================================
464
- # Spawn Command
465
- # =============================================================================
466
-
467
- def build_harness_command(harness: str, assignment: str) -> list[str]:
468
- """Build the command to run the harness."""
469
- if harness == "gemini":
470
- return [
471
- "gemini",
472
- "--yolo",
473
- "-m", "gemini-3-pro-preview",
474
- "--output-format", "stream-json",
475
- assignment,
476
- ]
477
- elif harness == "codex":
478
- return [
479
- "codex",
480
- "--yolo",
481
- "e",
482
- assignment,
483
- "--json",
484
- ]
485
- elif harness == "claude":
486
- return [
487
- "claude",
488
- "--dangerously-skip-permissions",
489
- "--verbose",
490
- "--output-format", "stream-json",
491
- "-p",
492
- assignment,
493
- ]
494
- else:
495
- raise ValueError(f"Unknown harness: {harness}")
496
-
497
-
498
- def _run_monitor_loop(
499
- process: subprocess.Popen,
500
- handler,
501
- manager: JobManager,
502
- harness: str,
503
- job_id: str,
504
- status: JobStatus,
505
- log_file,
506
- initial_lines: list[str],
507
- ) -> None:
508
- """
509
- Background monitor loop - runs in forked child process.
510
-
511
- Continues reading harness output, updating status.json, and enforcing idle timeout.
512
- """
513
- seen_completion = False
514
- last_event_time = time.time()
515
-
516
- try:
517
- # First, process any lines we already buffered before forking
518
- for line in initial_lines:
519
- event = try_parse_json(line)
520
- if event:
521
- last_event_time = time.time()
522
- status.operations += 1
523
- status.last_event_time = utc_now_iso()
524
- handler.process_event(event, status)
525
- if handler.is_completion_event(event):
526
- seen_completion = True
527
- manager.write_status(status)
528
-
529
- if log_file:
530
- log_file.write(line)
531
- log_file.flush()
532
-
533
- # Continue reading from harness
534
- for line in iter(process.stdout.readline, ""):
535
- if not line:
536
- if process.poll() is not None:
537
- break
538
- continue
539
-
540
- current_time = time.time()
541
-
542
- # Check idle timeout
543
- if (current_time - last_event_time) > IDLE_TIMEOUT_SEC:
544
- process.terminate()
545
- try:
546
- process.wait(timeout=5)
547
- except subprocess.TimeoutExpired:
548
- process.kill()
549
-
550
- status.status = "timeout"
551
- status.status_reason = "idle_timeout"
552
- status.end_time = utc_now_iso()
553
- manager.write_status(status)
554
-
555
- if log_file:
556
- log_file.close()
557
- return
558
-
559
- # Try to parse as JSON
560
- event = try_parse_json(line)
561
-
562
- if event:
563
- last_event_time = current_time
564
- status.operations += 1
565
- status.last_event_time = utc_now_iso()
566
-
567
- handler.process_event(event, status)
568
-
569
- if handler.is_completion_event(event):
570
- seen_completion = True
571
-
572
- manager.write_status(status)
573
-
574
- # Write to log file
575
- if log_file:
576
- log_file.write(line)
577
- log_file.flush()
578
-
579
- # Wait for process to complete
580
- exit_code = process.wait()
581
-
582
- # Finalize status
583
- status.end_time = utc_now_iso()
584
-
585
- if exit_code == 0 and seen_completion:
586
- status.status = "complete"
587
- else:
588
- status.status = "error"
589
- status.status_reason = f"process_exit_{exit_code}"
590
-
591
- manager.write_status(status)
592
-
593
- except Exception as e:
594
- if process.poll() is None:
595
- process.terminate()
596
-
597
- status.status = "error"
598
- status.status_reason = str(e)
599
- status.end_time = utc_now_iso()
600
- manager.write_status(status)
601
-
602
- finally:
603
- if log_file:
604
- log_file.close()
605
-
606
-
607
- def spawn_job(harness: str, assignment: str) -> dict:
608
- """
609
- Spawn a new agent job.
610
-
611
- Starts the harness, waits for job ID, then forks to background.
612
- Parent returns immediately with job info; child continues monitoring.
613
-
614
- Returns dict with job_id, status_path, log_path on success.
615
- """
616
- manager = JobManager()
617
-
618
- # Build and start harness command
619
- cmd = build_harness_command(harness, assignment)
620
-
621
- process = subprocess.Popen(
622
- cmd,
623
- stdin=subprocess.DEVNULL, # Detach from TTY to survive fork
624
- stdout=subprocess.PIPE,
625
- stderr=subprocess.STDOUT,
626
- text=True,
627
- bufsize=1, # Line buffered
628
- )
629
-
630
- # Create appropriate event handler
631
- if harness == "gemini":
632
- handler = GeminiEventHandler()
633
- elif harness == "claude":
634
- handler = ClaudeEventHandler()
635
- else:
636
- handler = CodexEventHandler()
637
-
638
- job_id: Optional[str] = None
639
- status: Optional[JobStatus] = None
640
- log_file = None
641
- buffered_lines: list[str] = [] # Lines read after job_id but before fork
642
- first_lines: list[str] = [] # Capture first few lines for error reporting
643
-
644
- try:
645
- # Read stdout until we get the job ID
646
- for line in iter(process.stdout.readline, ""):
647
- if not line:
648
- if process.poll() is not None:
649
- break
650
- continue
651
-
652
- # Capture first few lines for debugging if we fail
653
- if len(first_lines) < 10:
654
- first_lines.append(line.rstrip())
655
-
656
- # Try to parse as JSON
657
- event = try_parse_json(line)
658
-
659
- if event and not job_id:
660
- job_id = handler.extract_job_id(event)
661
-
662
- if job_id:
663
- # Initialize job
664
- manager.create_job_dir(job_id)
665
- log_file = open(manager.get_log_path(job_id), "w")
666
-
667
- # Write the init line to log
668
- log_file.write(line)
669
- log_file.flush()
670
-
671
- status = JobStatus(
672
- job_id=job_id,
673
- harness=harness,
674
- agent_id=job_id,
675
- pid=process.pid,
676
- logs=str(manager.get_log_path(job_id)),
677
- status="running",
678
- status_reason="initializing",
679
- start_time=utc_now_iso(),
680
- last_event_time=utc_now_iso(),
681
- operations=1, # Count the init event
682
- )
683
-
684
- # Write initial status
685
- manager.write_status(status)
686
-
687
- # Build result for parent to return
688
- result = {
689
- "job_id": job_id,
690
- "status_path": str(manager.get_status_path(job_id)),
691
- "log_path": str(manager.get_log_path(job_id)),
692
- }
693
-
694
- # Fork: parent returns, child continues monitoring
695
- pid = os.fork()
696
-
697
- if pid > 0:
698
- # Parent process - return immediately
699
- # Don't close log_file or process handles here;
700
- # the child owns them now
701
- return result
702
- else:
703
- # Child process - detach and continue monitoring
704
- # Create new session to detach from terminal
705
- os.setsid()
706
-
707
- # Redirect stdin/stdout/stderr to /dev/null
708
- # (we communicate via status.json now)
709
- devnull = os.open(os.devnull, os.O_RDWR)
710
- os.dup2(devnull, sys.stdin.fileno())
711
- os.dup2(devnull, sys.stdout.fileno())
712
- os.dup2(devnull, sys.stderr.fileno())
713
- os.close(devnull)
714
-
715
- # Run the monitor loop
716
- _run_monitor_loop(
717
- process=process,
718
- handler=handler,
719
- manager=manager,
720
- harness=harness,
721
- job_id=job_id,
722
- status=status,
723
- log_file=log_file,
724
- initial_lines=buffered_lines,
725
- )
726
- # Exit child process
727
- os._exit(0)
728
-
729
- elif job_id:
730
- # We have a job_id but haven't forked yet - buffer the line
731
- # (This shouldn't normally happen as we fork immediately after job_id)
732
- buffered_lines.append(line)
733
-
734
- # If we get here without a job_id, the harness exited early
735
- if log_file:
736
- log_file.close()
737
-
738
- return {
739
- "error": "Failed to extract job ID from harness output",
740
- "exit_code": process.poll(),
741
- "output": first_lines,
742
- }
743
-
744
- except Exception as e:
745
- # Clean up on error
746
- if process.poll() is None:
747
- process.terminate()
748
- if log_file:
749
- log_file.close()
750
-
751
- if status:
752
- status.status = "error"
753
- status.status_reason = str(e)
754
- status.end_time = utc_now_iso()
755
- manager.write_status(status)
756
-
757
- raise
758
-
759
-
760
- # =============================================================================
761
- # Status Command
762
- # =============================================================================
763
-
764
- def get_status(job_id: str) -> dict:
765
- """Get status for a job with computed fields."""
766
- manager = JobManager()
767
- status = manager.read_status(job_id)
768
-
769
- if not status:
770
- return {"error": f"Job not found: {job_id}"}
771
-
772
- now = datetime.now(timezone.utc)
773
-
774
- # Compute runtime_sec
775
- runtime_sec = 0.0
776
- if status.start_time:
777
- start = parse_iso_time(status.start_time)
778
- if status.end_time:
779
- end = parse_iso_time(status.end_time)
780
- else:
781
- end = now
782
- runtime_sec = (end - start).total_seconds()
783
-
784
- # Compute idle_sec
785
- idle_sec = 0.0
786
- if status.last_event_time:
787
- last = parse_iso_time(status.last_event_time)
788
- idle_sec = (now - last).total_seconds()
789
-
790
- # Build result
791
- result = {
792
- "job_id": status.job_id,
793
- "harness": status.harness,
794
- "pid": status.pid,
795
- "logs": status.logs,
796
- "status": status.status,
797
- "status_reason": status.status_reason,
798
- "start_time": status.start_time,
799
- "last_event_time": status.last_event_time,
800
- "runtime_sec": round(runtime_sec, 1),
801
- "operations": status.operations,
802
- }
803
-
804
- # Only include idle_sec when job is still running
805
- if status.status == "running":
806
- result["idle_sec"] = round(idle_sec, 1)
807
-
808
- # Only include end_time and completion when job has finished
809
- if status.end_time:
810
- result["end_time"] = status.end_time
811
- result["completion"] = {
812
- "messages": status.completion.messages,
813
- "tokens": {
814
- "input": status.completion.tokens.input,
815
- "output": status.completion.tokens.output,
816
- "total": status.completion.tokens.total,
817
- },
818
- "duration_ms": status.completion.duration_ms,
819
- }
820
-
821
- return result
822
-
823
-
824
- # =============================================================================
825
- # List Command
826
- # =============================================================================
827
-
828
- def list_jobs() -> list[dict]:
829
- """List all jobs."""
830
- manager = JobManager()
831
- return manager.list_jobs()
832
-
833
-
834
- # =============================================================================
835
- # CLI
836
- # =============================================================================
837
-
838
- def main():
839
- parser = argparse.ArgumentParser(
840
- description="Agent Job Adapter - Manage file-backed agent jobs"
841
- )
842
- subparsers = parser.add_subparsers(dest="command", required=True)
843
-
844
- # spawn command
845
- spawn_parser = subparsers.add_parser(
846
- "spawn",
847
- help="Spawn a new agent job"
848
- )
849
- spawn_parser.add_argument(
850
- "--harness",
851
- required=True,
852
- choices=["gemini", "codex", "claude"],
853
- help="Agent harness to use"
854
- )
855
- spawn_parser.add_argument(
856
- "--consultant",
857
- action="store_true",
858
- help="Append consultant template (WORKFLOW + TEAMWORK) to assignment"
859
- )
860
- spawn_parser.add_argument(
861
- "assignment",
862
- nargs=argparse.REMAINDER,
863
- help="Assignment text (after --)"
864
- )
865
-
866
- # status command
867
- status_parser = subparsers.add_parser(
868
- "status",
869
- help="Get status of a job"
870
- )
871
- status_parser.add_argument(
872
- "job_id",
873
- help="Job ID to query"
874
- )
875
-
876
- # list command
877
- subparsers.add_parser(
878
- "list",
879
- help="List all jobs"
880
- )
881
-
882
- args = parser.parse_args()
883
-
884
- try:
885
- if args.command == "spawn":
886
- # Handle assignment parsing (skip leading --)
887
- assignment_parts = args.assignment
888
- if assignment_parts and assignment_parts[0] == "--":
889
- assignment_parts = assignment_parts[1:]
890
-
891
- if not assignment_parts:
892
- print(json.dumps({"error": "No assignment provided"}))
893
- sys.exit(1)
894
-
895
- assignment = " ".join(assignment_parts)
896
-
897
- # Append consultant template if --consultant flag is set
898
- if args.consultant:
899
- template = get_consultant_template()
900
- if template:
901
- assignment = assignment + "\n\n" + template
902
-
903
- result = spawn_job(args.harness, assignment)
904
-
905
- if "error" in result:
906
- print(json.dumps(result, indent=2))
907
- sys.exit(1)
908
-
909
- # Print user-friendly message with status command
910
- job_id = result.get("job_id", "")
911
- script_path = sys.argv[0]
912
- print(f'Agent running. Check status with: `uv run {script_path} status {job_id}`')
913
- sys.stdout.flush()
914
-
915
- elif args.command == "status":
916
- result = get_status(args.job_id)
917
- print(json.dumps(result, indent=2))
918
-
919
- if "error" in result:
920
- sys.exit(1)
921
-
922
- elif args.command == "list":
923
- result = list_jobs()
924
- print(json.dumps(result, indent=2))
925
-
926
- except KeyboardInterrupt:
927
- print(json.dumps({"error": "Interrupted"}))
928
- sys.exit(130)
929
-
930
- except Exception as e:
931
- print(json.dumps({"error": str(e)}))
932
- sys.exit(1)
933
-
934
-
935
- if __name__ == "__main__":
936
- main()