claude-mpm 5.6.1__py3-none-any.whl → 5.6.76__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 (131) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/PM_INSTRUCTIONS.md +8 -3
  3. claude_mpm/auth/__init__.py +35 -0
  4. claude_mpm/auth/callback_server.py +328 -0
  5. claude_mpm/auth/models.py +104 -0
  6. claude_mpm/auth/oauth_manager.py +266 -0
  7. claude_mpm/auth/providers/__init__.py +12 -0
  8. claude_mpm/auth/providers/base.py +165 -0
  9. claude_mpm/auth/providers/google.py +261 -0
  10. claude_mpm/auth/token_storage.py +252 -0
  11. claude_mpm/cli/commands/commander.py +174 -4
  12. claude_mpm/cli/commands/mcp.py +29 -17
  13. claude_mpm/cli/commands/mcp_command_router.py +39 -0
  14. claude_mpm/cli/commands/mcp_service_commands.py +304 -0
  15. claude_mpm/cli/commands/oauth.py +481 -0
  16. claude_mpm/cli/commands/skill_source.py +51 -2
  17. claude_mpm/cli/commands/skills.py +5 -3
  18. claude_mpm/cli/executor.py +9 -0
  19. claude_mpm/cli/helpers.py +1 -1
  20. claude_mpm/cli/parsers/base_parser.py +13 -0
  21. claude_mpm/cli/parsers/commander_parser.py +43 -10
  22. claude_mpm/cli/parsers/mcp_parser.py +79 -0
  23. claude_mpm/cli/parsers/oauth_parser.py +165 -0
  24. claude_mpm/cli/parsers/skill_source_parser.py +4 -0
  25. claude_mpm/cli/parsers/skills_parser.py +5 -0
  26. claude_mpm/cli/startup.py +300 -33
  27. claude_mpm/cli/startup_display.py +4 -2
  28. claude_mpm/cli/startup_migrations.py +236 -0
  29. claude_mpm/commander/__init__.py +6 -0
  30. claude_mpm/commander/adapters/__init__.py +32 -3
  31. claude_mpm/commander/adapters/auggie.py +260 -0
  32. claude_mpm/commander/adapters/base.py +98 -1
  33. claude_mpm/commander/adapters/claude_code.py +32 -1
  34. claude_mpm/commander/adapters/codex.py +237 -0
  35. claude_mpm/commander/adapters/example_usage.py +310 -0
  36. claude_mpm/commander/adapters/mpm.py +389 -0
  37. claude_mpm/commander/adapters/registry.py +204 -0
  38. claude_mpm/commander/api/app.py +32 -16
  39. claude_mpm/commander/api/errors.py +21 -0
  40. claude_mpm/commander/api/routes/messages.py +11 -11
  41. claude_mpm/commander/api/routes/projects.py +20 -20
  42. claude_mpm/commander/api/routes/sessions.py +37 -26
  43. claude_mpm/commander/api/routes/work.py +86 -50
  44. claude_mpm/commander/api/schemas.py +4 -0
  45. claude_mpm/commander/chat/cli.py +47 -5
  46. claude_mpm/commander/chat/commands.py +44 -16
  47. claude_mpm/commander/chat/repl.py +1729 -82
  48. claude_mpm/commander/config.py +5 -3
  49. claude_mpm/commander/core/__init__.py +10 -0
  50. claude_mpm/commander/core/block_manager.py +325 -0
  51. claude_mpm/commander/core/response_manager.py +323 -0
  52. claude_mpm/commander/daemon.py +215 -10
  53. claude_mpm/commander/env_loader.py +59 -0
  54. claude_mpm/commander/events/manager.py +61 -1
  55. claude_mpm/commander/frameworks/base.py +91 -1
  56. claude_mpm/commander/frameworks/mpm.py +9 -14
  57. claude_mpm/commander/git/__init__.py +5 -0
  58. claude_mpm/commander/git/worktree_manager.py +212 -0
  59. claude_mpm/commander/instance_manager.py +546 -15
  60. claude_mpm/commander/memory/__init__.py +45 -0
  61. claude_mpm/commander/memory/compression.py +347 -0
  62. claude_mpm/commander/memory/embeddings.py +230 -0
  63. claude_mpm/commander/memory/entities.py +310 -0
  64. claude_mpm/commander/memory/example_usage.py +290 -0
  65. claude_mpm/commander/memory/integration.py +325 -0
  66. claude_mpm/commander/memory/search.py +381 -0
  67. claude_mpm/commander/memory/store.py +657 -0
  68. claude_mpm/commander/models/events.py +6 -0
  69. claude_mpm/commander/persistence/state_store.py +95 -1
  70. claude_mpm/commander/registry.py +10 -4
  71. claude_mpm/commander/runtime/monitor.py +32 -2
  72. claude_mpm/commander/tmux_orchestrator.py +3 -2
  73. claude_mpm/commander/work/executor.py +38 -20
  74. claude_mpm/commander/workflow/event_handler.py +25 -3
  75. claude_mpm/config/skill_sources.py +16 -0
  76. claude_mpm/constants.py +5 -0
  77. claude_mpm/core/claude_runner.py +152 -0
  78. claude_mpm/core/config.py +30 -22
  79. claude_mpm/core/config_constants.py +74 -9
  80. claude_mpm/core/constants.py +56 -12
  81. claude_mpm/core/hook_manager.py +2 -1
  82. claude_mpm/core/interactive_session.py +5 -4
  83. claude_mpm/core/logger.py +16 -2
  84. claude_mpm/core/logging_utils.py +40 -16
  85. claude_mpm/core/network_config.py +148 -0
  86. claude_mpm/core/oneshot_session.py +7 -6
  87. claude_mpm/core/output_style_manager.py +37 -7
  88. claude_mpm/core/socketio_pool.py +47 -15
  89. claude_mpm/core/unified_paths.py +68 -80
  90. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +30 -31
  91. claude_mpm/hooks/claude_hooks/event_handlers.py +285 -194
  92. claude_mpm/hooks/claude_hooks/hook_handler.py +115 -32
  93. claude_mpm/hooks/claude_hooks/installer.py +222 -54
  94. claude_mpm/hooks/claude_hooks/memory_integration.py +52 -32
  95. claude_mpm/hooks/claude_hooks/response_tracking.py +40 -59
  96. claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
  97. claude_mpm/hooks/claude_hooks/services/connection_manager.py +25 -30
  98. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +24 -28
  99. claude_mpm/hooks/claude_hooks/services/container.py +326 -0
  100. claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
  101. claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
  102. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +49 -75
  103. claude_mpm/hooks/session_resume_hook.py +22 -18
  104. claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
  105. claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
  106. claude_mpm/init.py +21 -14
  107. claude_mpm/mcp/__init__.py +9 -0
  108. claude_mpm/mcp/google_workspace_server.py +610 -0
  109. claude_mpm/scripts/claude-hook-handler.sh +10 -9
  110. claude_mpm/services/agents/agent_selection_service.py +2 -2
  111. claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
  112. claude_mpm/services/command_deployment_service.py +44 -26
  113. claude_mpm/services/hook_installer_service.py +77 -8
  114. claude_mpm/services/mcp_config_manager.py +99 -19
  115. claude_mpm/services/mcp_service_registry.py +294 -0
  116. claude_mpm/services/monitor/server.py +6 -1
  117. claude_mpm/services/pm_skills_deployer.py +5 -3
  118. claude_mpm/services/skills/git_skill_source_manager.py +79 -8
  119. claude_mpm/services/skills/selective_skill_deployer.py +28 -0
  120. claude_mpm/services/skills/skill_discovery_service.py +17 -1
  121. claude_mpm/services/skills_deployer.py +31 -5
  122. claude_mpm/skills/__init__.py +2 -1
  123. claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
  124. claude_mpm/skills/registry.py +295 -90
  125. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/METADATA +28 -3
  126. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/RECORD +131 -93
  127. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/WHEEL +1 -1
  128. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/entry_points.txt +2 -0
  129. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE +0 -0
  130. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  131. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/top_level.txt +0 -0
