gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -23,7 +23,7 @@ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
23
23
  from gobby.skills.loader import SkillLoader, SkillLoadError
24
24
  from gobby.skills.search import SearchFilters, SkillSearch
25
25
  from gobby.skills.updater import SkillUpdater
26
- from gobby.storage.skills import LocalSkillManager
26
+ from gobby.storage.skills import ChangeEvent, LocalSkillManager, SkillChangeNotifier
27
27
 
28
28
  if TYPE_CHECKING:
29
29
  from gobby.storage.database import DatabaseProtocol
@@ -61,8 +61,9 @@ def create_skills_registry(
61
61
  description="Skill management - list_skills, get_skill, search_skills, install_skill, update_skill, remove_skill",
62
62
  )
63
63
 
64
- # Initialize storage
65
- storage = LocalSkillManager(db)
64
+ # Initialize change notifier and storage
65
+ notifier = SkillChangeNotifier()
66
+ storage = LocalSkillManager(db, notifier=notifier)
66
67
 
67
68
  # --- list_skills tool ---
68
69
 
@@ -224,6 +225,13 @@ def create_skills_registry(
224
225
  # Index on registry creation
225
226
  _index_skills()
226
227
 
228
+ # Wire up change notifier to re-index on any skill mutation
229
+ def _on_skill_change(event: ChangeEvent) -> None:
230
+ """Re-index skills when any skill is created, updated, or deleted."""
231
+ _index_skills()
232
+
233
+ notifier.add_listener(_on_skill_change)
234
+
227
235
  @registry.tool(
228
236
  name="search_skills",
229
237
  description="Search for skills by query. Returns ranked results with relevance scores. Supports filtering by category and tags.",
@@ -359,17 +367,9 @@ def create_skills_registry(
359
367
  # Store the name before deletion
360
368
  skill_name = skill.name
361
369
 
362
- # Delete the skill
370
+ # Delete the skill (notifier triggers re-indexing automatically)
363
371
  storage.delete_skill(skill.id)
364
372
 
365
- # Re-index skills after deletion
366
- skills = storage.list_skills(
367
- project_id=project_id,
368
- limit=10000,
369
- include_global=True,
370
- )
371
- await search.index_skills_async(skills)
372
-
373
373
  return {
374
374
  "success": True,
375
375
  "removed": True,
@@ -430,17 +430,9 @@ def create_skills_registry(
430
430
  }
431
431
 
432
432
  # Use SkillUpdater to refresh from source
433
+ # (notifier triggers re-indexing automatically if updated)
433
434
  result = updater.update_skill(skill.id)
434
435
 
435
- # Re-index skills if updated
436
- if result.updated:
437
- skills = storage.list_skills(
438
- project_id=project_id,
439
- limit=10000,
440
- include_global=True,
441
- )
442
- await search.index_skills_async(skills)
443
-
444
436
  return {
445
437
  "success": result.success,
446
438
  "updated": result.updated,
@@ -606,14 +598,7 @@ def create_skills_registry(
606
598
  project_id=skill_project_id,
607
599
  enabled=True,
608
600
  )
609
-
610
- # Re-index skills
611
- skills = storage.list_skills(
612
- project_id=project_id,
613
- limit=10000,
614
- include_global=True,
615
- )
616
- await search.index_skills_async(skills)
601
+ # Notifier triggers re-indexing automatically via create_skill
617
602
 
618
603
  return {
619
604
  "success": True,
@@ -0,0 +1,455 @@
1
+ """
2
+ Unified spawn_agent MCP tool.
3
+
4
+ Consolidates three separate agent spawning tools into one:
5
+ - start_agent
6
+ - spawn_agent_in_worktree
7
+ - spawn_agent_in_clone
8
+
9
+ One tool: spawn_agent(prompt, agent="generic", isolation="current"|"worktree"|"clone", ...)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Literal, cast
18
+
19
+ from gobby.agents.definitions import AgentDefinition, AgentDefinitionLoader
20
+ from gobby.agents.isolation import (
21
+ SpawnConfig,
22
+ get_isolation_handler,
23
+ )
24
+ from gobby.agents.registry import RunningAgent, get_running_agent_registry
25
+ from gobby.agents.sandbox import SandboxConfig
26
+ from gobby.agents.spawn_executor import SpawnRequest, execute_spawn
27
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
28
+ from gobby.mcp_proxy.tools.tasks import resolve_task_id_for_mcp
29
+ from gobby.utils.machine_id import get_machine_id
30
+ from gobby.utils.project_context import get_project_context
31
+
32
+ if TYPE_CHECKING:
33
+ from gobby.agents.runner import AgentRunner
34
+ from gobby.storage.tasks import LocalTaskManager
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ async def spawn_agent_impl(
40
+ prompt: str,
41
+ runner: AgentRunner,
42
+ agent_def: AgentDefinition | None = None,
43
+ task_id: str | None = None,
44
+ task_manager: LocalTaskManager | None = None,
45
+ # Isolation
46
+ isolation: Literal["current", "worktree", "clone"] | None = None,
47
+ branch_name: str | None = None,
48
+ base_branch: str | None = None,
49
+ # Storage/managers for isolation
50
+ worktree_storage: Any | None = None,
51
+ git_manager: Any | None = None,
52
+ clone_storage: Any | None = None,
53
+ clone_manager: Any | None = None,
54
+ # Execution
55
+ workflow: str | None = None,
56
+ mode: Literal["terminal", "embedded", "headless"] | None = None,
57
+ terminal: str = "auto",
58
+ provider: str | None = None,
59
+ model: str | None = None,
60
+ # Limits
61
+ timeout: float | None = None,
62
+ max_turns: int | None = None,
63
+ # Sandbox
64
+ sandbox: bool | None = None,
65
+ sandbox_mode: Literal["permissive", "restrictive"] | None = None,
66
+ sandbox_allow_network: bool | None = None,
67
+ sandbox_extra_paths: list[str] | None = None,
68
+ # Context
69
+ parent_session_id: str | None = None,
70
+ project_path: str | None = None,
71
+ ) -> dict[str, Any]:
72
+ """
73
+ Core spawn_agent implementation that can be called directly.
74
+
75
+ This is the internal implementation used by both the spawn_agent MCP tool
76
+ and the deprecated spawn_agent_in_worktree/spawn_agent_in_clone tools.
77
+
78
+ Args:
79
+ prompt: Required - what the agent should do
80
+ runner: AgentRunner instance for executing agents
81
+ agent_def: Optional loaded agent definition
82
+ task_id: Optional - link to task (supports N, #N, UUID)
83
+ task_manager: Task manager for task resolution
84
+ isolation: Isolation mode (current/worktree/clone)
85
+ branch_name: Git branch name (auto-generated from task if not provided)
86
+ base_branch: Base branch for worktree/clone
87
+ worktree_storage: Storage for worktree records
88
+ git_manager: Git manager for worktree operations
89
+ clone_storage: Storage for clone records
90
+ clone_manager: Git manager for clone operations
91
+ workflow: Workflow to use
92
+ mode: Execution mode (terminal/embedded/headless)
93
+ terminal: Terminal type for terminal mode
94
+ provider: AI provider (claude/gemini/codex)
95
+ model: Model to use
96
+ timeout: Timeout in seconds
97
+ max_turns: Maximum conversation turns
98
+ sandbox: Enable sandbox (True/False/None). None inherits from agent_def.
99
+ sandbox_mode: Sandbox mode (permissive/restrictive). Overrides agent_def.
100
+ sandbox_allow_network: Allow network access. Overrides agent_def.
101
+ sandbox_extra_paths: Extra paths for sandbox write access.
102
+ parent_session_id: Parent session ID
103
+ project_path: Project path override
104
+
105
+ Returns:
106
+ Dict with success status, run_id, child_session_id, isolation metadata
107
+ """
108
+ # 1. Merge config: agent_def defaults < params
109
+ effective_isolation = isolation
110
+ if effective_isolation is None and agent_def:
111
+ effective_isolation = agent_def.isolation
112
+ effective_isolation = effective_isolation or "current"
113
+
114
+ effective_provider = provider
115
+ if effective_provider is None and agent_def:
116
+ effective_provider = agent_def.provider
117
+ effective_provider = effective_provider or "claude"
118
+
119
+ effective_mode: Literal["terminal", "embedded", "headless"] | None = mode
120
+ if effective_mode is None and agent_def:
121
+ effective_mode = cast(Literal["terminal", "embedded", "headless"], agent_def.mode)
122
+ effective_mode = effective_mode or "terminal"
123
+
124
+ effective_workflow = workflow
125
+ if effective_workflow is None and agent_def:
126
+ effective_workflow = agent_def.workflow
127
+
128
+ effective_base_branch = base_branch
129
+ if effective_base_branch is None and agent_def:
130
+ effective_base_branch = agent_def.base_branch
131
+ effective_base_branch = effective_base_branch or "main"
132
+
133
+ effective_branch_prefix = None
134
+ if agent_def:
135
+ effective_branch_prefix = agent_def.branch_prefix
136
+
137
+ # Build effective sandbox config (merge agent_def.sandbox with params)
138
+ effective_sandbox_config: SandboxConfig | None = None
139
+
140
+ # Start with agent_def.sandbox if present
141
+ base_sandbox = agent_def.sandbox if agent_def and hasattr(agent_def, "sandbox") else None
142
+
143
+ # Determine if sandbox should be enabled
144
+ sandbox_enabled = sandbox # Explicit param takes precedence
145
+ if sandbox_enabled is None and base_sandbox is not None:
146
+ sandbox_enabled = base_sandbox.enabled
147
+
148
+ # Build sandbox config if enabled or if we have params to apply
149
+ if sandbox_enabled is True or (
150
+ sandbox_enabled is None
151
+ and (sandbox_mode is not None or sandbox_allow_network is not None or sandbox_extra_paths)
152
+ ):
153
+ # Start from base or create new
154
+ if base_sandbox is not None:
155
+ effective_sandbox_config = SandboxConfig(
156
+ enabled=True if sandbox_enabled is None else sandbox_enabled,
157
+ mode=sandbox_mode if sandbox_mode is not None else base_sandbox.mode,
158
+ allow_network=(
159
+ sandbox_allow_network
160
+ if sandbox_allow_network is not None
161
+ else base_sandbox.allow_network
162
+ ),
163
+ extra_read_paths=base_sandbox.extra_read_paths,
164
+ extra_write_paths=(
165
+ list(base_sandbox.extra_write_paths) + (sandbox_extra_paths or [])
166
+ ),
167
+ )
168
+ else:
169
+ effective_sandbox_config = SandboxConfig(
170
+ enabled=True,
171
+ mode=sandbox_mode or "permissive",
172
+ allow_network=sandbox_allow_network if sandbox_allow_network is not None else True,
173
+ extra_write_paths=sandbox_extra_paths or [],
174
+ )
175
+ elif sandbox_enabled is False:
176
+ # Explicitly disabled - set config with enabled=False
177
+ effective_sandbox_config = SandboxConfig(enabled=False)
178
+
179
+ # 2. Resolve project context
180
+ ctx = get_project_context(Path(project_path) if project_path else None)
181
+ if ctx is None:
182
+ return {"success": False, "error": "Could not resolve project context"}
183
+
184
+ project_id = ctx.get("id") or ctx.get("project_id")
185
+ resolved_project_path = ctx.get("project_path")
186
+
187
+ if not project_id or not isinstance(project_id, str):
188
+ return {"success": False, "error": "Could not resolve project_id from context"}
189
+ if not resolved_project_path or not isinstance(resolved_project_path, str):
190
+ return {"success": False, "error": "Could not resolve project_path from context"}
191
+
192
+ # 3. Validate parent_session_id and spawn depth
193
+ if parent_session_id is None:
194
+ return {"success": False, "error": "parent_session_id is required"}
195
+
196
+ can_spawn, reason, _depth = runner.can_spawn(parent_session_id)
197
+ if not can_spawn:
198
+ return {"success": False, "error": reason}
199
+
200
+ # 4. Resolve task_id if provided (supports N, #N, UUID)
201
+ resolved_task_id: str | None = None
202
+ task_title: str | None = None
203
+ task_seq_num: int | None = None
204
+
205
+ if task_id and task_manager:
206
+ try:
207
+ resolved_task_id = resolve_task_id_for_mcp(task_manager, task_id, project_id)
208
+ task = task_manager.get_task(resolved_task_id)
209
+ if task:
210
+ task_title = task.title
211
+ task_seq_num = task.seq_num
212
+ except Exception as e:
213
+ logger.warning(f"Failed to resolve task_id {task_id}: {e}")
214
+
215
+ # 5. Get isolation handler
216
+ handler = get_isolation_handler(
217
+ effective_isolation,
218
+ git_manager=git_manager,
219
+ worktree_storage=worktree_storage,
220
+ clone_manager=clone_manager,
221
+ clone_storage=clone_storage,
222
+ )
223
+
224
+ # 6. Build spawn config
225
+ spawn_config = SpawnConfig(
226
+ prompt=prompt,
227
+ task_id=resolved_task_id,
228
+ task_title=task_title,
229
+ task_seq_num=task_seq_num,
230
+ branch_name=branch_name,
231
+ branch_prefix=effective_branch_prefix,
232
+ base_branch=effective_base_branch,
233
+ project_id=project_id,
234
+ project_path=resolved_project_path,
235
+ provider=effective_provider,
236
+ parent_session_id=parent_session_id,
237
+ )
238
+
239
+ # 7. Prepare environment (worktree/clone creation)
240
+ try:
241
+ isolation_ctx = await handler.prepare_environment(spawn_config)
242
+ except Exception as e:
243
+ logger.error(f"Failed to prepare environment: {e}", exc_info=True)
244
+ return {"success": False, "error": f"Failed to prepare environment: {e}"}
245
+
246
+ # 8. Build enhanced prompt with isolation context
247
+ enhanced_prompt = handler.build_context_prompt(prompt, isolation_ctx)
248
+
249
+ # 9. Generate session and run IDs
250
+ session_id = str(uuid.uuid4())
251
+ run_id = str(uuid.uuid4())
252
+
253
+ # 10. Execute spawn via SpawnExecutor
254
+ spawn_request = SpawnRequest(
255
+ prompt=enhanced_prompt,
256
+ cwd=isolation_ctx.cwd,
257
+ mode=effective_mode,
258
+ provider=effective_provider,
259
+ terminal=terminal,
260
+ session_id=session_id,
261
+ run_id=run_id,
262
+ parent_session_id=parent_session_id,
263
+ project_id=project_id,
264
+ workflow=effective_workflow,
265
+ worktree_id=isolation_ctx.worktree_id,
266
+ clone_id=isolation_ctx.clone_id,
267
+ session_manager=runner._child_session_manager,
268
+ machine_id=get_machine_id() or "unknown",
269
+ sandbox_config=effective_sandbox_config,
270
+ )
271
+
272
+ spawn_result = await execute_spawn(spawn_request)
273
+
274
+ # 11. Register with RunningAgentRegistry for send_to_parent/child messaging
275
+ # Only register if spawn succeeded and we have a valid child_session_id
276
+ if spawn_result.success and spawn_result.child_session_id is not None:
277
+ agent_registry = get_running_agent_registry()
278
+ agent_registry.add(
279
+ RunningAgent(
280
+ run_id=spawn_result.run_id,
281
+ session_id=spawn_result.child_session_id,
282
+ parent_session_id=parent_session_id,
283
+ mode=effective_mode,
284
+ pid=spawn_result.pid,
285
+ provider=effective_provider,
286
+ workflow_name=effective_workflow,
287
+ worktree_id=isolation_ctx.worktree_id,
288
+ )
289
+ )
290
+
291
+ # 12. Return response with isolation metadata
292
+ return {
293
+ "success": spawn_result.success,
294
+ "run_id": spawn_result.run_id,
295
+ "child_session_id": spawn_result.child_session_id,
296
+ "status": spawn_result.status,
297
+ "isolation": effective_isolation,
298
+ "branch_name": isolation_ctx.branch_name,
299
+ "worktree_id": isolation_ctx.worktree_id,
300
+ "worktree_path": isolation_ctx.cwd if effective_isolation == "worktree" else None,
301
+ "clone_id": isolation_ctx.clone_id,
302
+ "pid": spawn_result.pid,
303
+ "error": spawn_result.error,
304
+ "message": spawn_result.message,
305
+ }
306
+
307
+
308
+ def create_spawn_agent_registry(
309
+ runner: AgentRunner,
310
+ agent_loader: AgentDefinitionLoader | None = None,
311
+ task_manager: LocalTaskManager | None = None,
312
+ worktree_storage: Any | None = None,
313
+ git_manager: Any | None = None,
314
+ clone_storage: Any | None = None,
315
+ clone_manager: Any | None = None,
316
+ session_manager: Any | None = None,
317
+ ) -> InternalToolRegistry:
318
+ """
319
+ Create a spawn_agent tool registry with the unified spawn_agent tool.
320
+
321
+ Args:
322
+ runner: AgentRunner instance for executing agents.
323
+ agent_loader: Loader for agent definitions.
324
+ task_manager: Task manager for task resolution.
325
+ worktree_storage: Storage for worktree records.
326
+ git_manager: Git manager for worktree operations.
327
+ clone_storage: Storage for clone records.
328
+ clone_manager: Git manager for clone operations.
329
+ session_manager: Session manager for resolving session references.
330
+
331
+ Returns:
332
+ InternalToolRegistry with spawn_agent tool registered.
333
+ """
334
+
335
+ def _resolve_session_id(ref: str) -> str:
336
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID."""
337
+ if session_manager is None:
338
+ return ref # No resolution available, return as-is
339
+ ctx = get_project_context()
340
+ project_id = ctx.get("id") if ctx else None
341
+ return str(session_manager.resolve_session_reference(ref, project_id))
342
+
343
+ registry = InternalToolRegistry(
344
+ name="gobby-spawn-agent",
345
+ description="Unified agent spawning with isolation support",
346
+ )
347
+
348
+ # Use provided loader or create default
349
+ loader = agent_loader or AgentDefinitionLoader()
350
+
351
+ @registry.tool(
352
+ name="spawn_agent",
353
+ description=(
354
+ "Spawn a subagent to execute a task. Supports isolation modes: "
355
+ "'current' (work in current directory), 'worktree' (create git worktree), "
356
+ "'clone' (create shallow clone). Can use named agent definitions or raw parameters. "
357
+ "Accepts #N, N, UUID, or prefix for parent_session_id."
358
+ ),
359
+ )
360
+ async def spawn_agent(
361
+ prompt: str,
362
+ agent: str = "generic",
363
+ task_id: str | None = None,
364
+ # Isolation
365
+ isolation: Literal["current", "worktree", "clone"] | None = None,
366
+ branch_name: str | None = None,
367
+ base_branch: str | None = None,
368
+ # Execution
369
+ workflow: str | None = None,
370
+ mode: Literal["terminal", "embedded", "headless"] | None = None,
371
+ terminal: str = "auto",
372
+ provider: str | None = None,
373
+ model: str | None = None,
374
+ # Limits
375
+ timeout: float | None = None,
376
+ max_turns: int | None = None,
377
+ # Sandbox
378
+ sandbox: bool | None = None,
379
+ sandbox_mode: Literal["permissive", "restrictive"] | None = None,
380
+ sandbox_allow_network: bool | None = None,
381
+ sandbox_extra_paths: list[str] | None = None,
382
+ # Context
383
+ parent_session_id: str | None = None,
384
+ project_path: str | None = None,
385
+ ) -> dict[str, Any]:
386
+ """
387
+ Spawn a subagent with the specified configuration.
388
+
389
+ Args:
390
+ prompt: Required - what the agent should do
391
+ agent: Agent definition name (defaults to "generic")
392
+ task_id: Optional - link to task (supports N, #N, UUID)
393
+ isolation: Isolation mode (current/worktree/clone)
394
+ branch_name: Git branch name (auto-generated from task if not provided)
395
+ base_branch: Base branch for worktree/clone
396
+ workflow: Workflow to use
397
+ mode: Execution mode (terminal/embedded/headless)
398
+ terminal: Terminal type for terminal mode
399
+ provider: AI provider (claude/gemini/codex)
400
+ model: Model to use
401
+ timeout: Timeout in seconds
402
+ max_turns: Maximum conversation turns
403
+ sandbox: Enable sandbox (True/False/None). None inherits from agent_def.
404
+ sandbox_mode: Sandbox mode (permissive/restrictive). Overrides agent_def.
405
+ sandbox_allow_network: Allow network access. Overrides agent_def.
406
+ sandbox_extra_paths: Extra paths for sandbox write access.
407
+ parent_session_id: Session reference (accepts #N, N, UUID, or prefix) for the parent session
408
+ project_path: Project path override
409
+
410
+ Returns:
411
+ Dict with success status, run_id, child_session_id, isolation metadata
412
+ """
413
+ # Resolve parent_session_id to UUID (accepts #N, N, UUID, or prefix)
414
+ resolved_parent_session_id = parent_session_id
415
+ if parent_session_id:
416
+ try:
417
+ resolved_parent_session_id = _resolve_session_id(parent_session_id)
418
+ except ValueError as e:
419
+ return {"success": False, "error": str(e)}
420
+
421
+ # Load agent definition (defaults to "generic")
422
+ agent_def = loader.load(agent)
423
+ if agent_def is None and agent != "generic":
424
+ return {"success": False, "error": f"Agent '{agent}' not found"}
425
+
426
+ # Delegate to spawn_agent_impl
427
+ return await spawn_agent_impl(
428
+ prompt=prompt,
429
+ runner=runner,
430
+ agent_def=agent_def,
431
+ task_id=task_id,
432
+ task_manager=task_manager,
433
+ isolation=isolation,
434
+ branch_name=branch_name,
435
+ base_branch=base_branch,
436
+ worktree_storage=worktree_storage,
437
+ git_manager=git_manager,
438
+ clone_storage=clone_storage,
439
+ clone_manager=clone_manager,
440
+ workflow=workflow,
441
+ mode=mode,
442
+ terminal=terminal,
443
+ provider=provider,
444
+ model=model,
445
+ timeout=timeout,
446
+ max_turns=max_turns,
447
+ sandbox=sandbox,
448
+ sandbox_mode=sandbox_mode,
449
+ sandbox_allow_network=sandbox_allow_network,
450
+ sandbox_extra_paths=sandbox_extra_paths,
451
+ parent_session_id=resolved_parent_session_id,
452
+ project_path=project_path,
453
+ )
454
+
455
+ return registry
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
9
9
 
10
10
  from gobby.storage.projects import LocalProjectManager
11
11
  from gobby.storage.session_tasks import SessionTaskManager
12
+ from gobby.storage.sessions import LocalSessionManager
12
13
  from gobby.storage.task_dependencies import TaskDependencyManager
13
14
  from gobby.storage.tasks import LocalTaskManager
14
15
  from gobby.utils.project_context import get_project_context
@@ -42,6 +43,7 @@ class RegistryContext:
42
43
  # Derived managers (initialized in __post_init__)
43
44
  dep_manager: TaskDependencyManager = field(init=False)
44
45
  session_task_manager: SessionTaskManager = field(init=False)
46
+ session_manager: LocalSessionManager = field(init=False)
45
47
  workflow_state_manager: WorkflowStateManager = field(init=False)
46
48
  project_manager: LocalProjectManager = field(init=False)
47
49
 
@@ -56,6 +58,7 @@ class RegistryContext:
56
58
  db = self.task_manager.db
57
59
  self.dep_manager = TaskDependencyManager(db)
58
60
  self.session_task_manager = SessionTaskManager(db)
61
+ self.session_manager = LocalSessionManager(db)
59
62
  self.workflow_state_manager = WorkflowStateManager(db)
60
63
  self.project_manager = LocalProjectManager(db)
61
64
 
@@ -90,3 +93,18 @@ class RegistryContext:
90
93
  if not session_id:
91
94
  return None
92
95
  return self.workflow_state_manager.get_state(session_id)
96
+
97
+ def resolve_session_id(self, session_id: str) -> str:
98
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID.
99
+
100
+ Args:
101
+ session_id: Session reference string
102
+
103
+ Returns:
104
+ Resolved UUID string
105
+
106
+ Raises:
107
+ ValueError: If session cannot be resolved
108
+ """
109
+ project_id = self.get_current_project_id()
110
+ return self.session_manager.resolve_session_reference(session_id, project_id)
@@ -90,6 +90,13 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
90
90
  if effective_category is None:
91
91
  effective_category = _infer_category(title, description)
92
92
 
93
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
94
+ resolved_session_id = session_id
95
+ try:
96
+ resolved_session_id = ctx.resolve_session_id(session_id)
97
+ except ValueError:
98
+ pass # Fall back to raw value if resolution fails
99
+
93
100
  # Create task
94
101
  create_result = ctx.task_manager.create_task_with_decomposition(
95
102
  project_id=project_id,
@@ -101,14 +108,14 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
101
108
  labels=labels,
102
109
  category=effective_category,
103
110
  validation_criteria=validation_criteria,
104
- created_in_session_id=session_id,
111
+ created_in_session_id=resolved_session_id,
105
112
  )
106
113
 
107
114
  task = ctx.task_manager.get_task(create_result["task"]["id"])
108
115
 
109
116
  # Link task to session (best-effort) - tracks which session created the task
110
117
  try:
111
- ctx.session_task_manager.link_task(session_id, task.id, "created")
118
+ ctx.session_task_manager.link_task(resolved_session_id, task.id, "created")
112
119
  except Exception:
113
120
  pass # nosec B110 - best-effort linking
114
121
 
@@ -116,7 +123,7 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
116
123
  if claim:
117
124
  updated_task = ctx.task_manager.update_task(
118
125
  task.id,
119
- assignee=session_id,
126
+ assignee=resolved_session_id,
120
127
  status="in_progress",
121
128
  )
122
129
  if updated_task is None:
@@ -125,14 +132,14 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
125
132
  task = updated_task
126
133
  # Link task to session with "claimed" action (best-effort)
127
134
  try:
128
- ctx.session_task_manager.link_task(session_id, task.id, "claimed")
135
+ ctx.session_task_manager.link_task(resolved_session_id, task.id, "claimed")
129
136
  except Exception:
130
137
  pass # nosec B110 - best-effort linking
131
138
 
132
139
  # Set workflow state for Claude Code (CC doesn't include tool results in PostToolUse)
133
140
  # This mirrors close_task behavior in _lifecycle.py:196-207
134
141
  try:
135
- state = ctx.workflow_state_manager.get_state(session_id)
142
+ state = ctx.workflow_state_manager.get_state(resolved_session_id)
136
143
  if state:
137
144
  state.variables["task_claimed"] = True
138
145
  state.variables["claimed_task_id"] = task.id # Always use UUID
@@ -248,7 +255,7 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
248
255
  },
249
256
  "session_id": {
250
257
  "type": "string",
251
- "description": "Your session ID (from system context). Required to track which session created the task.",
258
+ "description": "Your session ID (accepts #N, N, UUID, or prefix). Required to track which session created the task.",
252
259
  },
253
260
  "claim": {
254
261
  "type": "boolean",