claude-task-master 0.1.1__py3-none-any.whl → 0.1.3__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 (42) hide show
  1. claude_task_master/__init__.py +1 -1
  2. claude_task_master/api/__init__.py +98 -0
  3. claude_task_master/api/models.py +553 -0
  4. claude_task_master/api/routes.py +1135 -0
  5. claude_task_master/api/routes_config.py +160 -0
  6. claude_task_master/api/routes_control.py +278 -0
  7. claude_task_master/api/routes_webhooks.py +980 -0
  8. claude_task_master/api/server.py +551 -0
  9. claude_task_master/auth/__init__.py +89 -0
  10. claude_task_master/auth/middleware.py +448 -0
  11. claude_task_master/auth/password.py +332 -0
  12. claude_task_master/bin/claudetm +1 -1
  13. claude_task_master/cli.py +4 -0
  14. claude_task_master/cli_commands/__init__.py +2 -0
  15. claude_task_master/cli_commands/ci_helpers.py +114 -0
  16. claude_task_master/cli_commands/control.py +191 -0
  17. claude_task_master/cli_commands/fix_pr.py +260 -0
  18. claude_task_master/cli_commands/fix_session.py +174 -0
  19. claude_task_master/cli_commands/workflow.py +51 -3
  20. claude_task_master/core/__init__.py +13 -0
  21. claude_task_master/core/agent_message.py +27 -5
  22. claude_task_master/core/control.py +466 -0
  23. claude_task_master/core/orchestrator.py +316 -4
  24. claude_task_master/core/pr_context.py +7 -2
  25. claude_task_master/core/prompts_working.py +32 -12
  26. claude_task_master/core/state.py +84 -2
  27. claude_task_master/core/state_exceptions.py +9 -6
  28. claude_task_master/core/workflow_stages.py +160 -21
  29. claude_task_master/github/client_pr.py +43 -1
  30. claude_task_master/mcp/auth.py +153 -0
  31. claude_task_master/mcp/server.py +268 -10
  32. claude_task_master/mcp/tools.py +281 -0
  33. claude_task_master/server.py +489 -0
  34. claude_task_master/webhooks/__init__.py +73 -0
  35. claude_task_master/webhooks/client.py +703 -0
  36. claude_task_master/webhooks/config.py +565 -0
  37. claude_task_master/webhooks/events.py +639 -0
  38. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/METADATA +144 -6
  39. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/RECORD +42 -21
  40. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/entry_points.txt +2 -0
  41. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/WHEEL +0 -0
  42. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1135 @@