@@ -7,7 +7,7 @@ conversation threads for projects.
7
7
  import uuid
8
8
  from typing import List
9
9
 
10
- from fastapi import APIRouter
10
+ from fastapi import APIRouter, Request
11
11
 
12
12
  from ...models import ThreadMessage
13
13
  from ..errors import ProjectNotFoundError
@@ -16,13 +16,11 @@ from ..schemas import MessageResponse, SendMessageRequest
16
16
  router = APIRouter()
17
17
 
18
18
 
19
- def _get_registry():
20
- """Get registry instance from app global."""
21
- from ..app import registry
22
-
23
- if registry is None:
19
+ def _get_registry(request: Request):
20
+ """Get registry instance from app.state."""
21
+ if not hasattr(request.app.state, "registry") or request.app.state.registry is None:
24
22
  raise RuntimeError("Registry not initialized")
25
- return registry
23
+ return request.app.state.registry
26
24
 
27
25
 
28
26
  def _message_to_response(message: ThreadMessage) -> MessageResponse:
@@ -44,7 +42,7 @@ def _message_to_response(message: ThreadMessage) -> MessageResponse:
44
42
 
45
43
 
46
44
  @router.get("/projects/{project_id}/thread", response_model=List[MessageResponse])
47
- async def get_thread(project_id: str) -> List[MessageResponse]:
45
+ async def get_thread(request: Request, project_id: str) -> List[MessageResponse]:
48
46
  """Get conversation thread for a project.
49
47
 
50
48
  Returns all messages in chronological order.
@@ -77,7 +75,7 @@ async def get_thread(project_id: str) -> List[MessageResponse]:
77
75
  }
78
76
  ]
79
77
  """
