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
gobby/adapters/codex.py
ADDED
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
"""Codex CLI integration for gobby-daemon.
|
|
2
|
+
|
|
3
|
+
This module provides two integration modes for Codex CLI:
|
|
4
|
+
|
|
5
|
+
1. App-Server Mode (programmatic control):
|
|
6
|
+
- CodexAppServerClient: Spawns `codex app-server` subprocess
|
|
7
|
+
- CodexAdapter: Translates app-server events to HookEvent
|
|
8
|
+
- Full control over threads, turns, and streaming events
|
|
9
|
+
|
|
10
|
+
2. Notify Mode (installed hooks via `gobby install --codex`):
|
|
11
|
+
- CodexNotifyAdapter: Handles HTTP webhooks from Codex notify config
|
|
12
|
+
- Fire-and-forget events on agent-turn-complete
|
|
13
|
+
|
|
14
|
+
Architecture:
|
|
15
|
+
App-Server Mode:
|
|
16
|
+
gobby-daemon
|
|
17
|
+
└── CodexAppServerClient
|
|
18
|
+
├── Spawns: `codex app-server` (stdio subprocess)
|
|
19
|
+
├── Protocol: JSON-RPC 2.0 over stdin/stdout
|
|
20
|
+
└── CodexAdapter (translates events to HookEvent)
|
|
21
|
+
|
|
22
|
+
Notify Mode:
|
|
23
|
+
Codex CLI
|
|
24
|
+
└── notify script (installed by `gobby install --codex`)
|
|
25
|
+
└── HTTP POST to /hooks/execute
|
|
26
|
+
└── CodexNotifyAdapter (translates to HookEvent)
|
|
27
|
+
|
|
28
|
+
See: https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import glob as glob_module
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import platform
|
|
39
|
+
import subprocess # nosec B404 - subprocess needed for Codex app-server process
|
|
40
|
+
import threading
|
|
41
|
+
import uuid
|
|
42
|
+
from collections.abc import AsyncIterator, Callable
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from datetime import UTC, datetime
|
|
45
|
+
from enum import Enum
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
48
|
+
|
|
49
|
+
from gobby.adapters.base import BaseAdapter
|
|
50
|
+
from gobby.hooks.events import HookEvent, HookEventType, HookResponse, SessionSource
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from gobby.hooks.hook_manager import HookManager
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Codex session storage location
|
|
58
|
+
CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# App-Server Data Types
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CodexConnectionState(Enum):
|
|
67
|
+
"""Connection state for the Codex app-server."""
|
|
68
|
+
|
|
69
|
+
DISCONNECTED = "disconnected"
|
|
70
|
+
CONNECTING = "connecting"
|
|
71
|
+
CONNECTED = "connected"
|
|
72
|
+
ERROR = "error"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class CodexThread:
|
|
77
|
+
"""Represents a Codex conversation thread."""
|
|
78
|
+
|
|
79
|
+
id: str
|
|
80
|
+
preview: str = ""
|
|
81
|
+
model_provider: str = "openai"
|
|
82
|
+
created_at: int = 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class CodexTurn:
|
|
87
|
+
"""Represents a turn in a Codex conversation."""
|
|
88
|
+
|
|
89
|
+
id: str
|
|
90
|
+
thread_id: str
|
|
91
|
+
status: str = "pending"
|
|
92
|
+
items: list[dict[str, Any]] = field(default_factory=list)
|
|
93
|
+
error: str | None = None
|
|
94
|
+
usage: dict[str, int] | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class CodexItem:
|
|
99
|
+
"""Represents an item (message, tool call, etc.) in a turn."""
|
|
100
|
+
|
|
101
|
+
id: str
|
|
102
|
+
type: str # "reasoning", "agent_message", "command_execution", "user_message", etc.
|
|
103
|
+
content: str = ""
|
|
104
|
+
status: str = "pending"
|
|
105
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Type alias for notification handlers
|
|
109
|
+
NotificationHandler = Callable[[str, dict[str, Any]], None]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# =============================================================================
|
|
113
|
+
# App-Server Client (Programmatic Control)
|
|
114
|
+
# =============================================================================
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class CodexAppServerClient:
|
|
118
|
+
"""
|
|
119
|
+
Client for the Codex app-server JSON-RPC protocol.
|
|
120
|
+
|
|
121
|
+
Manages the subprocess lifecycle and provides async methods for:
|
|
122
|
+
- Thread management (conversations)
|
|
123
|
+
- Turn management (message exchanges)
|
|
124
|
+
- Event streaming via notifications
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
async with CodexAppServerClient() as client:
|
|
128
|
+
thread = await client.start_thread(cwd="/path/to/project")
|
|
129
|
+
async for event in client.run_turn(thread.id, "Help me refactor"):
|
|
130
|
+
print(event)
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
CLIENT_NAME = "gobby-daemon"
|
|
134
|
+
CLIENT_TITLE = "Gobby Daemon"
|
|
135
|
+
CLIENT_VERSION = "0.1.0"
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
codex_command: str = "codex",
|
|
140
|
+
on_notification: NotificationHandler | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Initialize the Codex app-server client.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
codex_command: Path to the codex binary (default: "codex")
|
|
147
|
+
on_notification: Optional callback for all notifications
|
|
148
|
+
"""
|
|
149
|
+
self._codex_command = codex_command
|
|
150
|
+
self._on_notification = on_notification
|
|
151
|
+
|
|
152
|
+
self._process: subprocess.Popen[str] | None = None
|
|
153
|
+
self._state = CodexConnectionState.DISCONNECTED
|
|
154
|
+
self._request_id = 0
|
|
155
|
+
self._request_id_lock = threading.Lock()
|
|
156
|
+
|
|
157
|
+
# Pending requests waiting for responses
|
|
158
|
+
self._pending_requests: dict[int, asyncio.Future[Any]] = {}
|
|
159
|
+
self._pending_requests_lock = threading.Lock()
|
|
160
|
+
|
|
161
|
+
# Notification handlers by method
|
|
162
|
+
self._notification_handlers: dict[str, list[NotificationHandler]] = {}
|
|
163
|
+
|
|
164
|
+
# Reader task
|
|
165
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
166
|
+
self._shutdown_event = asyncio.Event()
|
|
167
|
+
|
|
168
|
+
# Thread tracking for session management
|
|
169
|
+
self._threads: dict[str, CodexThread] = {}
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def state(self) -> CodexConnectionState:
|
|
173
|
+
"""Get current connection state."""
|
|
174
|
+
return self._state
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def is_connected(self) -> bool:
|
|
178
|
+
"""Check if connected to app-server."""
|
|
179
|
+
return self._state == CodexConnectionState.CONNECTED
|
|
180
|
+
|
|
181
|
+
async def __aenter__(self) -> CodexAppServerClient:
|
|
182
|
+
"""Async context manager entry - starts the app-server."""
|
|
183
|
+
await self.start()
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
async def __aexit__(
|
|
187
|
+
self,
|
|
188
|
+
exc_type: type[BaseException] | None,
|
|
189
|
+
exc_val: BaseException | None,
|
|
190
|
+
exc_tb: object,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Async context manager exit - stops the app-server."""
|
|
193
|
+
await self.stop()
|
|
194
|
+
|
|
195
|
+
async def start(self) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Start the Codex app-server subprocess and initialize connection.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
RuntimeError: If already connected or failed to start
|
|
201
|
+
"""
|
|
202
|
+
if self._state == CodexConnectionState.CONNECTED:
|
|
203
|
+
logger.warning("CodexAppServerClient already connected")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
self._state = CodexConnectionState.CONNECTING
|
|
207
|
+
logger.debug("Starting Codex app-server...")
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# Start the subprocess
|
|
211
|
+
self._process = subprocess.Popen( # nosec B603 - hardcoded argument list
|
|
212
|
+
[self._codex_command, "app-server"],
|
|
213
|
+
stdin=subprocess.PIPE,
|
|
214
|
+
stdout=subprocess.PIPE,
|
|
215
|
+
stderr=subprocess.PIPE,
|
|
216
|
+
text=True,
|
|
217
|
+
bufsize=1, # Line buffered
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Start the reader task
|
|
221
|
+
self._shutdown_event.clear()
|
|
222
|
+
self._reader_task = asyncio.create_task(self._read_loop())
|
|
223
|
+
|
|
224
|
+
# Send initialize request
|
|
225
|
+
result = await self._send_request(
|
|
226
|
+
"initialize",
|
|
227
|
+
{
|
|
228
|
+
"clientInfo": {
|
|
229
|
+
"name": self.CLIENT_NAME,
|
|
230
|
+
"title": self.CLIENT_TITLE,
|
|
231
|
+
"version": self.CLIENT_VERSION,
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
user_agent = result.get("userAgent", "unknown")
|
|
237
|
+
logger.debug(f"Codex app-server initialized: {user_agent}")
|
|
238
|
+
|
|
239
|
+
# Send initialized notification
|
|
240
|
+
await self._send_notification("initialized", {})
|
|
241
|
+
|
|
242
|
+
self._state = CodexConnectionState.CONNECTED
|
|
243
|
+
logger.debug("Codex app-server connection established")
|
|
244
|
+
|
|
245
|
+
except Exception as e:
|
|
246
|
+
self._state = CodexConnectionState.ERROR
|
|
247
|
+
logger.error(f"Failed to start Codex app-server: {e}", exc_info=True)
|
|
248
|
+
await self.stop()
|
|
249
|
+
raise RuntimeError(f"Failed to start Codex app-server: {e}") from e
|
|
250
|
+
|
|
251
|
+
async def stop(self) -> None:
|
|
252
|
+
"""Stop the Codex app-server subprocess."""
|
|
253
|
+
logger.debug("Stopping Codex app-server...")
|
|
254
|
+
|
|
255
|
+
self._shutdown_event.set()
|
|
256
|
+
|
|
257
|
+
# Cancel reader task
|
|
258
|
+
if self._reader_task and not self._reader_task.done():
|
|
259
|
+
self._reader_task.cancel()
|
|
260
|
+
try:
|
|
261
|
+
await self._reader_task
|
|
262
|
+
except asyncio.CancelledError:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# Terminate process
|
|
266
|
+
if self._process:
|
|
267
|
+
try:
|
|
268
|
+
if self._process.stdin:
|
|
269
|
+
self._process.stdin.close()
|
|
270
|
+
self._process.terminate()
|
|
271
|
+
loop = asyncio.get_event_loop()
|
|
272
|
+
await asyncio.wait_for(loop.run_in_executor(None, self._process.wait), timeout=5.0)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.warning(f"Error terminating Codex app-server: {e}")
|
|
275
|
+
self._process.kill()
|
|
276
|
+
finally:
|
|
277
|
+
self._process = None
|
|
278
|
+
|
|
279
|
+
# Cancel pending requests
|
|
280
|
+
with self._pending_requests_lock:
|
|
281
|
+
for future in self._pending_requests.values():
|
|
282
|
+
if not future.done():
|
|
283
|
+
future.cancel()
|
|
284
|
+
self._pending_requests.clear()
|
|
285
|
+
|
|
286
|
+
self._state = CodexConnectionState.DISCONNECTED
|
|
287
|
+
logger.debug("Codex app-server stopped")
|
|
288
|
+
|
|
289
|
+
def add_notification_handler(self, method: str, handler: NotificationHandler) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Register a handler for a specific notification method.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
method: Notification method name (e.g., "turn/started", "item/completed")
|
|
295
|
+
handler: Callback function(method, params)
|
|
296
|
+
"""
|
|
297
|
+
if method not in self._notification_handlers:
|
|
298
|
+
self._notification_handlers[method] = []
|
|
299
|
+
self._notification_handlers[method].append(handler)
|
|
300
|
+
|
|
301
|
+
def remove_notification_handler(self, method: str, handler: NotificationHandler) -> None:
|
|
302
|
+
"""Remove a notification handler."""
|
|
303
|
+
if method in self._notification_handlers:
|
|
304
|
+
self._notification_handlers[method] = [
|
|
305
|
+
h for h in self._notification_handlers[method] if h != handler
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
# ===== Thread Management =====
|
|
309
|
+
|
|
310
|
+
async def start_thread(
|
|
311
|
+
self,
|
|
312
|
+
cwd: str | None = None,
|
|
313
|
+
model: str | None = None,
|
|
314
|
+
approval_policy: str | None = None,
|
|
315
|
+
sandbox: str | None = None,
|
|
316
|
+
) -> CodexThread:
|
|
317
|
+
"""
|
|
318
|
+
Start a new Codex conversation thread.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
cwd: Working directory for the session
|
|
322
|
+
model: Model override (e.g., "gpt-5.1-codex")
|
|
323
|
+
approval_policy: Approval policy ("never", "unlessTrusted", etc.)
|
|
324
|
+
sandbox: Sandbox mode ("workspaceWrite", "readOnly", etc.)
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
CodexThread object with thread ID
|
|
328
|
+
"""
|
|
329
|
+
params: dict[str, Any] = {}
|
|
330
|
+
if cwd:
|
|
331
|
+
params["cwd"] = cwd
|
|
332
|
+
if model:
|
|
333
|
+
params["model"] = model
|
|
334
|
+
if approval_policy:
|
|
335
|
+
params["approvalPolicy"] = approval_policy
|
|
336
|
+
if sandbox:
|
|
337
|
+
params["sandbox"] = sandbox
|
|
338
|
+
|
|
339
|
+
result = await self._send_request("thread/start", params)
|
|
340
|
+
|
|
341
|
+
thread_data = result.get("thread", {})
|
|
342
|
+
thread = CodexThread(
|
|
343
|
+
id=thread_data.get("id", ""),
|
|
344
|
+
preview=thread_data.get("preview", ""),
|
|
345
|
+
model_provider=thread_data.get("modelProvider", "openai"),
|
|
346
|
+
created_at=thread_data.get("createdAt", 0),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
self._threads[thread.id] = thread
|
|
350
|
+
logger.debug(f"Started Codex thread: {thread.id}")
|
|
351
|
+
return thread
|
|
352
|
+
|
|
353
|
+
async def resume_thread(self, thread_id: str) -> CodexThread:
|
|
354
|
+
"""
|
|
355
|
+
Resume an existing Codex conversation thread.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
thread_id: ID of the thread to resume
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
CodexThread object
|
|
362
|
+
"""
|
|
363
|
+
result = await self._send_request("thread/resume", {"threadId": thread_id})
|
|
364
|
+
|
|
365
|
+
thread_data = result.get("thread", {})
|
|
366
|
+
thread = CodexThread(
|
|
367
|
+
id=thread_data.get("id", thread_id),
|
|
368
|
+
preview=thread_data.get("preview", ""),
|
|
369
|
+
model_provider=thread_data.get("modelProvider", "openai"),
|
|
370
|
+
created_at=thread_data.get("createdAt", 0),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
self._threads[thread.id] = thread
|
|
374
|
+
logger.debug(f"Resumed Codex thread: {thread.id}")
|
|
375
|
+
return thread
|
|
376
|
+
|
|
377
|
+
async def list_threads(
|
|
378
|
+
self, cursor: str | None = None, limit: int = 25
|
|
379
|
+
) -> tuple[list[CodexThread], str | None]:
|
|
380
|
+
"""
|
|
381
|
+
List stored Codex threads with pagination.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
cursor: Pagination cursor from previous call
|
|
385
|
+
limit: Maximum threads to return
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Tuple of (threads list, next_cursor or None)
|
|
389
|
+
"""
|
|
390
|
+
params: dict[str, Any] = {"limit": limit}
|
|
391
|
+
if cursor:
|
|
392
|
+
params["cursor"] = cursor
|
|
393
|
+
|
|
394
|
+
result = await self._send_request("thread/list", params)
|
|
395
|
+
|
|
396
|
+
threads = []
|
|
397
|
+
for item in result.get("data", []):
|
|
398
|
+
threads.append(
|
|
399
|
+
CodexThread(
|
|
400
|
+
id=item.get("id", ""),
|
|
401
|
+
preview=item.get("preview", ""),
|
|
402
|
+
model_provider=item.get("modelProvider", "openai"),
|
|
403
|
+
created_at=item.get("createdAt", 0),
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
next_cursor = result.get("nextCursor")
|
|
408
|
+
return threads, next_cursor
|
|
409
|
+
|
|
410
|
+
async def archive_thread(self, thread_id: str) -> None:
|
|
411
|
+
"""
|
|
412
|
+
Archive a Codex thread.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
thread_id: ID of the thread to archive
|
|
416
|
+
"""
|
|
417
|
+
await self._send_request("thread/archive", {"threadId": thread_id})
|
|
418
|
+
self._threads.pop(thread_id, None)
|
|
419
|
+
logger.debug(f"Archived Codex thread: {thread_id}")
|
|
420
|
+
|
|
421
|
+
# ===== Turn Management =====
|
|
422
|
+
|
|
423
|
+
async def start_turn(
|
|
424
|
+
self,
|
|
425
|
+
thread_id: str,
|
|
426
|
+
prompt: str,
|
|
427
|
+
images: list[str] | None = None,
|
|
428
|
+
**config_overrides: Any,
|
|
429
|
+
) -> CodexTurn:
|
|
430
|
+
"""
|
|
431
|
+
Start a new turn (send user input and trigger generation).
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
thread_id: Thread ID to add turn to
|
|
435
|
+
prompt: User's input text
|
|
436
|
+
images: Optional list of image paths or URLs
|
|
437
|
+
**config_overrides: Optional config overrides (cwd, model, etc.)
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
CodexTurn object (initial state, updates via notifications)
|
|
441
|
+
"""
|
|
442
|
+
# Build input array
|
|
443
|
+
inputs: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
|
444
|
+
|
|
445
|
+
if images:
|
|
446
|
+
for img in images:
|
|
447
|
+
if img.startswith(("http://", "https://")):
|
|
448
|
+
inputs.append({"type": "image", "url": img})
|
|
449
|
+
else:
|
|
450
|
+
inputs.append({"type": "localImage", "path": img})
|
|
451
|
+
|
|
452
|
+
params: dict[str, Any] = {
|
|
453
|
+
"threadId": thread_id,
|
|
454
|
+
"input": inputs,
|
|
455
|
+
}
|
|
456
|
+
params.update(config_overrides)
|
|
457
|
+
|
|
458
|
+
result = await self._send_request("turn/start", params)
|
|
459
|
+
|
|
460
|
+
turn_data = result.get("turn", {})
|
|
461
|
+
turn = CodexTurn(
|
|
462
|
+
id=turn_data.get("id", ""),
|
|
463
|
+
thread_id=thread_id,
|
|
464
|
+
status=turn_data.get("status", "inProgress"),
|
|
465
|
+
items=turn_data.get("items", []),
|
|
466
|
+
error=turn_data.get("error"),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
logger.debug(f"Started turn {turn.id} in thread {thread_id}")
|
|
470
|
+
return turn
|
|
471
|
+
|
|
472
|
+
async def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
|
|
473
|
+
"""
|
|
474
|
+
Interrupt an in-progress turn.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
thread_id: Thread ID containing the turn
|
|
478
|
+
turn_id: Turn ID to interrupt
|
|
479
|
+
"""
|
|
480
|
+
await self._send_request("turn/interrupt", {"threadId": thread_id, "turnId": turn_id})
|
|
481
|
+
logger.debug(f"Interrupted turn {turn_id}")
|
|
482
|
+
|
|
483
|
+
async def run_turn(
|
|
484
|
+
self,
|
|
485
|
+
thread_id: str,
|
|
486
|
+
prompt: str,
|
|
487
|
+
images: list[str] | None = None,
|
|
488
|
+
**config_overrides: Any,
|
|
489
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
490
|
+
"""
|
|
491
|
+
Run a turn and yield streaming events.
|
|
492
|
+
|
|
493
|
+
This is the primary method for interacting with Codex. It starts a turn
|
|
494
|
+
and yields all events until completion.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
thread_id: Thread ID
|
|
498
|
+
prompt: User's input text
|
|
499
|
+
images: Optional image paths/URLs
|
|
500
|
+
**config_overrides: Config overrides
|
|
501
|
+
|
|
502
|
+
Yields:
|
|
503
|
+
Event dicts with "type" and event-specific data
|
|
504
|
+
|
|
505
|
+
Example:
|
|
506
|
+
async for event in client.run_turn(thread.id, "Help me refactor"):
|
|
507
|
+
if event["type"] == "item.completed":
|
|
508
|
+
print(event["item"]["text"])
|
|
509
|
+
"""
|
|
510
|
+
# Queue to receive notifications
|
|
511
|
+
event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
512
|
+
turn_completed = asyncio.Event()
|
|
513
|
+
|
|
514
|
+
def on_event(method: str, params: dict[str, Any]) -> None:
|
|
515
|
+
event_queue.put_nowait({"type": method, **params})
|
|
516
|
+
if method == "turn/completed":
|
|
517
|
+
turn_completed.set()
|
|
518
|
+
|
|
519
|
+
# Register handlers for all turn-related events
|
|
520
|
+
event_methods = [
|
|
521
|
+
"turn/started",
|
|
522
|
+
"turn/completed",
|
|
523
|
+
"item/started",
|
|
524
|
+
"item/completed",
|
|
525
|
+
"item/agentMessage/delta",
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
for method in event_methods:
|
|
529
|
+
self.add_notification_handler(method, on_event)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
# Start the turn
|
|
533
|
+
turn = await self.start_turn(thread_id, prompt, images=images, **config_overrides)
|
|
534
|
+
|
|
535
|
+
yield {"type": "turn/created", "turn": turn.__dict__}
|
|
536
|
+
|
|
537
|
+
# Yield events until turn completes
|
|
538
|
+
while not turn_completed.is_set():
|
|
539
|
+
try:
|
|
540
|
+
event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
|
|
541
|
+
yield event
|
|
542
|
+
except TimeoutError:
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
# Drain remaining events
|
|
546
|
+
while not event_queue.empty():
|
|
547
|
+
yield event_queue.get_nowait()
|
|
548
|
+
|
|
549
|
+
finally:
|
|
550
|
+
# Unregister handlers
|
|
551
|
+
for method in event_methods:
|
|
552
|
+
self.remove_notification_handler(method, on_event)
|
|
553
|
+
|
|
554
|
+
# ===== Authentication =====
|
|
555
|
+
|
|
556
|
+
async def login_with_api_key(self, api_key: str) -> dict[str, Any]:
|
|
557
|
+
"""
|
|
558
|
+
Authenticate using an OpenAI API key.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
api_key: OpenAI API key (sk-...)
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
Login result dict
|
|
565
|
+
"""
|
|
566
|
+
result = await self._send_request(
|
|
567
|
+
"account/login/start", {"type": "apiKey", "apiKey": api_key}
|
|
568
|
+
)
|
|
569
|
+
logger.debug("Logged in with API key")
|
|
570
|
+
return result
|
|
571
|
+
|
|
572
|
+
async def get_account_status(self) -> dict[str, Any]:
|
|
573
|
+
"""Get current account/authentication status."""
|
|
574
|
+
return await self._send_request("account/status", {})
|
|
575
|
+
|
|
576
|
+
# ===== Internal Methods =====
|
|
577
|
+
|
|
578
|
+
def _next_request_id(self) -> int:
|
|
579
|
+
"""Generate unique request ID."""
|
|
580
|
+
with self._request_id_lock:
|
|
581
|
+
self._request_id += 1
|
|
582
|
+
return self._request_id
|
|
583
|
+
|
|
584
|
+
async def _send_request(
|
|
585
|
+
self, method: str, params: dict[str, Any], timeout: float = 60.0
|
|
586
|
+
) -> dict[str, Any]:
|
|
587
|
+
"""
|
|
588
|
+
Send a JSON-RPC request and wait for response.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
method: RPC method name
|
|
592
|
+
params: Method parameters
|
|
593
|
+
timeout: Response timeout in seconds
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
Result dict from response
|
|
597
|
+
|
|
598
|
+
Raises:
|
|
599
|
+
RuntimeError: If not connected or request fails
|
|
600
|
+
TimeoutError: If response times out
|
|
601
|
+
"""
|
|
602
|
+
if not self._process or not self._process.stdin:
|
|
603
|
+
raise RuntimeError("Not connected to Codex app-server")
|
|
604
|
+
|
|
605
|
+
request_id = self._next_request_id()
|
|
606
|
+
request = {
|
|
607
|
+
"jsonrpc": "2.0",
|
|
608
|
+
"method": method,
|
|
609
|
+
"id": request_id,
|
|
610
|
+
"params": params,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
# Create future for response
|
|
614
|
+
loop = asyncio.get_event_loop()
|
|
615
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
616
|
+
|
|
617
|
+
with self._pending_requests_lock:
|
|
618
|
+
self._pending_requests[request_id] = future
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
# Send request
|
|
622
|
+
request_line = json.dumps(request) + "\n"
|
|
623
|
+
self._process.stdin.write(request_line)
|
|
624
|
+
self._process.stdin.flush()
|
|
625
|
+
|
|
626
|
+
logger.debug(f"Sent request: {method} (id={request_id})")
|
|
627
|
+
|
|
628
|
+
# Wait for response
|
|
629
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
630
|
+
return cast(dict[str, Any], result)
|
|
631
|
+
|
|
632
|
+
except TimeoutError:
|
|
633
|
+
logger.error(f"Request {method} (id={request_id}) timed out")
|
|
634
|
+
raise
|
|
635
|
+
finally:
|
|
636
|
+
with self._pending_requests_lock:
|
|
637
|
+
self._pending_requests.pop(request_id, None)
|
|
638
|
+
|
|
639
|
+
async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
640
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
641
|
+
if not self._process or not self._process.stdin:
|
|
642
|
+
raise RuntimeError("Not connected to Codex app-server")
|
|
643
|
+
|
|
644
|
+
notification = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
645
|
+
|
|
646
|
+
notification_line = json.dumps(notification) + "\n"
|
|
647
|
+
self._process.stdin.write(notification_line)
|
|
648
|
+
self._process.stdin.flush()
|
|
649
|
+
|
|
650
|
+
logger.debug(f"Sent notification: {method}")
|
|
651
|
+
|
|
652
|
+
async def _read_loop(self) -> None:
|
|
653
|
+
"""Background task to read responses and notifications."""
|
|
654
|
+
if not self._process or not self._process.stdout:
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
loop = asyncio.get_event_loop()
|
|
658
|
+
|
|
659
|
+
while not self._shutdown_event.is_set():
|
|
660
|
+
try:
|
|
661
|
+
# Read line in thread pool to avoid blocking
|
|
662
|
+
line = await loop.run_in_executor(None, self._process.stdout.readline)
|
|
663
|
+
|
|
664
|
+
if not line:
|
|
665
|
+
if self._process.poll() is not None:
|
|
666
|
+
logger.warning("Codex app-server process terminated")
|
|
667
|
+
self._state = CodexConnectionState.ERROR
|
|
668
|
+
break
|
|
669
|
+
continue
|
|
670
|
+
|
|
671
|
+
# Parse JSON-RPC message
|
|
672
|
+
try:
|
|
673
|
+
message = json.loads(line.strip())
|
|
674
|
+
except json.JSONDecodeError as e:
|
|
675
|
+
logger.warning(f"Invalid JSON from app-server: {e}")
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
# Handle response (has "id")
|
|
679
|
+
if "id" in message:
|
|
680
|
+
request_id = message["id"]
|
|
681
|
+
with self._pending_requests_lock:
|
|
682
|
+
future = self._pending_requests.get(request_id)
|
|
683
|
+
|
|
684
|
+
if future and not future.done():
|
|
685
|
+
if "error" in message:
|
|
686
|
+
error = message["error"]
|
|
687
|
+
future.set_exception(
|
|
688
|
+
RuntimeError(
|
|
689
|
+
f"RPC error {error.get('code')}: {error.get('message')}"
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
else:
|
|
693
|
+
future.set_result(message.get("result", {}))
|
|
694
|
+
|
|
695
|
+
# Handle notification (no "id")
|
|
696
|
+
elif "method" in message:
|
|
697
|
+
method = message["method"]
|
|
698
|
+
params = message.get("params", {})
|
|
699
|
+
|
|
700
|
+
logger.debug(f"Received notification: {method}")
|
|
701
|
+
|
|
702
|
+
# Call global handler
|
|
703
|
+
if self._on_notification:
|
|
704
|
+
try:
|
|
705
|
+
self._on_notification(method, params)
|
|
706
|
+
except Exception as e:
|
|
707
|
+
logger.error(f"Notification handler error: {e}")
|
|
708
|
+
|
|
709
|
+
# Call method-specific handlers
|
|
710
|
+
handlers = self._notification_handlers.get(method, [])
|
|
711
|
+
for handler in handlers:
|
|
712
|
+
try:
|
|
713
|
+
handler(method, params)
|
|
714
|
+
except Exception as e:
|
|
715
|
+
logger.error(f"Handler error for {method}: {e}")
|
|
716
|
+
|
|
717
|
+
except asyncio.CancelledError:
|
|
718
|
+
break
|
|
719
|
+
except Exception as e:
|
|
720
|
+
logger.error(f"Error in read loop: {e}", exc_info=True)
|
|
721
|
+
if self._shutdown_event.is_set():
|
|
722
|
+
break
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# =============================================================================
|
|
726
|
+
# Shared Utilities
|
|
727
|
+
# =============================================================================
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _get_machine_id() -> str:
|
|
731
|
+
"""Get or generate a stable machine identifier based on hostname."""
|
|
732
|
+
node = platform.node()
|
|
733
|
+
if node:
|
|
734
|
+
return str(uuid.uuid5(uuid.NAMESPACE_DNS, node))
|
|
735
|
+
return str(uuid.uuid4())
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# =============================================================================
|
|
739
|
+
# App-Server Adapter (for programmatic control)
|
|
740
|
+
# =============================================================================
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class CodexAdapter(BaseAdapter):
|
|
744
|
+
"""Adapter for Codex CLI session tracking via app-server events.
|
|
745
|
+
|
|
746
|
+
This adapter translates Codex app-server events to unified HookEvent
|
|
747
|
+
for session tracking. It can operate in two modes:
|
|
748
|
+
|
|
749
|
+
1. Integrated mode (recommended): Attach to existing CodexAppServerClient
|
|
750
|
+
- Call attach_to_client(codex_client) with the existing client
|
|
751
|
+
- Events are forwarded from the client's notification handlers
|
|
752
|
+
|
|
753
|
+
2. Standalone mode: Use without CodexAppServerClient
|
|
754
|
+
- Only provides translation methods for events received externally
|
|
755
|
+
- No subprocess management (use CodexAppServerClient for that)
|
|
756
|
+
|
|
757
|
+
Lifecycle (integrated mode):
|
|
758
|
+
- attach_to_client(codex_client) registers notification handlers
|
|
759
|
+
- Events processed through HookManager for session registration
|
|
760
|
+
- detach_from_client() removes handlers
|
|
761
|
+
"""
|
|
762
|
+
|
|
763
|
+
source = SessionSource.CODEX
|
|
764
|
+
|
|
765
|
+
# Event type mapping: Codex app-server methods -> unified HookEventType
|
|
766
|
+
EVENT_MAP: dict[str, HookEventType] = {
|
|
767
|
+
"thread/started": HookEventType.SESSION_START,
|
|
768
|
+
"thread/archive": HookEventType.SESSION_END,
|
|
769
|
+
"turn/started": HookEventType.BEFORE_AGENT,
|
|
770
|
+
"turn/completed": HookEventType.AFTER_AGENT,
|
|
771
|
+
# Approval requests map to BEFORE_TOOL
|
|
772
|
+
"item/commandExecution/requestApproval": HookEventType.BEFORE_TOOL,
|
|
773
|
+
"item/fileChange/requestApproval": HookEventType.BEFORE_TOOL,
|
|
774
|
+
# Completed items map to AFTER_TOOL
|
|
775
|
+
"item/completed": HookEventType.AFTER_TOOL,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
# Item types that represent tool operations
|
|
779
|
+
TOOL_ITEM_TYPES = {"commandExecution", "fileChange", "mcpToolCall"}
|
|
780
|
+
|
|
781
|
+
# Events we want to listen for session tracking
|
|
782
|
+
SESSION_TRACKING_EVENTS = [
|
|
783
|
+
"thread/started",
|
|
784
|
+
"turn/started",
|
|
785
|
+
"turn/completed",
|
|
786
|
+
"item/completed",
|
|
787
|
+
]
|
|
788
|
+
|
|
789
|
+
def __init__(self, hook_manager: HookManager | None = None):
|
|
790
|
+
"""Initialize the Codex adapter.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
hook_manager: Reference to HookManager for event processing.
|
|
794
|
+
"""
|
|
795
|
+
self._hook_manager = hook_manager
|
|
796
|
+
self._codex_client: CodexAppServerClient | None = None
|
|
797
|
+
self._machine_id: str | None = None
|
|
798
|
+
self._attached = False
|
|
799
|
+
|
|
800
|
+
@staticmethod
|
|
801
|
+
def is_codex_available() -> bool:
|
|
802
|
+
"""Check if Codex CLI is installed and available.
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
True if `codex` command is found in PATH.
|
|
806
|
+
"""
|
|
807
|
+
import shutil
|
|
808
|
+
|
|
809
|
+
return shutil.which("codex") is not None
|
|
810
|
+
|
|
811
|
+
def _get_machine_id(self) -> str:
|
|
812
|
+
"""Get or generate a machine identifier."""
|
|
813
|
+
if self._machine_id is None:
|
|
814
|
+
self._machine_id = _get_machine_id()
|
|
815
|
+
return self._machine_id
|
|
816
|
+
|
|
817
|
+
def attach_to_client(self, codex_client: CodexAppServerClient) -> None:
|
|
818
|
+
"""Attach to an existing CodexAppServerClient for event handling.
|
|
819
|
+
|
|
820
|
+
Registers notification handlers on the client to receive session
|
|
821
|
+
tracking events. This is the preferred integration mode.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
codex_client: The CodexAppServerClient to attach to.
|
|
825
|
+
"""
|
|
826
|
+
if self._attached:
|
|
827
|
+
logger.warning("CodexAdapter already attached to a client")
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
self._codex_client = codex_client
|
|
831
|
+
|
|
832
|
+
# Register handlers for session tracking events
|
|
833
|
+
for method in self.SESSION_TRACKING_EVENTS:
|
|
834
|
+
codex_client.add_notification_handler(method, self._handle_notification)
|
|
835
|
+
|
|
836
|
+
self._attached = True
|
|
837
|
+
logger.debug("CodexAdapter attached to CodexAppServerClient")
|
|
838
|
+
|
|
839
|
+
def detach_from_client(self) -> None:
|
|
840
|
+
"""Detach from the CodexAppServerClient.
|
|
841
|
+
|
|
842
|
+
Removes notification handlers. Call this before disposing the adapter.
|
|
843
|
+
"""
|
|
844
|
+
if not self._attached or not self._codex_client:
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
# Remove handlers
|
|
848
|
+
for method in self.SESSION_TRACKING_EVENTS:
|
|
849
|
+
self._codex_client.remove_notification_handler(method, self._handle_notification)
|
|
850
|
+
|
|
851
|
+
self._codex_client = None
|
|
852
|
+
self._attached = False
|
|
853
|
+
logger.debug("CodexAdapter detached from CodexAppServerClient")
|
|
854
|
+
|
|
855
|
+
def _handle_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
856
|
+
"""Handle notification from CodexAppServerClient.
|
|
857
|
+
|
|
858
|
+
This is the callback registered with the client for session tracking events.
|
|
859
|
+
"""
|
|
860
|
+
try:
|
|
861
|
+
hook_event = self.translate_to_hook_event({"method": method, "params": params})
|
|
862
|
+
|
|
863
|
+
if hook_event and self._hook_manager:
|
|
864
|
+
# Process through HookManager (fire-and-forget for notifications)
|
|
865
|
+
self._hook_manager.handle(hook_event)
|
|
866
|
+
logger.debug(f"Processed Codex event: {method} -> {hook_event.event_type}")
|
|
867
|
+
except Exception as e:
|
|
868
|
+
logger.error(f"Error handling Codex notification {method}: {e}")
|
|
869
|
+
|
|
870
|
+
def _translate_approval_event(self, method: str, params: dict[str, Any]) -> HookEvent | None:
|
|
871
|
+
"""Translate approval request to HookEvent."""
|
|
872
|
+
if method not in self.EVENT_MAP:
|
|
873
|
+
logger.debug(f"Unknown approval method: {method}")
|
|
874
|
+
return None
|
|
875
|
+
|
|
876
|
+
thread_id = params.get("threadId", "")
|
|
877
|
+
item_id = params.get("itemId", "")
|
|
878
|
+
|
|
879
|
+
# Determine tool name from method
|
|
880
|
+
if "commandExecution" in method:
|
|
881
|
+
tool_name = "Bash"
|
|
882
|
+
tool_input = params.get("parsedCmd", params.get("command", ""))
|
|
883
|
+
elif "fileChange" in method:
|
|
884
|
+
tool_name = "Write"
|
|
885
|
+
tool_input = params.get("changes", [])
|
|
886
|
+
else:
|
|
887
|
+
tool_name = "unknown"
|
|
888
|
+
tool_input = params
|
|
889
|
+
|
|
890
|
+
return HookEvent(
|
|
891
|
+
event_type=HookEventType.BEFORE_TOOL,
|
|
892
|
+
session_id=thread_id,
|
|
893
|
+
source=self.source,
|
|
894
|
+
timestamp=datetime.now(UTC),
|
|
895
|
+
machine_id=self._get_machine_id(),
|
|
896
|
+
data={
|
|
897
|
+
"tool_name": tool_name,
|
|
898
|
+
"tool_input": tool_input,
|
|
899
|
+
"item_id": item_id,
|
|
900
|
+
"turn_id": params.get("turnId", ""),
|
|
901
|
+
"reason": params.get("reason"),
|
|
902
|
+
"risk": params.get("risk"),
|
|
903
|
+
},
|
|
904
|
+
metadata={
|
|
905
|
+
"requires_response": True,
|
|
906
|
+
"item_id": item_id,
|
|
907
|
+
"approval_method": method,
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent | None:
|
|
912
|
+
"""Convert Codex app-server event to unified HookEvent.
|
|
913
|
+
|
|
914
|
+
Codex events come as JSON-RPC notifications:
|
|
915
|
+
{
|
|
916
|
+
"method": "thread/started",
|
|
917
|
+
"params": {
|
|
918
|
+
"thread": {"id": "thr_123", "preview": "...", ...}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
Args:
|
|
923
|
+
native_event: JSON-RPC notification with method and params.
|
|
924
|
+
|
|
925
|
+
Returns:
|
|
926
|
+
Unified HookEvent, or None for unsupported events.
|
|
927
|
+
"""
|
|
928
|
+
method = native_event.get("method", "")
|
|
929
|
+
params = native_event.get("params", {})
|
|
930
|
+
|
|
931
|
+
# Handle different event types
|
|
932
|
+
if method == "thread/started":
|
|
933
|
+
thread = params.get("thread", {})
|
|
934
|
+
return HookEvent(
|
|
935
|
+
event_type=HookEventType.SESSION_START,
|
|
936
|
+
session_id=thread.get("id", ""),
|
|
937
|
+
source=self.source,
|
|
938
|
+
timestamp=self._parse_timestamp(thread.get("createdAt")),
|
|
939
|
+
machine_id=self._get_machine_id(),
|
|
940
|
+
data={
|
|
941
|
+
"preview": thread.get("preview", ""),
|
|
942
|
+
"model_provider": thread.get("modelProvider", ""),
|
|
943
|
+
},
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
if method == "thread/archive":
|
|
947
|
+
return HookEvent(
|
|
948
|
+
event_type=HookEventType.SESSION_END,
|
|
949
|
+
session_id=params.get("threadId", ""),
|
|
950
|
+
source=self.source,
|
|
951
|
+
timestamp=datetime.now(UTC),
|
|
952
|
+
machine_id=self._get_machine_id(),
|
|
953
|
+
data=params,
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
if method == "turn/started":
|
|
957
|
+
turn = params.get("turn", {})
|
|
958
|
+
return HookEvent(
|
|
959
|
+
event_type=HookEventType.BEFORE_AGENT,
|
|
960
|
+
session_id=params.get("threadId", turn.get("id", "")),
|
|
961
|
+
source=self.source,
|
|
962
|
+
timestamp=datetime.now(UTC),
|
|
963
|
+
machine_id=self._get_machine_id(),
|
|
964
|
+
data={
|
|
965
|
+
"turn_id": turn.get("id", ""),
|
|
966
|
+
"status": turn.get("status", ""),
|
|
967
|
+
},
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
if method == "turn/completed":
|
|
971
|
+
turn = params.get("turn", {})
|
|
972
|
+
return HookEvent(
|
|
973
|
+
event_type=HookEventType.AFTER_AGENT,
|
|
974
|
+
session_id=params.get("threadId", turn.get("id", "")),
|
|
975
|
+
source=self.source,
|
|
976
|
+
timestamp=datetime.now(UTC),
|
|
977
|
+
machine_id=self._get_machine_id(),
|
|
978
|
+
data={
|
|
979
|
+
"turn_id": turn.get("id", ""),
|
|
980
|
+
"status": turn.get("status", ""),
|
|
981
|
+
"error": turn.get("error"),
|
|
982
|
+
},
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
if method == "item/completed":
|
|
986
|
+
item = params.get("item", {})
|
|
987
|
+
item_type = item.get("type", "")
|
|
988
|
+
|
|
989
|
+
# Only translate tool-related items
|
|
990
|
+
if item_type in self.TOOL_ITEM_TYPES:
|
|
991
|
+
return HookEvent(
|
|
992
|
+
event_type=HookEventType.AFTER_TOOL,
|
|
993
|
+
session_id=params.get("threadId", ""),
|
|
994
|
+
source=self.source,
|
|
995
|
+
timestamp=datetime.now(UTC),
|
|
996
|
+
machine_id=self._get_machine_id(),
|
|
997
|
+
data={
|
|
998
|
+
"item_id": item.get("id", ""),
|
|
999
|
+
"item_type": item_type,
|
|
1000
|
+
"status": item.get("status", ""),
|
|
1001
|
+
},
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
# Unknown/unsupported event
|
|
1005
|
+
logger.debug(f"Unsupported Codex event: {method}")
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
def translate_from_hook_response(
|
|
1009
|
+
self, response: HookResponse, hook_type: str | None = None
|
|
1010
|
+
) -> dict[str, Any]:
|
|
1011
|
+
"""Convert HookResponse to Codex approval response format.
|
|
1012
|
+
|
|
1013
|
+
Codex expects approval responses as:
|
|
1014
|
+
{
|
|
1015
|
+
"decision": "accept" | "decline"
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
Args:
|
|
1019
|
+
response: Unified HookResponse.
|
|
1020
|
+
hook_type: Original Codex method (unused, kept for interface).
|
|
1021
|
+
|
|
1022
|
+
Returns:
|
|
1023
|
+
Dict with decision field.
|
|
1024
|
+
"""
|
|
1025
|
+
return {
|
|
1026
|
+
"decision": "accept" if response.decision != "deny" else "decline",
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
def _parse_timestamp(self, unix_ts: int | float | None) -> datetime:
|
|
1030
|
+
"""Parse Unix timestamp to datetime.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
unix_ts: Unix timestamp (seconds).
|
|
1034
|
+
|
|
1035
|
+
Returns:
|
|
1036
|
+
datetime object, or now() if parsing fails.
|
|
1037
|
+
"""
|
|
1038
|
+
if unix_ts:
|
|
1039
|
+
try:
|
|
1040
|
+
return datetime.fromtimestamp(unix_ts)
|
|
1041
|
+
except (ValueError, OSError):
|
|
1042
|
+
pass
|
|
1043
|
+
return datetime.now(UTC)
|
|
1044
|
+
|
|
1045
|
+
async def sync_existing_sessions(self) -> int:
|
|
1046
|
+
"""Sync existing Codex threads to platform sessions.
|
|
1047
|
+
|
|
1048
|
+
Uses the attached CodexAppServerClient to list threads and registers
|
|
1049
|
+
them as sessions via HookManager.
|
|
1050
|
+
|
|
1051
|
+
Requires:
|
|
1052
|
+
- CodexAdapter attached to a CodexAppServerClient
|
|
1053
|
+
- CodexAppServerClient is connected
|
|
1054
|
+
- HookManager is set
|
|
1055
|
+
|
|
1056
|
+
Returns:
|
|
1057
|
+
Number of threads synced.
|
|
1058
|
+
"""
|
|
1059
|
+
if not self._hook_manager:
|
|
1060
|
+
logger.warning("No hook_manager - cannot sync sessions")
|
|
1061
|
+
return 0
|
|
1062
|
+
|
|
1063
|
+
if not self._codex_client:
|
|
1064
|
+
logger.warning("No CodexAppServerClient attached - cannot sync sessions")
|
|
1065
|
+
return 0
|
|
1066
|
+
|
|
1067
|
+
if not self._codex_client.is_connected:
|
|
1068
|
+
logger.warning("CodexAppServerClient not connected - cannot sync sessions")
|
|
1069
|
+
return 0
|
|
1070
|
+
|
|
1071
|
+
try:
|
|
1072
|
+
# Use CodexAppServerClient to list threads
|
|
1073
|
+
all_threads = []
|
|
1074
|
+
cursor = None
|
|
1075
|
+
|
|
1076
|
+
while True:
|
|
1077
|
+
threads, next_cursor = await self._codex_client.list_threads(
|
|
1078
|
+
cursor=cursor, limit=100
|
|
1079
|
+
)
|
|
1080
|
+
all_threads.extend(threads)
|
|
1081
|
+
|
|
1082
|
+
if not next_cursor:
|
|
1083
|
+
break
|
|
1084
|
+
cursor = next_cursor
|
|
1085
|
+
|
|
1086
|
+
synced = 0
|
|
1087
|
+
for thread in all_threads:
|
|
1088
|
+
try:
|
|
1089
|
+
event = HookEvent(
|
|
1090
|
+
event_type=HookEventType.SESSION_START,
|
|
1091
|
+
session_id=thread.id,
|
|
1092
|
+
source=self.source,
|
|
1093
|
+
timestamp=self._parse_timestamp(thread.created_at),
|
|
1094
|
+
machine_id=self._get_machine_id(),
|
|
1095
|
+
data={
|
|
1096
|
+
"preview": thread.preview,
|
|
1097
|
+
"model_provider": thread.model_provider,
|
|
1098
|
+
"synced_from_existing": True,
|
|
1099
|
+
},
|
|
1100
|
+
)
|
|
1101
|
+
self._hook_manager.handle(event)
|
|
1102
|
+
synced += 1
|
|
1103
|
+
except Exception as e:
|
|
1104
|
+
logger.error(f"Failed to sync thread {thread.id}: {e}")
|
|
1105
|
+
|
|
1106
|
+
logger.debug(f"Synced {synced} existing Codex threads")
|
|
1107
|
+
return synced
|
|
1108
|
+
|
|
1109
|
+
except Exception as e:
|
|
1110
|
+
logger.error(f"Failed to sync existing sessions: {e}")
|
|
1111
|
+
return 0
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
# =============================================================================
|
|
1115
|
+
# Notify Adapter (for installed hooks via `gobby install --codex`)
|
|
1116
|
+
# =============================================================================
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
class CodexNotifyAdapter(BaseAdapter):
|
|
1120
|
+
"""Adapter for Codex CLI notify events.
|
|
1121
|
+
|
|
1122
|
+
Translates notify payloads to unified HookEvent format.
|
|
1123
|
+
The notify hook only fires on `agent-turn-complete`, so we:
|
|
1124
|
+
- Treat first event for a thread as session start + prompt submit
|
|
1125
|
+
- Track thread IDs to avoid duplicate session registration
|
|
1126
|
+
|
|
1127
|
+
This adapter handles events from the hook_dispatcher.py script installed
|
|
1128
|
+
by `gobby install --codex`.
|
|
1129
|
+
"""
|
|
1130
|
+
|
|
1131
|
+
source = SessionSource.CODEX
|
|
1132
|
+
|
|
1133
|
+
def __init__(self, hook_manager: HookManager | None = None):
|
|
1134
|
+
"""Initialize the adapter.
|
|
1135
|
+
|
|
1136
|
+
Args:
|
|
1137
|
+
hook_manager: Optional HookManager reference.
|
|
1138
|
+
"""
|
|
1139
|
+
self._hook_manager = hook_manager
|
|
1140
|
+
self._machine_id: str | None = None
|
|
1141
|
+
# Track threads we've seen to avoid re-registering
|
|
1142
|
+
self._seen_threads: set[str] = set()
|
|
1143
|
+
|
|
1144
|
+
def _get_machine_id(self) -> str:
|
|
1145
|
+
"""Get or generate a machine identifier."""
|
|
1146
|
+
if self._machine_id is None:
|
|
1147
|
+
self._machine_id = _get_machine_id()
|
|
1148
|
+
return self._machine_id
|
|
1149
|
+
|
|
1150
|
+
def _find_jsonl_path(self, thread_id: str) -> str | None:
|
|
1151
|
+
"""Find the Codex session JSONL file for a thread.
|
|
1152
|
+
|
|
1153
|
+
Codex stores sessions at: ~/.codex/sessions/YYYY/MM/DD/rollout-{timestamp}-{thread-id}.jsonl
|
|
1154
|
+
|
|
1155
|
+
Args:
|
|
1156
|
+
thread_id: The Codex thread ID
|
|
1157
|
+
|
|
1158
|
+
Returns:
|
|
1159
|
+
Path to the JSONL file, or None if not found
|
|
1160
|
+
"""
|
|
1161
|
+
if not CODEX_SESSIONS_DIR.exists():
|
|
1162
|
+
return None
|
|
1163
|
+
|
|
1164
|
+
# Search for file ending with thread-id.jsonl
|
|
1165
|
+
# Escape special glob characters in thread_id
|
|
1166
|
+
safe_thread_id = glob_module.escape(thread_id)
|
|
1167
|
+
pattern = str(CODEX_SESSIONS_DIR / "**" / f"*{safe_thread_id}.jsonl")
|
|
1168
|
+
matches = glob_module.glob(pattern, recursive=True)
|
|
1169
|
+
|
|
1170
|
+
if matches:
|
|
1171
|
+
# Return the most recent match (in case of duplicates)
|
|
1172
|
+
return max(matches, key=os.path.getmtime)
|
|
1173
|
+
return None
|
|
1174
|
+
|
|
1175
|
+
def _get_first_prompt(self, input_messages: list[Any]) -> str | None:
|
|
1176
|
+
"""Extract the first user prompt from input_messages.
|
|
1177
|
+
|
|
1178
|
+
Args:
|
|
1179
|
+
input_messages: List of user messages from Codex
|
|
1180
|
+
|
|
1181
|
+
Returns:
|
|
1182
|
+
First prompt string, or None
|
|
1183
|
+
"""
|
|
1184
|
+
if input_messages and isinstance(input_messages, list) and len(input_messages) > 0:
|
|
1185
|
+
first = input_messages[0]
|
|
1186
|
+
if isinstance(first, str):
|
|
1187
|
+
return first
|
|
1188
|
+
elif isinstance(first, dict):
|
|
1189
|
+
return first.get("text") or first.get("content")
|
|
1190
|
+
return None
|
|
1191
|
+
|
|
1192
|
+
def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent | None:
|
|
1193
|
+
"""Convert Codex notify payload to HookEvent.
|
|
1194
|
+
|
|
1195
|
+
The native_event structure from /hooks/execute:
|
|
1196
|
+
{
|
|
1197
|
+
"hook_type": "AgentTurnComplete",
|
|
1198
|
+
"input_data": {
|
|
1199
|
+
"session_id": "thread-id",
|
|
1200
|
+
"event_type": "agent-turn-complete",
|
|
1201
|
+
"last_message": "...",
|
|
1202
|
+
"input_messages": [...],
|
|
1203
|
+
"cwd": "/path/to/project",
|
|
1204
|
+
"turn_id": "1"
|
|
1205
|
+
},
|
|
1206
|
+
"source": "codex"
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
Args:
|
|
1210
|
+
native_event: The payload from the HTTP endpoint.
|
|
1211
|
+
|
|
1212
|
+
Returns:
|
|
1213
|
+
HookEvent for processing, or None if unsupported.
|
|
1214
|
+
"""
|
|
1215
|
+
input_data = native_event.get("input_data", {})
|
|
1216
|
+
thread_id = input_data.get("session_id", "")
|
|
1217
|
+
event_type = input_data.get("event_type", "unknown")
|
|
1218
|
+
input_messages = input_data.get("input_messages", [])
|
|
1219
|
+
cwd = input_data.get("cwd") or os.getcwd()
|
|
1220
|
+
|
|
1221
|
+
if not thread_id:
|
|
1222
|
+
logger.warning("Codex notify event missing thread_id")
|
|
1223
|
+
return None
|
|
1224
|
+
|
|
1225
|
+
# Find the JSONL transcript file
|
|
1226
|
+
jsonl_path = self._find_jsonl_path(thread_id)
|
|
1227
|
+
|
|
1228
|
+
# Track if this is the first event for this thread (for title synthesis)
|
|
1229
|
+
is_first_event = thread_id not in self._seen_threads
|
|
1230
|
+
if is_first_event:
|
|
1231
|
+
self._seen_threads.add(thread_id)
|
|
1232
|
+
|
|
1233
|
+
# Get first prompt for title synthesis (only on first event)
|
|
1234
|
+
first_prompt = self._get_first_prompt(input_messages) if is_first_event else None
|
|
1235
|
+
|
|
1236
|
+
# All Codex notify events are AFTER_AGENT (turn complete)
|
|
1237
|
+
# The HookManager will auto-register the session if it doesn't exist
|
|
1238
|
+
return HookEvent(
|
|
1239
|
+
event_type=HookEventType.AFTER_AGENT,
|
|
1240
|
+
session_id=thread_id,
|
|
1241
|
+
source=self.source,
|
|
1242
|
+
timestamp=datetime.now(UTC),
|
|
1243
|
+
machine_id=self._get_machine_id(),
|
|
1244
|
+
data={
|
|
1245
|
+
"cwd": cwd,
|
|
1246
|
+
"event_type": event_type,
|
|
1247
|
+
"last_message": input_data.get("last_message", ""),
|
|
1248
|
+
"input_messages": input_messages,
|
|
1249
|
+
"transcript_path": jsonl_path,
|
|
1250
|
+
"is_first_event": is_first_event,
|
|
1251
|
+
"prompt": first_prompt, # For title synthesis on first event
|
|
1252
|
+
},
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
def translate_from_hook_response(
|
|
1256
|
+
self, response: HookResponse, hook_type: str | None = None
|
|
1257
|
+
) -> dict[str, Any]:
|
|
1258
|
+
"""Convert HookResponse to Codex-expected format.
|
|
1259
|
+
|
|
1260
|
+
Codex notify doesn't expect a response - it's fire-and-forget.
|
|
1261
|
+
This just returns a simple status dict for logging.
|
|
1262
|
+
|
|
1263
|
+
Args:
|
|
1264
|
+
response: The HookResponse from HookManager.
|
|
1265
|
+
hook_type: Ignored (notify doesn't need response routing).
|
|
1266
|
+
|
|
1267
|
+
Returns:
|
|
1268
|
+
Simple status dict.
|
|
1269
|
+
"""
|
|
1270
|
+
return {
|
|
1271
|
+
"status": "processed",
|
|
1272
|
+
"decision": response.decision,
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
def handle_native(
|
|
1276
|
+
self, native_event: dict[str, Any], hook_manager: HookManager
|
|
1277
|
+
) -> dict[str, Any]:
|
|
1278
|
+
"""Process native Codex notify event.
|
|
1279
|
+
|
|
1280
|
+
Args:
|
|
1281
|
+
native_event: The payload from HTTP endpoint.
|
|
1282
|
+
hook_manager: HookManager instance for processing.
|
|
1283
|
+
|
|
1284
|
+
Returns:
|
|
1285
|
+
Response dict.
|
|
1286
|
+
"""
|
|
1287
|
+
hook_event = self.translate_to_hook_event(native_event)
|
|
1288
|
+
if not hook_event:
|
|
1289
|
+
return {"status": "skipped", "message": "Unsupported event"}
|
|
1290
|
+
|
|
1291
|
+
hook_response = hook_manager.handle(hook_event)
|
|
1292
|
+
return self.translate_from_hook_response(hook_response)
|