crossagent 0.1.0__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.
- crossagent/__init__.py +3 -0
- crossagent/__main__.py +4 -0
- crossagent/advisors.py +166 -0
- crossagent/cli.py +240 -0
- crossagent/registry.py +72 -0
- crossagent-0.1.0.dist-info/METADATA +165 -0
- crossagent-0.1.0.dist-info/RECORD +10 -0
- crossagent-0.1.0.dist-info/WHEEL +4 -0
- crossagent-0.1.0.dist-info/entry_points.txt +2 -0
- crossagent-0.1.0.dist-info/licenses/LICENSE +21 -0
crossagent/__init__.py
ADDED
crossagent/__main__.py
ADDED
crossagent/advisors.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Advisor registry: how to invoke each peer coding-agent CLI.
|
|
2
|
+
|
|
3
|
+
An *advisor* is a peer AI agent you ask for a second opinion (Claude, Codex,
|
|
4
|
+
OpenCode, CommandCode, Gemini, ...). Each entry is a small, declarative spec that
|
|
5
|
+
tells the runner how to build the command line, where the prompt goes, and how to
|
|
6
|
+
read the result back out. Built-ins ship sane defaults; users override or add their
|
|
7
|
+
own via ~/.config/crossagent/advisors.json without touching code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# Where the prompt string is placed on the argv.
|
|
18
|
+
# "dashdash" -> [..., "--", prompt] (claude: everything after -- is the prompt)
|
|
19
|
+
# "positional" -> [..., prompt] (codex exec / opencode run)
|
|
20
|
+
# "flag:-p" -> [..., "-p", prompt] (gemini: prompt is the value of -p)
|
|
21
|
+
PROMPT_DELIVERIES = frozenset({"dashdash", "positional"})
|
|
22
|
+
|
|
23
|
+
# How to read the advisor's answer back out of its stdout.
|
|
24
|
+
# "claude-stream" -> parse newline-delimited stream-json events, take the result event
|
|
25
|
+
# "text" -> capture raw stdout as the answer
|
|
26
|
+
RESULT_PARSERS = frozenset({"claude-stream", "text"})
|
|
27
|
+
|
|
28
|
+
USER_CONFIG = Path.home() / ".config" / "crossagent" / "advisors.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Advisor:
|
|
33
|
+
"""Declarative recipe for invoking one peer-agent CLI."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
executable: str
|
|
37
|
+
base_args: tuple[str, ...] = ()
|
|
38
|
+
invoke_args: tuple[str, ...] = ()
|
|
39
|
+
prompt_delivery: str = "positional" # "dashdash" | "positional" | "flag:<flag>"
|
|
40
|
+
model_flag: str | None = None
|
|
41
|
+
stream_args: tuple[str, ...] = ()
|
|
42
|
+
json_args: tuple[str, ...] = ()
|
|
43
|
+
resume_flag: str | None = None
|
|
44
|
+
session_name_flag: str | None = None
|
|
45
|
+
fork_flag: str | None = None
|
|
46
|
+
result_parser: str = "text"
|
|
47
|
+
experimental: bool = False
|
|
48
|
+
notes: str = ""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def supports_sessions(self) -> bool:
|
|
52
|
+
return self.resume_flag is not None or self.session_name_flag is not None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def supports_stream(self) -> bool:
|
|
56
|
+
return self.result_parser == "claude-stream"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Built-in advisors -------------------------------------------------------
|
|
60
|
+
# claude is the reference implementation: fully featured, verified against the
|
|
61
|
+
# upstream `claude -p` behaviour. The rest are pragmatic best-effort defaults —
|
|
62
|
+
# marked experimental — that users can correct via the JSON override file.
|
|
63
|
+
|
|
64
|
+
_BUILTINS: dict[str, Advisor] = {
|
|
65
|
+
"claude": Advisor(
|
|
66
|
+
name="claude",
|
|
67
|
+
executable="claude",
|
|
68
|
+
invoke_args=("-p",),
|
|
69
|
+
prompt_delivery="dashdash",
|
|
70
|
+
model_flag="--model",
|
|
71
|
+
stream_args=("--verbose", "--output-format", "stream-json"),
|
|
72
|
+
json_args=("--output-format", "json"),
|
|
73
|
+
resume_flag="--resume",
|
|
74
|
+
session_name_flag="--name",
|
|
75
|
+
fork_flag="--fork-session",
|
|
76
|
+
result_parser="claude-stream",
|
|
77
|
+
),
|
|
78
|
+
"codex": Advisor(
|
|
79
|
+
name="codex",
|
|
80
|
+
executable="codex",
|
|
81
|
+
base_args=("exec",),
|
|
82
|
+
prompt_delivery="positional",
|
|
83
|
+
model_flag="--model",
|
|
84
|
+
result_parser="text",
|
|
85
|
+
experimental=True,
|
|
86
|
+
notes="Uses `codex exec <prompt>` (non-interactive). Resume not wired by default.",
|
|
87
|
+
),
|
|
88
|
+
"opencode": Advisor(
|
|
89
|
+
name="opencode",
|
|
90
|
+
executable="opencode",
|
|
91
|
+
base_args=("run",),
|
|
92
|
+
prompt_delivery="positional",
|
|
93
|
+
model_flag="--model",
|
|
94
|
+
result_parser="text",
|
|
95
|
+
experimental=True,
|
|
96
|
+
notes="Uses `opencode run <prompt>` (headless).",
|
|
97
|
+
),
|
|
98
|
+
"commandcode": Advisor(
|
|
99
|
+
name="commandcode",
|
|
100
|
+
executable="commandcode",
|
|
101
|
+
invoke_args=("-p",),
|
|
102
|
+
prompt_delivery="positional",
|
|
103
|
+
model_flag="--model",
|
|
104
|
+
result_parser="text",
|
|
105
|
+
experimental=True,
|
|
106
|
+
notes="Uses `commandcode -p <prompt>` (non-interactive). Resume not wired by default.",
|
|
107
|
+
),
|
|
108
|
+
"gemini": Advisor(
|
|
109
|
+
name="gemini",
|
|
110
|
+
executable="gemini",
|
|
111
|
+
prompt_delivery="flag:-p",
|
|
112
|
+
model_flag="--model",
|
|
113
|
+
result_parser="text",
|
|
114
|
+
experimental=True,
|
|
115
|
+
notes="Uses `gemini -p <prompt>` (non-interactive).",
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Friendly aliases callers may type.
|
|
120
|
+
_ALIASES = {"cmd": "commandcode", "cc": "claude", "oc": "opencode"}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _coerce(name: str, raw: dict[str, Any]) -> Advisor:
|
|
124
|
+
"""Build an Advisor from a user-config dict, layering onto a built-in if one exists."""
|
|
125
|
+
base = _BUILTINS.get(name, Advisor(name=name, executable=raw.get("executable", name)))
|
|
126
|
+
tuple_fields = {"base_args", "invoke_args", "stream_args", "json_args"}
|
|
127
|
+
overrides: dict[str, Any] = {}
|
|
128
|
+
for key, value in raw.items():
|
|
129
|
+
if key in tuple_fields and isinstance(value, list):
|
|
130
|
+
overrides[key] = tuple(value)
|
|
131
|
+
elif key != "name":
|
|
132
|
+
overrides[key] = value
|
|
133
|
+
return replace(base, name=name, **overrides)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _load_user_config(path: Path) -> dict[str, Advisor]:
|
|
137
|
+
if not path.exists():
|
|
138
|
+
return {}
|
|
139
|
+
try:
|
|
140
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
141
|
+
except (json.JSONDecodeError, OSError):
|
|
142
|
+
return {}
|
|
143
|
+
entries = data.get("advisors", data) if isinstance(data, dict) else {}
|
|
144
|
+
result: dict[str, Advisor] = {}
|
|
145
|
+
if isinstance(entries, dict):
|
|
146
|
+
for name, raw in entries.items():
|
|
147
|
+
if isinstance(raw, dict):
|
|
148
|
+
result[name] = _coerce(name, raw)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def available(config_path: Path | None = None) -> dict[str, Advisor]:
|
|
153
|
+
"""Return the merged advisor registry: built-ins overridden by user config."""
|
|
154
|
+
merged = dict(_BUILTINS)
|
|
155
|
+
merged.update(_load_user_config(config_path or USER_CONFIG))
|
|
156
|
+
return merged
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve(name: str, config_path: Path | None = None) -> Advisor:
|
|
160
|
+
"""Look up an advisor by name or alias. Raises KeyError with a helpful message."""
|
|
161
|
+
canonical = _ALIASES.get(name.strip().lower(), name.strip().lower())
|
|
162
|
+
registry = available(config_path)
|
|
163
|
+
if canonical not in registry:
|
|
164
|
+
known = ", ".join(sorted(registry))
|
|
165
|
+
raise KeyError(f"Unknown advisor '{name}'. Known advisors: {known}. Add your own in {USER_CONFIG}.")
|
|
166
|
+
return registry[canonical]
|
crossagent/cli.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Run a named, resumable second-opinion session with a peer coding-agent CLI.
|
|
2
|
+
|
|
3
|
+
`crossagent` lets one AI coding agent get a second opinion from another. It builds
|
|
4
|
+
the right command for the chosen advisor (Claude by default), keeps the process
|
|
5
|
+
alive until the advisor finishes, streams progress to stderr, prints the final
|
|
6
|
+
answer to stdout, and remembers the session so a follow-up can resume it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import advisors as advisors_mod
|
|
21
|
+
from . import registry as reg
|
|
22
|
+
from .advisors import Advisor
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_prompt(args: argparse.Namespace) -> str:
|
|
26
|
+
if args.prompt_file:
|
|
27
|
+
return Path(args.prompt_file).read_text(encoding="utf-8")
|
|
28
|
+
if args.prompt:
|
|
29
|
+
return args.prompt
|
|
30
|
+
if not sys.stdin.isatty():
|
|
31
|
+
return sys.stdin.read()
|
|
32
|
+
raise SystemExit("Provide --prompt-file, --prompt, or pipe the prompt on stdin.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _append_prompt(cmd: list[str], advisor: Advisor, prompt: str) -> None:
|
|
36
|
+
delivery = advisor.prompt_delivery
|
|
37
|
+
if delivery == "dashdash":
|
|
38
|
+
cmd.extend(["--", prompt])
|
|
39
|
+
elif delivery.startswith("flag:"):
|
|
40
|
+
cmd.extend([delivery.split(":", 1)[1], prompt])
|
|
41
|
+
else: # "positional"
|
|
42
|
+
cmd.append(prompt)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _redacted_command(cmd: list[str]) -> str:
|
|
46
|
+
"""Format an advisor command without exposing its final prompt argument."""
|
|
47
|
+
if not cmd:
|
|
48
|
+
return "<prompt>"
|
|
49
|
+
return shlex.join([*cmd[:-1], "<prompt>"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_command(advisor: Advisor, args: argparse.Namespace, registry: dict[str, Any]) -> tuple[list[str], str]:
|
|
53
|
+
cmd = [advisor.executable, *advisor.base_args, *advisor.invoke_args]
|
|
54
|
+
|
|
55
|
+
if args.model and advisor.model_flag:
|
|
56
|
+
cmd.extend([advisor.model_flag, args.model])
|
|
57
|
+
|
|
58
|
+
if advisor.supports_stream:
|
|
59
|
+
cmd.extend(args.stream and advisor.stream_args or advisor.json_args)
|
|
60
|
+
if args.stream and args.partial:
|
|
61
|
+
cmd.append("--include-partial-messages")
|
|
62
|
+
|
|
63
|
+
if args.safe_mode:
|
|
64
|
+
cmd.append("--safe-mode")
|
|
65
|
+
if args.permission_mode:
|
|
66
|
+
cmd.extend(["--permission-mode", args.permission_mode])
|
|
67
|
+
if args.tools is not None:
|
|
68
|
+
cmd.extend(["--tools", args.tools])
|
|
69
|
+
for allowed in args.allowed_tools:
|
|
70
|
+
cmd.extend(["--allowedTools", allowed])
|
|
71
|
+
if args.system_prompt:
|
|
72
|
+
cmd.extend(["--system-prompt", args.system_prompt])
|
|
73
|
+
for extra in args.raw_arg:
|
|
74
|
+
cmd.append(extra)
|
|
75
|
+
|
|
76
|
+
key = reg.session_key(advisor.name, args.name)
|
|
77
|
+
stored_id = reg.stored_session_id(registry, key)
|
|
78
|
+
|
|
79
|
+
if advisor.supports_sessions:
|
|
80
|
+
if args.resume and advisor.resume_flag:
|
|
81
|
+
cmd.extend([advisor.resume_flag, args.resume])
|
|
82
|
+
elif stored_id and not args.new_session and advisor.resume_flag:
|
|
83
|
+
cmd.extend([advisor.resume_flag, stored_id])
|
|
84
|
+
elif args.name and advisor.session_name_flag:
|
|
85
|
+
cmd.extend([advisor.session_name_flag, args.name])
|
|
86
|
+
if args.fork_session and advisor.fork_flag:
|
|
87
|
+
cmd.append(advisor.fork_flag)
|
|
88
|
+
|
|
89
|
+
_append_prompt(cmd, advisor, prompt=args._prompt)
|
|
90
|
+
return cmd, key
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- Claude stream-json handling --------------------------------------------
|
|
94
|
+
|
|
95
|
+
def summarize_event(event: dict[str, Any]) -> None:
|
|
96
|
+
kind = event.get("type")
|
|
97
|
+
if kind == "system" and event.get("subtype") == "init":
|
|
98
|
+
print(f"[crossagent] init session={event.get('session_id')} model={event.get('model')} "
|
|
99
|
+
f"cwd={event.get('cwd')}", file=sys.stderr)
|
|
100
|
+
elif kind == "assistant":
|
|
101
|
+
message = event.get("message", {})
|
|
102
|
+
blocks = message.get("content", []) if isinstance(message, dict) else []
|
|
103
|
+
text = "".join(b.get("text", "") for b in blocks
|
|
104
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
105
|
+
if text:
|
|
106
|
+
print(f"[crossagent] assistant: {text.replace(chr(10), ' ')[:240]}", file=sys.stderr)
|
|
107
|
+
elif kind == "result":
|
|
108
|
+
print(f"[crossagent] result subtype={event.get('subtype')} session={event.get('session_id')} "
|
|
109
|
+
f"cost={event.get('total_cost_usd')}", file=sys.stderr)
|
|
110
|
+
elif kind == "rate_limit_event":
|
|
111
|
+
info = event.get("rate_limit_info", {})
|
|
112
|
+
print(f"[crossagent] rate_limit status={info.get('status')} resetsAt={info.get('resetsAt')}",
|
|
113
|
+
file=sys.stderr)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_stream(cmd: list[str], cwd: str | None) -> tuple[int, dict[str, Any] | None]:
|
|
117
|
+
proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
118
|
+
text=True, bufsize=1)
|
|
119
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
120
|
+
final: dict[str, Any] | None = None
|
|
121
|
+
for line in proc.stdout:
|
|
122
|
+
stripped = line.strip()
|
|
123
|
+
if not stripped:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
event = json.loads(stripped)
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
print(stripped, file=sys.stderr)
|
|
129
|
+
continue
|
|
130
|
+
summarize_event(event)
|
|
131
|
+
if event.get("type") == "result":
|
|
132
|
+
final = event
|
|
133
|
+
err = proc.stderr.read()
|
|
134
|
+
if err:
|
|
135
|
+
print(err, file=sys.stderr, end="")
|
|
136
|
+
return proc.wait(), final
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _run_text(cmd: list[str], cwd: str | None) -> tuple[int, str]:
|
|
140
|
+
completed = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False)
|
|
141
|
+
if completed.stderr:
|
|
142
|
+
print(completed.stderr, file=sys.stderr, end="")
|
|
143
|
+
return completed.returncode, completed.stdout
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
147
|
+
parser = argparse.ArgumentParser(prog="crossagent", description=__doc__)
|
|
148
|
+
parser.add_argument("--agent", "--advisor", dest="agent", default="claude",
|
|
149
|
+
help="Peer agent to ask: claude, codex, opencode, commandcode, gemini, or a custom advisor. Default: claude.")
|
|
150
|
+
parser.add_argument("--name", help="Stable session name. Reused names auto-resume the stored session.")
|
|
151
|
+
parser.add_argument("--resume", help="Force a --resume target: session id or search term.")
|
|
152
|
+
parser.add_argument("--new-session", action="store_true", help="Ignore any stored session for --name and start fresh.")
|
|
153
|
+
parser.add_argument("--fork-session", action="store_true", help="Fork a new session from the existing conversation.")
|
|
154
|
+
parser.add_argument("--prompt-file", help="Path to the second-opinion prompt.")
|
|
155
|
+
parser.add_argument("--prompt", help="Prompt text. Prefer --prompt-file for long prompts.")
|
|
156
|
+
parser.add_argument("--cwd", help="Working directory for the advisor. Defaults to the current directory.")
|
|
157
|
+
parser.add_argument("--model", default="", help="Advisor model or alias (advisor-specific). Empty = advisor default.")
|
|
158
|
+
parser.add_argument("--safe-mode", action="store_true", help="Claude: run with --safe-mode (skip repo config/skills/hooks).")
|
|
159
|
+
parser.add_argument("--no-stream", dest="stream", action="store_false", help="Claude: use single JSON output instead of streaming.")
|
|
160
|
+
parser.add_argument("--partial", action="store_true", help="Claude: include partial stream messages.")
|
|
161
|
+
parser.add_argument("--tools", help='Claude: pass --tools, e.g. "" for none or "Read,Bash".')
|
|
162
|
+
parser.add_argument("--allowed-tools", action="append", default=[], help="Claude: repeatable --allowedTools value.")
|
|
163
|
+
parser.add_argument("--permission-mode", help="Claude: --permission-mode value.")
|
|
164
|
+
parser.add_argument("--system-prompt", help="Claude: --system-prompt value.")
|
|
165
|
+
parser.add_argument("--raw-arg", action="append", default=[], help="Repeatable raw argument passed straight to the advisor CLI.")
|
|
166
|
+
parser.add_argument("--registry", default=str(reg.DEFAULT_REGISTRY), help="Session registry path.")
|
|
167
|
+
parser.add_argument("--list-advisors", action="store_true", help="Print known advisors and exit.")
|
|
168
|
+
parser.set_defaults(stream=True)
|
|
169
|
+
return parser.parse_args(argv)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _print_advisors() -> int:
|
|
173
|
+
for name, adv in sorted(advisors_mod.available().items()):
|
|
174
|
+
tag = " (experimental)" if adv.experimental else ""
|
|
175
|
+
print(f"{name:14} -> {adv.executable}{tag}")
|
|
176
|
+
if adv.notes:
|
|
177
|
+
print(f"{'':14} {adv.notes}")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main(argv: list[str] | None = None) -> int:
|
|
182
|
+
args = parse_args(argv)
|
|
183
|
+
if args.list_advisors:
|
|
184
|
+
return _print_advisors()
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
advisor = advisors_mod.resolve(args.agent)
|
|
188
|
+
except KeyError as exc:
|
|
189
|
+
print(f"[crossagent] {exc}", file=sys.stderr)
|
|
190
|
+
return 2
|
|
191
|
+
|
|
192
|
+
if advisor.experimental:
|
|
193
|
+
print(f"[crossagent] advisor '{advisor.name}' is experimental — verify flags for your install.",
|
|
194
|
+
file=sys.stderr)
|
|
195
|
+
|
|
196
|
+
args._prompt = read_prompt(args)
|
|
197
|
+
registry_path = Path(args.registry).expanduser()
|
|
198
|
+
registry = reg.load(registry_path)
|
|
199
|
+
cmd, key = build_command(advisor, args, registry)
|
|
200
|
+
|
|
201
|
+
print(f"[crossagent] running: {_redacted_command(cmd)}", file=sys.stderr)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
return _dispatch(advisor, args, cmd, key, registry, registry_path)
|
|
205
|
+
except FileNotFoundError:
|
|
206
|
+
print(f"[crossagent] advisor CLI not found on PATH: '{advisor.executable}'. "
|
|
207
|
+
f"Install it, or point '{advisor.name}' at the right executable in "
|
|
208
|
+
f"{advisors_mod.USER_CONFIG}.", file=sys.stderr)
|
|
209
|
+
return 127
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _dispatch(advisor: Advisor, args: argparse.Namespace, cmd: list[str], key: str,
|
|
213
|
+
registry: dict[str, Any], registry_path: Path) -> int:
|
|
214
|
+
if advisor.supports_stream and args.stream:
|
|
215
|
+
code, final = _run_stream(cmd, args.cwd)
|
|
216
|
+
if final:
|
|
217
|
+
if final.get("is_error"):
|
|
218
|
+
errors = final.get("errors") or final.get("api_error_status") or "unknown error"
|
|
219
|
+
print(f"[crossagent] {advisor.name} returned error: {errors}", file=sys.stderr)
|
|
220
|
+
result = final.get("result")
|
|
221
|
+
structured = final.get("structured_output")
|
|
222
|
+
if result:
|
|
223
|
+
print(result)
|
|
224
|
+
elif structured is not None:
|
|
225
|
+
print(json.dumps(structured, indent=2, sort_keys=True))
|
|
226
|
+
session_id = final.get("session_id")
|
|
227
|
+
if key and session_id:
|
|
228
|
+
reg.record(registry_path, registry, key, session_id=session_id, name=args.name,
|
|
229
|
+
cwd=args.cwd or os.getcwd(), advisor=advisor.name, model=args.model)
|
|
230
|
+
print(f"[crossagent] saved session name={key} id={session_id}", file=sys.stderr)
|
|
231
|
+
return code
|
|
232
|
+
|
|
233
|
+
code, out = _run_text(cmd, args.cwd)
|
|
234
|
+
if out.strip():
|
|
235
|
+
print(out, end="" if out.endswith("\n") else "\n")
|
|
236
|
+
return code
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
raise SystemExit(main())
|
crossagent/registry.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Named, resumable second-opinion sessions.
|
|
2
|
+
|
|
3
|
+
Stores each session's underlying session id keyed by ``advisor:slug`` so a
|
|
4
|
+
later turn on the same decision can resume the same conversation. Only advisors
|
|
5
|
+
that emit a session id (currently Claude) populate this; the file is otherwise a
|
|
6
|
+
harmless no-op.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
DEFAULT_REGISTRY = Path.home() / ".config" / "crossagent" / "sessions.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def slugify(value: str) -> str:
|
|
22
|
+
slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip().lower())
|
|
23
|
+
slug = re.sub(r"-{2,}", "-", slug).strip("-")
|
|
24
|
+
return slug or "second-opinion"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def session_key(advisor: str, name: str | None) -> str:
|
|
28
|
+
return f"{advisor}:{slugify(name)}" if name else ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load(path: Path) -> dict[str, Any]:
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return {"sessions": {}}
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
backup = path.with_suffix(path.suffix + ".corrupt")
|
|
38
|
+
path.replace(backup)
|
|
39
|
+
print(f"[crossagent] Registry was invalid JSON; moved to {backup}", file=sys.stderr)
|
|
40
|
+
return {"sessions": {}}
|
|
41
|
+
if not isinstance(data, dict):
|
|
42
|
+
return {"sessions": {}}
|
|
43
|
+
if not isinstance(data.get("sessions"), dict):
|
|
44
|
+
data["sessions"] = {}
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save(path: Path, data: dict[str, Any]) -> None:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def stored_session_id(registry: dict[str, Any], key: str) -> str | None:
|
|
54
|
+
entry = registry.get("sessions", {}).get(key) if key else None
|
|
55
|
+
return entry.get("session_id") if isinstance(entry, dict) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def record(path: Path, registry: dict[str, Any], key: str, *, session_id: str, name: str | None,
|
|
59
|
+
cwd: str, advisor: str, model: str) -> dict[str, Any]:
|
|
60
|
+
"""Return a NEW registry dict with the session recorded, and persist it."""
|
|
61
|
+
sessions = dict(registry.get("sessions", {}))
|
|
62
|
+
sessions[key] = {
|
|
63
|
+
"session_id": session_id,
|
|
64
|
+
"name": name,
|
|
65
|
+
"advisor": advisor,
|
|
66
|
+
"cwd": str(Path(cwd).resolve()),
|
|
67
|
+
"model": model,
|
|
68
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
69
|
+
}
|
|
70
|
+
updated = {**registry, "sessions": sessions}
|
|
71
|
+
save(path, updated)
|
|
72
|
+
return updated
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crossagent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Get a second opinion from another AI coding agent. Ask Claude, Codex, OpenCode, CommandCode, or Gemini — from inside the agent you already use.
|
|
5
|
+
Project-URL: Homepage, https://github.com/datj9/crossagent
|
|
6
|
+
Project-URL: Repository, https://github.com/datj9/crossagent
|
|
7
|
+
Project-URL: Issues, https://github.com/datj9/crossagent/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/datj9/crossagent/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Dat Nguyen
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,ai,claude,cli,codex,coding-agent,mcp,opencode,second-opinion
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# crossagent
|
|
31
|
+
|
|
32
|
+
**Get a second opinion from another AI agent — without leaving the one you're in.**
|
|
33
|
+
|
|
34
|
+
`crossagent` lets your coding agent (Claude Code, Codex, Cursor, Cline, OpenCode, CommandCode…) pause and ask a *different* agent to independently evaluate a hard call — an architecture decision, a gnarly bug, a prompt rewrite — then hands you both viewpoints so **you** decide. One agent proposes; another one challenges. You get the disagreement, not just an echo.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
crossagent --agent claude --name payments-retry-design --prompt-file /tmp/decision.md
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- 🤝 **Cross-agent, by design.** Ask **Claude, Codex, OpenCode, CommandCode, or Gemini** — from inside whichever agent you already use.
|
|
41
|
+
- 🧠 **A teammate, not an oracle.** Ships a second-opinion protocol that forces an evidence-backed critique with cited files, risks, and a recommendation — then a synthesis step, so the second opinion sharpens *your* judgment instead of replacing it.
|
|
42
|
+
- 🔁 **Named, resumable Claude sessions.** Reuse a name to continue the same decision; fork to explore a branch. No re-explaining context every turn.
|
|
43
|
+
- 📡 **Long-running & streamed.** No default timeout, no default cost cap. It waits until the advisor finishes and streams progress as it goes.
|
|
44
|
+
- 🧩 **Agent Skill + CLI.** Installs as an [Agent Skill](https://agentskills.dev) *and* a standalone `crossagent` command. Zero runtime dependencies.
|
|
45
|
+
- 🔓 **Local & open.** Runs entirely on your machine against CLIs you already have. MIT licensed.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Why
|
|
50
|
+
|
|
51
|
+
Coding agents are confident. That's the problem. The same model that writes the code also reviews it, so a plausible-but-wrong call sails straight through. The fix engineers already use with each other — *"let me get a second pair of eyes"* — works for agents too, but only if the second agent is **actually different** and is briefed well enough to disagree on the merits.
|
|
52
|
+
|
|
53
|
+
`crossagent` makes that a one-liner. Package the decision, hand it to a peer agent, get back a structured critique — Position / Evidence / Risks / Recommendation / Unresolved — and reconcile it with your own view before you commit.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
Install the CLI from PyPI (using `pipx` keeps command-line applications isolated):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pipx install crossagent
|
|
61
|
+
# or: pip install crossagent
|
|
62
|
+
|
|
63
|
+
crossagent --list-advisors
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You also need at least one advisor CLI on your PATH — e.g. [`claude`](https://docs.claude.com/claude-code), `codex`, `opencode`, `commandcode`, or `gemini`.
|
|
67
|
+
|
|
68
|
+
To install both the CLI and the Agent Skill from the repository:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/datj9/crossagent.git
|
|
72
|
+
cd crossagent
|
|
73
|
+
./install.sh
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The installer checks the supported agent config dirs (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.commandcode`, `~/.cursor`), installs the skill into those present, and installs the CLI via `pipx`/`pip`.
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
Write a compact decision brief, then ask a peer:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cat > /tmp/decision.md <<'EOF'
|
|
84
|
+
<decision>Should the payment retry live in the worker or the API layer?</decision>
|
|
85
|
+
<current_state>Retries currently inline in the API handler; p99 latency regressed 40%.</current_state>
|
|
86
|
+
<constraints>No new infra this quarter. Must stay idempotent.</constraints>
|
|
87
|
+
<evidence>src/api/payments.ts:88, worker/queue.ts:12, load test in bench/2026-07.md</evidence>
|
|
88
|
+
<questions>1. Which layer, and why? 2. Strongest counterargument? 3. Cheapest validation?</questions>
|
|
89
|
+
EOF
|
|
90
|
+
|
|
91
|
+
crossagent --agent claude --name payments-retry-design --cwd "$PWD" --prompt-file /tmp/decision.md
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The advisor's answer prints to **stdout**; progress and session metadata go to **stderr**. Continue the same decision later — context is remembered:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
crossagent --name payments-retry-design --prompt-file /tmp/followup.md # auto-resumes
|
|
98
|
+
crossagent --name payments-retry-design --fork-session --prompt-file /tmp/alt.md # branch it
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Or let your agent do it for you — just say *"ask Claude about this"* / *"hỏi ý với Claude"* / *"get a second opinion from Codex"* and the skill fires.
|
|
102
|
+
|
|
103
|
+
## How it works
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
your agent ──▶ crossagent CLI ──▶ peer agent's CLI (claude -p / codex exec / …)
|
|
107
|
+
▲ │ │
|
|
108
|
+
└── synthesis ─┴──── streamed ◀──────┘
|
|
109
|
+
(you reconcile both views) result + session id
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
1. You (or your agent) package the decision using the [second-opinion protocol](skills/crossagent/references/second-opinion-protocol.md).
|
|
113
|
+
2. `crossagent` builds the right command for the chosen advisor and keeps it alive until it exits.
|
|
114
|
+
3. For session-capable advisors, it stores the `session_id` keyed by `advisor:name` so the next turn resumes.
|
|
115
|
+
4. You compare the two viewpoints and make the call.
|
|
116
|
+
|
|
117
|
+
## Advisors
|
|
118
|
+
|
|
119
|
+
| Advisor | Command | Status | Sessions |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| `claude` | `claude -p` | ✅ full | resume, name, fork, streamed |
|
|
122
|
+
| `codex` | `codex exec` | 🧪 experimental | text output |
|
|
123
|
+
| `opencode` | `opencode run` | 🧪 experimental | text output |
|
|
124
|
+
| `commandcode` | `commandcode -p` | 🧪 experimental | text output |
|
|
125
|
+
| `gemini` | `gemini -p` | 🧪 experimental | text output |
|
|
126
|
+
|
|
127
|
+
Experimental advisors ship best-effort default flags. If your install differs, fix them without touching code — see below.
|
|
128
|
+
|
|
129
|
+
### Add or fix an advisor
|
|
130
|
+
|
|
131
|
+
Create `~/.config/crossagent/advisors.json`:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"advisors": {
|
|
136
|
+
"codex": { "executable": "codex", "base_args": ["exec", "--full-auto"] },
|
|
137
|
+
"myllm": { "executable": "myllm", "prompt_delivery": "flag:-q", "model_flag": "--model" }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Fields layer onto the built-ins, so you only specify what differs. `prompt_delivery` is `dashdash` (prompt after `--`), `positional` (prompt as last arg), or `flag:<flag>` (prompt is the value of a flag).
|
|
143
|
+
|
|
144
|
+
## Skill usage inside an agent
|
|
145
|
+
|
|
146
|
+
Once installed, the skill auto-triggers on phrases like *"ask Claude"*, *"debate with Claude"*, *"ask Codex"*, *"second opinion"*, *"hỏi ý với Claude"*. The agent packages context, runs `crossagent`, and reports both views. See [`skills/crossagent/SKILL.md`](skills/crossagent/SKILL.md).
|
|
147
|
+
|
|
148
|
+
## Examples
|
|
149
|
+
|
|
150
|
+
- [Architecture decision](examples/architecture-decision.md)
|
|
151
|
+
- [Debugging second opinion](examples/debug-second-opinion.md)
|
|
152
|
+
|
|
153
|
+
## Security
|
|
154
|
+
|
|
155
|
+
- Secrets never belong in prompts, CLI args, logs, or copied context — the protocol says so and you should enforce it.
|
|
156
|
+
- Second-opinion sessions run read-only by convention; for Claude use `--tools ""` for pure reasoning or restrict with `--allowedTools`.
|
|
157
|
+
- Everything runs locally against CLIs you already trust. `crossagent` adds no network calls of its own.
|
|
158
|
+
|
|
159
|
+
## Contributing
|
|
160
|
+
|
|
161
|
+
Issues and PRs welcome — especially hardening the experimental advisors against real installs. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT © Dat Nguyen. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
crossagent/__init__.py,sha256=5hRebZC4Xy9EZUJt5T3Rm9sZsOZUM0-lO7y0oYZ1RR8,95
|
|
2
|
+
crossagent/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
3
|
+
crossagent/advisors.py,sha256=sH39ZgMee4rW3NHt5y6BlkPjGco6joijZ1kmCrbi6qE,6153
|
|
4
|
+
crossagent/cli.py,sha256=8NOz_Q9SQYHO8O-VOt_2cAjbcXm2oO3F9eAwLDjPQJ0,10594
|
|
5
|
+
crossagent/registry.py,sha256=a21PAerkh3CYgI3KIPqVmY1Kb4rwhJ3Ww9ojkF-BgsU,2452
|
|
6
|
+
crossagent-0.1.0.dist-info/METADATA,sha256=_deD8QC3bibmECd8u9QMgRTAelcqywOhZP5AWspRRek,8144
|
|
7
|
+
crossagent-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
crossagent-0.1.0.dist-info/entry_points.txt,sha256=yZQFKz8V9dRliDyibszp5uTrmsID8HhU4ylJ9TFmWns,51
|
|
9
|
+
crossagent-0.1.0.dist-info/licenses/LICENSE,sha256=8i4i2eLi11n7bvCGL4eOMBPaYV9azdtTDgOgPqBMrWc,1067
|
|
10
|
+
crossagent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dat Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|