80
- registry = _get_registry()
78
+ registry = _get_registry(request)
81
79
  project = registry.get(project_id)
82
80
 
83
81
  if project is None:
@@ -90,7 +88,9 @@ async def get_thread(project_id: str) -> List[MessageResponse]:
90
88
  @router.post(
91
89
  "/projects/{project_id}/messages", response_model=MessageResponse, status_code=201
92
90
  )
93
- async def send_message(project_id: str, req: SendMessageRequest) -> MessageResponse:
91
+ async def send_message(
92
+ request: Request, project_id: str, req: SendMessageRequest
93
+ ) -> MessageResponse:
94
94
  """Send a message to a project's active session.
95
95
 
96
96
  Adds message to conversation thread and sends to specified or active session.
@@ -119,7 +119,7 @@ async def send_message(project_id: str, req: SendMessageRequest) -> MessageRespo
119
119
  "timestamp": "2025-01-12T10:00:00Z"
120
120
  }
121
121
  """
122
- registry = _get_registry()
122
+ registry = _get_registry(request)
123
123
  project = registry.get(project_id)
124
124
 
125
125
  if project is None:
@@ -7,7 +7,7 @@ projects in the MPM Commander.
7
7
  from pathlib import Path
8
8
  from typing import List
9
9
 
10
- from fastapi import APIRouter, Response
10
+ from fastapi import APIRouter, Request, Response
11
11
 
12
12
  from ...models import ProjectState
13
13
  from ..errors import InvalidPathError, ProjectAlreadyExistsError, ProjectNotFoundError
@@ -16,13 +16,11 @@ from ..schemas import ProjectResponse, RegisterProjectRequest, SessionResponse
16
16
  router = APIRouter()
17
17
 
18
18
 
19
- def _get_registry():
20
- """Get registry instance from app global."""
21
- from ..app import registry
22
-
23
- if registry is None:
19
+ def _get_registry(request: Request):
20
+ """Get registry instance from app.state."""
21
+ if not hasattr(request.app.state, "registry") or request.app.state.registry is None:
24
22
  raise RuntimeError("Registry not initialized")
25
- return registry
23
+ return request.app.state.registry
26
24
 
27
25
 
28
26
  def _project_to_response(project) -> ProjectResponse:
@@ -61,7 +59,7 @@ def _project_to_response(project) -> ProjectResponse:
61
59
 
62
60
 
63
61
  @router.get("/projects", response_model=List[ProjectResponse])
64
- async def list_projects() -> List[ProjectResponse]:
62
+ async def list_projects(request: Request) -> List[ProjectResponse]:
65
63
  """List all registered projects.
66
64
 
67
65
  Returns:
@@ -83,13 +81,13 @@ async def list_projects() -> List[ProjectResponse]:
83
81
  }
84
82
  ]
85
83
  """
