gobby 0.2.7__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 (80) hide show
  1. gobby/adapters/claude_code.py +96 -35
  2. gobby/adapters/gemini.py +140 -38
  3. gobby/agents/isolation.py +130 -0
  4. gobby/agents/registry.py +11 -0
  5. gobby/agents/session.py +1 -0
  6. gobby/agents/spawn_executor.py +43 -13
  7. gobby/agents/spawners/macos.py +26 -1
  8. gobby/cli/__init__.py +0 -2
  9. gobby/cli/memory.py +185 -0
  10. gobby/clones/git.py +177 -0
  11. gobby/config/skills.py +31 -0
  12. gobby/hooks/event_handlers.py +109 -10
  13. gobby/hooks/hook_manager.py +19 -1
  14. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  15. gobby/mcp_proxy/instructions.py +2 -2
  16. gobby/mcp_proxy/registries.py +21 -4
  17. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  18. gobby/mcp_proxy/tools/agents.py +45 -9
  19. gobby/mcp_proxy/tools/artifacts.py +43 -9
  20. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  21. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  22. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  23. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  24. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  25. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  26. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  27. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  28. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  29. gobby/mcp_proxy/tools/workflows.py +84 -34
  30. gobby/mcp_proxy/tools/worktrees.py +32 -7
  31. gobby/memory/extractor.py +15 -1
  32. gobby/runner.py +13 -0
  33. gobby/servers/routes/mcp/hooks.py +50 -3
  34. gobby/servers/websocket.py +57 -1
  35. gobby/sessions/analyzer.py +2 -2
  36. gobby/sessions/manager.py +9 -0
  37. gobby/sessions/transcripts/gemini.py +100 -34
  38. gobby/storage/database.py +9 -2
  39. gobby/storage/memories.py +32 -21
  40. gobby/storage/migrations.py +23 -4
  41. gobby/storage/sessions.py +4 -2
  42. gobby/storage/skills.py +43 -3
  43. gobby/workflows/detection_helpers.py +38 -24
  44. gobby/workflows/enforcement/blocking.py +13 -1
  45. gobby/workflows/engine.py +93 -0
  46. gobby/workflows/evaluator.py +110 -0
  47. gobby/workflows/hooks.py +41 -0
  48. gobby/workflows/memory_actions.py +11 -0
  49. gobby/workflows/safe_evaluator.py +8 -0
  50. gobby/workflows/summary_actions.py +123 -50
  51. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
  52. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
  53. gobby/cli/tui.py +0 -34
  54. gobby/tui/__init__.py +0 -5
  55. gobby/tui/api_client.py +0 -278
  56. gobby/tui/app.py +0 -329
  57. gobby/tui/screens/__init__.py +0 -25
  58. gobby/tui/screens/agents.py +0 -333
  59. gobby/tui/screens/chat.py +0 -450
  60. gobby/tui/screens/dashboard.py +0 -377
  61. gobby/tui/screens/memory.py +0 -305
  62. gobby/tui/screens/metrics.py +0 -231
  63. gobby/tui/screens/orchestrator.py +0 -903
  64. gobby/tui/screens/sessions.py +0 -412
  65. gobby/tui/screens/tasks.py +0 -440
  66. gobby/tui/screens/workflows.py +0 -289
  67. gobby/tui/screens/worktrees.py +0 -174
  68. gobby/tui/widgets/__init__.py +0 -21
  69. gobby/tui/widgets/chat.py +0 -210
  70. gobby/tui/widgets/conductor.py +0 -104
  71. gobby/tui/widgets/menu.py +0 -132
  72. gobby/tui/widgets/message_panel.py +0 -160
  73. gobby/tui/widgets/review_gate.py +0 -224
  74. gobby/tui/widgets/task_tree.py +0 -99
  75. gobby/tui/widgets/token_budget.py +0 -166
  76. gobby/tui/ws_client.py +0 -258
  77. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
  78. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  79. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  80. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -27,9 +27,18 @@ def register_commits_tools(
27
27
  session_manager: LocalSessionManager instance for session operations
28
28
  """
29
29
 
30
+ def _resolve_session_id(ref: str) -> str:
31
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID."""
32
+ from gobby.utils.project_context import get_project_context
33
+
34
+ project_ctx = get_project_context()
35
+ project_id = project_ctx.get("id") if project_ctx else None
36
+
37
+ return session_manager.resolve_session_reference(ref, project_id)
38
+
30
39
  @registry.tool(
31
40
  name="get_session_commits",
32
- description="Get git commits made during a session timeframe.",
41
+ description="Get git commits made during a session timeframe. Accepts #N, N, UUID, or prefix for session_id.",
33
42
  )
34
43
  def get_session_commits(
35
44
  session_id: str,
@@ -42,7 +51,7 @@ def register_commits_tools(
42
51
  git log within that timeframe.
43
52
 
44
53
  Args:
45
- session_id: Session ID
54
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix
46
55
  max_commits: Maximum commits to return (default 20)
47
56
 
48
57
  Returns:
@@ -55,21 +64,15 @@ def register_commits_tools(
55
64
  if session_manager is None:
56
65
  return {"error": "Session manager not available"}
57
66
 
58
- # Get session
59
- session = session_manager.get(session_id)
67
+ # Resolve session reference (#N, N, UUID, or prefix)
68
+ try:
69
+ resolved_id = _resolve_session_id(session_id)
70
+ session = session_manager.get(resolved_id)
71
+ except ValueError as e:
72
+ return {"error": str(e)}
73
+
60
74
  if not session:
61
- # Try prefix match
62
- sessions = session_manager.list(limit=100)
63
- matches = [s for s in sessions if s.id.startswith(session_id)]
64
- if len(matches) == 1:
65
- session = matches[0]
66
- elif len(matches) > 1:
67
- return {
68
- "error": f"Ambiguous session ID prefix '{session_id}'",
69
- "matches": [s.id for s in matches[:5]],
70
- }
71
- else:
72
- return {"error": f"Session {session_id} not found"}
75
+ return {"error": f"Session {session_id} not found"}
73
76
 
74
77
  # Get working directory from transcript path or project
75
78
  cwd = None
@@ -163,12 +166,12 @@ def register_commits_tools(
163
166
 
164
167
  @registry.tool(
165
168
  name="mark_loop_complete",
166
- description="""Mark the autonomous loop as complete, preventing session chaining.
169
+ description="""Mark the autonomous loop as complete, preventing session chaining. Accepts #N, N, UUID, or prefix for session_id.
167
170
 
168
171
  Args:
169
- session_id: (REQUIRED) Your session ID. Get it from:
170
- 1. Your injected context (look for 'session_id: xxx')
171
- 2. Or call get_current(external_id, source) first""",
172
+ session_id: (REQUIRED) Your session ID. Accepts #N, N, UUID, or prefix. Get it from:
173
+ 1. Your injected context (look for 'Session Ref: #N' or 'session_id: xxx')
174
+ 2. Or call get_current_session(external_id, source) first""",
172
175
  )
173
176
  def mark_loop_complete(session_id: str) -> dict[str, Any]:
174
177
  """
@@ -184,16 +187,20 @@ Args:
184
187
  - The user has explicitly asked to stop
185
188
 
186
189
  Args:
187
- session_id: Session ID (REQUIRED)
190
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (REQUIRED)
188
191
 
189
192
  Returns:
190
193
  Success status and session details
191
194
  """
192
195
  if not session_manager:
193
- raise RuntimeError("Session manager not available")
196
+ return {"error": "Session manager not available"}
194
197
 
195
- # Find session - session_id is now required
196
- session = session_manager.get(session_id)
198
+ # Resolve session reference (#N, N, UUID, or prefix)
199
+ try:
200
+ resolved_id = _resolve_session_id(session_id)
201
+ session = session_manager.get(resolved_id)
202
+ except ValueError as e:
203
+ return {"error": str(e), "session_id": session_id}
197
204
 
198
205
  if not session:
199
206
  return {"error": f"Session {session_id} not found", "session_id": session_id}
@@ -2,7 +2,7 @@
2
2
 
3
3
  This module contains MCP tools for:
4
4
  - Getting session details (get_session)
5
- - Getting current session (get_current)
5
+ - Getting current session (get_current_session)
6
6
  - Listing sessions (list_sessions)
7
7
  - Session statistics (session_stats)
8
8
  """
@@ -71,7 +71,7 @@ def register_crud_tools(
71
71
  }
72
72
 
73
73
  @registry.tool(
74
- name="get_current",
74
+ name="get_current_session",
75
75
  description="""Get YOUR current session ID - the CORRECT way to look up your session.
76
76
 
77
77
  Use this when session_id wasn't in your injected context. Pass your external_id
@@ -79,7 +79,7 @@ Use this when session_id wasn't in your injected context. Pass your external_id
79
79
 
80
80
  DO NOT use list_sessions to find your session - it won't work with multiple active sessions.""",
81
81
  )
82
- def get_current(
82
+ def get_current_session(
83
83
  external_id: str,
84
84
  source: str,
85
85
  ) -> dict[str, Any]:
@@ -148,7 +148,7 @@ DO NOT use list_sessions to find your session - it won't work with multiple acti
148
148
  WARNING: Do NOT use this to find your own session_id!
149
149
  - `list_sessions(status="active", limit=1)` will NOT reliably return YOUR session
150
150
  - Multiple sessions can be active simultaneously (parallel agents, multiple terminals)
151
- - Use `get_current(external_id, source)` instead - it uses your unique session key
151
+ - Use `get_current_session(external_id, source)` instead - it uses your unique session key
152
152
 
153
153
  This tool is for browsing/listing sessions, not for self-identification.""",
154
154
  )
@@ -192,7 +192,7 @@ This tool is for browsing/listing sessions, not for self-identification.""",
192
192
  "warning": (
193
193
  "list_sessions(status='active', limit=1) will NOT reliably get YOUR session_id! "
194
194
  "Multiple sessions can be active simultaneously. "
195
- "Use get_current(external_id='<your-external-id>', source='claude') instead."
195
+ "Use get_current_session(external_id='<your-external-id>', source='claude') instead."
196
196
  ),
197
197
  "hint": "Your external_id is in your transcript path: /path/to/<external_id>.jsonl",
198
198
  "sessions": [s.to_dict() for s in sessions],
@@ -123,23 +123,29 @@ def register_handoff_tools(
123
123
  registry: The InternalToolRegistry to register tools with
124
124
  session_manager: LocalSessionManager instance for session operations
125
125
  """
126
+ from gobby.utils.project_context import get_project_context
127
+
128
+ def _resolve_session_id(ref: str) -> str:
129
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID."""
130
+ project_ctx = get_project_context()
131
+ project_id = project_ctx.get("id") if project_ctx else None
132
+
133
+ return session_manager.resolve_session_reference(ref, project_id)
126
134
 
127
135
  @registry.tool(
128
136
  name="get_handoff_context",
129
- description="Get the handoff context (compact_markdown) for a session. Accepts #N, UUID, or prefix.",
137
+ description="Get the handoff context (compact_markdown) for a session. Accepts #N, N, UUID, or prefix.",
130
138
  )
131
139
  def get_handoff_context(session_id: str) -> dict[str, Any]:
132
140
  """
133
141
  Retrieve stored handoff context.
134
142
 
135
143
  Args:
136
- session_id: Session reference - supports #N (project-scoped), UUID, or prefix
144
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix
137
145
 
138
146
  Returns:
139
147
  Session ID, compact_markdown, and whether context exists
140
148
  """
141
- from gobby.utils.project_context import get_project_context
142
-
143
149
  if not session_manager:
144
150
  raise RuntimeError("Session manager not available")
145
151
 
@@ -165,12 +171,12 @@ def register_handoff_tools(
165
171
 
166
172
  @registry.tool(
167
173
  name="create_handoff",
168
- description="""Create handoff context by extracting structured data from the session transcript.
174
+ description="""Create handoff context by extracting structured data from the session transcript. Accepts #N, N, UUID, or prefix for session_id.
169
175
 
170
176
  Args:
171
- session_id: (REQUIRED) Your session ID. Get it from:
172
- 1. Your injected context (look for 'session_id: xxx')
173
- 2. Or call get_current(external_id, source) first""",
177
+ session_id: (REQUIRED) Your session ID. Accepts #N, N, UUID, or prefix. Get it from:
178
+ 1. Your injected context (look for 'Session Ref: #N' or 'session_id: xxx')
179
+ 2. Or call get_current_session(external_id, source) first""",
174
180
  )
175
181
  async def create_handoff(
176
182
  session_id: str,
@@ -187,7 +193,7 @@ Args:
187
193
  Always saves to database. Optionally writes to file.
188
194
 
189
195
  Args:
190
- session_id: Session ID (REQUIRED)
196
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (REQUIRED)
191
197
  notes: Additional notes to include in handoff
192
198
  compact: Generate compact summary only (default: False, neither = both)
193
199
  full: Generate full LLM summary only (default: False, neither = both)
@@ -207,19 +213,12 @@ Args:
207
213
  if session_manager is None:
208
214
  return {"success": False, "error": "Session manager not available"}
209
215
 
210
- # Find session - session_id is now required
211
- session = session_manager.get(session_id)
212
- if not session:
213
- # Try prefix match
214
- sessions = session_manager.list(limit=100)
215
- matches = [s for s in sessions if s.id.startswith(session_id)]
216
- if len(matches) == 1:
217
- session = matches[0]
218
- elif len(matches) > 1:
219
- return {
220
- "error": f"Ambiguous session ID prefix '{session_id}'",
221
- "matches": [s.id for s in matches[:5]],
222
- }
216
+ # Resolve session reference (#N, N, UUID, or prefix)
217
+ try:
218
+ resolved_id = _resolve_session_id(session_id)
219
+ session = session_manager.get(resolved_id)
220
+ except ValueError as e:
221
+ return {"success": False, "error": str(e), "session_id": session_id}
223
222
 
224
223
  if not session:
225
224
  return {"success": False, "error": "No session found", "session_id": session_id}
@@ -397,7 +396,7 @@ Args:
397
396
 
398
397
  @registry.tool(
399
398
  name="pickup",
400
- description="Restore context from a previous session's handoff. For CLIs/IDEs without hooks.",
399
+ description="Restore context from a previous session's handoff. For CLIs/IDEs without hooks. Accepts #N, N, UUID, or prefix for session_id.",
401
400
  )
402
401
  def pickup(
403
402
  session_id: str | None = None,
@@ -413,10 +412,10 @@ Args:
413
412
  for injection into a new session.
414
413
 
415
414
  Args:
416
- session_id: Specific session ID to pickup from (optional)
415
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (optional)
417
416
  project_id: Project ID to find parent session in (optional)
418
417
  source: Filter by CLI source - claude_code, gemini, codex (optional)
419
- link_child_session_id: If provided, links this session as a child
418
+ link_child_session_id: Session to link as child - supports #N, N, UUID, or prefix (optional)
420
419
 
421
420
  Returns:
422
421
  Handoff context markdown and session metadata
@@ -428,20 +427,13 @@ Args:
428
427
 
429
428
  parent_session = None
430
429
 
431
- # Option 1: Direct session_id lookup
430
+ # Option 1: Direct session_id lookup with resolution
432
431
  if session_id:
433
- parent_session = session_manager.get(session_id)
434
- if not parent_session:
435
- # Try prefix match
436
- sessions = session_manager.list(limit=100)
437
- matches = [s for s in sessions if s.id.startswith(session_id)]
438
- if len(matches) == 1:
439
- parent_session = matches[0]
440
- elif len(matches) > 1:
441
- return {
442
- "error": f"Ambiguous session ID prefix '{session_id}'",
443
- "matches": [s.id for s in matches[:5]],
444
- }
432
+ try:
433
+ resolved_id = _resolve_session_id(session_id)
434
+ parent_session = session_manager.get(resolved_id)
435
+ except ValueError as e:
436
+ return {"error": str(e)}
445
437
 
446
438
  # Option 2: Find parent by project_id and source
447
439
  if not parent_session and project_id:
@@ -481,9 +473,21 @@ Args:
481
473
  "message": "Session found but has no handoff context",
482
474
  }
483
475
 
484
- # Optionally link child session
476
+ # Optionally link child session (resolve if using #N format)
477
+ resolved_child_id = None
485
478
  if link_child_session_id:
486
- session_manager.update_parent_session_id(link_child_session_id, parent_session.id)
479
+ try:
480
+ resolved_child_id = _resolve_session_id(link_child_session_id)
481
+ session_manager.update_parent_session_id(resolved_child_id, parent_session.id)
482
+ except ValueError as e:
483
+ # Do not fallback to raw reference - propagate the error
484
+ return {
485
+ "found": True,
486
+ "session_id": parent_session.id,
487
+ "has_context": True,
488
+ "error": f"Failed to resolve child session '{link_child_session_id}': {e}",
489
+ "context": context,
490
+ }
487
491
 
488
492
  return {
489
493
  "found": True,
@@ -495,5 +499,5 @@ Args:
495
499
  ),
496
500
  "parent_title": parent_session.title,
497
501
  "parent_status": parent_session.status,
498
- "linked_child": link_child_session_id,
502
+ "linked_child": resolved_child_id or link_child_session_id,
499
503
  }
@@ -12,11 +12,13 @@ from typing import TYPE_CHECKING, Any
12
12
  if TYPE_CHECKING:
13
13
  from gobby.mcp_proxy.tools.internal import InternalToolRegistry
14
14
  from gobby.storage.session_messages import LocalSessionMessageManager
15
+ from gobby.storage.sessions import LocalSessionManager
15
16
 
16
17
 
17
18
  def register_message_tools(
18
19
  registry: InternalToolRegistry,
19
20
  message_manager: LocalSessionMessageManager,
21
+ session_manager: LocalSessionManager | None = None,
20
22
  ) -> None:
21
23
  """
22
24
  Register message retrieval and search tools with a registry.
@@ -24,11 +26,31 @@ def register_message_tools(
24
26
  Args:
25
27
  registry: The InternalToolRegistry to register tools with
26
28
  message_manager: LocalSessionMessageManager instance for message operations
29
+ session_manager: LocalSessionManager for resolving session references
27
30
  """
28
31
 
32
+ def _resolve_session_id(session_id: str) -> str:
33
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID.
34
+
35
+ Returns:
36
+ str: Resolved UUID on success
37
+
38
+ Raises:
39
+ ValueError: If session reference cannot be resolved (when session_manager exists)
40
+ """
41
+ if not session_manager:
42
+ return session_id # Fall back to raw value if no manager (backward compat)
43
+
44
+ from gobby.utils.project_context import get_project_context
45
+
46
+ project_ctx = get_project_context()
47
+ project_id = project_ctx.get("id") if project_ctx else None
48
+
49
+ return session_manager.resolve_session_reference(session_id, project_id)
50
+
29
51
  @registry.tool(
30
52
  name="get_session_messages",
31
- description="Get messages for a session.",
53
+ description="Get messages for a session. Accepts #N, N, UUID, or prefix for session_id.",
32
54
  )
33
55
  async def get_session_messages(
34
56
  session_id: str,
@@ -40,7 +62,7 @@ def register_message_tools(
40
62
  Get messages for a session.
41
63
 
42
64
  Args:
43
- session_id: The session ID
65
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix
44
66
  limit: Max messages to return
45
67
  offset: Offset for pagination
46
68
  full_content: If True, returns full content. If False (default), truncates large content.
@@ -48,8 +70,10 @@ def register_message_tools(
48
70
  try:
49
71
  if not message_manager:
50
72
  raise RuntimeError("Message manager not available")
73
+
74
+ resolved_id = _resolve_session_id(session_id)
51
75
  messages = await message_manager.get_messages(
52
- session_id=session_id,
76
+ session_id=resolved_id,
53
77
  limit=limit,
54
78
  offset=offset,
55
79
  )
@@ -79,7 +103,7 @@ def register_message_tools(
79
103
  ):
80
104
  tr["content"] = tr["content"][:200] + "... (truncated)"
81
105
 
82
- session_total = await message_manager.count_messages(session_id)
106
+ session_total = await message_manager.count_messages(resolved_id)
83
107
 
84
108
  return {
85
109
  "success": True,
@@ -95,7 +119,7 @@ def register_message_tools(
95
119
 
96
120
  @registry.tool(
97
121
  name="search_messages",
98
- description="Search messages using Full Text Search (FTS).",
122
+ description="Search messages using Full Text Search (FTS). Accepts #N, N, UUID, or prefix for session_id.",
99
123
  )
100
124
  async def search_messages(
101
125
  query: str,
@@ -108,16 +132,20 @@ def register_message_tools(
108
132
 
109
133
  Args:
110
134
  query: Search query
111
- session_id: Optional session filter
135
+ session_id: Optional session filter - supports #N, N (seq_num), UUID, or prefix
112
136
  limit: Max results
113
137
  full_content: If True, returns full content. If False (default), truncates large content.
114
138
  """
115
139
  try:
116
140
  if not message_manager:
117
141
  raise RuntimeError("Message manager not available")
142
+
143
+ resolved_session_id = None
144
+ if session_id:
145
+ resolved_session_id = _resolve_session_id(session_id)
118
146
  results = await message_manager.search_messages(
119
147
  query_text=query,
120
- session_id=session_id,
148
+ session_id=resolved_session_id,
121
149
  limit=limit,
122
150
  )
123
151
 
@@ -12,7 +12,6 @@ One tool: spawn_agent(prompt, agent="generic", isolation="current"|"worktree"|"c
12
12
  from __future__ import annotations
13
13
 
14
14
  import logging
15
- import socket
16
15
  import uuid
17
16
  from pathlib import Path
18
17
  from typing import TYPE_CHECKING, Any, Literal, cast
@@ -22,10 +21,12 @@ from gobby.agents.isolation import (
22
21
  SpawnConfig,
23
22
  get_isolation_handler,
24
23
  )
24
+ from gobby.agents.registry import RunningAgent, get_running_agent_registry
25
25
  from gobby.agents.sandbox import SandboxConfig
26
26
  from gobby.agents.spawn_executor import SpawnRequest, execute_spawn
27
27
  from gobby.mcp_proxy.tools.internal import InternalToolRegistry
28
28
  from gobby.mcp_proxy.tools.tasks import resolve_task_id_for_mcp
29
+ from gobby.utils.machine_id import get_machine_id
29
30
  from gobby.utils.project_context import get_project_context
30
31
 
31
32
  if TYPE_CHECKING:
@@ -264,13 +265,30 @@ async def spawn_agent_impl(
264
265
  worktree_id=isolation_ctx.worktree_id,
265
266
  clone_id=isolation_ctx.clone_id,
266
267
  session_manager=runner._child_session_manager,
267
- machine_id=socket.gethostname(),
268
+ machine_id=get_machine_id() or "unknown",
268
269
  sandbox_config=effective_sandbox_config,
269
270
  )
270
271
 
271
272
  spawn_result = await execute_spawn(spawn_request)
272
273
 
273
- # 11. Return response with isolation metadata
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
274
292
  return {
275
293
  "success": spawn_result.success,
276
294
  "run_id": spawn_result.run_id,
@@ -295,6 +313,7 @@ def create_spawn_agent_registry(
295
313
  git_manager: Any | None = None,
296
314
  clone_storage: Any | None = None,
297
315
  clone_manager: Any | None = None,
316
+ session_manager: Any | None = None,
298
317
  ) -> InternalToolRegistry:
299
318
  """
300
319
  Create a spawn_agent tool registry with the unified spawn_agent tool.
@@ -307,10 +326,20 @@ def create_spawn_agent_registry(
307
326
  git_manager: Git manager for worktree operations.
308
327
  clone_storage: Storage for clone records.
309
328
  clone_manager: Git manager for clone operations.
329
+ session_manager: Session manager for resolving session references.
310
330
 
311
331
  Returns:
312
332
  InternalToolRegistry with spawn_agent tool registered.
313
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
+
314
343
  registry = InternalToolRegistry(
315
344
  name="gobby-spawn-agent",
316
345
  description="Unified agent spawning with isolation support",
@@ -324,7 +353,8 @@ def create_spawn_agent_registry(
324
353
  description=(
325
354
  "Spawn a subagent to execute a task. Supports isolation modes: "
326
355
  "'current' (work in current directory), 'worktree' (create git worktree), "
327
- "'clone' (create shallow clone). Can use named agent definitions or raw parameters."
356
+ "'clone' (create shallow clone). Can use named agent definitions or raw parameters. "
357
+ "Accepts #N, N, UUID, or prefix for parent_session_id."
328
358
  ),
329
359
  )
330
360
  async def spawn_agent(
@@ -374,12 +404,20 @@ def create_spawn_agent_registry(
374
404
  sandbox_mode: Sandbox mode (permissive/restrictive). Overrides agent_def.
375
405
  sandbox_allow_network: Allow network access. Overrides agent_def.
376
406
  sandbox_extra_paths: Extra paths for sandbox write access.
377
- parent_session_id: Parent session ID
407
+ parent_session_id: Session reference (accepts #N, N, UUID, or prefix) for the parent session
378
408
  project_path: Project path override
379
409
 
380
410
  Returns:
381
411
  Dict with success status, run_id, child_session_id, isolation metadata
382
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
+
383
421
  # Load agent definition (defaults to "generic")
384
422
  agent_def = loader.load(agent)
385
423
  if agent_def is None and agent != "generic":
@@ -410,7 +448,7 @@ def create_spawn_agent_registry(
410
448
  sandbox_mode=sandbox_mode,
411
449
  sandbox_allow_network=sandbox_allow_network,
412
450
  sandbox_extra_paths=sandbox_extra_paths,
413
- parent_session_id=parent_session_id,
451
+ parent_session_id=resolved_parent_session_id,
414
452
  project_path=project_path,
415
453
  )
416
454
 
@@ -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",