@trac3er/oh-my-god 2.0.0 → 2.0.2
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.
- package/.claude-plugin/marketplace.json +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/pr_generator.py +404 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- package/tools/web_search.py +622 -0
- package/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Kimi Code CLI provider -- implements CLIProvider for the ``kimi`` binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from runtime.cli_provider import CLIProvider, register_provider
|
|
15
|
+
from runtime.tmux_session_manager import TmuxSessionManager
|
|
16
|
+
|
|
17
|
+
_logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KimiCodeProvider(CLIProvider):
|
|
21
|
+
"""CLIProvider implementation for the Kimi Code CLI (``kimi``)."""
|
|
22
|
+
|
|
23
|
+
# -- identity -----------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def get_name(self) -> str: # noqa: D401
|
|
26
|
+
"""Return the canonical provider name."""
|
|
27
|
+
return "kimi"
|
|
28
|
+
|
|
29
|
+
# -- detection ----------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def detect(self) -> bool:
|
|
32
|
+
"""Return ``True`` when the ``kimi`` binary is available on PATH."""
|
|
33
|
+
return shutil.which("kimi") is not None
|
|
34
|
+
|
|
35
|
+
# -- authentication -----------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def check_auth(self) -> tuple[bool | None, str]:
|
|
38
|
+
"""Check Kimi authentication by parsing ``~/.kimi/config.toml`` for stored credentials."""
|
|
39
|
+
try:
|
|
40
|
+
config_path = os.path.expanduser("~/.kimi/config.toml")
|
|
41
|
+
if not os.path.exists(config_path):
|
|
42
|
+
return False, "not authenticated — config file not found"
|
|
43
|
+
|
|
44
|
+
with open(config_path) as fh:
|
|
45
|
+
content = fh.read()
|
|
46
|
+
|
|
47
|
+
# Look for a token entry in the TOML file
|
|
48
|
+
if "token" in content:
|
|
49
|
+
return True, "authenticated"
|
|
50
|
+
return False, "not authenticated — no token in config"
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
return None, f"kimi auth check failed: {exc}"
|
|
53
|
+
|
|
54
|
+
# -- invocation ---------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def invoke(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
57
|
+
"""Invoke ``kimi --print -p`` via subprocess."""
|
|
58
|
+
try:
|
|
59
|
+
result = self.run_tool(
|
|
60
|
+
["kimi", "--print", "-p", prompt],
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
)
|
|
63
|
+
return {
|
|
64
|
+
"model": "kimi-cli",
|
|
65
|
+
"output": result.stdout,
|
|
66
|
+
"exit_code": result.returncode,
|
|
67
|
+
}
|
|
68
|
+
except subprocess.TimeoutExpired:
|
|
69
|
+
return {"error": "kimi-cli timeout", "fallback": "claude"}
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return {"error": "kimi-cli not found", "fallback": "claude"}
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
return {"error": str(exc), "fallback": "claude"}
|
|
74
|
+
|
|
75
|
+
def invoke_json(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
76
|
+
"""Invoke ``kimi --print --output-format stream-json -p`` for JSONL event stream output."""
|
|
77
|
+
try:
|
|
78
|
+
result = self.run_tool(
|
|
79
|
+
["kimi", "--print", "--output-format", "stream-json", "-p", prompt],
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
)
|
|
82
|
+
return {
|
|
83
|
+
"model": "kimi-cli",
|
|
84
|
+
"output": result.stdout,
|
|
85
|
+
"exit_code": result.returncode,
|
|
86
|
+
}
|
|
87
|
+
except subprocess.TimeoutExpired:
|
|
88
|
+
return {"error": "kimi-cli timeout", "fallback": "claude"}
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
return {"error": "kimi-cli not found", "fallback": "claude"}
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
return {"error": str(exc), "fallback": "claude"}
|
|
93
|
+
|
|
94
|
+
def invoke_tmux(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
95
|
+
"""Invoke ``kimi --print -p`` via a persistent tmux session.
|
|
96
|
+
|
|
97
|
+
Falls back to :meth:`invoke` on failure.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
mgr = TmuxSessionManager()
|
|
101
|
+
session_name = mgr.make_session_name("kimi", unique_id=str(uuid.uuid4())[:8])
|
|
102
|
+
session = mgr.get_or_create_session(session_name)
|
|
103
|
+
output = mgr.send_command(session, f"kimi --print -p {shlex.quote(prompt)}", timeout=timeout)
|
|
104
|
+
mgr.kill_session(session)
|
|
105
|
+
return {"model": "kimi-cli", "output": output, "exit_code": 0}
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
_logger.warning("tmux kimi invocation failed, falling back to subprocess: %s", exc)
|
|
108
|
+
return self.invoke(prompt, project_dir, timeout=timeout)
|
|
109
|
+
|
|
110
|
+
# -- command helpers ----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def get_non_interactive_cmd(self, prompt: str) -> list[str]:
|
|
113
|
+
"""Return the non-interactive command for kimi."""
|
|
114
|
+
return ["kimi", "--print", "-p", prompt]
|
|
115
|
+
|
|
116
|
+
# -- configuration ------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def get_config_path(self) -> str:
|
|
119
|
+
"""Return the Kimi MCP configuration file path."""
|
|
120
|
+
return os.path.expanduser("~/.kimi/mcp.json")
|
|
121
|
+
|
|
122
|
+
def write_mcp_config(self, server_url: str, server_name: str = "memory-server") -> None:
|
|
123
|
+
"""Write an MCP server entry to ``~/.kimi/mcp.json``.
|
|
124
|
+
|
|
125
|
+
Uses standard ``mcpServers`` JSON format with ``type: "http"`` and ``url`` field,
|
|
126
|
+
merging into any existing configuration.
|
|
127
|
+
"""
|
|
128
|
+
config_path = self.get_config_path()
|
|
129
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
130
|
+
|
|
131
|
+
# Load existing config or start fresh
|
|
132
|
+
existing: dict[str, Any] = {} # pyright: ignore[reportExplicitAny]
|
|
133
|
+
if os.path.exists(config_path):
|
|
134
|
+
with open(config_path) as fh:
|
|
135
|
+
try:
|
|
136
|
+
existing = json.load(fh)
|
|
137
|
+
except (json.JSONDecodeError, ValueError):
|
|
138
|
+
existing = {}
|
|
139
|
+
|
|
140
|
+
# Ensure mcpServers dict exists
|
|
141
|
+
if "mcpServers" not in existing:
|
|
142
|
+
existing["mcpServers"] = {}
|
|
143
|
+
|
|
144
|
+
existing["mcpServers"][server_name] = {"type": "http", "url": server_url}
|
|
145
|
+
|
|
146
|
+
with open(config_path, "w") as fh:
|
|
147
|
+
json.dump(existing, fh, indent=2)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# -- auto-register on import -----------------------------------------------
|
|
151
|
+
register_provider(KimiCodeProvider())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""OpenCode CLI provider -- implements CLIProvider for the ``opencode`` binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from runtime.cli_provider import CLIProvider, register_provider
|
|
15
|
+
from runtime.tmux_session_manager import TmuxSessionManager
|
|
16
|
+
|
|
17
|
+
_logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenCodeProvider(CLIProvider):
|
|
21
|
+
"""CLIProvider implementation for the OpenCode CLI (``opencode``)."""
|
|
22
|
+
|
|
23
|
+
# -- identity -----------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def get_name(self) -> str: # noqa: D401
|
|
26
|
+
"""Return the canonical provider name."""
|
|
27
|
+
return "opencode"
|
|
28
|
+
|
|
29
|
+
# -- detection ----------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def detect(self) -> bool:
|
|
32
|
+
"""Return ``True`` when the ``opencode`` binary is available on PATH."""
|
|
33
|
+
return shutil.which("opencode") is not None
|
|
34
|
+
|
|
35
|
+
# -- authentication -----------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def check_auth(self) -> tuple[bool | None, str]:
|
|
38
|
+
"""Check OpenCode authentication status via ``opencode auth list``."""
|
|
39
|
+
try:
|
|
40
|
+
result = self.run_tool(["opencode", "auth", "list"], timeout=30)
|
|
41
|
+
if result.returncode == 0:
|
|
42
|
+
return True, result.stdout.strip()
|
|
43
|
+
return False, result.stderr.strip() or result.stdout.strip()
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
return None, f"opencode auth check failed: {exc}"
|
|
46
|
+
|
|
47
|
+
# -- invocation ---------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def invoke(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
50
|
+
"""Invoke ``opencode run`` via subprocess."""
|
|
51
|
+
try:
|
|
52
|
+
result = self.run_tool(
|
|
53
|
+
["opencode", "run", prompt],
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
return {
|
|
57
|
+
"model": "opencode-cli",
|
|
58
|
+
"output": result.stdout,
|
|
59
|
+
"exit_code": result.returncode,
|
|
60
|
+
}
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
return {"error": "opencode-cli timeout", "fallback": "claude"}
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return {"error": "opencode-cli not found", "fallback": "claude"}
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
return {"error": str(exc), "fallback": "claude"}
|
|
67
|
+
|
|
68
|
+
def invoke_json(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
69
|
+
"""Invoke ``opencode run --format json`` for raw JSON event stream output."""
|
|
70
|
+
try:
|
|
71
|
+
result = self.run_tool(
|
|
72
|
+
["opencode", "run", "--format", "json", prompt],
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
)
|
|
75
|
+
return {
|
|
76
|
+
"model": "opencode-cli",
|
|
77
|
+
"output": result.stdout,
|
|
78
|
+
"exit_code": result.returncode,
|
|
79
|
+
}
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
return {"error": "opencode-cli timeout", "fallback": "claude"}
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return {"error": "opencode-cli not found", "fallback": "claude"}
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
return {"error": str(exc), "fallback": "claude"}
|
|
86
|
+
|
|
87
|
+
def invoke_tmux(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
88
|
+
"""Invoke ``opencode run`` via a persistent tmux session.
|
|
89
|
+
|
|
90
|
+
Falls back to :meth:`invoke` on failure.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
mgr = TmuxSessionManager()
|
|
94
|
+
session_name = mgr.make_session_name("opencode", unique_id=str(uuid.uuid4())[:8])
|
|
95
|
+
session = mgr.get_or_create_session(session_name)
|
|
96
|
+
output = mgr.send_command(session, f"opencode run {shlex.quote(prompt)}", timeout=timeout)
|
|
97
|
+
mgr.kill_session(session)
|
|
98
|
+
return {"model": "opencode-cli", "output": output, "exit_code": 0}
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
_logger.warning("tmux opencode invocation failed, falling back to subprocess: %s", exc)
|
|
101
|
+
return self.invoke(prompt, project_dir, timeout=timeout)
|
|
102
|
+
|
|
103
|
+
# -- command helpers ----------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def get_non_interactive_cmd(self, prompt: str) -> list[str]:
|
|
106
|
+
"""Return the non-interactive command for opencode."""
|
|
107
|
+
return ["opencode", "run", prompt]
|
|
108
|
+
|
|
109
|
+
# -- configuration ------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def get_config_path(self) -> str:
|
|
112
|
+
"""Return the OpenCode configuration file path."""
|
|
113
|
+
return os.path.expanduser("~/.config/opencode/opencode.json")
|
|
114
|
+
|
|
115
|
+
def write_mcp_config(self, server_url: str, server_name: str = "memory-server") -> None:
|
|
116
|
+
"""Write an MCP server entry to ``~/.config/opencode/opencode.json``.
|
|
117
|
+
|
|
118
|
+
Uses JSON format with ``mcp`` key, ``type: "remote"``, and ``url`` field,
|
|
119
|
+
merging into any existing configuration.
|
|
120
|
+
"""
|
|
121
|
+
config_path = self.get_config_path()
|
|
122
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
123
|
+
|
|
124
|
+
# Load existing config or start fresh
|
|
125
|
+
existing: dict[str, Any] = {} # pyright: ignore[reportExplicitAny]
|
|
126
|
+
if os.path.exists(config_path):
|
|
127
|
+
with open(config_path) as fh:
|
|
128
|
+
try:
|
|
129
|
+
existing = json.load(fh)
|
|
130
|
+
except (json.JSONDecodeError, ValueError):
|
|
131
|
+
existing = {}
|
|
132
|
+
|
|
133
|
+
# Ensure mcp dict exists
|
|
134
|
+
if "mcp" not in existing:
|
|
135
|
+
existing["mcp"] = {}
|
|
136
|
+
|
|
137
|
+
existing["mcp"][server_name] = {"type": "remote", "url": server_url}
|
|
138
|
+
|
|
139
|
+
with open(config_path, "w") as fh:
|
|
140
|
+
json.dump(existing, fh, indent=2)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# -- auto-register on import -----------------------------------------------
|
|
144
|
+
register_provider(OpenCodeProvider())
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Parallel Execution Backend — concurrent subagent job management.
|
|
2
|
+
|
|
3
|
+
Manages concurrent subagent jobs with isolation, artifact streaming,
|
|
4
|
+
and a 100-job limit using stdlib ThreadPoolExecutor.
|
|
5
|
+
|
|
6
|
+
Feature flag: OMG_PARALLEL_SUBAGENTS_ENABLED (default: False)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
import threading
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# --- Path resolution (never relies on CWD) ---
|
|
20
|
+
_DISPATCHER_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
+
_OMG_ROOT = os.path.dirname(_DISPATCHER_DIR)
|
|
22
|
+
|
|
23
|
+
# --- Constants ---
|
|
24
|
+
MAX_JOBS = 100
|
|
25
|
+
|
|
26
|
+
# --- Module-level singletons ---
|
|
27
|
+
_executor: ThreadPoolExecutor | None = None
|
|
28
|
+
_jobs: dict[str, dict[str, Any]] = {}
|
|
29
|
+
_lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_feature_flag() -> Any:
|
|
33
|
+
"""Lazy-import get_feature_flag from hooks/_common.py."""
|
|
34
|
+
hooks_dir = os.path.join(_OMG_ROOT, "hooks")
|
|
35
|
+
if hooks_dir not in sys.path:
|
|
36
|
+
sys.path.insert(0, hooks_dir)
|
|
37
|
+
try:
|
|
38
|
+
from _common import get_feature_flag # pyright: ignore[reportMissingImports]
|
|
39
|
+
return get_feature_flag
|
|
40
|
+
except ImportError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_atomic_json_write() -> Any:
|
|
45
|
+
"""Lazy-import atomic_json_write from hooks/_common.py."""
|
|
46
|
+
hooks_dir = os.path.join(_OMG_ROOT, "hooks")
|
|
47
|
+
if hooks_dir not in sys.path:
|
|
48
|
+
sys.path.insert(0, hooks_dir)
|
|
49
|
+
try:
|
|
50
|
+
from _common import atomic_json_write # pyright: ignore[reportMissingImports]
|
|
51
|
+
return atomic_json_write
|
|
52
|
+
except ImportError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_enabled() -> bool:
|
|
57
|
+
"""Check if parallel subagents feature is enabled.
|
|
58
|
+
|
|
59
|
+
Resolution: env var OMG_PARALLEL_SUBAGENTS_ENABLED → settings.json → default False.
|
|
60
|
+
"""
|
|
61
|
+
# Fast path: check env var directly
|
|
62
|
+
env_val = os.environ.get("OMG_PARALLEL_SUBAGENTS_ENABLED", "").lower()
|
|
63
|
+
if env_val in ("0", "false", "no"):
|
|
64
|
+
return False
|
|
65
|
+
if env_val in ("1", "true", "yes"):
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
# Slow path: check via get_feature_flag
|
|
69
|
+
get_flag = _get_feature_flag()
|
|
70
|
+
if get_flag is not None:
|
|
71
|
+
return get_flag("PARALLEL_SUBAGENTS", default=False)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_project_dir() -> str:
|
|
76
|
+
"""Get project directory from env or cwd."""
|
|
77
|
+
return os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _jobs_dir() -> str:
|
|
81
|
+
"""Return the jobs state directory path."""
|
|
82
|
+
return os.path.join(_get_project_dir(), ".omg", "state", "jobs")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _job_path(job_id: str) -> str:
|
|
86
|
+
"""Return the file path for a specific job."""
|
|
87
|
+
return os.path.join(_jobs_dir(), f"{job_id}.json")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _persist_job(job_id: str, record: dict[str, Any]) -> None:
|
|
91
|
+
"""Persist job record to disk via atomic_json_write."""
|
|
92
|
+
writer = _get_atomic_json_write()
|
|
93
|
+
if writer is not None:
|
|
94
|
+
writer(_job_path(job_id), record)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_job_from_disk(job_id: str) -> dict[str, Any] | None:
|
|
98
|
+
"""Load a job record from disk if it exists."""
|
|
99
|
+
path = _job_path(job_id)
|
|
100
|
+
if not os.path.exists(path):
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
import json
|
|
104
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
105
|
+
return json.load(f)
|
|
106
|
+
except (OSError, ValueError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_executor() -> ThreadPoolExecutor:
|
|
111
|
+
"""Lazy-init and return the module-level ThreadPoolExecutor singleton."""
|
|
112
|
+
global _executor
|
|
113
|
+
if _executor is None:
|
|
114
|
+
_executor = ThreadPoolExecutor(max_workers=MAX_JOBS)
|
|
115
|
+
return _executor
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _running_count() -> int:
|
|
119
|
+
"""Count jobs with status 'running'."""
|
|
120
|
+
return len([j for j in _jobs.values() if j["status"] == "running"])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def submit_job(
|
|
124
|
+
agent_name: str,
|
|
125
|
+
task_text: str,
|
|
126
|
+
isolation: str = "none",
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Submit a subagent job for concurrent execution.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
agent_name: Name of the agent to dispatch.
|
|
132
|
+
task_text: Task description / prompt for the agent.
|
|
133
|
+
isolation: Isolation backend — "none" (default) or "worktree".
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
job_id (8-char hex string).
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
RuntimeError: If feature is disabled or job limit reached.
|
|
140
|
+
"""
|
|
141
|
+
if not _is_enabled():
|
|
142
|
+
raise RuntimeError("feature disabled")
|
|
143
|
+
|
|
144
|
+
with _lock:
|
|
145
|
+
if _running_count() >= MAX_JOBS:
|
|
146
|
+
raise RuntimeError("job limit reached")
|
|
147
|
+
|
|
148
|
+
job_id = uuid.uuid4().hex[:8]
|
|
149
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
150
|
+
record: dict[str, Any] = {
|
|
151
|
+
"job_id": job_id,
|
|
152
|
+
"agent_name": agent_name,
|
|
153
|
+
"task_text": task_text,
|
|
154
|
+
"isolation": isolation,
|
|
155
|
+
"status": "queued",
|
|
156
|
+
"created_at": now,
|
|
157
|
+
"artifacts": [],
|
|
158
|
+
"error": None,
|
|
159
|
+
}
|
|
160
|
+
_jobs[job_id] = record
|
|
161
|
+
|
|
162
|
+
# Persist initial state
|
|
163
|
+
_persist_job(job_id, record)
|
|
164
|
+
|
|
165
|
+
# Submit to thread pool — returns immediately
|
|
166
|
+
executor = get_executor()
|
|
167
|
+
executor.submit(_run_job, job_id)
|
|
168
|
+
|
|
169
|
+
return job_id
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_git_available() -> bool:
|
|
173
|
+
"""Return True if git is available on PATH."""
|
|
174
|
+
import shutil
|
|
175
|
+
return shutil.which("git") is not None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _setup_worktree(job_id: str) -> str | None:
|
|
179
|
+
"""Attempt to create a git worktree for job isolation.
|
|
180
|
+
|
|
181
|
+
Returns worktree path on success, None on failure.
|
|
182
|
+
"""
|
|
183
|
+
if not _check_git_available():
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
import subprocess
|
|
187
|
+
|
|
188
|
+
project_dir = _get_project_dir()
|
|
189
|
+
worktree_dir = os.path.join(project_dir, ".omg", "worktrees", job_id)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
os.makedirs(os.path.dirname(worktree_dir), exist_ok=True)
|
|
193
|
+
subprocess.run(
|
|
194
|
+
["git", "worktree", "add", "--detach", worktree_dir],
|
|
195
|
+
capture_output=True,
|
|
196
|
+
text=True,
|
|
197
|
+
check=True,
|
|
198
|
+
timeout=30,
|
|
199
|
+
cwd=project_dir,
|
|
200
|
+
)
|
|
201
|
+
return worktree_dir
|
|
202
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _cleanup_worktree(worktree_dir: str) -> None:
|
|
207
|
+
"""Remove a git worktree (best-effort)."""
|
|
208
|
+
import subprocess
|
|
209
|
+
import shutil
|
|
210
|
+
|
|
211
|
+
project_dir = _get_project_dir()
|
|
212
|
+
try:
|
|
213
|
+
subprocess.run(
|
|
214
|
+
["git", "worktree", "remove", "--force", worktree_dir],
|
|
215
|
+
capture_output=True,
|
|
216
|
+
text=True,
|
|
217
|
+
check=False,
|
|
218
|
+
timeout=15,
|
|
219
|
+
cwd=project_dir,
|
|
220
|
+
)
|
|
221
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
# Fallback: remove directory if still exists
|
|
225
|
+
try:
|
|
226
|
+
if os.path.isdir(worktree_dir):
|
|
227
|
+
shutil.rmtree(worktree_dir, ignore_errors=True)
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _run_job(job_id: str) -> None:
|
|
233
|
+
"""Execute a subagent job in the thread pool.
|
|
234
|
+
|
|
235
|
+
Updates job status and persists artifacts as they are produced.
|
|
236
|
+
NOTE: Does NOT actually spawn Claude — simulates execution for now.
|
|
237
|
+
"""
|
|
238
|
+
with _lock:
|
|
239
|
+
record = _jobs.get(job_id)
|
|
240
|
+
if record is None:
|
|
241
|
+
return
|
|
242
|
+
if record["status"] == "cancelled":
|
|
243
|
+
return
|
|
244
|
+
record["status"] = "running"
|
|
245
|
+
record["started_at"] = datetime.now(timezone.utc).isoformat()
|
|
246
|
+
|
|
247
|
+
_persist_job(job_id, record)
|
|
248
|
+
|
|
249
|
+
worktree_dir: str | None = None
|
|
250
|
+
try:
|
|
251
|
+
# Setup isolation if requested
|
|
252
|
+
if record.get("isolation") == "worktree":
|
|
253
|
+
worktree_dir = _setup_worktree(job_id)
|
|
254
|
+
if worktree_dir:
|
|
255
|
+
with _lock:
|
|
256
|
+
record["worktree"] = worktree_dir
|
|
257
|
+
_persist_job(job_id, record)
|
|
258
|
+
|
|
259
|
+
# --- Simulated execution ---
|
|
260
|
+
# Real implementation would dispatch to an agent here.
|
|
261
|
+
# For now, simulate work + produce an artifact.
|
|
262
|
+
time.sleep(0.05) # Simulate brief work
|
|
263
|
+
|
|
264
|
+
artifact = {
|
|
265
|
+
"type": "result",
|
|
266
|
+
"agent": record["agent_name"],
|
|
267
|
+
"content": f"Simulated output for: {record['task_text'][:100]}",
|
|
268
|
+
"produced_at": datetime.now(timezone.utc).isoformat(),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
with _lock:
|
|
272
|
+
# Check for cancellation mid-execution
|
|
273
|
+
if record["status"] == "cancelled":
|
|
274
|
+
return
|
|
275
|
+
record["artifacts"].append(artifact)
|
|
276
|
+
record["status"] = "completed"
|
|
277
|
+
record["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
278
|
+
|
|
279
|
+
_persist_job(job_id, record)
|
|
280
|
+
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
with _lock:
|
|
283
|
+
record["status"] = "failed"
|
|
284
|
+
record["error"] = str(exc)
|
|
285
|
+
record["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
286
|
+
_persist_job(job_id, record)
|
|
287
|
+
|
|
288
|
+
finally:
|
|
289
|
+
# Cleanup worktree if created
|
|
290
|
+
if worktree_dir:
|
|
291
|
+
_cleanup_worktree(worktree_dir)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_job_status(job_id: str) -> dict[str, Any]:
|
|
295
|
+
"""Get the current status and artifacts for a job.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
job_id: Job identifier returned by submit_job().
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Job record dict, or {"error": "not found"} if job doesn't exist.
|
|
302
|
+
"""
|
|
303
|
+
with _lock:
|
|
304
|
+
record = _jobs.get(job_id)
|
|
305
|
+
if record is not None:
|
|
306
|
+
return dict(record)
|
|
307
|
+
|
|
308
|
+
# Not in memory — try loading from disk
|
|
309
|
+
disk_record = _load_job_from_disk(job_id)
|
|
310
|
+
if disk_record is not None:
|
|
311
|
+
return disk_record
|
|
312
|
+
|
|
313
|
+
return {"error": "not found"}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cancel_job(job_id: str) -> bool:
|
|
317
|
+
"""Cancel a queued or running job.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
job_id: Job identifier to cancel.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
True if the job was cancelled, False if not found or already terminal.
|
|
324
|
+
"""
|
|
325
|
+
with _lock:
|
|
326
|
+
record = _jobs.get(job_id)
|
|
327
|
+
if record is None:
|
|
328
|
+
return False
|
|
329
|
+
if record["status"] in ("completed", "failed", "cancelled"):
|
|
330
|
+
return False
|
|
331
|
+
record["status"] = "cancelled"
|
|
332
|
+
record["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
333
|
+
|
|
334
|
+
_persist_job(job_id, record)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def list_jobs(status_filter: str | None = None) -> list[dict[str, Any]]:
|
|
339
|
+
"""List all jobs, optionally filtered by status.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
status_filter: If provided, only return jobs matching this status.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
List of job record dicts.
|
|
346
|
+
"""
|
|
347
|
+
with _lock:
|
|
348
|
+
if status_filter is None:
|
|
349
|
+
return [dict(j) for j in _jobs.values()]
|
|
350
|
+
return [dict(j) for j in _jobs.values() if j["status"] == status_filter]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def shutdown(wait: bool = True) -> None:
|
|
354
|
+
"""Shut down the executor gracefully.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
wait: If True, wait for running jobs to complete before returning.
|
|
358
|
+
"""
|
|
359
|
+
global _executor
|
|
360
|
+
if _executor is not None:
|
|
361
|
+
_executor.shutdown(wait=wait)
|
|
362
|
+
_executor = None
|