86
- registry = _get_registry()
84
+ registry = _get_registry(request)
87
85
  projects = registry.list_all()
88
86
  return [_project_to_response(p) for p in projects]
89
87
 
90
88
 
91
89
  @router.get("/projects/{project_id}", response_model=ProjectResponse)
92
- async def get_project(project_id: str) -> ProjectResponse:
90
+ async def get_project(request: Request, project_id: str) -> ProjectResponse:
93
91
  """Get project details by ID.
94
92
 
95
93
  Args:
@@ -111,7 +109,7 @@ async def get_project(project_id: str) -> ProjectResponse:
111
109
  ...
112
110
  }
113
111
  """
114
- registry = _get_registry()
112
+ registry = _get_registry(request)
115
113
  project = registry.get(project_id)
116
114
 
117
115
  if project is None:
@@ -121,7 +119,9 @@ async def get_project(project_id: str) -> ProjectResponse:
121
119
 
122
120
 
123
121
  @router.post("/projects", response_model=ProjectResponse, status_code=201)
124
- async def register_project(req: RegisterProjectRequest) -> ProjectResponse:
122
+ async def register_project(
123
+ request: Request, req: RegisterProjectRequest
124
+ ) -> ProjectResponse:
125
125
  """Register a new project.
126
126
 
127
127
  Args:
@@ -148,7 +148,7 @@ async def register_project(req: RegisterProjectRequest) -> ProjectResponse:
148
148
  ...
149
149
  }
150
150
  """
151
- registry = _get_registry()
151
+ registry = _get_registry(request)
152
152
 
153
153
  # Validate path exists and is directory
154
154
  path_obj = Path(req.path)
@@ -156,7 +156,7 @@ async def register_project(req: RegisterProjectRequest) -> ProjectResponse:
156
156
  raise InvalidPathError(req.path)
157
157
 
158
158
  try:
159
- project = registry.register(req.path, req.name)
159
+ project = registry.register(req.path, req.name, req.project_id)
160
160
  return _project_to_response(project)
161
161
  except ValueError as e:
162
162
  # Registry raises ValueError for duplicate registration
@@ -168,7 +168,7 @@ async def register_project(req: RegisterProjectRequest) -> ProjectResponse:
168
168
 
169
169
 
170
170
  @router.delete("/projects/{project_id}", status_code=204)
171
- async def unregister_project(project_id: str) -> Response:
171
+ async def unregister_project(request: Request, project_id: str) -> Response:
172
172
  """Unregister a project.
173
173
 
174
174
  Args:
@@ -184,7 +184,7 @@ async def unregister_project(project_id: str) -> Response:
184
184
  DELETE /api/projects/abc-123
185
185
  Response: 204 No Content
186
186
  """
187
- registry = _get_registry()
187
+ registry = _get_registry(request)
188
188
 
189
189
  try:
190
190
  registry.unregister(project_id)
@@ -194,7 +194,7 @@ async def unregister_project(project_id: str) -> Response:
194
194
 
195
195
 
196
196
  @router.post("/projects/{project_id}/pause", response_model=ProjectResponse)
197
- async def pause_project(project_id: str) -> ProjectResponse:
197
+ async def pause_project(request: Request, project_id: str) -> ProjectResponse:
198
198
  """Pause a project.
199
199
 
200
200
  Sets project state to PAUSED to prevent automatic work processing.
@@ -217,7 +217,7 @@ async def pause_project(project_id: str) -> ProjectResponse:
217
217
  ...
218
218
  }
219
219
  """
220
- registry = _get_registry()
220
+ registry = _get_registry(request)
221
221
  project = registry.get(project_id)
222
222
 
223
223
  if project is None:
@@ -233,7 +233,7 @@ async def pause_project(project_id: str) -> ProjectResponse:
233
233
 
234
234
 
235
235
  @router.post("/projects/{project_id}/resume", response_model=ProjectResponse)
236
- async def resume_project(project_id: str) -> ProjectResponse:
236
+ async def resume_project(request: Request, project_id: str) -> ProjectResponse:
237
237
  """Resume a paused project.
238
238
 
239
239
  Sets project state back to IDLE to allow work processing.
@@ -256,7 +256,7 @@ async def resume_project(project_id: str) -> ProjectResponse:
256
256
  ...
257
257
  }
258
258
  """
