gobby 0.2.8__py3-none-any.whl → 0.2.11__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 (168) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +5 -28
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +64 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/utils.py +5 -17
  35. gobby/cli/workflows.py +38 -17
  36. gobby/config/app.py +5 -0
  37. gobby/config/features.py +0 -20
  38. gobby/config/skills.py +23 -2
  39. gobby/config/tasks.py +4 -0
  40. gobby/hooks/broadcaster.py +9 -0
  41. gobby/hooks/event_handlers/__init__.py +155 -0
  42. gobby/hooks/event_handlers/_agent.py +175 -0
  43. gobby/hooks/event_handlers/_base.py +92 -0
  44. gobby/hooks/event_handlers/_misc.py +66 -0
  45. gobby/hooks/event_handlers/_session.py +487 -0
  46. gobby/hooks/event_handlers/_tool.py +196 -0
  47. gobby/hooks/events.py +48 -0
  48. gobby/hooks/hook_manager.py +27 -3
  49. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  50. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  51. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  52. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  53. gobby/llm/__init__.py +14 -1
  54. gobby/llm/claude.py +594 -43
  55. gobby/llm/service.py +149 -0
  56. gobby/mcp_proxy/importer.py +4 -41
  57. gobby/mcp_proxy/instructions.py +9 -27
  58. gobby/mcp_proxy/manager.py +13 -3
  59. gobby/mcp_proxy/models.py +1 -0
  60. gobby/mcp_proxy/registries.py +66 -5
  61. gobby/mcp_proxy/server.py +6 -2
  62. gobby/mcp_proxy/services/recommendation.py +2 -28
  63. gobby/mcp_proxy/services/tool_filter.py +7 -0
  64. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  65. gobby/mcp_proxy/stdio.py +37 -21
  66. gobby/mcp_proxy/tools/agents.py +7 -0
  67. gobby/mcp_proxy/tools/artifacts.py +3 -3
  68. gobby/mcp_proxy/tools/hub.py +30 -1
  69. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  70. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  71. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  72. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  73. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  74. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  75. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  76. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  77. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  78. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  79. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  80. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  81. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  82. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  83. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  84. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  85. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  86. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  87. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  88. gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
  89. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  90. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  91. gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
  92. gobby/mcp_proxy/tools/workflows/_query.py +226 -0
  93. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  94. gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
  95. gobby/mcp_proxy/tools/worktrees.py +54 -15
  96. gobby/memory/components/__init__.py +0 -0
  97. gobby/memory/components/ingestion.py +98 -0
  98. gobby/memory/components/search.py +108 -0
  99. gobby/memory/context.py +5 -5
  100. gobby/memory/manager.py +16 -25
  101. gobby/paths.py +51 -0
  102. gobby/prompts/loader.py +1 -35
  103. gobby/runner.py +131 -16
  104. gobby/servers/http.py +193 -150
  105. gobby/servers/routes/__init__.py +2 -0
  106. gobby/servers/routes/admin.py +56 -0
  107. gobby/servers/routes/mcp/endpoints/execution.py +33 -32
  108. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  109. gobby/servers/routes/mcp/hooks.py +10 -1
  110. gobby/servers/routes/pipelines.py +227 -0
  111. gobby/servers/websocket.py +314 -1
  112. gobby/sessions/analyzer.py +89 -3
  113. gobby/sessions/manager.py +5 -5
  114. gobby/sessions/transcripts/__init__.py +3 -0
  115. gobby/sessions/transcripts/claude.py +5 -0
  116. gobby/sessions/transcripts/codex.py +5 -0
  117. gobby/sessions/transcripts/gemini.py +5 -0
  118. gobby/skills/hubs/__init__.py +25 -0
  119. gobby/skills/hubs/base.py +234 -0
  120. gobby/skills/hubs/claude_plugins.py +328 -0
  121. gobby/skills/hubs/clawdhub.py +289 -0
  122. gobby/skills/hubs/github_collection.py +465 -0
  123. gobby/skills/hubs/manager.py +263 -0
  124. gobby/skills/hubs/skillhub.py +342 -0
  125. gobby/skills/parser.py +23 -0
  126. gobby/skills/sync.py +5 -4
  127. gobby/storage/artifacts.py +19 -0
  128. gobby/storage/memories.py +4 -4
  129. gobby/storage/migrations.py +118 -3
  130. gobby/storage/pipelines.py +367 -0
  131. gobby/storage/sessions.py +23 -4
  132. gobby/storage/skills.py +48 -8
  133. gobby/storage/tasks/_aggregates.py +2 -2
  134. gobby/storage/tasks/_lifecycle.py +4 -4
  135. gobby/storage/tasks/_models.py +7 -1
  136. gobby/storage/tasks/_queries.py +3 -3
  137. gobby/sync/memories.py +4 -3
  138. gobby/tasks/commits.py +48 -17
  139. gobby/tasks/external_validator.py +4 -17
  140. gobby/tasks/validation.py +13 -87
  141. gobby/tools/summarizer.py +18 -51
  142. gobby/utils/status.py +13 -0
  143. gobby/workflows/actions.py +80 -0
  144. gobby/workflows/context_actions.py +265 -27
  145. gobby/workflows/definitions.py +119 -1
  146. gobby/workflows/detection_helpers.py +23 -11
  147. gobby/workflows/enforcement/__init__.py +11 -1
  148. gobby/workflows/enforcement/blocking.py +96 -0
  149. gobby/workflows/enforcement/handlers.py +35 -1
  150. gobby/workflows/enforcement/task_policy.py +18 -0
  151. gobby/workflows/engine.py +26 -4
  152. gobby/workflows/evaluator.py +8 -5
  153. gobby/workflows/lifecycle_evaluator.py +59 -27
  154. gobby/workflows/loader.py +567 -30
  155. gobby/workflows/lobster_compat.py +147 -0
  156. gobby/workflows/pipeline_executor.py +801 -0
  157. gobby/workflows/pipeline_state.py +172 -0
  158. gobby/workflows/pipeline_webhooks.py +206 -0
  159. gobby/workflows/premature_stop.py +5 -0
  160. gobby/worktrees/git.py +135 -20
  161. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  162. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
  163. gobby/hooks/event_handlers.py +0 -1008
  164. gobby/mcp_proxy/tools/workflows.py +0 -1023
  165. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  166. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  167. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  168. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,192 @@
