steerdev 0.4.27__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.
Files changed (57) hide show
  1. steerdev-0.4.27.dist-info/METADATA +224 -0
  2. steerdev-0.4.27.dist-info/RECORD +57 -0
  3. steerdev-0.4.27.dist-info/WHEEL +4 -0
  4. steerdev-0.4.27.dist-info/entry_points.txt +2 -0
  5. steerdev_agent/__init__.py +10 -0
  6. steerdev_agent/api/__init__.py +32 -0
  7. steerdev_agent/api/activity.py +278 -0
  8. steerdev_agent/api/agents.py +145 -0
  9. steerdev_agent/api/client.py +158 -0
  10. steerdev_agent/api/commands.py +399 -0
  11. steerdev_agent/api/configs.py +238 -0
  12. steerdev_agent/api/context.py +306 -0
  13. steerdev_agent/api/events.py +294 -0
  14. steerdev_agent/api/hooks.py +178 -0
  15. steerdev_agent/api/implementation_plan.py +408 -0
  16. steerdev_agent/api/messages.py +231 -0
  17. steerdev_agent/api/prd.py +281 -0
  18. steerdev_agent/api/runs.py +526 -0
  19. steerdev_agent/api/sessions.py +403 -0
  20. steerdev_agent/api/specs.py +321 -0
  21. steerdev_agent/api/tasks.py +659 -0
  22. steerdev_agent/api/workflow_runs.py +351 -0
  23. steerdev_agent/api/workflows.py +191 -0
  24. steerdev_agent/cli.py +2254 -0
  25. steerdev_agent/config/__init__.py +19 -0
  26. steerdev_agent/config/models.py +236 -0
  27. steerdev_agent/config/platform.py +272 -0
  28. steerdev_agent/config/settings.py +62 -0
  29. steerdev_agent/daemon.py +675 -0
  30. steerdev_agent/executor/__init__.py +64 -0
  31. steerdev_agent/executor/base.py +121 -0
  32. steerdev_agent/executor/claude.py +328 -0
  33. steerdev_agent/executor/stream.py +163 -0
  34. steerdev_agent/git/__init__.py +1 -0
  35. steerdev_agent/handlers/__init__.py +5 -0
  36. steerdev_agent/handlers/prd.py +533 -0
  37. steerdev_agent/integration.py +334 -0
  38. steerdev_agent/prompt/__init__.py +10 -0
  39. steerdev_agent/prompt/builder.py +263 -0
  40. steerdev_agent/prompt/templates.py +422 -0
  41. steerdev_agent/py.typed +0 -0
  42. steerdev_agent/runner.py +829 -0
  43. steerdev_agent/setup/__init__.py +5 -0
  44. steerdev_agent/setup/claude_setup.py +560 -0
  45. steerdev_agent/setup/templates/claude_md_section.md +140 -0
  46. steerdev_agent/setup/templates/settings.json +69 -0
  47. steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
  48. steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
  49. steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
  50. steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
  51. steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
  52. steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
  53. steerdev_agent/setup/templates/steerdev.yaml +51 -0
  54. steerdev_agent/version.py +149 -0
  55. steerdev_agent/workflow/__init__.py +10 -0
  56. steerdev_agent/workflow/executor.py +494 -0
  57. steerdev_agent/workflow/memory.py +185 -0