259
- registry = _get_registry()
259
+ registry = _get_registry(request)
260
260
  project = registry.get(project_id)
261
261
 
262
262
  if project is None:
@@ -5,13 +5,19 @@ This module implements REST endpoints for creating and managing tool sessions
5
5
  """
6
6
 
7
7
  import logging
8
+ import subprocess # nosec B404 - needed for tmux error handling
8
9
  import uuid
9
10
  from typing import List
10
11
 
11
- from fastapi import APIRouter, Response
12
+ from fastapi import APIRouter, Request, Response
12
13
 
13
14
  from ...models import ToolSession
14
- from ..errors import InvalidRuntimeError, ProjectNotFoundError, SessionNotFoundError
15
+ from ..errors import (
16
+ InvalidRuntimeError,
17
+ ProjectNotFoundError,
18
+ SessionNotFoundError,
19
+ TmuxNoSpaceError,
20
+ )
15
21
  from ..schemas import CreateSessionRequest, SessionResponse
16
22
 
17
23
  router = APIRouter()
@@ -21,22 +27,18 @@ logger = logging.getLogger(__name__)
21
27
  VALID_RUNTIMES = {"claude-code"}
22
28
 
23
29
 
24
- def _get_registry():
25
- """Get registry instance from app global."""
26
- from ..app import registry
27
-
28
- if registry is None:
30
+ def _get_registry(request: Request):
31
+ """Get registry instance from app.state."""
32
+ if not hasattr(request.app.state, "registry") or request.app.state.registry is None:
29
33
  raise RuntimeError("Registry not initialized")
30
- return registry
31
-
34
+ return request.app.state.registry
32
35
 
33
- def _get_tmux():
34
- """Get tmux orchestrator instance from app global."""
35
- from ..app import tmux
36
36
 
37
- if tmux is None:
37
+ def _get_tmux(request: Request):
38
+ """Get tmux orchestrator instance from app.state."""
39
+ if not hasattr(request.app.state, "tmux") or request.app.state.tmux is None:
38
40
  raise RuntimeError("Tmux orchestrator not initialized")
39
- return tmux
41
+ return request.app.state.tmux
40
42
 
41
43
 
42
44
  def _session_to_response(session: ToolSession) -> SessionResponse:
@@ -59,7 +61,7 @@ def _session_to_response(session: ToolSession) -> SessionResponse:
59
61
 
60
62
 
61
63
  @router.get("/projects/{project_id}/sessions", response_model=List[SessionResponse])
62
- async def list_sessions(project_id: str) -> List[SessionResponse]:
64
+ async def list_sessions(request: Request, project_id: str) -> List[SessionResponse]:
63
65
  """List all sessions for a project.
64
66
 
65
67
  Args:
@@ -84,7 +86,7 @@ async def list_sessions(project_id: str) -> List[SessionResponse]:
84
86
  }
85
87
  ]
86
88
  """
87
- registry = _get_registry()
89
+ registry = _get_registry(request)
88
90
  project = registry.get(project_id)
89
91
 
90
92
  if project is None:
@@ -97,7 +99,9 @@ async def list_sessions(project_id: str) -> List[SessionResponse]:
97
99
  @router.post(
98
100
  "/projects/{project_id}/sessions", response_model=SessionResponse, status_code=201
99
101
  )
100
- async def create_session(project_id: str, req: CreateSessionRequest) -> SessionResponse:
102
+ async def create_session(
103
+ request: Request, project_id: str, req: CreateSessionRequest
104
+ ) -> SessionResponse:
101
105
  """Create a new session for a project.
102
106
 
103
107
  Creates a new tmux pane and initializes the specified runtime adapter.
@@ -112,6 +116,7 @@ async def create_session(project_id: str, req: CreateSessionRequest) -> SessionR
112
116
  Raises:
113
117
  ProjectNotFoundError: If project_id doesn't exist
114
118
  InvalidRuntimeError: If runtime is not supported
119
+ TmuxNoSpaceError: If tmux has no space for new pane
115
120
 
116
121
  Example:
117
122
  POST /api/projects/abc-123/sessions
@@ -128,8 +133,8 @@ async def create_session(project_id: str, req: CreateSessionRequest) -> SessionR
128
133
  "created_at": "2025-01-12T10:00:00Z"