1
+ """REST API routes for Claude Task Master.
2
+
3
+ This module defines API endpoint routes that can be registered with a FastAPI app.
4
+ Each route function takes the necessary dependencies and returns the configured router.
5
+
6
+ Endpoints:
7
+ - GET /status: Get current task status
8
+ - GET /plan: Get task plan content
9
+ - GET /logs: Get log content
10
+ - GET /progress: Get progress summary
11
+ - GET /context: Get accumulated context/learnings
12
+ - GET /health: Health check endpoint
13
+ - POST /task/init: Initialize a new task
14
+ - DELETE /task: Delete/cleanup current task
15
+ - POST /control/stop: Stop a running task with optional cleanup
16
+ - POST /control/resume: Resume a paused or blocked task
17
+ - PATCH /config: Update runtime configuration options
18
+
19
+ Usage:
20
+ from claude_task_master.api.routes import (
21
+ create_info_router,
22
+ create_control_router,
23
+ create_task_router,
24
+ )
25
+
26
+ router = create_info_router()
27
+ app.include_router(router)
28
+
29
+ control_router = create_control_router()
30
+ app.include_router(control_router)
31
+
32
+ task_router = create_task_router()
33
+ app.include_router(task_router)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import logging
40
+ import shutil
41
+ import time
42
+ from pathlib import Path
43
+ from typing import TYPE_CHECKING, Any
44
+
45
+ from claude_task_master import __version__
46
+ from claude_task_master.api.models import (
47
+ ConfigUpdateRequest,
48
+ ContextResponse,
49
+ ControlResponse,
50
+ ErrorResponse,
51
+ HealthResponse,
52
+ LogsResponse,
53
+ PlanResponse,
54
+ ProgressResponse,
55
+ ResumeRequest,
56
+ StopRequest,
57
+ TaskDeleteResponse,
58
+ TaskInitRequest,
59
+ TaskInitResponse,
60
+ TaskOptionsResponse,
61
+ TaskProgressInfo,
62
+ TaskStatus,
63
+ TaskStatusResponse,
64
+ WebhookStatusInfo,
65
+ WorkflowStage,
66
+ )
67
+ from claude_task_master.api.routes_webhooks import create_webhooks_router
68
+ from claude_task_master.core.agent import ModelType
69
+ from claude_task_master.core.control import ControlManager
70
+ from claude_task_master.core.credentials import CredentialManager
71
+ from claude_task_master.core.state import StateManager, TaskOptions
72
+
73
+ if TYPE_CHECKING:
74
+ from fastapi import APIRouter, FastAPI, Query, Request
75
+ from fastapi.responses import JSONResponse
76
+
77
+ # Import FastAPI - using try/except for graceful degradation
78
+ try:
79
+ from fastapi import APIRouter, Query, Request
80
+ from fastapi.responses import JSONResponse
81
+
82
+ FASTAPI_AVAILABLE = True
83
+ except ImportError:
84
+ FASTAPI_AVAILABLE = False
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+
89
+ # =============================================================================
90
+ # Helper Functions
91
+ # =============================================================================
92
+
93
+
94
+ def _parse_plan_tasks(plan: str) -> list[tuple[str, bool]]:
95
+ """Parse task checkboxes from plan markdown.
96
+
97
+ Args:
98
+ plan: The plan content in markdown format.
99
+
100
+ Returns:
101
+ List of (task_description, is_completed) tuples.
102
+ """
103
+ tasks: list[tuple[str, bool]] = []
104
+ for line in plan.splitlines():
105
+ line = line.strip()
106
+ if line.startswith("- [ ] "):
107
+ tasks.append((line[6:], False))
108
+ elif line.startswith("- [x] ") or line.startswith("- [X] "):
109
+ tasks.append((line[6:], True))
110
+ return tasks
111
+
112
+
113
+ def _get_state_manager(request: Request) -> StateManager:
114
+ """Get state manager from request, using working directory from app state.
115
+
116
+ Args:
117
+ request: The FastAPI request object.
118
+
119
+ Returns:
120
+ StateManager instance configured for the app's working directory.
121
+ """
122
+ working_dir: Path = getattr(request.app.state, "working_dir", Path.cwd())
123
+ state_dir = working_dir / ".claude-task-master"
124
+ return StateManager(state_dir=state_dir)
125
+
126
+
127
+ def _get_webhook_status(request: Request) -> WebhookStatusInfo | None:
128
+ """Get webhook configuration status summary.
129
+
130
+ Args:
131
+ request: The FastAPI request object.
132
+
133
+ Returns:
134
+ WebhookStatusInfo with counts of total/enabled/disabled webhooks,
135
+ or None if webhooks file doesn't exist or can't be loaded.
136
+ """
137
+ working_dir: Path = getattr(request.app.state, "working_dir", Path.cwd())
138
+ webhooks_file = working_dir / ".claude-task-master" / "webhooks.json"
139
+
140
+ if not webhooks_file.exists():
141
+ return None
142
+
143
+ try:
144
+ with open(webhooks_file) as f:
145
+ data = json.load(f)
146
+ webhooks: dict[str, dict[str, Any]] = data.get("webhooks", {})
147
+
148
+ total = len(webhooks)
149
+ enabled = sum(1 for wh in webhooks.values() if wh.get("enabled", True))
150
+ disabled = total - enabled
151
+
152
+ return WebhookStatusInfo(
153
+ total=total,
154
+ enabled=enabled,
155
+ disabled=disabled,
156
+ )
157
+ except (json.JSONDecodeError, OSError) as e:
158
+ logger.warning(f"Failed to load webhook status: {e}")
159
+ return None
160
+
161
+
162
+ # =============================================================================
163
+ # Info Router (Status, Plan, Logs, Progress, Context, Health)
164
+ # =============================================================================
165
+
166
+
167
+ def create_info_router() -> APIRouter:
168
+ """Create router for info endpoints.
169
+
170
+ These are read-only endpoints that provide information about the
171
+ current task state without modifying anything.
172
+
173
+ Returns:
174
+ APIRouter configured with info endpoints.
175
+
176
+ Raises:
177
+ ImportError: If FastAPI is not installed.
178
+ """
179
+ if not FASTAPI_AVAILABLE:
180
+ raise ImportError(
181
+ "FastAPI not installed. Install with: pip install claude-task-master[api]"
182
+ )
183
+
184
+ router = APIRouter(tags=["Info"])
185
+
186
+ @router.get(
187
+ "/status",
188
+ response_model=TaskStatusResponse,
189
+ responses={
190
+ 404: {"model": ErrorResponse, "description": "No active task found"},
191
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
192
+ },
193
+ summary="Get Task Status",
194
+ description="Get comprehensive status information about the current task.",
195
+ )
196
+ async def get_status(request: Request) -> TaskStatusResponse | JSONResponse:
197
+ """Get current task status.
198
+
199
+ Returns comprehensive information about the current task including:
200
+ - Goal and current status
201
+ - Model being used
202
+ - Session count and current task index
203
+ - PR information (if applicable)
204
+ - Task options/configuration
205
+ - Task progress (completed/total)
206
+ - Webhook configuration status (total/enabled/disabled counts)
207
+
208
+ Returns:
209
+ TaskStatusResponse with full task status information.
210
+
211
+ Raises:
212
+ 404: If no active task exists.
213
+ 500: If an error occurs loading state.
214
+ """
215
+ state_manager = _get_state_manager(request)
216
+
217
+ if not state_manager.exists():
218
+ return JSONResponse(
219
+ status_code=404,
220
+ content=ErrorResponse(
221
+ error="not_found",
222
+ message="No active task found",
223
+ suggestion="Start a new task with 'claudetm start <goal>'",
224
+ ).model_dump(),
225
+ )
226
+
227
+ try:
228
+ state = state_manager.load_state()
229
+ goal = state_manager.load_goal()
230
+
231
+ # Calculate task progress from plan
232
+ tasks_info: TaskProgressInfo | None = None
233
+ plan = state_manager.load_plan()
234
+ if plan:
235
+ tasks = _parse_plan_tasks(plan)
236
+ completed = sum(1 for _, done in tasks if done)
237
+ total = len(tasks)
238
+ tasks_info = TaskProgressInfo(
239
+ completed=completed,
240
+ total=total,
241
+ progress=f"{completed}/{total}" if total > 0 else "No tasks",
242
+ )
243
+
244
+ # Convert status and workflow_stage to enums with defensive error handling
245
+ try:
246
+ status_enum = TaskStatus(state.status)
247
+ except ValueError as e:
248
+ logger.error(f"Invalid status value '{state.status}' in persisted state")
249
+ raise ValueError(f"Corrupted state: invalid status '{state.status}'") from e
250
+
251
+ workflow_stage_enum = None
252
+ if state.workflow_stage:
253
+ try:
254
+ workflow_stage_enum = WorkflowStage(state.workflow_stage)
255
+ except ValueError as e:
256
+ logger.error(
257
+ f"Invalid workflow_stage value '{state.workflow_stage}' in persisted state"
258
+ )
259
+ raise ValueError(
260
+ f"Corrupted state: invalid workflow_stage '{state.workflow_stage}'"
261
+ ) from e
262
+
263
+ # Load webhook status
264
+ webhooks_info = _get_webhook_status(request)
265
+
266
+ return TaskStatusResponse(
267
+ success=True,
268
+ goal=goal,
269
+ status=status_enum,
270
+ model=state.model,
271
+ current_task_index=state.current_task_index,
272
+ session_count=state.session_count,
273
+ run_id=state.run_id,
274
+ current_pr=state.current_pr,
275
+ workflow_stage=workflow_stage_enum,
276
+ options=TaskOptionsResponse(
277
+ auto_merge=state.options.auto_merge,
278
+ max_sessions=state.options.max_sessions,
279
+ pause_on_pr=state.options.pause_on_pr,
280
+ enable_checkpointing=state.options.enable_checkpointing,
281
+ log_level=state.options.log_level,
282
+ log_format=state.options.log_format,
283
+ pr_per_task=state.options.pr_per_task,
284
+ ),
285
+ created_at=state.created_at,
286
+ updated_at=state.updated_at,
287
+ tasks=tasks_info,
288
+ webhooks=webhooks_info,
289
+ )
290
+
291
+ except Exception as e:
292
+ logger.exception("Error loading task status")
293
+ return JSONResponse(
294
+ status_code=500,
295
+ content=ErrorResponse(
296
+ error="internal_error",
297
+ message="Failed to load task status",
298
+ detail=str(e),
299
+ ).model_dump(),
300
+ )
301
+
302
+ @router.get(
303
+ "/plan",
304
+ response_model=PlanResponse,
305
+ responses={
306
+ 404: {"model": ErrorResponse, "description": "No active task or plan found"},
307
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
308
+ },
309
+ summary="Get Task Plan",
310
+ description="Get the current task plan with markdown checkboxes.",
311
+ )
312
+ async def get_plan(request: Request) -> PlanResponse | JSONResponse:
313
+ """Get task plan content.
314
+
315
+ Returns the plan markdown content with task checkboxes
316
+ indicating completion status.
317
+
318
+ Returns:
319
+ PlanResponse with plan content.
320
+
321
+ Raises:
322
+ 404: If no active task or plan exists.
323
+ 500: If an error occurs loading the plan.
324
+ """
325
+ state_manager = _get_state_manager(request)
326
+
327
+ if not state_manager.exists():
328
+ return JSONResponse(
329
+ status_code=404,
330
+ content=ErrorResponse(
331
+ error="not_found",
332
+ message="No active task found",
333
+ suggestion="Start a new task with 'claudetm start <goal>'",
334
+ ).model_dump(),
335
+ )
336
+
337
+ try:
338
+ plan = state_manager.load_plan()
339
+
340
+ if not plan:
341
+ return JSONResponse(
342
+ status_code=404,
343
+ content=ErrorResponse(
344
+ error="not_found",
345
+ message="No plan found",
346
+ suggestion="Task may still be in planning phase",
347
+ ).model_dump(),
348
+ )
349
+
350
+ return PlanResponse(success=True, plan=plan)
351
+
352
+ except Exception as e:
353
+ logger.exception("Error loading task plan")
354
+ return JSONResponse(
355
+ status_code=500,
356
+ content=ErrorResponse(
357
+ error="internal_error",
358
+ message="Failed to load task plan",
359
+ detail=str(e),
360
+ ).model_dump(),
361
+ )
362
+
363
+ @router.get(
364
+ "/logs",
365
+ response_model=LogsResponse,
366
+ responses={
367
+ 404: {"model": ErrorResponse, "description": "No active task or logs found"},
368
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
369
+ },
370
+ summary="Get Logs",
371
+ description="Get log content from the current run.",
372
+ )
373
+ async def get_logs(
374
+ request: Request,
375
+ tail: int = Query(
376
+ default=100,
377
+ ge=1,
378
+ le=10000,
379
+ description="Number of lines to return from the end of the log",
380
+ ),
381
+ ) -> LogsResponse | JSONResponse:
382
+ """Get log content.
383
+
384
+ Returns the last N lines from the current run's log file.
385
+
386
+ Args:
387
+ tail: Number of lines to return (default: 100, max: 10000).
388
+
389
+ Returns:
390
+ LogsResponse with log content and file path.
391
+
392
+ Raises:
393
+ 404: If no active task or log file exists.
394
+ 500: If an error occurs reading logs.
395
+ """
396
+ state_manager = _get_state_manager(request)
397
+
398
+ if not state_manager.exists():
399
+ return JSONResponse(
400
+ status_code=404,
401
+ content=ErrorResponse(
402
+ error="not_found",
403
+ message="No active task found",
404
+ suggestion="Start a new task with 'claudetm start <goal>'",
405
+ ).model_dump(),
406
+ )
407
+
408
+ try:
409
+ state = state_manager.load_state()
410
+ log_file = state_manager.get_log_file(state.run_id)
411
+
412
+ if not log_file.exists():
413
+ return JSONResponse(
414
+ status_code=404,
415
+ content=ErrorResponse(
416
+ error="not_found",
417
+ message="No log file found",
418
+ suggestion="Task may not have started execution yet",
419
+ ).model_dump(),
420
+ )
421
+
422
+ with open(log_file) as f:
423
+ lines = f.readlines()
424
+
425
+ # Return last N lines
426
+ log_content = "".join(lines[-tail:])
427
+
428
+ return LogsResponse(
429
+ success=True,
430
+ log_content=log_content,
431
+ log_file=str(log_file),
432
+ )
433
+
434
+ except Exception as e:
435
+ logger.exception("Error loading logs")
436
+ return JSONResponse(
437
+ status_code=500,
438
+ content=ErrorResponse(
439
+ error="internal_error",
440
+ message="Failed to load logs",
441
+ detail=str(e),
442
+ ).model_dump(),
443
+ )
444
+
445
+ @router.get(
446
+ "/progress",
447
+ response_model=ProgressResponse,
448
+ responses={
449
+ 404: {"model": ErrorResponse, "description": "No active task found"},
450
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
451
+ },
452
+ summary="Get Progress",
453
+ description="Get human-readable progress summary.",
454
+ )
455
+ async def get_progress(request: Request) -> ProgressResponse | JSONResponse:
456
+ """Get progress summary.
457
+
458
+ Returns the human-readable progress summary showing what has been
459
+ accomplished and what remains.
460
+
461
+ Returns:
462
+ ProgressResponse with progress content.
463
+
464
+ Raises:
465
+ 404: If no active task exists.
466
+ 500: If an error occurs loading progress.
467
+ """
468
+ state_manager = _get_state_manager(request)
469
+
470
+ if not state_manager.exists():
471
+ return JSONResponse(
472
+ status_code=404,
473
+ content=ErrorResponse(
474
+ error="not_found",
475
+ message="No active task found",
476
+ suggestion="Start a new task with 'claudetm start <goal>'",
477
+ ).model_dump(),
478
+ )
479
+
480
+ try:
481
+ progress = state_manager.load_progress()
482
+
483
+ if not progress:
484
+ return ProgressResponse(
485
+ success=True,
486
+ progress=None,
487
+ message="No progress recorded yet",
488
+ )
489
+
490
+ return ProgressResponse(success=True, progress=progress)
491
+
492
+ except Exception as e:
493
+ logger.exception("Error loading progress")
494
+ return JSONResponse(
495
+ status_code=500,
496
+ content=ErrorResponse(
497
+ error="internal_error",
498
+ message="Failed to load progress",
499
+ detail=str(e),
500
+ ).model_dump(),
501
+ )
502
+
503
+ @router.get(
504
+ "/context",
505
+ response_model=ContextResponse,
506
+ responses={
507
+ 404: {"model": ErrorResponse, "description": "No active task found"},
508
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
509
+ },
510
+ summary="Get Context",
511
+ description="Get accumulated context and learnings.",
512
+ )
513
+ async def get_context(request: Request) -> ContextResponse | JSONResponse:
514
+ """Get accumulated context.
515
+
516
+ Returns the accumulated context and learnings that inform
517
+ future sessions.
518
+
519
+ Returns:
520
+ ContextResponse with context content.
521
+
522
+ Raises:
523
+ 404: If no active task exists.
524
+ 500: If an error occurs loading context.
525
+ """
526
+ state_manager = _get_state_manager(request)
527
+
528
+ if not state_manager.exists():
529
+ return JSONResponse(
530
+ status_code=404,
531
+ content=ErrorResponse(
532
+ error="not_found",
533
+ message="No active task found",
534
+ suggestion="Start a new task with 'claudetm start <goal>'",
535
+ ).model_dump(),
536
+ )
537
+
538
+ try:
539
+ context = state_manager.load_context()
540
+
541
+ if not context:
542
+ return ContextResponse(
543
+ success=True,
544
+ context=None,
545
+ )
546
+
547
+ return ContextResponse(success=True, context=context)
548
+
549
+ except Exception as e:
550
+ logger.exception("Error loading context")
551
+ return JSONResponse(
552
+ status_code=500,
553
+ content=ErrorResponse(
554
+ error="internal_error",
555
+ message="Failed to load context",
556
+ detail=str(e),
557
+ ).model_dump(),
558
+ )
559
+
560
+ @router.get(
561
+ "/health",
562
+ response_model=HealthResponse,
563
+ summary="Health Check",
564
+ description="Health check endpoint for monitoring and load balancers.",
565
+ )
566
+ async def get_health(request: Request) -> HealthResponse:
567
+ """Health check endpoint.
568
+
569
+ Returns server health information including:
570
+ - Server status (healthy, degraded, unhealthy)
571
+ - Version information
572
+ - Uptime in seconds
573
+ - Number of active tasks
574
+
575
+ This endpoint is suitable for load balancer health checks
576
+ and monitoring systems.
577
+
578
+ Returns:
579
+ HealthResponse with health status.
580
+ """
581
+ uptime: float | None = None
582
+ if hasattr(request.app.state, "start_time"):
583
+ uptime = time.time() - request.app.state.start_time
584
+
585
+ active_tasks: int = getattr(request.app.state, "active_tasks", 0)
586
+
587
+ # Check if state directory exists to determine if a task is active
588
+ state_manager = _get_state_manager(request)
589
+ status = "healthy"
590
+ if state_manager.exists():
591
+ try:
592
+ state = state_manager.load_state()
593
+ if state.status in ("blocked", "failed"):
594
+ status = "degraded"
595
+ except Exception:
596
+ # Can't load state - might be degraded
597
+ status = "degraded"
598
+
599
+ return HealthResponse(
600
+ status=status,
601
+ version=__version__,
602
+ server_name="claude-task-master-api",
603
+ uptime_seconds=uptime,
604
+ active_tasks=active_tasks,
605
+ )
606
+
607
+ return router
608
+
609
+
610
+ # =============================================================================
611
+ # Control Router (Stop)
612
+ # =============================================================================
613
+
614
+
615
+ def create_control_router() -> APIRouter:
616
+ """Create router for control endpoints.
617
+
618
+ These endpoints allow runtime control of task execution including
619
+ stopping and resuming tasks.
620
+
621
+ Returns:
622
+ APIRouter configured with control endpoints.
623
+
624
+ Raises:
625
+ ImportError: If FastAPI is not installed.
626
+ """
627
+ if not FASTAPI_AVAILABLE:
628
+ raise ImportError(
629
+ "FastAPI not installed. Install with: pip install claude-task-master[api]"
630
+ )
631
+
632
+ router = APIRouter(tags=["Control"])
633
+
634
+ @router.post(
635
+ "/control/stop",
636
+ response_model=ControlResponse,
637
+ responses={
638
+ 400: {"model": ErrorResponse, "description": "Invalid operation for current state"},
639
+ 404: {"model": ErrorResponse, "description": "No active task found"},
640
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
641
+ },
642
+ summary="Stop Task",
643
+ description="Stop a running task with optional cleanup of state files.",
644
+ )
645
+ async def stop_task(
646
+ request: Request, stop_request: StopRequest
647
+ ) -> ControlResponse | JSONResponse:
648
+ """Stop a running task.
649
+
650
+ Stops the current task and optionally cleans up state files.
651
+ The task must be in a stoppable state (planning, working, blocked, or paused).
652
+
653
+ Args:
654
+ stop_request: Stop request with optional reason and cleanup flag.
655
+
656
+ Returns:
657
+ ControlResponse with operation result.
658
+
659
+ Raises:
660
+ 404: If no active task exists.
661
+ 400: If the task cannot be stopped in its current state.
662
+ 500: If an error occurs during the operation.
663
+ """
664
+ state_manager = _get_state_manager(request)
665
+
666
+ if not state_manager.exists():
667
+ return JSONResponse(
668
+ status_code=404,
669
+ content=ErrorResponse(
670
+ error="not_found",
671
+ message="No active task found",
672
+ suggestion="Start a new task with 'claudetm start <goal>'",
673
+ ).model_dump(),
674
+ )
675
+
676
+ try:
677
+ # Create control manager and perform stop operation
678
+ control = ControlManager(state_manager=state_manager)
679
+ result = control.stop(reason=stop_request.reason, cleanup=stop_request.cleanup)
680
+
681
+ return ControlResponse(
682
+ success=result.success,
683
+ message=result.message,
684
+ operation=result.operation,
685
+ previous_status=result.previous_status,
686
+ new_status=result.new_status,
687
+ details=result.details,
688
+ )
689
+
690
+ except Exception as e:
691
+ logger.exception("Error stopping task")
692
+
693
+ # Check if it's a known control error
694
+ if "Cannot stop task" in str(e):
695
+ return JSONResponse(
696
+ status_code=400,
697
+ content=ErrorResponse(
698
+ error="invalid_operation",
699
+ message=str(e),
700
+ suggestion="Task may be in a terminal state or already stopped",
701
+ ).model_dump(),
702
+ )
703
+
704
+ return JSONResponse(
705
+ status_code=500,
706
+ content=ErrorResponse(
707
+ error="internal_error",
708
+ message="Failed to stop task",
709
+ detail=str(e),
710
+ ).model_dump(),
711
+ )
712
+
713
+ @router.post(
714
+ "/control/resume",
715
+ response_model=ControlResponse,
716
+ responses={
717
+ 400: {"model": ErrorResponse, "description": "Invalid operation for current state"},
718
+ 404: {"model": ErrorResponse, "description": "No active task found"},
719
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
720
+ },
721
+ summary="Resume Task",
722
+ description="Resume a paused or blocked task.",
723
+ )
724
+ async def resume_task(
725
+ request: Request, resume_request: ResumeRequest
726
+ ) -> ControlResponse | JSONResponse:
727
+ """Resume a paused or blocked task.
728
+
729
+ Resumes the current task from paused or blocked status.
730
+ The task must be in a resumable state (paused, stopped, blocked, or working).
731
+
732
+ Returns:
733
+ ControlResponse with operation result.
734
+
735
+ Raises:
736
+ 404: If no active task exists.
737
+ 400: If the task cannot be resumed in its current state.
738
+ 500: If an error occurs during the operation.
739
+ """
740
+ state_manager = _get_state_manager(request)
741
+
742
+ if not state_manager.exists():
743
+ return JSONResponse(
744
+ status_code=404,
745
+ content=ErrorResponse(
746
+ error="not_found",
747
+ message="No active task found",
748
+ suggestion="Start a new task with 'claudetm start <goal>'",
749
+ ).model_dump(),
750
+ )
751
+
752
+ try:
753
+ # Create control manager and perform resume operation
754
+ control = ControlManager(state_manager=state_manager)
755
+ result = control.resume()
756
+
757
+ return ControlResponse(
758
+ success=result.success,
759
+ message=result.message,
760
+ operation=result.operation,
761
+ previous_status=result.previous_status,
762
+ new_status=result.new_status,
763
+ details=result.details,
764
+ )
765
+
766
+ except Exception as e:
767
+ logger.exception("Error resuming task")
768
+
769
+ # Check if it's a known control error
770
+ if "Cannot resume task" in str(e):
771
+ return JSONResponse(
772
+ status_code=400,
773
+ content=ErrorResponse(
774
+ error="invalid_operation",
775
+ message=str(e),
776
+ suggestion="Task may be in a terminal state or already running",
777
+ ).model_dump(),
778
+ )
779
+
780
+ return JSONResponse(
781
+ status_code=500,
782
+ content=ErrorResponse(
783
+ error="internal_error",
784
+ message="Failed to resume task",
785
+ detail=str(e),
786
+ ).model_dump(),
787
+ )
788
+
789
+ @router.patch(
790
+ "/config",
791
+ response_model=ControlResponse,
792
+ responses={
793
+ 400: {
794
+ "model": ErrorResponse,
795
+ "description": "Invalid configuration or no updates provided",
796
+ },
797
+ 404: {"model": ErrorResponse, "description": "No active task found"},
798
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
799
+ },
800
+ summary="Update Configuration",
801
+ description="Update runtime task configuration options.",
802
+ )
803
+ async def update_config(
804
+ request: Request, config_update: ConfigUpdateRequest
805
+ ) -> ControlResponse | JSONResponse:
806
+ """Update task configuration at runtime.
807
+
808
+ Updates the specified configuration options for the current task.
809
+ Only the fields specified in the request are updated; all other
810
+ configuration options retain their current values.
811
+
812
+ Supported options:
813
+ - auto_merge: Whether to auto-merge PRs when approved
814
+ - max_sessions: Maximum number of work sessions before pausing
815
+ - pause_on_pr: Whether to pause after creating PR for manual review
816
+ - enable_checkpointing: Whether to enable state checkpointing
817
+ - log_level: Log level (quiet, normal, verbose)
818
+ - log_format: Log format (text, json)
819
+ - pr_per_task: Whether to create PR per task vs per group
820
+
821
+ Args:
822
+ config_update: Configuration update request with fields to update.
823
+
824
+ Returns:
825
+ ControlResponse with operation result including updated values.
826
+
827
+ Raises:
828
+ 404: If no active task exists.
829
+ 400: If no configuration updates were provided or invalid values.
830
+ 500: If an error occurs during the operation.
831
+ """
832
+ state_manager = _get_state_manager(request)
833
+
834
+ if not state_manager.exists():
835
+ return JSONResponse(
836
+ status_code=404,
837
+ content=ErrorResponse(
838
+ error="not_found",
839
+ message="No active task found",
840
+ suggestion="Start a new task with 'claudetm start <goal>'",
841
+ ).model_dump(),
842
+ )
843
+
844
+ # Validate that at least one field is being updated
845
+ if not config_update.has_updates():
846
+ return JSONResponse(
847
+ status_code=400,
848
+ content=ErrorResponse(
849
+ error="invalid_request",
850
+ message="No configuration updates provided",
851
+ suggestion="Specify at least one configuration field to update",
852
+ ).model_dump(),
853
+ )
854
+
855
+ try:
856
+ # Create control manager and perform config update
857
+ control = ControlManager(state_manager=state_manager)
858
+
859
+ # Convert config update to kwargs dictionary
860
+ update_kwargs = config_update.to_update_dict()
861
+ result = control.update_config(**update_kwargs)
862
+
863
+ return ControlResponse(
864
+ success=result.success,
865
+ message=result.message,
866
+ operation=result.operation,
867
+ previous_status=result.previous_status,
868
+ new_status=result.new_status,
869
+ details=result.details,
870
+ )
871
+
872
+ except ValueError as e:
873
+ logger.exception("Invalid configuration update")
874
+ return JSONResponse(
875
+ status_code=400,
876
+ content=ErrorResponse(
877
+ error="invalid_configuration",
878
+ message="Invalid configuration option",
879
+ detail=str(e),
880
+ ).model_dump(),
881
+ )
882
+
883
+ except Exception as e:
884
+ logger.exception("Error updating configuration")
885
+ return JSONResponse(
886
+ status_code=500,
887
+ content=ErrorResponse(
888
+ error="internal_error",
889
+ message="Failed to update configuration",
890
+ detail=str(e),
891
+ ).model_dump(),
892
+ )
893
+
894
+ return router
895
+
896
+
897
+ # =============================================================================
898
+ # Task Management Router (Init, Delete)
899
+ # =============================================================================
900
+
901
+
902
+ def create_task_router() -> APIRouter:
903
+ """Create router for task management endpoints.
904
+
905
+ These endpoints allow task lifecycle management including
906
+ initializing new tasks and deleting existing tasks.
907
+
908
+ Returns:
909
+ APIRouter configured with task management endpoints.
910
+
911
+ Raises:
912
+ ImportError: If FastAPI is not installed.
913
+ """
914
+ if not FASTAPI_AVAILABLE:
915
+ raise ImportError(
916
+ "FastAPI not installed. Install with: pip install claude-task-master[api]"
917
+ )
918
+
919
+ router = APIRouter(tags=["Task Management"])
920
+
921
+ @router.post(
922
+ "/task/init",
923
+ response_model=TaskInitResponse,
924
+ responses={
925
+ 400: {"model": ErrorResponse, "description": "Invalid request or task already exists"},
926
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
927
+ },
928
+ summary="Initialize Task",
929
+ description="Initialize a new task with the given goal and options.",
930
+ )
931
+ async def init_task(
932
+ request: Request, task_init: TaskInitRequest
933
+ ) -> TaskInitResponse | JSONResponse:
934
+ """Initialize a new task.
935
+
936
+ Creates a new task with the specified goal and configuration options.
937
+ The task will be in 'planning' status after initialization.
938
+
939
+ Args:
940
+ task_init: Task initialization request with goal and options.
941
+
942
+ Returns:
943
+ TaskInitResponse with initialization result including run_id.
944
+
945
+ Raises:
946
+ 400: If a task already exists or request is invalid.
947
+ 500: If an error occurs during initialization.
948
+ """
949
+ state_manager = _get_state_manager(request)
950
+
951
+ # Check if task already exists
952
+ if state_manager.exists():
953
+ return JSONResponse(
954
+ status_code=400,
955
+ content=ErrorResponse(
956
+ error="task_exists",
957
+ message="A task already exists",
958
+ suggestion="Use DELETE /task to remove the existing task first",
959
+ ).model_dump(),
960
+ )
961
+
962
+ try:
963
+ # Validate model type
964
+ try:
965
+ ModelType(task_init.model)
966
+ except ValueError:
967
+ return JSONResponse(
968
+ status_code=400,
969
+ content=ErrorResponse(
970
+ error="invalid_model",
971
+ message=f"Invalid model '{task_init.model}'",
972
+ detail="Model must be one of: opus, sonnet, haiku",
973
+ suggestion="Use 'opus', 'sonnet', or 'haiku'",
974
+ ).model_dump(),
975
+ )
976
+
977
+ # Load credentials to verify we can authenticate
978
+ try:
979
+ cred_manager = CredentialManager()
980
+ cred_manager.get_valid_token()
981
+ except Exception as e:
982
+ logger.exception("Failed to load credentials")
983
+ return JSONResponse(
984
+ status_code=500,
985
+ content=ErrorResponse(
986
+ error="credentials_error",
987
+ message="Failed to load Claude credentials",
988
+ detail=str(e),
989
+ suggestion="Ensure you have authenticated with 'claude auth'",
990
+ ).model_dump(),
991
+ )
992
+
993
+ # Initialize task state
994
+ logger.info(f"Initializing new task: {task_init.goal}")
995
+ options = TaskOptions(
996
+ auto_merge=task_init.auto_merge,
997
+ max_sessions=task_init.max_sessions,
998
+ pause_on_pr=task_init.pause_on_pr,
999
+ enable_checkpointing=False, # Default to False
1000
+ log_level="normal", # Default to normal
1001
+ log_format="text", # Default to text
1002
+ pr_per_task=False, # Default to False
1003
+ )
1004
+ state = state_manager.initialize(
1005
+ goal=task_init.goal, model=task_init.model, options=options
1006
+ )
1007
+
1008
+ logger.info(f"Task initialized with run_id: {state.run_id}")
1009
+
1010
+ return TaskInitResponse(
1011
+ success=True,
1012
+ message="Task initialized successfully",
1013
+ run_id=state.run_id,
1014
+ status=state.status,
1015
+ )
1016
+
1017
+ except Exception as e:
1018
+ logger.exception("Error initializing task")
1019
+ return JSONResponse(
1020
+ status_code=500,
1021
+ content=ErrorResponse(
1022
+ error="internal_error",
1023
+ message="Failed to initialize task",
1024
+ detail=str(e),
1025
+ ).model_dump(),
1026
+ )
1027
+
1028
+ @router.delete(
1029
+ "/task",
1030
+ response_model=TaskDeleteResponse,
1031
+ responses={
1032
+ 404: {"model": ErrorResponse, "description": "No active task found"},
1033
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
1034
+ },
1035
+ summary="Delete Task",
1036
+ description="Delete the current task and cleanup all state files.",
1037
+ )
1038
+ async def delete_task(request: Request) -> TaskDeleteResponse | JSONResponse:
1039
+ """Delete the current task.
1040
+
1041
+ Removes all task state files including plan, progress, context,
1042
+ and state. This operation cannot be undone.
1043
+
1044
+ Returns:
1045
+ TaskDeleteResponse with deletion result.
1046
+
1047
+ Raises:
1048
+ 404: If no active task exists.
1049
+ 500: If an error occurs during deletion.
1050
+ """
1051
+ state_manager = _get_state_manager(request)
1052
+
1053
+ if not state_manager.exists():
1054
+ return JSONResponse(
1055
+ status_code=404,
1056
+ content=ErrorResponse(
1057
+ error="not_found",
1058
+ message="No active task found",
1059
+ suggestion="No task to delete",
1060
+ ).model_dump(),
1061
+ )
1062
+
1063
+ try:
1064
+ # Check if session is active
1065
+ is_active = state_manager.is_session_active()
1066
+
1067
+ if is_active:
1068
+ logger.warning("Deleting task while session is active")
1069
+ # Release session lock before deletion
1070
+ state_manager.release_session_lock()
1071
+
1072
+ # Remove state directory
1073
+ state_dir = state_manager.state_dir
1074
+ if state_dir.exists():
1075
+ shutil.rmtree(state_dir)
1076
+ logger.info(f"Task state deleted: {state_dir}")
1077
+ files_removed = True
1078
+ else:
1079
+ logger.warning(f"State directory not found: {state_dir}")
1080
+ files_removed = False
1081
+
1082
+ return TaskDeleteResponse(
1083
+ success=True,
1084
+ message="Task deleted successfully",
1085
+ files_removed=files_removed,
1086
+ )
1087
+
1088
+ except Exception as e:
1089
+ logger.exception("Error deleting task")
1090
+ return JSONResponse(
1091
+ status_code=500,
1092
+ content=ErrorResponse(
1093
+ error="internal_error",
1094
+ message="Failed to delete task",
1095
+ detail=str(e),
1096
+ ).model_dump(),
1097
+ )
1098
+
1099
+ return router
1100
+
1101
+
1102
+ # =============================================================================
1103
+ # Router Registration
1104
+ # =============================================================================
1105
+
1106
+
1107
+ def register_routes(app: FastAPI) -> None:
1108
+ """Register all API routes with the FastAPI app.
1109
+
1110
+ This function creates and registers all routers with the app.
1111
+ It's the main entry point for route registration.
1112
+
1113
+ Args:
1114
+ app: The FastAPI application to register routes with.
1115
+ """
1116
+ # Create and register info router
1117
+ info_router = create_info_router()
1118
+ app.include_router(info_router)
1119
+
1120
+ # Create and register control router
1121
+ control_router = create_control_router()
1122
+ app.include_router(control_router)
1123
+
1124
+ # Create and register task management router
1125
+ task_router = create_task_router()
1126
+ app.include_router(task_router)
1127
+
1128
+ # Create and register webhooks router
1129
+ webhooks_router = create_webhooks_router()
1130
+ app.include_router(webhooks_router, prefix="/webhooks")
1131
+
1132
+ logger.debug("Registered info routes: /status, /plan, /logs, /progress, /context, /health")
1133
+ logger.debug("Registered control routes: /control/stop, /control/resume, /config")
1134
+ logger.debug("Registered task routes: /task/init, /task")
1135
+ logger.debug("Registered webhook routes: /webhooks, /webhooks/{id}, /webhooks/test")