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 ADDED
@@ -0,0 +1,3 @@
1
+ """crossagent — get a second opinion from another AI coding agent."""
2
+
3
+ __version__ = "0.1.0"
crossagent/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ crossagent = crossagent.cli:main
@@ -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.