129
134
  }
130
135
  """
131
- registry = _get_registry()
132
- tmux_orch = _get_tmux()
136
+ registry = _get_registry(request)
137
+ tmux_orch = _get_tmux(request)
133
138
 
134
139
  # Validate project exists
135
140
  project = registry.get(project_id)
@@ -144,10 +149,16 @@ async def create_session(project_id: str, req: CreateSessionRequest) -> SessionR
144
149
  session_id = str(uuid.uuid4())
145
150
 
146
151
  # Create tmux pane for session
147
- tmux_target = tmux_orch.create_pane(
148
- pane_id=f"{project.name}-{req.runtime}",
149
- working_dir=project.path,
150
- )
152
+ try:
153
+ tmux_target = tmux_orch.create_pane(
154
+ pane_id=f"{project.name}-{req.runtime}",
155
+ working_dir=project.path,
156
+ )
157
+ except subprocess.CalledProcessError as e:
158
+ stderr = e.stderr.decode() if e.stderr else ""
159
+ if "no space for new pane" in stderr.lower():
160
+ raise TmuxNoSpaceError() from None
161
+ raise # Re-raise other subprocess errors
151
162
 
152
163
  # Create session object
153
164
  session = ToolSession(
@@ -168,7 +179,7 @@ async def create_session(project_id: str, req: CreateSessionRequest) -> SessionR
168
179
 
169
180
 
170
181
  @router.delete("/sessions/{session_id}", status_code=204)
171
- async def stop_session(session_id: str) -> Response:
182
+ async def stop_session(request: Request, session_id: str) -> Response:
172
183
  """Stop and remove a session.
173
184
 
174
185
  Kills the tmux pane and removes the session from its project.
@@ -186,8 +197,8 @@ async def stop_session(session_id: str) -> Response:
186
197
  DELETE /api/sessions/sess-456
187
198
  Response: 204 No Content
188
199
  """
189
- registry = _get_registry()
190
- tmux_orch = _get_tmux()
200
+ registry = _get_registry(request)
201
+ tmux_orch = _get_tmux(request)
191
202
 
192
203
  # Find session across all projects
193
204
  session = None
@@ -4,25 +4,39 @@ This module implements REST endpoints for managing work items
4
4
  in project work queues.
5
5
  """
6
6
 
7
- from typing import List, Optional
7
+ from typing import Dict, List, Optional
8
8
 
9
- from fastapi import APIRouter, HTTPException, Query
9
+ from fastapi import APIRouter, HTTPException, Query, Request
10
10
 
11
11
  from ...models.work import WorkPriority, WorkState
12
12
  from ...work import WorkQueue
13
- from ..errors import ProjectNotFoundError
14
13
  from ..schemas import CreateWorkRequest, WorkItemResponse
15
14
 
16
15
  router = APIRouter()
17
16
 
18
17
 
19
- def _get_registry():
20
- """Get registry instance from app global."""
21
- from ..app import registry
22
-
23
- if registry is None:
18
+ def _get_registry(request: Request):
19
+ """Get registry instance from app.state."""
20
+ if not hasattr(request.app.state, "registry") or request.app.state.registry is None:
24
21
  raise RuntimeError("Registry not initialized")
25
- return registry
22
+ return request.app.state.registry
23
+
24
+
25
+ def _get_work_queues(request: Request) -> Dict:
26
+ """Get work queues dict from app.state (shared with daemon)."""
27
+ if (
28
+ not hasattr(request.app.state, "work_queues")
29
+ or request.app.state.work_queues is None
30
+ ):
31
+ raise RuntimeError("Work queues not initialized")
32
+ return request.app.state.work_queues
33
+
34
+
35
+ def _get_daemon(request: Request):
36
+ """Get daemon instance from app.state."""
37
+ if not hasattr(request.app.state, "daemon_instance"):
38
+ return None
39
+ return request.app.state.daemon_instance
26
40
 
27
41
 
28
42
  def _work_item_to_response(work_item) -> WorkItemResponse:
@@ -51,10 +65,13 @@ def _work_item_to_response(work_item) -> WorkItemResponse:
51
65
 
52
66
 
53
67
  @router.post("/projects/{project_id}/work", response_model=WorkItemResponse)
