nullabot 1.0.1__py3-none-any.whl

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,785 @@
1
+ """
2
+ Claude Code Agent - Uses Claude Code CLI for autonomous work.
3
+
4
+ Features:
5
+ - Memory system (long-term + short-term like ChatGPT)
6
+ - Agent handoff (designer sees what thinker did)
7
+ - Cost tracking per project
8
+ - 30-minute timeout for complex tasks
9
+ - Clean folder structure (each agent has its own folder)
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import signal
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Optional
19
+
20
+ from rich.console import Console
21
+ from rich.panel import Panel
22
+ from rich.markdown import Markdown
23
+
24
+ from nullabot.core.memory import ProjectMemory, UsageTracker, UserMemory
25
+ from nullabot.core.rate_limiter import ClaudeCodeRateLimiter
26
+ from nullabot.core.reliability import ExitDetector, ProgressTracker
27
+
28
+ console = Console()
29
+
30
+ # Timeout: 30 minutes (was 10)
31
+ DEFAULT_TIMEOUT = 1800
32
+
33
+
34
+ class ClaudeAgent:
35
+ """
36
+ Agent that uses Claude Code CLI directly.
37
+
38
+ Each agent works in its own subdirectory:
39
+ project/
40
+ thinker/ - Research & ideation output
41
+ designer/ - UI/UX design output
42
+ coder/ - Source code output
43
+ .nullabot/ - Shared state, memory, usage
44
+
45
+ Features:
46
+ - Uses your Claude Code subscription (no API key needed)
47
+ - Memory system for context sharing between agents
48
+ - Cost tracking per project
49
+ - Automatic handoff context from previous agents
50
+ """
51
+
52
+ PROMPTS = {
53
+ "thinker": """You are a Research & Ideation specialist working autonomously.
54
+
55
+ Your task: {task}
56
+
57
+ {context}
58
+
59
+ ## Your Workspace
60
+ You are working in the `thinker/` folder of this project.
61
+ Write all your output files here.
62
+ {previous_work_hint}
63
+
64
+ ## Instructions
65
+ 1. Think deeply about this topic
66
+ 2. Research and explore multiple angles
67
+ 3. Write your findings to files:
68
+ - research/ - Research notes and findings
69
+ - ideas/ - Brainstormed ideas
70
+ - analysis/ - Detailed analysis
71
+ - recommendations.md - Final recommendations
72
+
73
+ Work continuously. After each major step, summarize your progress.
74
+ Focus on creating valuable, well-organized output files.
75
+
76
+ IMPORTANT:
77
+ - At the end of your response, include a STATUS line like:
78
+ STATUS: Working on [current task] | Files: [count] | Progress: [percentage]%
79
+ - Also include a SUMMARY line with what you accomplished:
80
+ SUMMARY: [Brief description of what you did this cycle]""",
81
+
82
+ "designer": """You are a UI/UX Design specialist working autonomously.
83
+
84
+ Your task: {task}
85
+
86
+ {context}
87
+
88
+ ## Your Workspace
89
+ You are working in the `designer/` folder of this project.
90
+ Write all your output files here.
91
+
92
+ {previous_work_hint}
93
+
94
+ ## Instructions
95
+ 1. FIRST: Read the thinker's work in `../thinker/` folder if it exists
96
+ 2. Analyze the design requirements based on research findings
97
+ 3. Create detailed component specifications
98
+ 4. Define design tokens and standards
99
+ 5. Write everything to files:
100
+ - components/ - Component specs (one file per component)
101
+ - patterns/ - UI pattern documentation
102
+ - tokens/ - Design tokens (colors, spacing, typography)
103
+ - design-system.md - Master design system document
104
+
105
+ IMPORTANT:
106
+ - At the end of your response, include a STATUS line like:
107
+ STATUS: Working on [current task] | Files: [count] | Progress: [percentage]%
108
+ - Also include a SUMMARY line with what you accomplished:
109
+ SUMMARY: [Brief description of what you did this cycle]""",
110
+
111
+ "coder": """You are a Software Engineer working autonomously.
112
+
113
+ Your task: {task}
114
+
115
+ {context}
116
+
117
+ ## Your Workspace
118
+ You are working in the `coder/` folder of this project.
119
+ Write all your output files here.
120
+
121
+ {previous_work_hint}
122
+
123
+ ## Instructions
124
+ 1. FIRST: Read previous work:
125
+ - `../thinker/` - Research and requirements
126
+ - `../designer/` - UI/UX specs and components
127
+ 2. Understand the requirements and design decisions already made
128
+ 3. Plan the architecture based on previous work
129
+ 4. Write clean, production-ready code
130
+ 5. Create comprehensive tests
131
+ 6. Organize files properly:
132
+ - src/ - Source code
133
+ - tests/ - Test files
134
+ - docs/ - Documentation
135
+ - ARCHITECTURE.md - Design decisions
136
+
137
+ IMPORTANT:
138
+ - At the end of your response, include a STATUS line like:
139
+ STATUS: Working on [current task] | Files: [count] | Progress: [percentage]%
140
+ - Also include a SUMMARY line with what you accomplished:
141
+ SUMMARY: [Brief description of what you did this cycle]""",
142
+ }
143
+
144
+ def __init__(
145
+ self,
146
+ workspace: Path,
147
+ agent_type: str = "thinker",
148
+ model: str = "opus",
149
+ on_notify: Optional[Callable[[str, str, dict], Any]] = None,
150
+ timeout: int = DEFAULT_TIMEOUT,
151
+ user_id: int = None,
152
+ base_dir: Path = None,
153
+ ):
154
+ """
155
+ Initialize Claude Agent.
156
+
157
+ Args:
158
+ workspace: Project directory (not agent-specific)
159
+ agent_type: Type of agent (thinker, designer, coder)
160
+ model: Model to use (opus, sonnet, haiku)
161
+ on_notify: Callback for notifications (event_type, message, data)
162
+ timeout: Timeout in seconds (default 30 min)
163
+ user_id: User ID for user-level memory
164
+ base_dir: Base directory (nullabot repo root) for user memory
165
+ """
166
+ # Project-level paths
167
+ self.project_dir = workspace.resolve()
168
+ self.project_dir.mkdir(parents=True, exist_ok=True)
169
+
170
+ # Agent-specific workspace (project/agent_type/)
171
+ self.agent_type = agent_type
172
+ self.workspace = self.project_dir / agent_type
173
+ self.workspace.mkdir(parents=True, exist_ok=True)
174
+
175
+ self.model = model
176
+ self.on_notify = on_notify
177
+ self.timeout = timeout
178
+ self.user_id = user_id
179
+
180
+ # Shared state at project level (.nullabot/)
181
+ self.state_dir = self.project_dir / ".nullabot"
182
+ self.state_dir.mkdir(exist_ok=True)
183
+
184
+ # Agent-specific state file
185
+ self.state_file = self.state_dir / f"state_{agent_type}.json"
186
+ self.log_file = self.state_dir / f"log_{agent_type}.jsonl"
187
+
188
+ # Base dir (nullabot repo root, default: grandparent of project)
189
+ self.base_dir = base_dir or self.project_dir.parent.parent
190
+
191
+ # Shared memory and usage at project level
192
+ self.memory = ProjectMemory(self.project_dir)
193
+ self.usage = UsageTracker(self.project_dir, self.base_dir)
194
+
195
+ # User-level memory (at {base_dir}/users/{id}/)
196
+ self.user_memory = UserMemory(self.base_dir, user_id) if user_id else None
197
+
198
+ # Control flags
199
+ self._should_stop = False
200
+ self._is_running = False
201
+
202
+ # Rate limiter - tracks 5-hour and weekly limits
203
+ self.rate_limiter = ClaudeCodeRateLimiter(
204
+ plan="max_200",
205
+ auto_wait=False, # Don't auto-wait, exit gracefully instead
206
+ )
207
+
208
+ # Exit detector - tracks when to stop
209
+ self.exit_detector = ExitDetector(
210
+ max_cycles=100,
211
+ max_cycles_no_progress=5,
212
+ )
213
+
214
+ # Progress tracker
215
+ self.progress_tracker = ProgressTracker()
216
+
217
+ # Signal handlers
218
+ signal.signal(signal.SIGINT, self._handle_signal)
219
+ signal.signal(signal.SIGTERM, self._handle_signal)
220
+
221
+ def _handle_signal(self, signum: int, frame: Any) -> None:
222
+ """Handle shutdown signals."""
223
+ console.print("\n[yellow]Stopping agent gracefully...[/yellow]")
224
+ self._should_stop = True
225
+ self._notify("stop_requested", "🛑 Stop requested, saving state...")
226
+
227
+ async def _notify(self, event: str, message: str, data: dict = None) -> None:
228
+ """Send notification via callback."""
229
+ if self.on_notify:
230
+ try:
231
+ result = self.on_notify(event, message, data or {})
232
+ if asyncio.iscoroutine(result):
233
+ await result
234
+ except Exception as e:
235
+ console.print(f"[dim]Notification error: {e}[/dim]")
236
+
237
+ def _load_state(self) -> dict:
238
+ """Load agent state from disk."""
239
+ if self.state_file.exists():
240
+ try:
241
+ return json.loads(self.state_file.read_text())
242
+ except:
243
+ pass
244
+ return {
245
+ "task": None,
246
+ "status": "idle",
247
+ "cycles": 0,
248
+ "files_created": [],
249
+ "last_checkpoint": None,
250
+ "started_at": None,
251
+ "total_time_seconds": 0,
252
+ }
253
+
254
+ def _save_state(self, state: dict) -> None:
255
+ """Save agent state to disk."""
256
+ state["updated_at"] = datetime.now().isoformat()
257
+ self.state_file.write_text(json.dumps(state, indent=2))
258
+
259
+ def _log(self, event: str, data: dict = None) -> None:
260
+ """Append to log file."""
261
+ entry = {
262
+ "timestamp": datetime.now().isoformat(),
263
+ "event": event,
264
+ "data": data or {},
265
+ }
266
+ with open(self.log_file, "a") as f:
267
+ f.write(json.dumps(entry) + "\n")
268
+
269
+ def _count_files(self) -> tuple[int, list[str]]:
270
+ """Count files in workspace (excluding .nullabot)."""
271
+ files = []
272
+ for f in self.workspace.rglob("*"):
273
+ if f.is_file() and ".nullabot" not in str(f):
274
+ rel = str(f.relative_to(self.workspace))
275
+ files.append(rel)
276
+ return len(files), files[:20]
277
+
278
+ def _extract_status(self, response: str) -> Optional[str]:
279
+ """Extract STATUS line from response."""
280
+ for line in response.split("\n"):
281
+ if line.strip().startswith("STATUS:"):
282
+ return line.strip()
283
+ return None
284
+
285
+ def _extract_summary(self, response: str) -> Optional[str]:
286
+ """Extract SUMMARY line from response."""
287
+ for line in response.split("\n"):
288
+ if line.strip().startswith("SUMMARY:"):
289
+ return line.strip().replace("SUMMARY:", "").strip()
290
+ # Fallback: first 200 chars
291
+ return response[:200].replace("\n", " ").strip()
292
+
293
+ def _make_progress_bar(self, percentage: float, width: int = 10) -> str:
294
+ """Create a text progress bar for notifications."""
295
+ filled = int(width * percentage / 100)
296
+ empty = width - filled
297
+ bar = "█" * filled + "░" * empty
298
+ return f"[{bar}]"
299
+
300
+ async def _run_claude_async(
301
+ self,
302
+ prompt: str,
303
+ ) -> tuple[str, bool]:
304
+ """Run Claude Code CLI asynchronously."""
305
+ cmd = [
306
+ "claude",
307
+ "-p",
308
+ "--output-format", "text",
309
+ "--model", self.model,
310
+ "--max-turns", "50",
311
+ "--dangerously-skip-permissions",
312
+ prompt,
313
+ ]
314
+
315
+ try:
316
+ process = await asyncio.create_subprocess_exec(
317
+ *cmd,
318
+ stdout=asyncio.subprocess.PIPE,
319
+ stderr=asyncio.subprocess.PIPE,
320
+ cwd=str(self.workspace),
321
+ env={
322
+ **os.environ,
323
+ "CLAUDE_CODE_ENTRYPOINT": "nullabot",
324
+ },
325
+ )
326
+
327
+ stdout, stderr = await asyncio.wait_for(
328
+ process.communicate(),
329
+ timeout=self.timeout,
330
+ )
331
+
332
+ output = stdout.decode().strip()
333
+ if process.returncode != 0:
334
+ error = stderr.decode() or "Unknown error"
335
+ return f"Error: {error}", False
336
+
337
+ return output, True
338
+
339
+ except asyncio.TimeoutError:
340
+ timeout_min = self.timeout // 60
341
+ return f"Error: Claude timed out after {timeout_min} minutes", False
342
+ except Exception as e:
343
+ return f"Error: {str(e)}", False
344
+
345
+ def _get_previous_work_hint(self) -> str:
346
+ """Get hint about where to find previous agents' work."""
347
+ hints = []
348
+
349
+ # Check what other agents have done
350
+ if self.agent_type == "designer":
351
+ thinker_dir = self.project_dir / "thinker"
352
+ if thinker_dir.exists() and any(thinker_dir.iterdir()):
353
+ hints.append("📂 Thinker's research is available in `../thinker/`")
354
+
355
+ elif self.agent_type == "coder":
356
+ thinker_dir = self.project_dir / "thinker"
357
+ designer_dir = self.project_dir / "designer"
358
+
359
+ if thinker_dir.exists() and any(thinker_dir.iterdir()):
360
+ hints.append("📂 Thinker's research is available in `../thinker/`")
361
+ if designer_dir.exists() and any(designer_dir.iterdir()):
362
+ hints.append("📂 Designer's specs are available in `../designer/`")
363
+
364
+ if hints:
365
+ return "## Previous Work Available\n" + "\n".join(hints)
366
+ return ""
367
+
368
+ def get_system_prompt(self, task: str) -> str:
369
+ """Get system prompt for agent type with memory context."""
370
+ template = self.PROMPTS.get(self.agent_type, self.PROMPTS["thinker"])
371
+
372
+ # Build user-level context (global preferences across all projects)
373
+ user_context = self.user_memory.build_user_context() if self.user_memory else ""
374
+
375
+ # Build project-level context
376
+ project_context = self.memory.build_context_for_agent(self.agent_type)
377
+
378
+ # Combine contexts
379
+ context_parts = []
380
+ if user_context:
381
+ context_parts.append(f"## User Preferences (global)\n\n{user_context}")
382
+ if project_context:
383
+ context_parts.append(f"## Project Context (from previous work)\n\n{project_context}")
384
+
385
+ if context_parts:
386
+ context = "\n\n".join(context_parts)
387
+ else:
388
+ context = "## Project Context\n\nThis is a new project. No previous work exists yet."
389
+
390
+ # Add hints about where previous work is located
391
+ previous_work_hint = self._get_previous_work_hint()
392
+
393
+ return template.format(
394
+ task=task,
395
+ context=context,
396
+ previous_work_hint=previous_work_hint
397
+ )
398
+
399
+ async def start(self, task: str, continuous: bool = True) -> None:
400
+ """
401
+ Start the agent on a task.
402
+
403
+ Args:
404
+ task: What to work on
405
+ continuous: Keep working in cycles until stopped
406
+ """
407
+ if self._is_running:
408
+ console.print("[red]Agent already running[/red]")
409
+ return
410
+
411
+ self._is_running = True
412
+ self._should_stop = False
413
+ start_time = datetime.now()
414
+
415
+ # Load or initialize state
416
+ state = self._load_state()
417
+
418
+ # Check for resume
419
+ can_resume = (
420
+ state.get("task") == task and
421
+ (state.get("last_checkpoint") or state.get("cycles", 0) > 0)
422
+ )
423
+
424
+ if can_resume:
425
+ console.print("[green]Resuming from checkpoint...[/green]")
426
+ await self._notify("resume", f"🔄 Resuming `{self.workspace.name}` from cycle {state.get('cycles', 0)}")
427
+
428
+ checkpoint = state.get("last_checkpoint", "")
429
+ if checkpoint:
430
+ resume_context = f"\n\nPrevious progress:\n{checkpoint}\n\nContinue from where you left off."
431
+ else:
432
+ resume_context = "\n\nContinue working on this task. You've made some progress already."
433
+
434
+ state["status"] = "running"
435
+ else:
436
+ resume_context = ""
437
+ state = {
438
+ "task": task,
439
+ "agent_type": self.agent_type,
440
+ "status": "running",
441
+ "cycles": 0,
442
+ "files_created": [],
443
+ "started_at": datetime.now().isoformat(),
444
+ "last_checkpoint": None,
445
+ "total_time_seconds": 0,
446
+ }
447
+ # Note in short-term memory
448
+ self.memory.note(f"Started {self.agent_type} agent with task: {task[:100]}")
449
+
450
+ # Extract rules from user's task prompt
451
+ if self.user_memory:
452
+ self.user_memory.extract_from_response(task)
453
+
454
+ self._save_state(state)
455
+ self._log("started", {"task": task})
456
+
457
+ # Get usage summary for notification
458
+ usage_summary = self.usage.get_summary()
459
+
460
+ # Send start notification
461
+ await self._notify(
462
+ "start",
463
+ f"🚀 *{self.agent_type.upper()}* started on `{self.workspace.name}`\n\n"
464
+ f"📋 *Task:* {task[:200]}\n"
465
+ f"🤖 *Model:* {self.model}\n"
466
+ f"💰 *Project cost so far:* ${usage_summary['total_cost_usd']:.2f}",
467
+ {"task": task, "model": self.model}
468
+ )
469
+
470
+ console.print(Panel(
471
+ f"[bold green]Starting {self.agent_type} agent[/bold green]\n\n"
472
+ f"[bold]Task:[/bold] {task}\n"
473
+ f"[bold]Workspace:[/bold] {self.workspace}\n"
474
+ f"[bold]Model:[/bold] {self.model}\n"
475
+ f"[bold]Timeout:[/bold] {self.timeout // 60} minutes\n"
476
+ f"[bold]Project cost:[/bold] ${usage_summary['total_cost_usd']:.2f}\n\n"
477
+ "[dim]Press Ctrl+C to stop gracefully[/dim]",
478
+ title="Nullabot",
479
+ ))
480
+
481
+ # Build initial prompt with memory context
482
+ system_prompt = self.get_system_prompt(task)
483
+ current_prompt = system_prompt + resume_context
484
+
485
+ cycle = state.get("cycles", 0)
486
+ errors_in_row = 0
487
+
488
+ try:
489
+ while not self._should_stop:
490
+ cycle += 1
491
+ cycle_start = datetime.now()
492
+
493
+ console.print(f"\n[bold blue]═══ Cycle {cycle} ═══[/bold blue]\n")
494
+
495
+ # Notify cycle start
496
+ await self._notify(
497
+ "cycle_start",
498
+ f"🔄 *Cycle {cycle}* starting...",
499
+ {"cycle": cycle}
500
+ )
501
+
502
+ # Run Claude
503
+ response, success = await self._run_claude_async(current_prompt)
504
+
505
+ cycle_duration = (datetime.now() - cycle_start).total_seconds()
506
+
507
+ # Track usage
508
+ usage_info = self.usage.record_cycle(
509
+ model=self.model,
510
+ agent_type=self.agent_type,
511
+ input_tokens=len(current_prompt) // 4, # Rough estimate
512
+ output_tokens=len(response) // 4 if success else 0,
513
+ duration_seconds=cycle_duration,
514
+ )
515
+
516
+ if not success:
517
+ errors_in_row += 1
518
+ console.print(f"[red]{response}[/red]")
519
+ self._log("error", {"message": response})
520
+
521
+ # Check if this is a rate limit error (5-hour limit reached)
522
+ if self.rate_limiter.is_limit_error(response):
523
+ is_limit, wait_time = self.rate_limiter.record_error(response)
524
+
525
+ # Mark the GLOBAL 5-hour window as reached
526
+ self.usage.mark_limit_reached()
527
+
528
+ if wait_time > 0:
529
+ wait_hours = wait_time / 3600
530
+ wait_minutes = (wait_time % 3600) / 60
531
+
532
+ await self._notify(
533
+ "rate_limit",
534
+ f"⏰ *5-hour limit reached!*\n\n"
535
+ f"Claude Code subscription limit hit at 100%.\n"
536
+ f"Window resets in ~{wait_hours:.1f}h ({wait_minutes:.0f}m)\n\n"
537
+ f"🔄 *Cycles completed:* {cycle}\n"
538
+ f"💰 *Cost this session:* ${self.usage.get_summary()['total_cost_usd']:.2f}\n\n"
539
+ f"Agent will stop. Run again after the window resets.",
540
+ {"cycle": cycle, "wait_time": wait_time}
541
+ )
542
+ else:
543
+ await self._notify(
544
+ "rate_limit",
545
+ f"⏰ *Rate limit reached!*\n\n"
546
+ f"`{response[:200]}`\n\n"
547
+ f"Agent stopping gracefully.",
548
+ {"cycle": cycle}
549
+ )
550
+
551
+ console.print(f"\n[yellow]Rate limit reached. Stopping agent.[/yellow]")
552
+ break
553
+
554
+ # Notify error
555
+ await self._notify(
556
+ "error",
557
+ f"❌ *Error in cycle {cycle}:*\n`{response[:300]}`",
558
+ {"cycle": cycle, "error": response}
559
+ )
560
+
561
+ if "not found" in response.lower():
562
+ await self._notify("fatal", "💀 Claude CLI not found! Install it first.")
563
+ break
564
+
565
+ # "Reached max turns" is not really an error - it's normal completion
566
+ if "max turns" in response.lower() or "reached max" in response.lower():
567
+ console.print(f"[yellow]Claude reached max turns limit. Cycle complete.[/yellow]")
568
+ errors_in_row = 0 # Don't count as error
569
+ # Continue to next cycle
570
+ await asyncio.sleep(5)
571
+ continue
572
+
573
+ if errors_in_row >= 3:
574
+ await self._notify("fatal", f"💀 Too many errors ({errors_in_row}). Stopping.")
575
+ break
576
+
577
+ await asyncio.sleep(10)
578
+ continue
579
+
580
+ errors_in_row = 0
581
+
582
+ # Record success for rate limiter tracking
583
+ self.rate_limiter.record_success(self.model)
584
+
585
+ # Check exit conditions (max cycles, no progress, etc.)
586
+ should_exit, exit_reason = self.exit_detector.should_exit(
587
+ current_cycle=cycle,
588
+ cycles_no_progress=0, # Will be updated below
589
+ progress_tracker=self.progress_tracker,
590
+ )
591
+ if should_exit:
592
+ await self._notify(
593
+ "exit_condition",
594
+ f"🛑 *Exit condition reached:* `{exit_reason}`\n\n"
595
+ f"Completed {cycle} cycles.",
596
+ {"cycle": cycle, "reason": exit_reason}
597
+ )
598
+ console.print(f"\n[yellow]Exit condition: {exit_reason}[/yellow]")
599
+ break
600
+
601
+ # Display response (truncated)
602
+ console.print(Markdown(response[:2000]))
603
+ self._log("response", {"content": response[:1000]})
604
+
605
+ # Extract and save summary to memory
606
+ summary = self._extract_summary(response)
607
+ self.memory.note(f"Cycle {cycle}: {summary}")
608
+ self.memory.extract_memories_from_response(response)
609
+
610
+ # Extract user preferences to global memory
611
+ if self.user_memory:
612
+ self.user_memory.extract_from_response(response)
613
+
614
+ # Count files
615
+ file_count, file_list = self._count_files()
616
+
617
+ # Track file purposes (basic)
618
+ for f in file_list:
619
+ if f not in self.memory.get_file_purposes():
620
+ # Try to infer purpose from path
621
+ if "research" in f.lower():
622
+ self.memory.set_file_purpose(f, "Research findings")
623
+ elif "design" in f.lower() or "component" in f.lower():
624
+ self.memory.set_file_purpose(f, "Design specifications")
625
+ elif "src" in f.lower() or f.endswith(".py") or f.endswith(".js"):
626
+ self.memory.set_file_purpose(f, "Source code")
627
+
628
+ # Extract status line
629
+ status_line = self._extract_status(response) or f"Cycle {cycle} complete"
630
+
631
+ # Update state
632
+ state["cycles"] = cycle
633
+ state["last_checkpoint"] = response[:2000]
634
+ state["status"] = "running"
635
+ state["files_created"] = file_list
636
+ state["total_time_seconds"] = (datetime.now() - start_time).total_seconds()
637
+ self._save_state(state)
638
+
639
+ # Send cycle complete notification with window usage
640
+ window_pct = usage_info.get('window_usage_pct', 0)
641
+ window_bar = self._make_progress_bar(window_pct)
642
+
643
+ await self._notify(
644
+ "cycle_complete",
645
+ f"✅ *Cycle {cycle}* complete ({cycle_duration:.0f}s)\n\n"
646
+ f"📁 *Files:* {file_count}\n"
647
+ f"⏱ *5hr window:* {window_bar} {window_pct:.0f}%\n"
648
+ f"💰 *Est. cost:* ${usage_info['cycle_cost']:.2f} (Total: ${usage_info['total_cost']:.2f})\n"
649
+ f"📊 {status_line}\n\n"
650
+ f"💬 _{summary[:200]}_",
651
+ {
652
+ "cycle": cycle,
653
+ "duration": cycle_duration,
654
+ "file_count": file_count,
655
+ "files": file_list[:10],
656
+ "summary": summary,
657
+ "cost": usage_info,
658
+ }
659
+ )
660
+
661
+ # Warn if approaching 5-hour limit
662
+ if window_pct >= 90:
663
+ await self._notify(
664
+ "limit_warning",
665
+ f"⚠️ *Warning:* 5-hour window at {window_pct:.0f}%!\n"
666
+ f"Agent will stop soon to prevent hitting the limit.",
667
+ {"window_pct": window_pct}
668
+ )
669
+ if window_pct >= 95:
670
+ console.print(f"\n[yellow]Approaching 5-hour limit ({window_pct:.0f}%). Stopping gracefully.[/yellow]")
671
+ break
672
+
673
+ if not continuous:
674
+ break
675
+
676
+ # Check for completion signals
677
+ completion_signals = ["task complete", "all done", "finished", "nothing left"]
678
+ if any(sig in response.lower() for sig in completion_signals):
679
+ await self._notify(
680
+ "maybe_complete",
681
+ f"🤔 Agent thinks it might be done. Continue? (auto-continues in 30s)",
682
+ {"cycle": cycle}
683
+ )
684
+
685
+ # Prepare next cycle prompt (include recent memory context)
686
+ recent_context = "\n".join(self.memory.get_short_term_context(3))
687
+ current_prompt = f"""Continue working on: {task}
688
+
689
+ Recent activity:
690
+ {recent_context}
691
+
692
+ Your previous output was:
693
+ {response[:1500]}
694
+
695
+ Continue making progress. Create or update files as needed.
696
+ If you've completed everything, summarize what you accomplished.
697
+
698
+ Remember to include STATUS and SUMMARY lines at the end."""
699
+
700
+ # Brief pause between cycles
701
+ console.print("\n[dim]Next cycle in 5 seconds...[/dim]")
702
+ await asyncio.sleep(5)
703
+
704
+ except Exception as e:
705
+ console.print(f"[red]Error: {e}[/red]")
706
+ self._log("error", {"message": str(e)})
707
+ await self._notify("error", f"❌ *Fatal error:*\n`{str(e)[:300]}`")
708
+
709
+ finally:
710
+ total_time = (datetime.now() - start_time).total_seconds()
711
+ file_count, file_list = self._count_files()
712
+
713
+ # Save final summary to memory for next agent
714
+ final_summary = f"Completed {cycle} cycles. Created {file_count} files. Last status: {status_line if 'status_line' in dir() else 'N/A'}"
715
+ self.memory.save_agent_summary(self.agent_type, final_summary)
716
+ self.memory.remember(f"{self.agent_type} completed task: {task[:100]}", category="milestone")
717
+
718
+ # Update user memory with project summary (global)
719
+ if self.user_memory:
720
+ project_name = self.project_dir.name
721
+ self.user_memory.update_project_summary(
722
+ project_name,
723
+ f"{self.agent_type}: {task[:100]} ({cycle} cycles, {file_count} files)"
724
+ )
725
+
726
+ state["status"] = "paused" if self._should_stop else "completed"
727
+ state["total_time_seconds"] = total_time
728
+ state["files_created"] = file_list
729
+ self._save_state(state)
730
+ self._log("stopped", {"cycles": cycle, "total_time": total_time})
731
+ self._is_running = False
732
+
733
+ # Get final usage
734
+ usage_summary = self.usage.get_summary()
735
+
736
+ # Send completion notification
737
+ hours = int(total_time // 3600)
738
+ minutes = int((total_time % 3600) // 60)
739
+ time_str = f"{hours}h {minutes}m" if hours else f"{minutes}m"
740
+
741
+ window_pct = usage_summary.get('window_usage_pct', 0)
742
+ window_hours = usage_summary.get('window_hours', 0)
743
+ window_bar = self._make_progress_bar(window_pct)
744
+
745
+ await self._notify(
746
+ "complete",
747
+ f"🏁 *Agent finished!*\n\n"
748
+ f"📁 *Project:* `{self.workspace.name}`\n"
749
+ f"🔄 *Cycles:* {cycle}\n"
750
+ f"⏱ *Duration:* {time_str}\n"
751
+ f"📂 *Files created:* {file_count}\n"
752
+ f"⏱ *5hr window:* {window_bar} {window_pct:.0f}% ({window_hours:.1f}h used)\n"
753
+ f"💰 *Est. cost:* ${usage_summary['total_cost_usd']:.2f}\n\n"
754
+ f"*Files:*\n" + "\n".join(f"• `{f}`" for f in file_list[:10]),
755
+ {
756
+ "cycles": cycle,
757
+ "duration": total_time,
758
+ "file_count": file_count,
759
+ "files": file_list,
760
+ "cost": usage_summary,
761
+ }
762
+ )
763
+
764
+ console.print(f"\n[green]Agent stopped. Completed {cycle} cycles in {time_str}.[/green]")
765
+ console.print(f"[dim]Workspace: {self.workspace}[/dim]")
766
+ console.print(f"[dim]5-hour window: {window_pct:.0f}% used ({window_hours:.1f}h)[/dim]")
767
+ console.print(f"[dim]Est. cost: ${usage_summary['total_cost_usd']:.2f}[/dim]")
768
+
769
+ def stop(self) -> None:
770
+ """Request agent to stop."""
771
+ self._should_stop = True
772
+
773
+ def status(self) -> dict:
774
+ """Get current agent status."""
775
+ state = self._load_state()
776
+ file_count, files = self._count_files()
777
+ state["current_file_count"] = file_count
778
+ state["files"] = files
779
+ state["usage"] = self.usage.get_summary()
780
+ state["memory"] = {
781
+ "long_term_count": len(self.memory.get_long_term_memories()),
782
+ "short_term_count": len(self.memory.get_short_term_context()),
783
+ "agent_summaries": list(self.memory.get_all_agent_summaries().keys()),
784
+ }
785
+ return state