@@ -0,0 +1,675 @@
1
+ """Persistent agent daemon mode.
2
+
3
+ Runs indefinitely, polling for commands from the API and falling back
4
+ to the task queue when idle. Each command/task execution creates a session
5
+ with full event streaming, reusing the existing Runner infrastructure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import signal
13
+ import socket
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+ from loguru import logger
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+
21
+ from steerdev_agent.api.commands import CommandResponse, CommandsClient
22
+ from steerdev_agent.api.events import EventsClient
23
+ from steerdev_agent.api.sessions import SessionCreateRequest, SessionsClient
24
+ from steerdev_agent.api.tasks import TasksClient
25
+ from steerdev_agent.config.models import DaemonConfig, ExecutorConfig
26
+ from steerdev_agent.executor import ExecutorFactory
27
+ from steerdev_agent.executor.base import EventType
28
+ from steerdev_agent.executor.claude import ClaudeExecutorError
29
+ from steerdev_agent.prompt.builder import PromptBuilder, PromptContext, TaskContext
30
+
31
+ console = Console()
32
+
33
+
34
+ class DaemonError(Exception):
35
+ """Error during daemon execution."""
36
+
37
+
38
+ class Daemon:
39
+ """Persistent agent that polls for commands and executes them.
40
+
41
+ Lifecycle:
42
+ 1. Register agent (get/create agent_id via API)
43
+ 2. Recover stale commands from previous crash
44
+ 3. Start heartbeat background task
45
+ 4. Enter poll loop:
46
+ a. Poll /commands/next
47
+ b. If command found -> execute it
48
+ c. If no command AND auto_fetch_tasks -> fetch next task
49
+ d. If nothing -> sleep(idle_poll_interval)
50
+ e. Check for shutdown signal
51
+ 5. On shutdown: send offline heartbeat, exit
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ project_id: str,
57
+ agent_name: str,
58
+ working_directory: str | Path | None = None,
59
+ api_key: str | None = None,
60
+ agent_type: str = "claude",
61
+ model: str | None = None,
62
+ max_turns: int | None = None,
63
+ daemon_config: DaemonConfig | None = None,
64
+ executor_config: ExecutorConfig | None = None,
65
+ ) -> None:
66
+ self.project_id = project_id
67
+ self.agent_name = agent_name
68
+ self.working_directory = Path(working_directory or Path.cwd())
69
+ self._api_key = api_key
70
+ self.agent_type = agent_type
71
+ self.model = model
72
+ self.max_turns = max_turns
73
+ self._daemon_config = daemon_config or DaemonConfig()
74
+ self._executor_config = executor_config or ExecutorConfig()
75
+
76
+ # State
77
+ self._agent_id: str | None = None
78
+ self._shutdown_event = asyncio.Event()
79
+ self._is_busy = False
80
+ self._consecutive_errors = 0
81
+ self._commands_executed = 0
82
+ self._commands_succeeded = 0
83
+ self._commands_failed = 0
84
+ self._tasks_executed = 0
85
+
86
+ # Components
87
+ self._commands_client: CommandsClient | None = None
88
+ self._sessions_client: SessionsClient | None = None
89
+ self._prompt_builder = PromptBuilder()
90
+ self._heartbeat_task: asyncio.Task[None] | None = None
91
+
92
+ async def start(self) -> None:
93
+ """Start the daemon loop."""
94
+ self._setup_signal_handlers()
95
+
96
+ console.print(
97
+ Panel(
98
+ f"[bold blue]SteerDev Daemon[/bold blue]\n"
99
+ f"Project: {self.project_id}\n"
100
+ f"Agent: {self.agent_name}\n"
101
+ f"Working Dir: {self.working_directory}\n"
102
+ f"Poll Interval: {self._daemon_config.poll_interval_seconds}s\n"
103
+ f"Auto-fetch Tasks: {self._daemon_config.auto_fetch_tasks}",
104
+ title="Starting Daemon",
105
+ )
106
+ )
107
+
108
+ try:
109
+ # Step 1: Register agent
110
+ self._agent_id = await self._register_agent()
111
+ if not self._agent_id:
112
+ raise DaemonError("Failed to register agent")
113
+
114
+ logger.info(f"Agent registered: {self._agent_id}")
115
+
116
+ # Initialize clients
117
+ self._commands_client = CommandsClient(
118
+ agent_id=self._agent_id,
119
+ api_key=self._api_key,
120
+ )
121
+ self._sessions_client = SessionsClient(api_key=self._api_key)
122
+
123
+ # Step 2: Recover stale commands
124
+ await self._recover_stale_commands()
125
+
126
+ # Step 3: Start heartbeat
127
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
128
+
129
+ # Step 4: Main poll loop
130
+ await self._poll_loop()
131
+
132
+ except DaemonError as e:
133
+ logger.error(f"Daemon error: {e}")
134
+ console.print(f"[red]Daemon error: {e}[/red]")
135
+ except Exception as e:
136
+ logger.exception(f"Unexpected daemon error: {e}")
137
+ console.print(f"[red]Unexpected error: {e}[/red]")
138
+ finally:
139
+ # Step 5: Shutdown
140
+ await self._shutdown()
141
+
142
+ async def _register_agent(self) -> str | None:
143
+ """Register this agent via the API and return the agent_id."""
144
+ try:
145
+ async with httpx.AsyncClient(timeout=30.0) as client:
146
+ from steerdev_agent.api.client import get_api_endpoint, get_api_key
147
+
148
+ api_key = self._api_key or get_api_key()
149
+ api_base = get_api_endpoint()
150
+
151
+ response = await client.post(
152
+ f"{api_base}/agents",
153
+ headers={
154
+ "Authorization": f"Bearer {api_key}",
155
+ "Content-Type": "application/json",
156
+ },
157
+ json={
158
+ "name": self.agent_name,
159
+ "application": self.agent_type,
160
+ "hostname": socket.gethostname(),
161
+ },
162
+ )
163
+
164
+ if response.status_code in (200, 201):
165
+ data = response.json()
166
+ return data["id"]
167
+
168
+ logger.error(f"Failed to register agent: {response.status_code} - {response.text}")
169
+ return None
170
+ except httpx.RequestError as e:
171
+ logger.error(f"Request error registering agent: {e}")
172
+ return None
173
+
174
+ async def _recover_stale_commands(self) -> None:
175
+ """Mark any claimed/executing commands from a previous crash as failed."""
176
+ if not self._commands_client:
177
+ return
178
+
179
+ for stale_status in ("claimed", "executing"):
180
+ result = await self._commands_client.list_commands(status=stale_status) # type: ignore[arg-type]
181
+ if not result:
182
+ continue
183
+
184
+ for cmd in result.commands:
185
+ logger.warning(f"Recovering stale command {cmd.id} (was {cmd.status})")
186
+ await self._commands_client.mark_failed(
187
+ cmd.id,
188
+ error=f"Recovered on daemon restart (was {cmd.status})",
189
+ )
190
+
191
+ async def _poll_loop(self) -> None:
192
+ """Main loop: poll for commands, execute them, or fall back to tasks."""
193
+ logger.info("Entering poll loop")
194
+ console.print("[green]Daemon ready, polling for commands...[/green]")
195
+
196
+ while not self._shutdown_event.is_set():
197
+ try:
198
+ executed = await self._poll_once()
199
+
200
+ if executed:
201
+ self._consecutive_errors = 0
202
+ interval = self._daemon_config.poll_interval_seconds
203
+ else:
204
+ interval = self._daemon_config.idle_poll_interval_seconds
205
+
206
+ except Exception as e:
207
+ self._consecutive_errors += 1
208
+ logger.error(f"Poll error ({self._consecutive_errors}): {e}")
209
+
210
+ if self._consecutive_errors >= self._daemon_config.max_consecutive_errors:
211
+ logger.error("Max consecutive errors reached, shutting down")
212
+ console.print("[red]Max consecutive errors reached, shutting down[/red]")
213
+ break
214
+
215
+ # Exponential backoff: 2^n seconds, capped at 60s
216
+ backoff = min(2**self._consecutive_errors, 60)
217
+ interval = backoff
218
+
219
+ # Sleep with cancellation support
220
+ try:
221
+ await asyncio.wait_for(
222
+ self._shutdown_event.wait(),
223
+ timeout=interval,
224
+ )
225
+ # If we get here, shutdown was signaled
226
+ break
227
+ except TimeoutError:
228
+ # Normal timeout, continue polling
229
+ pass
230
+
231
+ async def _poll_once(self) -> bool:
232
+ """Poll for one command or task. Returns True if something was executed."""
233
+ assert self._commands_client is not None
234
+
235
+ # Try command queue first
236
+ command = await self._commands_client.poll_next()
237
+
238
+ if command:
239
+ # Handle control commands specially
240
+ if command.command_type == "control":
241
+ return await self._handle_control_command(command)
242
+
243
+ await self._execute_command(command)
244
+ return True
245
+
246
+ # Fall back to task queue if enabled
247
+ if self._daemon_config.auto_fetch_tasks:
248
+ executed = await self._execute_task_fallback()
249
+ if executed:
250
+ return True
251
+
252
+ return False
253
+
254
+ async def _handle_control_command(self, command: CommandResponse) -> bool:
255
+ """Handle control commands (shutdown, etc.)."""
256
+ assert self._commands_client is not None
257
+
258
+ if command.control_action == "shutdown":
259
+ logger.info("Received shutdown command")
260
+ console.print("[yellow]Received shutdown command[/yellow]")
261
+ await self._commands_client.mark_completed(command.id, result="Shutdown acknowledged")
262
+ self._shutdown_event.set()
263
+ return True
264
+
265
+ logger.warning(f"Unknown control action: {command.control_action}")
266
+ await self._commands_client.mark_failed(
267
+ command.id,
268
+ error=f"Unknown control action: {command.control_action}",
269
+ )
270
+ return True
271
+
272
+ async def _execute_command(self, command: CommandResponse) -> None:
273
+ """Execute a single command, creating a session and streaming events."""
274
+ assert self._commands_client is not None
275
+ assert self._sessions_client is not None
276
+
277
+ self._is_busy = True
278
+ self._commands_executed += 1
279
+
280
+ cmd_desc = (
281
+ f"task={command.task_id}"
282
+ if command.command_type == "task"
283
+ else f"prompt={command.prompt[:50]}..."
284
+ if command.prompt and len(command.prompt) > 50
285
+ else f"prompt={command.prompt}"
286
+ if command.prompt
287
+ else command.command_type
288
+ )
289
+ console.print(f"\n[bold cyan]Executing command: {cmd_desc}[/bold cyan]")
290
+ console.print(f"[dim]Command ID: {command.id}[/dim]")
291
+
292
+ # Build prompt based on command type
293
+ prompt = await self._build_prompt_for_command(command)
294
+ if not prompt:
295
+ await self._commands_client.mark_failed(command.id, error="Failed to build prompt")
296
+ self._commands_failed += 1
297
+ self._is_busy = False
298
+ return
299
+
300
+ # Create session
301
+ session = await self._sessions_client.create_session(
302
+ SessionCreateRequest(
303
+ project_id=self.project_id,
304
+ task_id=command.task_id,
305
+ agent_name=self.agent_name,
306
+ agent_type=self.agent_type,
307
+ prompt=prompt,
308
+ working_directory=str(command.working_directory or self.working_directory),
309
+ )
310
+ )
311
+
312
+ if not session:
313
+ await self._commands_client.mark_failed(command.id, error="Failed to create session")
314
+ self._commands_failed += 1
315
+ self._is_busy = False
316
+ return
317
+
318
+ # Mark command as executing with session_id
319
+ await self._commands_client.mark_executing(command.id, session.id)
320
+ await self._sessions_client.mark_running(session.id)
321
+
322
+ # Execute
323
+ events_client = EventsClient(session_id=session.id, api_key=self._api_key)
324
+ await events_client.start()
325
+ events_sent = 0
326
+
327
+ try:
328
+ executor = ExecutorFactory.create(
329
+ config=self._executor_config,
330
+ working_directory=str(command.working_directory or self.working_directory),
331
+ model=self.model,
332
+ max_turns=self.max_turns,
333
+ )
334
+
335
+ # Start with timeout
336
+ await executor.start(prompt)
337
+ console.print("[dim]Agent started, streaming output...[/dim]")
338
+
339
+ async def _run_execution() -> int:
340
+ nonlocal events_sent
341
+ async for event in executor.stream_events():
342
+ await events_client.add_event(
343
+ event_type=event.event_type.value,
344
+ data=event.data,
345
+ raw_json=event.raw_json,
346
+ timestamp=event.timestamp,
347
+ )
348
+ events_sent += 1
349
+
350
+ if event.event_type == EventType.ASSISTANT:
351
+ msg = event.data.get("message", {})
352
+ content = msg.get("content", "")
353
+ if isinstance(content, str) and content:
354
+ preview = content[:100] + "..." if len(content) > 100 else content
355
+ console.print(f"[cyan]Assistant:[/cyan] {preview}")
356
+
357
+ # Check for cancellation
358
+ if self._shutdown_event.is_set():
359
+ logger.info("Shutdown during execution, stopping executor")
360
+ await executor.stop()
361
+ return -1
362
+
363
+ return await executor.wait()
364
+
365
+ exit_code = await asyncio.wait_for(
366
+ _run_execution(),
367
+ timeout=self._daemon_config.command_timeout_seconds,
368
+ )
369
+
370
+ agent_session_id = executor.session_id
371
+
372
+ if exit_code != 0:
373
+ stderr = await executor.get_stderr()
374
+ error_msg = stderr.strip() if stderr else f"Process exited with code {exit_code}"
375
+ await self._commands_client.mark_failed(command.id, error=error_msg)
376
+ await self._sessions_client.mark_failed(
377
+ session.id,
378
+ metadata={"error": error_msg, "exit_code": exit_code},
379
+ )
380
+ self._commands_failed += 1
381
+ console.print(f"[red]Command failed: {error_msg}[/red]")
382
+ else:
383
+ await self._commands_client.mark_completed(
384
+ command.id,
385
+ result=f"Completed successfully. Events: {events_sent}",
386
+ )
387
+ await self._sessions_client.mark_completed(
388
+ session.id,
389
+ agent_session_id=agent_session_id,
390
+ )
391
+ self._commands_succeeded += 1
392
+ console.print("[green]Command completed successfully[/green]")
393
+
394
+ except TimeoutError:
395
+ error_msg = f"Command timed out after {self._daemon_config.command_timeout_seconds}s"
396
+ logger.error(error_msg)
397
+ await self._commands_client.mark_failed(command.id, error=error_msg)
398
+ await self._sessions_client.mark_failed(session.id, metadata={"error": error_msg})
399
+ self._commands_failed += 1
400
+ console.print(f"[red]{error_msg}[/red]")
401
+
402
+ except ClaudeExecutorError as e:
403
+ error_msg = str(e)
404
+ logger.error(f"Executor error: {error_msg}")
405
+ await self._commands_client.mark_failed(command.id, error=error_msg)
406
+ await self._sessions_client.mark_failed(session.id, metadata={"error": error_msg})
407
+ self._commands_failed += 1
408
+
409
+ except Exception as e:
410
+ error_msg = str(e)
411
+ logger.exception(f"Unexpected error executing command: {error_msg}")
412
+ await self._commands_client.mark_failed(command.id, error=error_msg)
413
+ await self._sessions_client.mark_failed(session.id, metadata={"error": error_msg})
414
+ self._commands_failed += 1
415
+
416
+ finally:
417
+ await events_client.close()
418
+ self._is_busy = False
419
+
420
+ async def _build_prompt_for_command(self, command: CommandResponse) -> str | None:
421
+ """Build a prompt string from a command."""
422
+ if command.command_type == "prompt":
423
+ return command.prompt
424
+
425
+ if command.command_type == "task":
426
+ if not command.task_id:
427
+ return None
428
+
429
+ # Fetch task details
430
+ with TasksClient(api_key=self._api_key) as client:
431
+ task = client.get_task(command.task_id)
432
+ if not task:
433
+ logger.error(f"Task not found: {command.task_id}")
434
+ return None
435
+
436
+ context = PromptContext(
437
+ task=TaskContext(
438
+ id=task.get("id", ""),
439
+ title=task.get("title", "Unknown"),
440
+ prompt=task.get("prompt", ""),
441
+ status=task.get("status", "unstarted"),
442
+ priority=task.get("priority", 3),
443
+ working_directory=task.get("working_directory"),
444
+ ),
445
+ )
446
+ return self._prompt_builder.build(context)
447
+
448
+ return None
449
+
450
+ async def _execute_task_fallback(self) -> bool:
451
+ """Fetch and execute the next task from the task queue.
452
+
453
+ Returns True if a task was executed, False if none available.
454
+ """
455
+ with TasksClient(api_key=self._api_key) as client:
456
+ task = client.get_next_task(project_id=self.project_id)
457
+
458
+ if not task:
459
+ return False
460
+
461
+ task_id = task.get("id", "")
462
+ task_title = task.get("title", "Unknown")
463
+
464
+ console.print(f"\n[bold cyan]Auto-fetched task: {task_title}[/bold cyan]")
465
+ console.print(f"[dim]Task ID: {task_id}[/dim]")
466
+
467
+ self._is_busy = True
468
+ self._tasks_executed += 1
469
+
470
+ # Build prompt
471
+ context = PromptContext(
472
+ task=TaskContext(
473
+ id=task_id,
474
+ title=task_title,
475
+ prompt=task.get("prompt", ""),
476
+ status=task.get("status", "unstarted"),
477
+ priority=task.get("priority", 3),
478
+ working_directory=task.get("working_directory"),
479
+ ),
480
+ )
481
+ prompt = self._prompt_builder.build(context)
482
+
483
+ # Create session
484
+ assert self._sessions_client is not None
485
+ session = await self._sessions_client.create_session(
486
+ SessionCreateRequest(
487
+ project_id=self.project_id,
488
+ task_id=task_id,
489
+ agent_name=self.agent_name,
490
+ agent_type=self.agent_type,
491
+ prompt=prompt,
492
+ working_directory=str(self.working_directory),
493
+ )
494
+ )
495
+
496
+ if not session:
497
+ logger.error("Failed to create session for task fallback")
498
+ self._is_busy = False
499
+ return True # We tried, count as executed
500
+
501
+ await self._sessions_client.mark_running(session.id)
502
+
503
+ events_client = EventsClient(session_id=session.id, api_key=self._api_key)
504
+ await events_client.start()
505
+
506
+ try:
507
+ executor = ExecutorFactory.create(
508
+ config=self._executor_config,
509
+ working_directory=str(self.working_directory),
510
+ model=self.model,
511
+ max_turns=self.max_turns,
512
+ )
513
+
514
+ await executor.start(prompt)
515
+
516
+ async for event in executor.stream_events():
517
+ await events_client.add_event(
518
+ event_type=event.event_type.value,
519
+ data=event.data,
520
+ raw_json=event.raw_json,
521
+ timestamp=event.timestamp,
522
+ )
523
+
524
+ if event.event_type == EventType.ASSISTANT:
525
+ msg = event.data.get("message", {})
526
+ content = msg.get("content", "")
527
+ if isinstance(content, str) and content:
528
+ preview = content[:100] + "..." if len(content) > 100 else content
529
+ console.print(f"[cyan]Assistant:[/cyan] {preview}")
530
+
531
+ exit_code = await executor.wait()
532
+ agent_session_id = executor.session_id
533
+
534
+ if exit_code != 0:
535
+ stderr = await executor.get_stderr()
536
+ error_msg = stderr.strip() if stderr else f"Process exited with code {exit_code}"
537
+ await self._sessions_client.mark_failed(
538
+ session.id,
539
+ metadata={"error": error_msg, "exit_code": exit_code},
540
+ )
541
+ console.print(f"[red]Task failed: {error_msg}[/red]")
542
+ else:
543
+ await self._sessions_client.mark_completed(
544
+ session.id,
545
+ agent_session_id=agent_session_id,
546
+ )
547
+ console.print("[green]Task completed successfully[/green]")
548
+
549
+ except Exception as e:
550
+ logger.exception(f"Error executing task fallback: {e}")
551
+ await self._sessions_client.mark_failed(
552
+ session.id,
553
+ metadata={"error": str(e)},
554
+ )
555
+ finally:
556
+ await events_client.close()
557
+ self._is_busy = False
558
+
559
+ return True
560
+
561
+ async def _heartbeat_loop(self) -> None:
562
+ """Background task: send heartbeat every N seconds."""
563
+ while not self._shutdown_event.is_set():
564
+ try:
565
+ await self._send_heartbeat()
566
+ except Exception as e:
567
+ logger.warning(f"Heartbeat error: {e}")
568
+
569
+ try:
570
+ await asyncio.wait_for(
571
+ self._shutdown_event.wait(),
572
+ timeout=self._daemon_config.heartbeat_interval_seconds,
573
+ )
574
+ break # Shutdown signaled
575
+ except TimeoutError:
576
+ pass
577
+
578
+ async def _send_heartbeat(self) -> None:
579
+ """Send a heartbeat to update agent status."""
580
+ try:
581
+ async with httpx.AsyncClient(timeout=10.0) as client:
582
+ from steerdev_agent.api.client import get_api_endpoint, get_api_key
583
+
584
+ api_key = self._api_key or get_api_key()
585
+ api_base = get_api_endpoint()
586
+
587
+ # Re-register to update heartbeat (getOrCreateAgent updates last_heartbeat_at)
588
+ await client.post(
589
+ f"{api_base}/agents",
590
+ headers={
591
+ "Authorization": f"Bearer {api_key}",
592
+ "Content-Type": "application/json",
593
+ },
594
+ json={
595
+ "name": self.agent_name,
596
+ "application": self.agent_type,
597
+ "hostname": socket.gethostname(),
598
+ },
599
+ )
600
+ except httpx.RequestError as e:
601
+ logger.debug(f"Heartbeat request error: {e}")
602
+
603
+ def _setup_signal_handlers(self) -> None:
604
+ """Set up SIGINT/SIGTERM handlers for graceful shutdown."""
605
+ loop = asyncio.get_running_loop()
606
+
607
+ def _signal_handler() -> None:
608
+ logger.info("Received shutdown signal")
609
+ console.print("\n[yellow]Received shutdown signal, finishing current work...[/yellow]")
610
+ self._shutdown_event.set()
611
+
612
+ for sig in (signal.SIGINT, signal.SIGTERM):
613
+ loop.add_signal_handler(sig, _signal_handler)
614
+
615
+ async def _shutdown(self) -> None:
616
+ """Clean up resources and send offline heartbeat."""
617
+ console.print("[dim]Shutting down daemon...[/dim]")
618
+
619
+ # Cancel heartbeat task
620
+ if self._heartbeat_task and not self._heartbeat_task.done():
621
+ self._heartbeat_task.cancel()
622
+ with contextlib.suppress(asyncio.CancelledError):
623
+ await self._heartbeat_task
624
+
625
+ # Send offline heartbeat (best effort)
626
+ with contextlib.suppress(Exception):
627
+ await self._send_heartbeat()
628
+
629
+ # Close clients
630
+ if self._commands_client:
631
+ await self._commands_client.close()
632
+ if self._sessions_client:
633
+ await self._sessions_client.close()
634
+
635
+ # Print summary
636
+ console.print(
637
+ Panel(
638
+ f"[bold]Daemon Summary[/bold]\n"
639
+ f"Commands Executed: {self._commands_executed}\n"
640
+ f"Commands Succeeded: {self._commands_succeeded}\n"
641
+ f"Commands Failed: {self._commands_failed}\n"
642
+ f"Tasks Auto-fetched: {self._tasks_executed}",
643
+ title="Shutdown Complete",
644
+ border_style="yellow",
645
+ )
646
+ )
647
+
648
+
649
+ async def run_daemon(
650
+ project_id: str,
651
+ agent_name: str,
652
+ working_directory: str | None = None,
653
+ api_key: str | None = None,
654
+ agent_type: str = "claude",
655
+ model: str | None = None,
656
+ max_turns: int | None = None,
657
+ daemon_config: DaemonConfig | None = None,
658
+ executor_config: ExecutorConfig | None = None,
659
+ ) -> None:
660
+ """Run the daemon.
661
+
662
+ Convenience function for running the daemon with minimal setup.
663
+ """
664
+ daemon = Daemon(
665
+ project_id=project_id,
666
+ agent_name=agent_name,
667
+ working_directory=working_directory,
668
+ api_key=api_key,
669
+ agent_type=agent_type,
670
+ model=model,
671
+ max_turns=max_turns,
672
+ daemon_config=daemon_config,
673
+ executor_config=executor_config,
674
+ )
675
+ await daemon.start()