54
- async def add_work(project_id: str, work: CreateWorkRequest) -> WorkItemResponse:
68
+ async def add_work(
69
+ request: Request, project_id: str, work: CreateWorkRequest
70
+ ) -> WorkItemResponse:
55
71
  """Add work item to project queue.
56
72
 
57
73
  Args:
74
+ request: FastAPI request (for accessing app.state)
58
75
  project_id: Project identifier
59
76
  work: Work item creation request
60
77
 
@@ -80,22 +97,29 @@ async def add_work(project_id: str, work: CreateWorkRequest) -> WorkItemResponse
80
97
  ...
81
98
  }
82
99
  """
83
- registry = _get_registry()
100
+ registry = _get_registry(request)
101
+ work_queues = _get_work_queues(request)
102
+ daemon = _get_daemon(request)
84
103
 
85
104
  # Get project
86
- try:
87
- project = registry.get(project_id)
88
- except ProjectNotFoundError as e:
89
- raise HTTPException(status_code=404, detail=str(e)) from e
105
+ project = registry.get(project_id)
106
+ if project is None:
107
+ raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
90
108
 
91
- # Get or create work queue for project
92
- # Note: In full implementation, this would be managed by ProjectSession
93
- # For now, we'll need to integrate with project's work queue
94
- # Access or create work queue
95
- if not hasattr(project, "_work_queue"):
96
- project._work_queue = WorkQueue(project_id)
109
+ # Get or create work queue (shared with daemon)
110
+ import logging
97
111
 
98
- queue = project._work_queue
112
+ logger = logging.getLogger(__name__)
113
+ logger.info(
114
+ f"work_queues dict id: {id(work_queues)}, keys: {list(work_queues.keys())}"
115
+ )
116
+
117
+ if project_id not in work_queues:
118
+ logger.info(f"Creating new work queue for {project_id}")
119
+ work_queues[project_id] = WorkQueue(project_id)
120
+ logger.info(f"After creation, work_queues keys: {list(work_queues.keys())}")
121
+
122
+ queue = work_queues[project_id]
99
123
 
100
124
  # Convert priority int to enum
101
125
  priority = WorkPriority(work.priority)
@@ -105,16 +129,25 @@ async def add_work(project_id: str, work: CreateWorkRequest) -> WorkItemResponse
105
129
  content=work.content, priority=priority, depends_on=work.depends_on
106
130
  )
107
131
 
132
+ # Ensure daemon has a session for this project (creates if needed)
133
+ if daemon and not daemon.sessions.get(project_id):
134
+ # Session creation will be handled by daemon's main loop
135
+ # when it detects work in the queue
136
+ pass
137
+
108
138
  return _work_item_to_response(work_item)
109
139
 
110
140
 
111
141
  @router.get("/projects/{project_id}/work", response_model=List[WorkItemResponse])
112
142
  async def list_work(
113
- project_id: str, state: Optional[str] = Query(None, description="Filter by state")
143
+ request: Request,
144
+ project_id: str,
145
+ state: Optional[str] = Query(None, description="Filter by state"),
114
146
  ) -> List[WorkItemResponse]:
115
147
  """List work items for project.
116
148
 
117
149
  Args:
150
+ request: FastAPI request (for accessing app.state)
118
151
  project_id: Project identifier
119
152
  state: Optional state filter (pending, queued, in_progress, etc.)
120
153
 
@@ -136,19 +169,20 @@ async def list_work(
136
169
  {"id": "work-1", "state": "queued", ...}
137
170
  ]
138
171
  """
139
- registry = _get_registry()
172
+ registry = _get_registry(request)
173
+ work_queues = _get_work_queues(request)
140
174
 
141
175
  # Get project
142
- try:
143
- project = registry.get(project_id)
144
- except ProjectNotFoundError as e:
145
- raise HTTPException(status_code=404, detail=str(e)) from e
176
+ project = registry.get(project_id)
177
+ if project is None:
178
+ raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
146
179
 
147
- # Get work queue
148
- if not hasattr(project, "_work_queue"):
149
- project._work_queue = WorkQueue(project_id)
180
+ # Get work queue (shared with daemon)
181
+ if project_id not in work_queues:
182
+ # Return empty list if no work queue exists yet
183
+ return []
150
184
 