1
+ """
2
+ PTY Reader for streaming terminal output from embedded agents.
3
+
4
+ Reads from PTY master file descriptors and broadcasts output via callbacks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ import select
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from gobby.agents.registry import RunningAgent
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Type for output callback: async function(run_id, data)
22
+ OutputCallback = Callable[[str, str], Awaitable[None]]
23
+
24
+
25
+ class PTYReaderManager:
26
+ """
27
+ Manages PTY reading tasks for embedded agents.
28
+
29
+ Starts a reader task for each agent with a master_fd and
30
+ broadcasts output via the provided callback.
31
+ """
32
+
33
+ def __init__(self, output_callback: OutputCallback | None = None):
34
+ """
35
+ Initialize the PTY reader manager.
36
+
37
+ Args:
38
+ output_callback: Async callback for terminal output (run_id, data)
39
+ """
40
+ self._output_callback = output_callback
41
+ self._reader_tasks: dict[str, asyncio.Task[None]] = {}
42
+ self._stop_events: dict[str, asyncio.Event] = {}
43
+ self._lock = asyncio.Lock()
44
+
45
+ def set_output_callback(self, callback: OutputCallback) -> None:
46
+ """Set the output callback for terminal data."""
47
+ self._output_callback = callback
48
+
49
+ async def start_reader(self, agent: RunningAgent) -> bool:
50
+ """
51
+ Start a PTY reader for an agent.
52
+
53
+ Args:
54
+ agent: RunningAgent with master_fd set
55
+
56
+ Returns:
57
+ True if reader was started, False if already running or no fd
58
+ """
59
+ if agent.master_fd is None:
60
+ return False
61
+
62
+ async with self._lock:
63
+ if agent.run_id in self._reader_tasks:
64
+ return False # Already reading
65
+
66
+ stop_event = asyncio.Event()
67
+ self._stop_events[agent.run_id] = stop_event
68
+
69
+ task = asyncio.create_task(
70
+ self._read_loop(agent.run_id, agent.master_fd, stop_event),
71
+ name=f"pty_reader_{agent.run_id}",
72
+ )
73
+ self._reader_tasks[agent.run_id] = task
74
+
75
+ logger.debug(f"Started PTY reader for agent {agent.run_id}")
76
+ return True
77
+
78
+ async def stop_reader(self, run_id: str) -> bool:
79
+ """
80
+ Stop a PTY reader for an agent.
81
+
82
+ Args:
83
+ run_id: Agent run ID
84
+
85
+ Returns:
86
+ True if reader was stopped, False if not running
87
+ """
88
+ async with self._lock:
89
+ stop_event = self._stop_events.pop(run_id, None)
90
+ task = self._reader_tasks.pop(run_id, None)
91
+
92
+ if stop_event:
93
+ stop_event.set()
94
+
95
+ if task:
96
+ task.cancel()
97
+ try:
98
+ await asyncio.wait_for(task, timeout=1.0)
99
+ except (asyncio.CancelledError, TimeoutError):
100
+ pass
101
+ logger.debug(f"Stopped PTY reader for agent {run_id}")
102
+ return True
103
+
104
+ return False
105
+
106
+ async def stop_all(self) -> None:
107
+ """Stop all PTY readers."""
108
+ async with self._lock:
109
+ run_ids = list(self._reader_tasks.keys())
110
+
111
+ for run_id in run_ids:
112
+ await self.stop_reader(run_id)
113
+
114
+ async def _read_loop(
115
+ self,
116
+ run_id: str,
117
+ master_fd: int,
118
+ stop_event: asyncio.Event,
119
+ ) -> None:
120
+ """
121
+ Read loop for a single PTY.
122
+
123
+ Reads from the master_fd and broadcasts via callback.
124
+ Uses select to avoid blocking the event loop.
125
+
126
+ Args:
127
+ run_id: Agent run ID
128
+ master_fd: PTY master file descriptor
129
+ stop_event: Event to signal stop
130
+ """
131
+ loop = asyncio.get_running_loop()
132
+
133
+ try:
134
+ while not stop_event.is_set():
135
+ # Use select with timeout to check for data
136
+ try:
137
+ ready, _, _ = await loop.run_in_executor(
138
+ None,
139
+ lambda: select.select([master_fd], [], [], 0.1),
140
+ )
141
+ except (ValueError, OSError):
142
+ # FD closed or invalid
143
+ break
144
+
145
+ if not ready:
146
+ continue
147
+
148
+ # Read available data
149
+ try:
150
+ data = await loop.run_in_executor(
151
+ None,
152
+ lambda: os.read(master_fd, 4096),
153
+ )
154
+ except OSError as e:
155
+ # FD closed or error
156
+ logger.debug(f"PTY read error for {run_id}: {e}")
157
+ break
158
+
159
+ if not data:
160
+ # EOF
161
+ break
162
+
163
+ # Decode and broadcast
164
+ try:
165
+ text = data.decode("utf-8", errors="replace")
166
+ except Exception:
167
+ text = data.decode("latin-1")
168
+
169
+ if self._output_callback:
170
+ try:
171
+ await self._output_callback(run_id, text)
172
+ except Exception as e:
173
+ logger.warning(f"Output callback error for {run_id}: {e}")
174
+
175
+ except asyncio.CancelledError:
176
+ pass
177
+ except Exception as e:
178
+ logger.error(f"PTY reader error for {run_id}: {e}")
179
+ finally:
180
+ logger.debug(f"PTY reader finished for {run_id}")
181
+
182
+
183
+ # Global singleton
184
+ _pty_reader_manager: PTYReaderManager | None = None
185
+
186
+
187
+ def get_pty_reader_manager() -> PTYReaderManager:
188
+ """Get the global PTY reader manager singleton."""
189
+ global _pty_reader_manager
190
+ if _pty_reader_manager is None:
191
+ _pty_reader_manager = PTYReaderManager()
192
+ return _pty_reader_manager
gobby/agents/registry.py CHANGED
@@ -338,9 +338,18 @@ class RunningAgentRegistry:
338
338
  cmdline = ps_result.stdout.strip()
339
339
  # Verify it's actually the agent process
340
340
  # (contains session-id and matches expected CLI)
341
+ # Check both provider name and "claude" (for cursor/windsurf/copilot)
342
+ is_matched = agent.provider in cmdline.lower()
343
+ if not is_matched and agent.provider in (
344
+ "cursor",
345
+ "windsurf",
346
+ "copilot",
347
+ ):
348
+ is_matched = "claude" in cmdline.lower()
349
+
341
350
  if (
342
351
  f"session-id {agent.session_id}" in cmdline
343
- and agent.provider in cmdline.lower()
352
+ and is_matched
344
353
  ):
345
354
  if matched_pid is not None:
346
355
  # Multiple matches - ambiguous
gobby/agents/runner.py CHANGED
@@ -49,7 +49,7 @@ class AgentConfig:
49
49
  """Machine identifier. Defaults to hostname if not provided."""
50
50
 
51
51
  source: str = "claude"
52
- """CLI source (claude, gemini, codex)."""
52
+ """CLI source (claude, gemini, codex, cursor, windsurf, copilot)."""
53
53
 
54
54
  # New spec-aligned parameters
55
55
  workflow: str | None = None
@@ -352,6 +352,20 @@ class AgentRunner:
352
352
  ),
353
353
  turns_used=0,
354
354
  )
355
+ # Agent spawning only supports WorkflowDefinition, not PipelineDefinition
356
+ if not isinstance(workflow_definition, WorkflowDefinition):
357
+ self.logger.error(
358
+ f"Cannot use pipeline '{effective_workflow}' for agent spawning"
359
+ )
360
+ return AgentResult(
361
+ output="",
362
+ status="error",
363
+ error=(
364
+ f"'{effective_workflow}' is a pipeline, not a step workflow. "
365
+ f"Agent spawning requires a step-based workflow."
366
+ ),
367
+ turns_used=0,
368
+ )
355
369
 
356
370
  # Create child session (now safe - workflow validated above)
357
371
  try:
@@ -377,20 +391,22 @@ class AgentRunner:
377
391
  )
378
392
 
379
393
  # Initialize workflow state if workflow was loaded
394
+ # workflow_definition is WorkflowDefinition | None at this point (PipelineDefinition rejected above)
380
395
  workflow_state = None
381
- if workflow_definition:
396
+ workflow_config: WorkflowDefinition | None = None
397
+ if workflow_definition and isinstance(workflow_definition, WorkflowDefinition):
398
+ workflow_config = workflow_definition
382
399
  self.logger.info(
383
- f"Loaded workflow '{effective_workflow}' for agent "
384
- f"(type={workflow_definition.type})"
400
+ f"Loaded workflow '{effective_workflow}' for agent (type={workflow_config.type})"
385
401
  )
386
402
 
387
403
  # Initialize workflow state for child session
388
404
  initial_step = ""
389
- if workflow_definition.steps:
390
- initial_step = workflow_definition.steps[0].name
405
+ if workflow_config.steps:
406
+ initial_step = workflow_config.steps[0].name
391
407
 
392
408
  # Build initial variables with agent depth information
393
- initial_variables = dict(workflow_definition.variables)
409
+ initial_variables = dict(workflow_config.variables)
394
410
  initial_variables["agent_depth"] = child_session.agent_depth
395
411
  initial_variables["max_agent_depth"] = self._child_session_manager.max_agent_depth
396
412
  initial_variables["can_spawn"] = (
@@ -448,7 +464,7 @@ class AgentRunner:
448
464
  session=session_obj,
449
465
  run=agent_run,
450
466
  workflow_state=workflow_state,
451
- workflow_config=workflow_definition,
467
+ workflow_config=workflow_config,
452
468
  )
453
469
 
454
470
  async def execute_run(
gobby/agents/sandbox.py CHANGED
@@ -7,6 +7,7 @@ built-in sandbox implementation - Gobby just passes the right flags.
7
7
  """
