gobby 0.2.5__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 +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Headless spawner for agent execution with output capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import subprocess # nosec B404 - subprocess needed for headless agent spawning
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from gobby.agents.constants import get_terminal_env_vars
|
|
12
|
+
from gobby.agents.spawners.base import HeadlessResult
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
__all__ = ["HeadlessSpawner"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Import these from spawn.py to avoid duplication
|
|
21
|
+
def _get_spawn_utils() -> tuple[
|
|
22
|
+
Callable[..., list[str]],
|
|
23
|
+
Callable[[str, str], str],
|
|
24
|
+
int,
|
|
25
|
+
]:
|
|
26
|
+
"""Lazy import to avoid circular dependencies."""
|
|
27
|
+
from gobby.agents.spawn import (
|
|
28
|
+
MAX_ENV_PROMPT_LENGTH,
|
|
29
|
+
_create_prompt_file,
|
|
30
|
+
build_cli_command,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return build_cli_command, _create_prompt_file, MAX_ENV_PROMPT_LENGTH
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HeadlessSpawner:
|
|
37
|
+
"""
|
|
38
|
+
Spawner for headless mode with output capture.
|
|
39
|
+
|
|
40
|
+
Runs the process without a visible terminal, capturing all output
|
|
41
|
+
to a buffer that can be stored in the session transcript.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def spawn(
|
|
45
|
+
self,
|
|
46
|
+
command: list[str],
|
|
47
|
+
cwd: str | Path,
|
|
48
|
+
env: dict[str, str] | None = None,
|
|
49
|
+
) -> HeadlessResult:
|
|
50
|
+
"""
|
|
51
|
+
Spawn a headless process with output capture.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
command: Command to run
|
|
55
|
+
cwd: Working directory
|
|
56
|
+
env: Environment variables to set
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
HeadlessResult with process handle for output capture
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
# Merge environment
|
|
63
|
+
spawn_env = os.environ.copy()
|
|
64
|
+
if env:
|
|
65
|
+
spawn_env.update(env)
|
|
66
|
+
|
|
67
|
+
# Spawn process with captured output
|
|
68
|
+
# Use DEVNULL for stdin since headless mode uses -p flag (print mode)
|
|
69
|
+
# which reads prompt from CLI args, not stdin. A pipe stdin would hang.
|
|
70
|
+
process = subprocess.Popen( # nosec B603 - command built from config
|
|
71
|
+
command,
|
|
72
|
+
cwd=cwd,
|
|
73
|
+
env=spawn_env,
|
|
74
|
+
stdout=subprocess.PIPE,
|
|
75
|
+
stderr=subprocess.STDOUT,
|
|
76
|
+
stdin=subprocess.DEVNULL,
|
|
77
|
+
text=True,
|
|
78
|
+
bufsize=1, # Line buffered
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return HeadlessResult(
|
|
82
|
+
success=True,
|
|
83
|
+
message=f"Spawned headless process with PID {process.pid}",
|
|
84
|
+
pid=process.pid,
|
|
85
|
+
process=process,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return HeadlessResult(
|
|
90
|
+
success=False,
|
|
91
|
+
message=f"Failed to spawn headless process: {e}",
|
|
92
|
+
error=str(e),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def spawn_and_capture(
|
|
96
|
+
self,
|
|
97
|
+
command: list[str],
|
|
98
|
+
cwd: str | Path,
|
|
99
|
+
env: dict[str, str] | None = None,
|
|
100
|
+
timeout: float | None = None,
|
|
101
|
+
on_output: Callable[[str], None] | None = None,
|
|
102
|
+
) -> HeadlessResult:
|
|
103
|
+
"""
|
|
104
|
+
Spawn a headless process and capture output asynchronously.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
command: Command to run
|
|
108
|
+
cwd: Working directory
|
|
109
|
+
env: Environment variables to set
|
|
110
|
+
timeout: Optional timeout in seconds
|
|
111
|
+
on_output: Optional callback for each line of output
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
HeadlessResult with captured output
|
|
115
|
+
"""
|
|
116
|
+
result = self.spawn(command, cwd, env)
|
|
117
|
+
if not result.success or result.process is None:
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Read output asynchronously
|
|
122
|
+
async def read_output() -> None:
|
|
123
|
+
if result.process and result.process.stdout:
|
|
124
|
+
loop = asyncio.get_running_loop()
|
|
125
|
+
while True:
|
|
126
|
+
line = await loop.run_in_executor(None, result.process.stdout.readline)
|
|
127
|
+
if not line:
|
|
128
|
+
break
|
|
129
|
+
line = line.rstrip("\n")
|
|
130
|
+
result.output_buffer.append(line)
|
|
131
|
+
if on_output:
|
|
132
|
+
on_output(line)
|
|
133
|
+
|
|
134
|
+
if timeout:
|
|
135
|
+
await asyncio.wait_for(read_output(), timeout=timeout)
|
|
136
|
+
else:
|
|
137
|
+
await read_output()
|
|
138
|
+
|
|
139
|
+
# Wait for process to complete without blocking the event loop
|
|
140
|
+
if result.process:
|
|
141
|
+
loop = asyncio.get_running_loop()
|
|
142
|
+
await loop.run_in_executor(None, result.process.wait)
|
|
143
|
+
|
|
144
|
+
except TimeoutError:
|
|
145
|
+
if result.process:
|
|
146
|
+
result.process.terminate()
|
|
147
|
+
# Also wait for termination to complete (non-blocking)
|
|
148
|
+
try:
|
|
149
|
+
loop = asyncio.get_running_loop()
|
|
150
|
+
await loop.run_in_executor(None, result.process.wait)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass # nosec B110 - Best-effort process cleanup
|
|
153
|
+
result.error = "Process timed out"
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
result.error = str(e)
|
|
157
|
+
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
def spawn_agent(
|
|
161
|
+
self,
|
|
162
|
+
cli: str,
|
|
163
|
+
cwd: str | Path,
|
|
164
|
+
session_id: str,
|
|
165
|
+
parent_session_id: str,
|
|
166
|
+
agent_run_id: str,
|
|
167
|
+
project_id: str,
|
|
168
|
+
workflow_name: str | None = None,
|
|
169
|
+
agent_depth: int = 1,
|
|
170
|
+
max_agent_depth: int = 3,
|
|
171
|
+
prompt: str | None = None,
|
|
172
|
+
) -> HeadlessResult:
|
|
173
|
+
"""
|
|
174
|
+
Spawn a CLI agent in headless mode.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
cli: CLI to run
|
|
178
|
+
cwd: Working directory
|
|
179
|
+
session_id: Pre-created child session ID
|
|
180
|
+
parent_session_id: Parent session ID
|
|
181
|
+
agent_run_id: Agent run record ID
|
|
182
|
+
project_id: Project ID
|
|
183
|
+
workflow_name: Optional workflow to activate
|
|
184
|
+
agent_depth: Current nesting depth
|
|
185
|
+
max_agent_depth: Maximum allowed depth
|
|
186
|
+
prompt: Optional initial prompt
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
HeadlessResult with process handle
|
|
190
|
+
"""
|
|
191
|
+
build_cli_command, _create_prompt_file, max_env_prompt_length = _get_spawn_utils()
|
|
192
|
+
|
|
193
|
+
# Build command with prompt as CLI argument and auto-approve for autonomous work
|
|
194
|
+
command = build_cli_command(
|
|
195
|
+
cli,
|
|
196
|
+
prompt=prompt,
|
|
197
|
+
session_id=session_id,
|
|
198
|
+
auto_approve=True, # Subagents need to work autonomously
|
|
199
|
+
working_directory=str(cwd) if cli == "codex" else None,
|
|
200
|
+
mode="headless", # Non-interactive headless mode
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Handle prompt for environment variables (backup for hooks/context)
|
|
204
|
+
prompt_env: str | None = None
|
|
205
|
+
prompt_file: str | None = None
|
|
206
|
+
|
|
207
|
+
if prompt:
|
|
208
|
+
if len(prompt) <= max_env_prompt_length:
|
|
209
|
+
prompt_env = prompt
|
|
210
|
+
else:
|
|
211
|
+
# Write to temp file with secure permissions
|
|
212
|
+
prompt_file = _create_prompt_file(prompt, session_id)
|
|
213
|
+
|
|
214
|
+
env = get_terminal_env_vars(
|
|
215
|
+
session_id=session_id,
|
|
216
|
+
parent_session_id=parent_session_id,
|
|
217
|
+
agent_run_id=agent_run_id,
|
|
218
|
+
project_id=project_id,
|
|
219
|
+
workflow_name=workflow_name,
|
|
220
|
+
agent_depth=agent_depth,
|
|
221
|
+
max_agent_depth=max_agent_depth,
|
|
222
|
+
prompt=prompt_env,
|
|
223
|
+
prompt_file=prompt_file,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return self.spawn(command, cwd, env)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Linux terminal spawners: GNOME Terminal and Konsole."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess # nosec B404 - subprocess needed for terminal spawning
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from gobby.agents.spawners.base import SpawnResult, TerminalSpawnerBase, TerminalType
|
|
11
|
+
from gobby.agents.tty_config import get_tty_config
|
|
12
|
+
|
|
13
|
+
__all__ = ["GnomeTerminalSpawner", "KonsoleSpawner"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GnomeTerminalSpawner(TerminalSpawnerBase):
|
|
17
|
+
"""Spawner for GNOME Terminal."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def terminal_type(self) -> TerminalType:
|
|
21
|
+
return TerminalType.GNOME_TERMINAL
|
|
22
|
+
|
|
23
|
+
def is_available(self) -> bool:
|
|
24
|
+
config = get_tty_config().get_terminal_config("gnome-terminal")
|
|
25
|
+
if not config.enabled:
|
|
26
|
+
return False
|
|
27
|
+
command = config.command or "gnome-terminal"
|
|
28
|
+
return shutil.which(command) is not None
|
|
29
|
+
|
|
30
|
+
def spawn(
|
|
31
|
+
self,
|
|
32
|
+
command: list[str],
|
|
33
|
+
cwd: str | Path,
|
|
34
|
+
env: dict[str, str] | None = None,
|
|
35
|
+
title: str | None = None,
|
|
36
|
+
) -> SpawnResult:
|
|
37
|
+
try:
|
|
38
|
+
tty_config = get_tty_config().get_terminal_config("gnome-terminal")
|
|
39
|
+
cli_command = tty_config.command or "gnome-terminal"
|
|
40
|
+
args = [cli_command, f"--working-directory={cwd}"]
|
|
41
|
+
# Add extra options from config
|
|
42
|
+
args.extend(tty_config.options)
|
|
43
|
+
if title:
|
|
44
|
+
args.extend(["--title", title])
|
|
45
|
+
args.extend(["--", *command])
|
|
46
|
+
|
|
47
|
+
spawn_env = os.environ.copy()
|
|
48
|
+
if env:
|
|
49
|
+
spawn_env.update(env)
|
|
50
|
+
|
|
51
|
+
process = subprocess.Popen( # nosec B603 - args built from config
|
|
52
|
+
args,
|
|
53
|
+
env=spawn_env,
|
|
54
|
+
start_new_session=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return SpawnResult(
|
|
58
|
+
success=True,
|
|
59
|
+
message=f"Spawned GNOME Terminal with PID {process.pid}",
|
|
60
|
+
pid=process.pid,
|
|
61
|
+
terminal_type=self.terminal_type.value,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return SpawnResult(
|
|
66
|
+
success=False,
|
|
67
|
+
message=f"Failed to spawn GNOME Terminal: {e}",
|
|
68
|
+
error=str(e),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class KonsoleSpawner(TerminalSpawnerBase):
|
|
73
|
+
"""Spawner for KDE Konsole."""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def terminal_type(self) -> TerminalType:
|
|
77
|
+
return TerminalType.KONSOLE
|
|
78
|
+
|
|
79
|
+
def is_available(self) -> bool:
|
|
80
|
+
config = get_tty_config().get_terminal_config("konsole")
|
|
81
|
+
if not config.enabled:
|
|
82
|
+
return False
|
|
83
|
+
command = config.command or "konsole"
|
|
84
|
+
return shutil.which(command) is not None
|
|
85
|
+
|
|
86
|
+
def spawn(
|
|
87
|
+
self,
|
|
88
|
+
command: list[str],
|
|
89
|
+
cwd: str | Path,
|
|
90
|
+
env: dict[str, str] | None = None,
|
|
91
|
+
title: str | None = None,
|
|
92
|
+
) -> SpawnResult:
|
|
93
|
+
try:
|
|
94
|
+
tty_config = get_tty_config().get_terminal_config("konsole")
|
|
95
|
+
cli_command = tty_config.command or "konsole"
|
|
96
|
+
args = [cli_command, "--workdir", str(cwd)]
|
|
97
|
+
# Add extra options from config
|
|
98
|
+
args.extend(tty_config.options)
|
|
99
|
+
if title:
|
|
100
|
+
args.extend(["-p", f"tabtitle={title}"])
|
|
101
|
+
args.extend(["-e", *command])
|
|
102
|
+
|
|
103
|
+
spawn_env = os.environ.copy()
|
|
104
|
+
if env:
|
|
105
|
+
spawn_env.update(env)
|
|
106
|
+
|
|
107
|
+
process = subprocess.Popen( # nosec B603 - args built from config
|
|
108
|
+
args,
|
|
109
|
+
env=spawn_env,
|
|
110
|
+
start_new_session=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return SpawnResult(
|
|
114
|
+
success=True,
|
|
115
|
+
message=f"Spawned Konsole with PID {process.pid}",
|
|
116
|
+
pid=process.pid,
|
|
117
|
+
terminal_type=self.terminal_type.value,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
return SpawnResult(
|
|
122
|
+
success=False,
|
|
123
|
+
message=f"Failed to spawn Konsole: {e}",
|
|
124
|
+
error=str(e),
|
|
125
|
+
)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""macOS terminal spawners: Ghostty, iTerm2, and Terminal.app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess # nosec B404 - subprocess needed for terminal spawning
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from gobby.agents.spawners.base import SpawnResult, TerminalSpawnerBase, TerminalType
|
|
14
|
+
from gobby.agents.tty_config import get_tty_config
|
|
15
|
+
|
|
16
|
+
__all__ = ["GhosttySpawner", "ITermSpawner", "TerminalAppSpawner"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def escape_applescript(s: str) -> str:
|
|
20
|
+
"""Escape special characters for embedding in AppleScript strings.
|
|
21
|
+
|
|
22
|
+
AppleScript uses backslash escaping for quotes and backslashes.
|
|
23
|
+
"""
|
|
24
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GhosttySpawner(TerminalSpawnerBase):
|
|
28
|
+
"""Spawner for Ghostty terminal."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def terminal_type(self) -> TerminalType:
|
|
32
|
+
return TerminalType.GHOSTTY
|
|
33
|
+
|
|
34
|
+
def is_available(self) -> bool:
|
|
35
|
+
config = get_tty_config().get_terminal_config("ghostty")
|
|
36
|
+
if not config.enabled:
|
|
37
|
+
return False
|
|
38
|
+
# On macOS, check for the app bundle; on other platforms check CLI
|
|
39
|
+
if platform.system() == "Darwin":
|
|
40
|
+
app_path = config.app_path or "/Applications/Ghostty.app"
|
|
41
|
+
return Path(app_path).exists()
|
|
42
|
+
command = config.command or "ghostty"
|
|
43
|
+
return shutil.which(command) is not None
|
|
44
|
+
|
|
45
|
+
def spawn(
|
|
46
|
+
self,
|
|
47
|
+
command: list[str],
|
|
48
|
+
cwd: str | Path,
|
|
49
|
+
env: dict[str, str] | None = None,
|
|
50
|
+
title: str | None = None,
|
|
51
|
+
) -> SpawnResult:
|
|
52
|
+
try:
|
|
53
|
+
tty_config = get_tty_config().get_terminal_config("ghostty")
|
|
54
|
+
# On macOS, ghostty CLI doesn't support launching the emulator directly
|
|
55
|
+
# Must use 'open -na Ghostty.app --args' instead
|
|
56
|
+
# Note: Ghostty requires --key=value syntax, not --key value
|
|
57
|
+
if platform.system() == "Darwin":
|
|
58
|
+
app_path = tty_config.app_path or "/Applications/Ghostty.app"
|
|
59
|
+
# Build args for open command
|
|
60
|
+
# open -na /path/to/Ghostty.app --args [ghostty-options] -e [command]
|
|
61
|
+
# Note: 'open' doesn't pass cwd, so we must use --working-directory
|
|
62
|
+
ghostty_args = [f"--working-directory={cwd}"]
|
|
63
|
+
if title:
|
|
64
|
+
ghostty_args.append(f"--title={title}")
|
|
65
|
+
# Add any extra options from config
|
|
66
|
+
ghostty_args.extend(tty_config.options)
|
|
67
|
+
ghostty_args.extend(["-e"] + command)
|
|
68
|
+
|
|
69
|
+
args = ["open", "-na", app_path, "--args"] + ghostty_args
|
|
70
|
+
else:
|
|
71
|
+
# On Linux/other platforms, use ghostty CLI directly
|
|
72
|
+
cli_command = tty_config.command or "ghostty"
|
|
73
|
+
args = [cli_command]
|
|
74
|
+
if title:
|
|
75
|
+
args.append(f"--title={title}")
|
|
76
|
+
# Add any extra options from config
|
|
77
|
+
args.extend(tty_config.options)
|
|
78
|
+
args.extend(["-e"] + command)
|
|
79
|
+
|
|
80
|
+
# Merge environment
|
|
81
|
+
spawn_env = os.environ.copy()
|
|
82
|
+
if env:
|
|
83
|
+
spawn_env.update(env)
|
|
84
|
+
|
|
85
|
+
# Spawn process
|
|
86
|
+
process = subprocess.Popen( # nosec B603 - args built from config
|
|
87
|
+
args,
|
|
88
|
+
cwd=cwd,
|
|
89
|
+
env=spawn_env,
|
|
90
|
+
start_new_session=True,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return SpawnResult(
|
|
94
|
+
success=True,
|
|
95
|
+
message=f"Spawned Ghostty with PID {process.pid}",
|
|
96
|
+
pid=process.pid,
|
|
97
|
+
terminal_type=self.terminal_type.value,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return SpawnResult(
|
|
102
|
+
success=False,
|
|
103
|
+
message=f"Failed to spawn Ghostty: {e}",
|
|
104
|
+
error=str(e),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ITermSpawner(TerminalSpawnerBase):
|
|
109
|
+
"""Spawner for iTerm2 on macOS."""
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def terminal_type(self) -> TerminalType:
|
|
113
|
+
return TerminalType.ITERM
|
|
114
|
+
|
|
115
|
+
def is_available(self) -> bool:
|
|
116
|
+
if platform.system() != "Darwin":
|
|
117
|
+
return False
|
|
118
|
+
config = get_tty_config().get_terminal_config("iterm")
|
|
119
|
+
if not config.enabled:
|
|
120
|
+
return False
|
|
121
|
+
app_path = config.app_path or "/Applications/iTerm.app"
|
|
122
|
+
return Path(app_path).exists()
|
|
123
|
+
|
|
124
|
+
def spawn(
|
|
125
|
+
self,
|
|
126
|
+
command: list[str],
|
|
127
|
+
cwd: str | Path,
|
|
128
|
+
env: dict[str, str] | None = None,
|
|
129
|
+
title: str | None = None,
|
|
130
|
+
) -> SpawnResult:
|
|
131
|
+
try:
|
|
132
|
+
# Write command to a temp script to avoid escaping issues
|
|
133
|
+
# This is the most reliable way to pass complex commands to iTerm
|
|
134
|
+
script_content = "#!/bin/bash\n"
|
|
135
|
+
script_content += f"cd {shlex.quote(str(cwd))}\n"
|
|
136
|
+
if env:
|
|
137
|
+
for k, v in env.items():
|
|
138
|
+
if k.isidentifier():
|
|
139
|
+
script_content += f"export {k}={shlex.quote(v)}\n"
|
|
140
|
+
script_content += shlex.join(command) + "\n"
|
|
141
|
+
script_content += "exit\n" # Exit shell so terminal window closes
|
|
142
|
+
|
|
143
|
+
# Create temp script file
|
|
144
|
+
script_dir = Path(tempfile.gettempdir()) / "gobby-scripts"
|
|
145
|
+
script_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
script_path = script_dir / f"iterm-{os.getpid()}-{id(command)}.sh"
|
|
147
|
+
script_path.write_text(script_content)
|
|
148
|
+
script_path.chmod(0o755)
|
|
149
|
+
|
|
150
|
+
# Check if iTerm was running before we launch it
|
|
151
|
+
# If running: create new window with command
|
|
152
|
+
# If not running: use the default window that gets auto-created
|
|
153
|
+
# Escape script_path to prevent AppleScript injection
|
|
154
|
+
safe_script_path = escape_applescript(str(script_path))
|
|
155
|
+
applescript = f"""
|
|
156
|
+
set iTermWasRunning to application "iTerm" is running
|
|
157
|
+
tell application "iTerm"
|
|
158
|
+
activate
|
|
159
|
+
if iTermWasRunning then
|
|
160
|
+
-- iTerm already running, create a new window
|
|
161
|
+
create window with default profile command "{safe_script_path}"
|
|
162
|
+
else
|
|
163
|
+
-- iTerm just launched, use the default window
|
|
164
|
+
-- Wait for shell to be ready, then exec script (replaces shell so it closes when done)
|
|
165
|
+
delay 0.5
|
|
166
|
+
tell current session of current window
|
|
167
|
+
write text "exec {safe_script_path}"
|
|
168
|
+
end tell
|
|
169
|
+
end if
|
|
170
|
+
end tell
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
process = subprocess.Popen( # nosec B603 - osascript with internal AppleScript
|
|
174
|
+
["/usr/bin/osascript", "-e", applescript],
|
|
175
|
+
start_new_session=True,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return SpawnResult(
|
|
179
|
+
success=True,
|
|
180
|
+
message="Spawned iTerm window",
|
|
181
|
+
pid=process.pid,
|
|
182
|
+
terminal_type=self.terminal_type.value,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
return SpawnResult(
|
|
187
|
+
success=False,
|
|
188
|
+
message=f"Failed to spawn iTerm: {e}",
|
|
189
|
+
error=str(e),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TerminalAppSpawner(TerminalSpawnerBase):
|
|
194
|
+
"""Spawner for Terminal.app on macOS."""
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def terminal_type(self) -> TerminalType:
|
|
198
|
+
return TerminalType.TERMINAL_APP
|
|
199
|
+
|
|
200
|
+
def is_available(self) -> bool:
|
|
201
|
+
if platform.system() != "Darwin":
|
|
202
|
+
return False
|
|
203
|
+
config = get_tty_config().get_terminal_config("terminal.app")
|
|
204
|
+
if not config.enabled:
|
|
205
|
+
return False
|
|
206
|
+
app_path = config.app_path or "/System/Applications/Utilities/Terminal.app"
|
|
207
|
+
return Path(app_path).exists()
|
|
208
|
+
|
|
209
|
+
def spawn(
|
|
210
|
+
self,
|
|
211
|
+
command: list[str],
|
|
212
|
+
cwd: str | Path,
|
|
213
|
+
env: dict[str, str] | None = None,
|
|
214
|
+
title: str | None = None,
|
|
215
|
+
) -> SpawnResult:
|
|
216
|
+
try:
|
|
217
|
+
# Build AppleScript for Terminal.app with proper escaping
|
|
218
|
+
# Shell-quote the command to prevent injection
|
|
219
|
+
cmd_str = shlex.join(command)
|
|
220
|
+
|
|
221
|
+
# Shell-quote environment variable assignments
|
|
222
|
+
env_exports = ""
|
|
223
|
+
if env:
|
|
224
|
+
# Quote both keys and values to prevent injection
|
|
225
|
+
exports = []
|
|
226
|
+
for k, v in env.items():
|
|
227
|
+
# Validate key is a valid shell variable name
|
|
228
|
+
if k.isidentifier():
|
|
229
|
+
exports.append(f"export {k}={shlex.quote(v)};")
|
|
230
|
+
env_exports = " ".join(exports)
|
|
231
|
+
|
|
232
|
+
# Quote cwd for shell
|
|
233
|
+
safe_cwd = shlex.quote(str(cwd))
|
|
234
|
+
|
|
235
|
+
shell_command = f"cd {safe_cwd} && {env_exports} {cmd_str}"
|
|
236
|
+
safe_shell_command = escape_applescript(shell_command)
|
|
237
|
+
|
|
238
|
+
# Check if Terminal was running before we launch it
|
|
239
|
+
# If running: do script creates a new window (correct behavior)
|
|
240
|
+
# If not running: Terminal opens a default window on launch, so we use that
|
|
241
|
+
# instead of calling do script (which would open a second window)
|
|
242
|
+
script = f"""
|
|
243
|
+
set termWasRunning to application "Terminal" is running
|
|
244
|
+
tell application "Terminal"
|
|
245
|
+
activate
|
|
246
|
+
if termWasRunning then
|
|
247
|
+
-- Terminal already running, create a new window
|
|
248
|
+
do script "{safe_shell_command}"
|
|
249
|
+
else
|
|
250
|
+
-- Terminal just launched, use the default window
|
|
251
|
+
-- Wait for shell to be ready, then exec our command
|
|
252
|
+
delay 0.3
|
|
253
|
+
tell front window
|
|
254
|
+
do script "{safe_shell_command}" in selected tab
|
|
255
|
+
end tell
|
|
256
|
+
end if
|
|
257
|
+
end tell
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
process = subprocess.Popen( # nosec B603 - osascript with internal AppleScript
|
|
261
|
+
["/usr/bin/osascript", "-e", script],
|
|
262
|
+
start_new_session=True,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return SpawnResult(
|
|
266
|
+
success=True,
|
|
267
|
+
message="Spawned Terminal.app window",
|
|
268
|
+
pid=process.pid,
|
|
269
|
+
terminal_type=self.terminal_type.value,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
return SpawnResult(
|
|
274
|
+
success=False,
|
|
275
|
+
message=f"Failed to spawn Terminal.app: {e}",
|
|
276
|
+
error=str(e),
|
|
277
|
+
)
|