151
- queue = project._work_queue
185
+ queue = work_queues[project_id]
152
186
 
153
187
  # Parse state filter
154
188
  state_filter = None
@@ -169,10 +203,11 @@ async def list_work(
169
203
 
170
204
 
171
205
  @router.get("/projects/{project_id}/work/{work_id}", response_model=WorkItemResponse)
172
- async def get_work(project_id: str, work_id: str) -> WorkItemResponse:
206
+ async def get_work(request: Request, project_id: str, work_id: str) -> WorkItemResponse:
173
207
  """Get work item details.
174
208
 
175
209
  Args:
210
+ request: FastAPI request (for accessing app.state)
176
211
  project_id: Project identifier
177
212
  work_id: Work item identifier
178
213
 
@@ -191,19 +226,19 @@ async def get_work(project_id: str, work_id: str) -> WorkItemResponse:
191
226
  ...
192
227
  }
193
228
  """
194
- registry = _get_registry()
229
+ registry = _get_registry(request)
230
+ work_queues = _get_work_queues(request)
195
231
 
196
232
  # Get project
197
- try:
198
- project = registry.get(project_id)
199
- except ProjectNotFoundError as e:
200
- raise HTTPException(status_code=404, detail=str(e)) from e
233
+ project = registry.get(project_id)
234
+ if project is None:
235
+ raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
201
236
 
202
- # Get work queue
203
- if not hasattr(project, "_work_queue"):
237
+ # Get work queue (shared with daemon)
238
+ if project_id not in work_queues:
204
239
  raise HTTPException(status_code=404, detail="Work queue not found")
205
240
 
206
- queue = project._work_queue
241
+ queue = work_queues[project_id]
207
242
 
208
243
  # Get work item
209
244
  work_item = queue.get(work_id)
@@ -214,10 +249,11 @@ async def get_work(project_id: str, work_id: str) -> WorkItemResponse:
214
249
 
215
250
 
216
251
  @router.post("/projects/{project_id}/work/{work_id}/cancel")
217
- async def cancel_work(project_id: str, work_id: str) -> dict:
252
+ async def cancel_work(request: Request, project_id: str, work_id: str) -> dict:
218
253
  """Cancel pending work item.
219
254
 
220
255
  Args:
256
+ request: FastAPI request (for accessing app.state)
221
257
  project_id: Project identifier
222
258
  work_id: Work item identifier
223
259
 
@@ -231,19 +267,19 @@ async def cancel_work(project_id: str, work_id: str) -> dict:
231
267
  POST /api/projects/proj-123/work/work-xyz/cancel
232
268
  Response: {"status": "cancelled", "id": "work-xyz"}
233
269
  """
234
- registry = _get_registry()
270
+ registry = _get_registry(request)
271
+ work_queues = _get_work_queues(request)
235
272
 
236
273
  # Get project
237
- try:
238
- project = registry.get(project_id)
239
- except ProjectNotFoundError as e:
240
- raise HTTPException(status_code=404, detail=str(e)) from e
274
+ project = registry.get(project_id)
275
+ if project is None:
276
+ raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
241
277
 
242
- # Get work queue
243
- if not hasattr(project, "_work_queue"):
278
+ # Get work queue (shared with daemon)
279
+ if project_id not in work_queues:
244
280
  raise HTTPException(status_code=404, detail="Work queue not found")
245
281
 
246
- queue = project._work_queue
282
+ queue = work_queues[project_id]
247
283
 
248
284
  # Cancel work item
249
285
  if not queue.cancel(work_id):
@@ -17,10 +17,14 @@ class RegisterProjectRequest(BaseModel):
17
17
 
18
18
  Attributes:
19
19
  path: Filesystem path to project directory
20
+ project_id: Optional project identifier (UUID generated if omitted)
20
21
  name: Optional display name (derived from path if omitted)
21
22
  """
22
23
 
23
24
  path: str = Field(..., description="Filesystem path to project")
25
+ project_id: Optional[str] = Field(
26
+ None, description="Project identifier (UUID generated if omitted)"
27
+ )
24
28
  name: Optional[str] = Field(
25
29
  None, description="Display name (derived from path if omitted)"
26
30
  )