8
8
 
9
9
  from abc import ABC, abstractmethod
10
+ from pathlib import Path
10
11
  from typing import Literal
11
12
 
12
13
  from pydantic import BaseModel, Field
@@ -202,7 +203,7 @@ def get_sandbox_resolver(cli: str) -> SandboxResolver:
202
203
  Factory function to get the appropriate sandbox resolver for a CLI.
203
204
 
204
205
  Args:
205
- cli: The CLI name ("claude", "codex", or "gemini")
206
+ cli: The CLI name ("claude", "codex", "gemini", "cursor", "windsurf", or "copilot")
206
207
 
207
208
  Returns:
208
209
  The appropriate SandboxResolver subclass instance.
@@ -214,6 +215,9 @@ def get_sandbox_resolver(cli: str) -> SandboxResolver:
214
215
  "claude": ClaudeSandboxResolver,
215
216
  "codex": CodexSandboxResolver,
216
217
  "gemini": GeminiSandboxResolver,
218
+ "cursor": ClaudeSandboxResolver,
219
+ "windsurf": ClaudeSandboxResolver,
220
+ "copilot": ClaudeSandboxResolver,
217
221
  }
218
222
 
219
223
  if cli not in resolvers:
@@ -249,8 +253,9 @@ def compute_sandbox_paths(
249
253
  if path not in write_paths:
250
254
  write_paths.append(path)
251
255
 
252
- # Collect read paths
253
- read_paths = list(config.extra_read_paths)
256
+ # Collect read paths - always include ~/.gobby/ for machine_id access
257
+ gobby_home = str(Path("~/.gobby").expanduser())
258
+ read_paths = [gobby_home] + list(config.extra_read_paths)
254
259
 
255
260
  return ResolvedSandboxPaths(
256
261
  workspace_path=workspace_path,
gobby/agents/session.py CHANGED
@@ -53,6 +53,9 @@ class ChildSessionConfig:
53
53
  lifecycle_variables: dict[str, Any] | None = None
54
54
  """Lifecycle variables for the session."""
55
55
 
56
+ step_variables: dict[str, Any] | None = None
57
+ """Variables to pass during workflow auto-activation (e.g., assigned_task_id)."""
58
+
56
59
 
57
60
  class ChildSessionManager:
58
61
  """
@@ -197,6 +200,7 @@ class ChildSessionManager:
197
200
  agent_depth=child_depth,
198
201
  spawned_by_agent_id=config.agent_id,
199
202
  workflow_name=config.workflow_name,
203
+ step_variables=config.step_variables,
200
204
  )
201
205
 
202
206
  child_id = child.id
gobby/agents/spawn.py CHANGED
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import logging
15
15
  from dataclasses import dataclass
16
16
  from pathlib import Path
17
+ from typing import Any
17
18
 
18
19
  from gobby.agents.constants import get_terminal_env_vars
19
20
  from gobby.agents.sandbox import SandboxConfig, compute_sandbox_paths, get_sandbox_resolver
@@ -239,7 +240,7 @@ class TerminalSpawner:
239
240
  Spawn a CLI agent in a new terminal with Gobby environment variables.
240
241
 
241
242
  Args:
242
- cli: CLI to run (e.g., "claude", "gemini", "codex")
243
+ cli: CLI to run (e.g., "claude", "gemini", "codex", "cursor", "windsurf", "copilot")
243
244
  cwd: Working directory (usually project root or worktree)
244
245
  session_id: Pre-created child session ID
245
246
  parent_session_id: Parent session for context resolution
@@ -366,6 +367,7 @@ def prepare_terminal_spawn(
366
367
  source: str = "claude",
367
368
  agent_id: str | None = None,
368
369
  workflow_name: str | None = None,
370
+ step_variables: dict[str, Any] | None = None,
369
371
  title: str | None = None,
370
372
  git_branch: str | None = None,
371
373
  prompt: str | None = None,
@@ -384,7 +386,7 @@ def prepare_terminal_spawn(
384
386
  parent_session_id: Parent session ID
385
387
  project_id: Project ID
386
388
  machine_id: Machine ID
387
- source: CLI source (claude, gemini, codex)
389
+ source: CLI source (claude, gemini, codex, cursor, windsurf, copilot)
388
390
  agent_id: Optional agent ID
389
391
  workflow_name: Optional workflow to activate
390
392
  title: Optional session title
@@ -408,6 +410,7 @@ def prepare_terminal_spawn(
408
410
  source=source,
409
411
  agent_id=agent_id,
410
412
  workflow_name=workflow_name,
413
+ step_variables=step_variables,
411
414
  title=title,
412
415
  git_branch=git_branch,
413
416
  )
@@ -460,6 +463,7 @@ async def prepare_gemini_spawn_with_preflight(
460
463
  machine_id: str,
461
464
  agent_id: str | None = None,
462
465
  workflow_name: str | None = None,
466
+ step_variables: dict[str, Any] | None = None,
463
467
  title: str | None = None,
464
468
  git_branch: str | None = None,
465
469
  prompt: str | None = None,
@@ -512,6 +516,7 @@ async def prepare_gemini_spawn_with_preflight(
512
516
  source="gemini",
513
517
  agent_id=agent_id,
514
518
  workflow_name=workflow_name,
519
+ step_variables=step_variables,
515
520
  title=title,
516
521
  git_branch=git_branch,
517
522
  external_id=gemini_info.session_id, # Link to Gemini's session
@@ -569,6 +574,7 @@ async def prepare_codex_spawn_with_preflight(
569
574
  machine_id: str,
570
575
  agent_id: str | None = None,
571
576
  workflow_name: str | None = None,
577
+ step_variables: dict[str, Any] | None = None,
572
578
  title: str | None = None,
573
579
  git_branch: str | None = None,
574
580
  prompt: str | None = None,
@@ -621,6 +627,7 @@ async def prepare_codex_spawn_with_preflight(
621
627
  source="codex",
622
628
  agent_id=agent_id,
623
629
  workflow_name=workflow_name,
630
+ step_variables=step_variables,
624
631
  title=title,
625
632
  git_branch=git_branch,
626
633
  external_id=codex_info.session_id, # Link to Codex's session
@@ -20,10 +20,10 @@ if TYPE_CHECKING:
20
20
  from gobby.agents.session import ChildSessionManager
21
21
  from gobby.agents.spawn import (
22
22
  TerminalSpawner,
23
+ build_cli_command,
23
24
  build_codex_command_with_resume,
24
- build_gemini_command_with_resume,
25
25
  prepare_codex_spawn_with_preflight,
26
- prepare_gemini_spawn_with_preflight,
26
+ prepare_terminal_spawn,
27
27
  )
28
28
  from gobby.agents.spawners.embedded import EmbeddedSpawner
29
29
  from gobby.agents.spawners.headless import HeadlessSpawner
@@ -38,7 +38,7 @@ class SpawnRequest:
38
38
  # Required fields
39
39
  prompt: str
40
40
  cwd: str
41
- mode: Literal["terminal", "embedded", "headless"]
41
+ mode: Literal["terminal", "embedded", "headless", "self"]
42
42
  provider: str
43
43
  terminal: str
44
44
  session_id: str
@@ -48,8 +48,10 @@ class SpawnRequest:
48
48
 
49
49
  # Optional fields
50
50
  workflow: str | None = None
51
+ step_variables: dict[str, Any] | None = None # Variables for step workflow activation
51
52
  worktree_id: str | None = None
52
53
  clone_id: str | None = None
54
+ branch_name: str | None = None # Git branch for worktree/clone isolation
53
55
  agent_depth: int = 0
54
56
  max_agent_depth: int = 3
55
57
  session_manager: Any | None = None # Required for Gemini/Codex preflight
@@ -221,15 +223,16 @@ async def _spawn_headless(request: SpawnRequest) -> SpawnResult:
221
223
 
222
224
  async def _spawn_gemini_terminal(request: SpawnRequest) -> SpawnResult:
223
225
  """
224
- Spawn Gemini agent in terminal with preflight session capture.
226
+ Spawn Gemini agent in terminal with direct spawn (no preflight).
225
227
 
226
- Uses preflight to capture Gemini's session_id before launching interactive mode:
227
- 1. Run `gemini --output-format stream-json` to capture Gemini's session_id
228
- 2. Pre-create Gobby session with parent_session_id linked and external_id set
229
- 3. Resume Gemini session with `-r {session_id}` flag
228
+ Session linkage approach:
229
+ 1. Pre-create Gobby session with parent linkage (no external_id yet)
230
+ 2. Pass GOBBY_SESSION_ID and other env vars to the terminal
231
+ 3. Gemini's hook dispatcher reads env vars and includes in SessionStart
232
+ 4. Daemon updates external_id when SessionStart fires with Gemini's native session_id
230
233
 
231
- This approach ensures session linkage works without relying on env vars,
232
- which don't pass through macOS's `open` command.
234
+ This avoids the preflight+resume approach which failed because Gemini
235
+ doesn't persist sessions when terminated.
233
236
  """
234
237
  if request.session_manager is None:
235
238
  return SpawnResult(
@@ -237,60 +240,36 @@ async def _spawn_gemini_terminal(request: SpawnRequest) -> SpawnResult:
237
240
  run_id=request.run_id,
238
241
  child_session_id=None,
239
242
  status="failed",
240
- error="session_manager is required for Gemini preflight",
243
+ error="session_manager is required for Gemini spawn",
241
244
  )
242
245
 
243
- try:
244
- # Preflight capture: gets Gemini's session_id and creates linked Gobby session
245
- spawn_context = await prepare_gemini_spawn_with_preflight(
246
- session_manager=cast("ChildSessionManager", request.session_manager),
247
- parent_session_id=request.parent_session_id,
248
- project_id=request.project_id,
249
- machine_id=request.machine_id or "unknown",
250
- workflow_name=request.workflow,
251
- git_branch=None, # Will be detected by hook
252
- prompt=request.prompt,
253
- max_agent_depth=request.max_agent_depth,
254
- )
255
- except FileNotFoundError as e:
256
- logger.error(
257
- f"Gemini spawn failed - command not found: {e}",
258
- extra={"project_id": request.project_id, "run_id": request.run_id},
259
- )
260
- return SpawnResult(
261
- success=False,
262
- run_id=request.run_id,
263
- child_session_id=None,
264
- status="failed",
265
- error=str(e),
266
- )
267
- except Exception as e:
268
- logger.error(
269
- f"Gemini preflight capture failed: {e}",
270
- extra={"project_id": request.project_id, "run_id": request.run_id},
271
- exc_info=True,
272
- )
273
- return SpawnResult(
274
- success=False,
275
- run_id=request.run_id,
276
- child_session_id=None,
277
- status="failed",
278
- error=f"Gemini preflight capture failed: {e}",
279
- )
246
+ # Prepare spawn context (creates child session, builds env vars)
247
+ spawn_context = prepare_terminal_spawn(
248
+ session_manager=cast("ChildSessionManager", request.session_manager),
249
+ parent_session_id=request.parent_session_id,
250
+ project_id=request.project_id,
251
+ machine_id=request.machine_id or "unknown",
252
+ source="gemini",
253
+ workflow_name=request.workflow,
254
+ step_variables=request.step_variables,
255
+ prompt=request.prompt,
256
+ max_agent_depth=request.max_agent_depth,
257
+ git_branch=request.branch_name,
258
+ )
280
259
 
281
- # Extract IDs from prepared spawn context
282
260
  gobby_session_id = spawn_context.session_id
283
- gemini_session_id = spawn_context.env_vars["GOBBY_GEMINI_EXTERNAL_ID"]
284
261
 
285
- # Build command with resume (no env vars needed - session already linked)
286
- cmd = build_gemini_command_with_resume(
287
- gemini_external_id=gemini_session_id,
262
+ # Build command for fresh Gemini session (not resume)
263
+ # Session context is injected via additionalContext at SessionStart by the daemon
264
+ cmd = build_cli_command(
265
+ cli="gemini",
288
266
  prompt=request.prompt,
289
267
  auto_approve=True,
290
- gobby_session_id=gobby_session_id,
268
+ mode="terminal",
291
269
  )
292
270
 
293
271
  # Resolve sandbox config if provided
272
+ sandbox_args: list[str] = []
294
273
  sandbox_env: dict[str, str] = {}
295
274
  if request.sandbox_config and request.sandbox_config.enabled:
296
275
  resolver = GeminiSandboxResolver()
@@ -299,16 +278,25 @@ async def _spawn_gemini_terminal(request: SpawnRequest) -> SpawnResult:
299
278
  workspace_path=request.cwd,
300
279
  )
301
280
  sandbox_args, sandbox_env = resolver.resolve(request.sandbox_config, paths)
302
- # Append sandbox args to command (e.g., -s flag)
281
+ # Append sandbox args to command
303
282
  cmd.extend(sandbox_args)
304
283
 
305
- # Spawn in terminal
284
+ # Merge env vars: spawn context + sandbox
285
+ env = spawn_context.env_vars.copy()
286
+ if sandbox_env:
287
+ env.update(sandbox_env)
288
+
289
+ # Pass machine_id as env var for sandboxed agents that can't read ~/.gobby/machine_id
290
+ if request.machine_id:
291
+ env["GOBBY_MACHINE_ID"] = request.machine_id
292
+
293
+ # Spawn in terminal with env vars
306
294
  terminal_spawner = TerminalSpawner()
307
295
  terminal_result = terminal_spawner.spawn(
308
296
  command=cmd,
309
297
  cwd=request.cwd,
310
298
  terminal=request.terminal,
311
- env=sandbox_env if sandbox_env else None,
299
+ env=env,
312
300
  )
313
301
 
314
302
  if not terminal_result.success:
@@ -322,11 +310,10 @@ async def _spawn_gemini_terminal(request: SpawnRequest) -> SpawnResult:
322
310
 
323
311
  return SpawnResult(
324
312
  success=True,
325
- run_id=f"gemini-{gemini_session_id[:8]}",
326
- child_session_id=gobby_session_id, # Now properly set!
313
+ run_id=spawn_context.agent_run_id,
314
+ child_session_id=gobby_session_id,
327
315
  status="pending",
328
316
  pid=terminal_result.pid,
329
- gemini_session_id=gemini_session_id,
330
317
  message=f"Gemini agent spawned in terminal with session {gobby_session_id}",
331
318
  )
332
319
 
@@ -354,7 +341,8 @@ async def _spawn_codex_terminal(request: SpawnRequest) -> SpawnResult:
354
341
  project_id=request.project_id,
355
342
  machine_id=request.machine_id or "unknown",
356
343
  workflow_name=request.workflow,
357
- git_branch=None, # Will be detected by hook
344
+ step_variables=request.step_variables,
345
+ git_branch=request.branch_name,
358
346
  )
359
347
  except FileNotFoundError as e:
360
348
  return SpawnResult(
@@ -35,9 +35,9 @@ def build_cli_command(
35
35
  - Or: codex -c 'sandbox_permissions=["disk-full-read-access"]' -a never [PROMPT]
36
36
 
37
37
  Args:
38
- cli: CLI name (claude, gemini, codex)
38
+ cli: CLI name (claude, gemini, codex, cursor, windsurf, copilot)
39
39
  prompt: Optional prompt to pass
40
- session_id: Optional session ID (used by Claude CLI)
40
+ session_id: Optional session ID (used by Claude-compatible CLIs)
41
41
  auto_approve: If True, add flags to auto-approve actions/permissions
42
42
  working_directory: Optional working directory (used by Codex -C flag)
43
43
  mode: Execution mode - "terminal" (interactive) or "headless" (non-interactive)
@@ -48,8 +48,8 @@ def build_cli_command(
48
48
  """
49
49
  command = [cli]
50
50
 
51
- if cli == "claude":
52
- # Claude CLI flags
51
+ if cli in ("claude", "cursor", "windsurf", "copilot"):
52
+ # Claude-compatible CLI flags
53
53
  if session_id:
54
54
  command.extend(["--session-id", session_id])
55
55
  if auto_approve: