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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +13 -0
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +217 -51
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
-
|
|
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=
|
|
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(
|
|
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=
|
|
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(
|
|
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(
|
|
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 (
|
|
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",
|