crossagent 0.1.0__tar.gz
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-0.1.0/.gitignore +18 -0
- crossagent-0.1.0/LICENSE +21 -0
- crossagent-0.1.0/PKG-INFO +165 -0
- crossagent-0.1.0/README.md +136 -0
- crossagent-0.1.0/examples/architecture-decision.md +62 -0
- crossagent-0.1.0/examples/debug-second-opinion.md +48 -0
- crossagent-0.1.0/pyproject.toml +54 -0
- crossagent-0.1.0/skills/crossagent/SKILL.md +62 -0
- crossagent-0.1.0/skills/crossagent/references/second-opinion-protocol.md +138 -0
- crossagent-0.1.0/src/crossagent/__init__.py +3 -0
- crossagent-0.1.0/src/crossagent/__main__.py +4 -0
- crossagent-0.1.0/src/crossagent/advisors.py +166 -0
- crossagent-0.1.0/src/crossagent/cli.py +240 -0
- crossagent-0.1.0/src/crossagent/registry.py +72 -0
crossagent-0.1.0/LICENSE
ADDED
|
@@ -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.
|
|
@@ -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,136 @@
|
|
|
1
|
+
# crossagent
|
|
2
|
+
|
|
3
|
+
**Get a second opinion from another AI agent — without leaving the one you're in.**
|
|
4
|
+
|
|
5
|
+
`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.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
crossagent --agent claude --name payments-retry-design --prompt-file /tmp/decision.md
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- 🤝 **Cross-agent, by design.** Ask **Claude, Codex, OpenCode, CommandCode, or Gemini** — from inside whichever agent you already use.
|
|
12
|
+
- 🧠 **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.
|
|
13
|
+
- 🔁 **Named, resumable Claude sessions.** Reuse a name to continue the same decision; fork to explore a branch. No re-explaining context every turn.
|
|
14
|
+
- 📡 **Long-running & streamed.** No default timeout, no default cost cap. It waits until the advisor finishes and streams progress as it goes.
|
|
15
|
+
- 🧩 **Agent Skill + CLI.** Installs as an [Agent Skill](https://agentskills.dev) *and* a standalone `crossagent` command. Zero runtime dependencies.
|
|
16
|
+
- 🔓 **Local & open.** Runs entirely on your machine against CLIs you already have. MIT licensed.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Why
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
`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.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
Install the CLI from PyPI (using `pipx` keeps command-line applications isolated):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pipx install crossagent
|
|
32
|
+
# or: pip install crossagent
|
|
33
|
+
|
|
34
|
+
crossagent --list-advisors
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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`.
|
|
38
|
+
|
|
39
|
+
To install both the CLI and the Agent Skill from the repository:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git clone https://github.com/datj9/crossagent.git
|
|
43
|
+
cd crossagent
|
|
44
|
+
./install.sh
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
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`.
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
Write a compact decision brief, then ask a peer:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cat > /tmp/decision.md <<'EOF'
|
|
55
|
+
<decision>Should the payment retry live in the worker or the API layer?</decision>
|
|
56
|
+
<current_state>Retries currently inline in the API handler; p99 latency regressed 40%.</current_state>
|
|
57
|
+
<constraints>No new infra this quarter. Must stay idempotent.</constraints>
|
|
58
|
+
<evidence>src/api/payments.ts:88, worker/queue.ts:12, load test in bench/2026-07.md</evidence>
|
|
59
|
+
<questions>1. Which layer, and why? 2. Strongest counterargument? 3. Cheapest validation?</questions>
|
|
60
|
+
EOF
|
|
61
|
+
|
|
62
|
+
crossagent --agent claude --name payments-retry-design --cwd "$PWD" --prompt-file /tmp/decision.md
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The advisor's answer prints to **stdout**; progress and session metadata go to **stderr**. Continue the same decision later — context is remembered:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
crossagent --name payments-retry-design --prompt-file /tmp/followup.md # auto-resumes
|
|
69
|
+
crossagent --name payments-retry-design --fork-session --prompt-file /tmp/alt.md # branch it
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
your agent ──▶ crossagent CLI ──▶ peer agent's CLI (claude -p / codex exec / …)
|
|
78
|
+
▲ │ │
|
|
79
|
+
└── synthesis ─┴──── streamed ◀──────┘
|
|
80
|
+
(you reconcile both views) result + session id
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
1. You (or your agent) package the decision using the [second-opinion protocol](skills/crossagent/references/second-opinion-protocol.md).
|
|
84
|
+
2. `crossagent` builds the right command for the chosen advisor and keeps it alive until it exits.
|
|
85
|
+
3. For session-capable advisors, it stores the `session_id` keyed by `advisor:name` so the next turn resumes.
|
|
86
|
+
4. You compare the two viewpoints and make the call.
|
|
87
|
+
|
|
88
|
+
## Advisors
|
|
89
|
+
|
|
90
|
+
| Advisor | Command | Status | Sessions |
|
|
91
|
+
|---|---|---|---|
|
|
92
|
+
| `claude` | `claude -p` | ✅ full | resume, name, fork, streamed |
|
|
93
|
+
| `codex` | `codex exec` | 🧪 experimental | text output |
|
|
94
|
+
| `opencode` | `opencode run` | 🧪 experimental | text output |
|
|
95
|
+
| `commandcode` | `commandcode -p` | 🧪 experimental | text output |
|
|
96
|
+
| `gemini` | `gemini -p` | 🧪 experimental | text output |
|
|
97
|
+
|
|
98
|
+
Experimental advisors ship best-effort default flags. If your install differs, fix them without touching code — see below.
|
|
99
|
+
|
|
100
|
+
### Add or fix an advisor
|
|
101
|
+
|
|
102
|
+
Create `~/.config/crossagent/advisors.json`:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"advisors": {
|
|
107
|
+
"codex": { "executable": "codex", "base_args": ["exec", "--full-auto"] },
|
|
108
|
+
"myllm": { "executable": "myllm", "prompt_delivery": "flag:-q", "model_flag": "--model" }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
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).
|
|
114
|
+
|
|
115
|
+
## Skill usage inside an agent
|
|
116
|
+
|
|
117
|
+
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).
|
|
118
|
+
|
|
119
|
+
## Examples
|
|
120
|
+
|
|
121
|
+
- [Architecture decision](examples/architecture-decision.md)
|
|
122
|
+
- [Debugging second opinion](examples/debug-second-opinion.md)
|
|
123
|
+
|
|
124
|
+
## Security
|
|
125
|
+
|
|
126
|
+
- Secrets never belong in prompts, CLI args, logs, or copied context — the protocol says so and you should enforce it.
|
|
127
|
+
- Second-opinion sessions run read-only by convention; for Claude use `--tools ""` for pure reasoning or restrict with `--allowedTools`.
|
|
128
|
+
- Everything runs locally against CLIs you already trust. `crossagent` adds no network calls of its own.
|
|
129
|
+
|
|
130
|
+
## Contributing
|
|
131
|
+
|
|
132
|
+
Issues and PRs welcome — especially hardening the experimental advisors against real installs. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT © Dat Nguyen. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Example — Architecture decision
|
|
2
|
+
|
|
3
|
+
**Situation:** You're in Codex working on a payments service and you're torn on
|
|
4
|
+
where a retry should live. You want Claude to challenge your leaning before you
|
|
5
|
+
commit.
|
|
6
|
+
|
|
7
|
+
## 1. Package the decision
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cat > /tmp/retry.md <<'EOF'
|
|
11
|
+
<role>
|
|
12
|
+
You are a senior teammate in a joint decision. Evaluate independently, inspect
|
|
13
|
+
read-only context if useful, and return an evidence-backed critique. Do not edit files.
|
|
14
|
+
</role>
|
|
15
|
+
<decision>Should the payment retry live in the async worker or the API request path?</decision>
|
|
16
|
+
<current_state>
|
|
17
|
+
Retries are inline in the API handler today. p99 latency regressed 40% under load.
|
|
18
|
+
Idempotency keys already exist on the charge endpoint.
|
|
19
|
+
</current_state>
|
|
20
|
+
<constraints>No new infra this quarter. Must remain idempotent. Ship in 1 week.</constraints>
|
|
21
|
+
<evidence>
|
|
22
|
+
- src/api/payments.ts:88 (inline retry loop)
|
|
23
|
+
- worker/queue.ts:12 (existing queue consumer)
|
|
24
|
+
- bench/2026-07.md (load test showing the regression)
|
|
25
|
+
</evidence>
|
|
26
|
+
<questions>
|
|
27
|
+
1. Which layer, and why?
|
|
28
|
+
2. Strongest counterargument to your pick?
|
|
29
|
+
3. Cheapest way to validate before committing?
|
|
30
|
+
</questions>
|
|
31
|
+
<response_format>Position / Evidence / Risks And Counterarguments / Recommendation / Unresolved Questions</response_format>
|
|
32
|
+
EOF
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 2. Ask a peer
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
crossagent --agent claude --name payments-retry-design --cwd "$PWD" \
|
|
39
|
+
--tools "Read,Grep" --prompt-file /tmp/retry.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--tools "Read,Grep"` lets Claude open the cited files to verify claims, but not modify anything.
|
|
43
|
+
|
|
44
|
+
## 3. Follow up on the same decision
|
|
45
|
+
|
|
46
|
+
New load numbers came in. Resume — no need to re-explain:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cat > /tmp/retry-followup.md <<'EOF'
|
|
50
|
+
Resuming the same decision. New evidence: moving retries to the worker in a spike
|
|
51
|
+
dropped p99 by 35% but added ~2s worst-case settlement delay. Does that change your
|
|
52
|
+
recommendation?
|
|
53
|
+
EOF
|
|
54
|
+
|
|
55
|
+
crossagent --name payments-retry-design --prompt-file /tmp/retry-followup.md
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 4. Synthesize
|
|
59
|
+
|
|
60
|
+
Reconcile Claude's critique with your own view: where do you agree, where do you
|
|
61
|
+
diverge, and what's the cheapest next check? Make the call — don't just adopt the
|
|
62
|
+
last answer.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Example — Debugging second opinion
|
|
2
|
+
|
|
3
|
+
**Situation:** You've been staring at a flaky test for an hour. Your current agent
|
|
4
|
+
keeps proposing the same fix. Get a fresh, independent diagnosis from a different
|
|
5
|
+
agent before you spend more time.
|
|
6
|
+
|
|
7
|
+
## 1. Package the symptom, not your theory
|
|
8
|
+
|
|
9
|
+
Give the advisor the evidence and let it form its own hypothesis — don't lead it
|
|
10
|
+
to your conclusion.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cat > /tmp/flaky.md <<'EOF'
|
|
14
|
+
<decision>Root cause of the intermittent failure in tests/orders.spec.ts::"cancels a pending order".</decision>
|
|
15
|
+
<current_state>
|
|
16
|
+
Fails ~1 in 5 local runs, ~1 in 3 in CI. Always the same assertion: order.status
|
|
17
|
+
expected "cancelled" but got "pending".
|
|
18
|
+
</current_state>
|
|
19
|
+
<evidence>
|
|
20
|
+
- tests/orders.spec.ts:41
|
|
21
|
+
- src/orders/cancel.ts:20 (awaits a DB write then reads back)
|
|
22
|
+
- No explicit transaction around the write+read.
|
|
23
|
+
</evidence>
|
|
24
|
+
<context_collection>
|
|
25
|
+
You may run: rg, sed, git log -p on these files. Read-only. Do not run the test suite.
|
|
26
|
+
</context_collection>
|
|
27
|
+
<questions>
|
|
28
|
+
1. Your independent hypothesis for the flakiness?
|
|
29
|
+
2. What evidence in the files supports or refutes it?
|
|
30
|
+
3. Smallest reliable reproduction or check?
|
|
31
|
+
</questions>
|
|
32
|
+
<response_format>Position / Evidence / Risks And Counterarguments / Recommendation / Unresolved Questions</response_format>
|
|
33
|
+
EOF
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 2. Ask a different model than the one that's stuck
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
crossagent --agent claude --name orders-flaky-cancel --cwd "$PWD" \
|
|
40
|
+
--tools "Read,Grep,Bash" --prompt-file /tmp/flaky.md
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 3. Compare
|
|
44
|
+
|
|
45
|
+
If the advisor independently lands on the same root cause you suspected, your
|
|
46
|
+
confidence should rise. If it points somewhere else — a race between the write
|
|
47
|
+
and the read-back, say — you just saved an hour chasing the wrong fix. Either
|
|
48
|
+
way, **you** verify the cited lines before changing code.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crossagent"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Get a second opinion from another AI coding agent. Ask Claude, Codex, OpenCode, CommandCode, or Gemini — from inside the agent you already use."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Dat Nguyen" }]
|
|
14
|
+
keywords = ["claude", "codex", "opencode", "agent", "mcp", "coding-agent", "cli", "ai", "second-opinion"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Software Development :: Libraries",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/datj9/crossagent"
|
|
33
|
+
Repository = "https://github.com/datj9/crossagent"
|
|
34
|
+
Issues = "https://github.com/datj9/crossagent/issues"
|
|
35
|
+
Changelog = "https://github.com/datj9/crossagent/blob/main/CHANGELOG.md"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
crossagent = "crossagent.cli:main"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = ["pytest>=7"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "src/crossagent/__init__.py"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/crossagent"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = ["src/crossagent", "skills", "examples", "README.md", "LICENSE"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
pythonpath = ["src"]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crossagent
|
|
3
|
+
description: Use when the user wants a second opinion from another AI coding agent before deciding — "crossagent", "ask Claude", "hoi y voi Claude", "ask Codex", "debate with Claude", "get a second opinion", "what would Claude/Codex say". Runs crossagent to ask a peer agent (Claude, Codex, OpenCode, CommandCode, Gemini) via its CLI with named, resumable Claude sessions, streamed progress, a packaged context prompt, and a final synthesis comparing both viewpoints.
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
author: Dat Nguyen
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# crossagent
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
Treat a peer AI agent as a teammate for decision-quality work, not as an oracle.
|
|
13
|
+
Call the peer's CLI with enough context for it to evaluate the problem
|
|
14
|
+
independently, wait for the answer, then compare its view with your own before
|
|
15
|
+
recommending a path. The default advisor is **Claude** (`claude -p`); Codex,
|
|
16
|
+
OpenCode, CommandCode, and Gemini are also supported.
|
|
17
|
+
|
|
18
|
+
## Decision Flow
|
|
19
|
+
|
|
20
|
+
1. Confirm the second-opinion target: decision to make, current state, constraints, options considered, and what evidence would change the answer.
|
|
21
|
+
2. Decide whether to ask a peer. Do so when the user asks directly, when architecture or prompt decisions benefit from an adversarial second view, or when the problem has credible tradeoffs. Skip for trivial commands, purely mechanical edits, or anything requiring secrets.
|
|
22
|
+
3. Build a compact context package: absolute paths, relevant files, current findings, known unknowns, and safe read-only commands the advisor may run.
|
|
23
|
+
4. Choose a stable session name before the first call. Reuse it for the same problem so later turns resume. Use a new name for a different decision; use `--fork-session` for an alternative branch of the same history.
|
|
24
|
+
5. Call the advisor and keep the process alive until it exits. Do independent local work while it runs; if the next step depends on the answer, wait and monitor progress instead of interrupting.
|
|
25
|
+
6. Compare outputs. Separate confirmed facts, claims needing verification, disagreements, unresolved questions, and the recommended synthesis. Do not outsource final judgment.
|
|
26
|
+
7. If material conflict remains, run a follow-up in the same session with the conflict framed explicitly. Stop when the remaining uncertainty is named and decision-useful.
|
|
27
|
+
|
|
28
|
+
## CLI Helper
|
|
29
|
+
|
|
30
|
+
Prefer the bundled helper for non-trivial second-opinion sessions:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
crossagent --agent claude \
|
|
34
|
+
--name "project-topic-decision" \
|
|
35
|
+
--cwd "$PWD" \
|
|
36
|
+
--prompt-file /tmp/crossagent.md
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The helper:
|
|
40
|
+
|
|
41
|
+
- Invokes the chosen advisor's CLI (default `claude -p`).
|
|
42
|
+
- Streams progress by default (Claude `stream-json`); other advisors capture text output.
|
|
43
|
+
- Stores `session_id` by `advisor:name` in `~/.config/crossagent/sessions.json` and auto-resumes when the same `--name` is reused.
|
|
44
|
+
- Has no default timeout and no default cost cap; it waits until the advisor exits.
|
|
45
|
+
- Writes the advisor's final answer to stdout and progress/metadata to stderr.
|
|
46
|
+
|
|
47
|
+
Switch advisor with `--agent codex|opencode|commandcode|gemini`. List what's available with `crossagent --list-advisors`. Add or fix an advisor in `~/.config/crossagent/advisors.json` (no code change needed). Use `--new-session` to ignore a stored session, `--resume ID_OR_SEARCH` to force a resume target, `--fork-session` to branch, and `--model` to raise the model only when the decision justifies it.
|
|
48
|
+
|
|
49
|
+
## Prompting The Advisor
|
|
50
|
+
|
|
51
|
+
Read [references/second-opinion-protocol.md](references/second-opinion-protocol.md) when preparing a substantive prompt (architecture, prompt, eval, or debugging decisions).
|
|
52
|
+
|
|
53
|
+
Use XML-like sections. Ask the advisor to evaluate, inspect, compare, or trace; avoid "think step by step". Give it permission to gather more context with read-only commands, and ask it to cite file paths, commands, or assumptions behind claims.
|
|
54
|
+
|
|
55
|
+
## Operating Rules
|
|
56
|
+
|
|
57
|
+
- Keep secrets out of prompts, CLI args, logs, and copied context.
|
|
58
|
+
- Default to a routine model; raise `--model` only when the decision justifies it.
|
|
59
|
+
- No cost cap by default. Add one only when the user explicitly asks for a spend limit.
|
|
60
|
+
- For Claude: use `--safe-mode` when the advisor does not need repo config/skills/hooks; `--tools ""` for pure reasoning; allow read/search tools only when it must inspect files.
|
|
61
|
+
- Report both the advisor's answer and your own synthesis. Name where the two views agree, diverge, or leave uncertainty.
|
|
62
|
+
- Preserve long-running calls. Do not abandon a started second-opinion session unless the user cancels it or the process fails.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Second-Opinion Protocol
|
|
2
|
+
|
|
3
|
+
Use this reference when a second opinion affects architecture, prompt behavior,
|
|
4
|
+
debugging strategy, eval interpretation, or another decision where the second
|
|
5
|
+
opinion must be evidence-backed.
|
|
6
|
+
|
|
7
|
+
## Context Package
|
|
8
|
+
|
|
9
|
+
Provide enough context for the advisor to form an independent view:
|
|
10
|
+
|
|
11
|
+
- Target decision: the answer the two agents are trying to settle.
|
|
12
|
+
- Current state: what exists, what has been observed, and what already works.
|
|
13
|
+
- Constraints: time, scope, ownership boundaries, safety rules, explicit cost caps if any, and anti-scope.
|
|
14
|
+
- Evidence: file paths, command outputs, logs, test failures, screenshots, artifacts, or prior conclusions.
|
|
15
|
+
- Candidate options: the realistic options you see so far, including the boring/default option when relevant.
|
|
16
|
+
- Read-only collection plan: commands or files the advisor may inspect to verify claims.
|
|
17
|
+
- Output contract: the exact comparison format you need to synthesize a decision.
|
|
18
|
+
|
|
19
|
+
Keep the package compact. Do not paste large files when file paths and targeted read instructions are enough.
|
|
20
|
+
|
|
21
|
+
## Session Naming
|
|
22
|
+
|
|
23
|
+
Use names that are stable across turns and narrow enough to avoid cross-topic contamination:
|
|
24
|
+
|
|
25
|
+
- `brandmind-slide-service-architecture`
|
|
26
|
+
- `atlas-kb-ingest-wakeup-boundary`
|
|
27
|
+
- `prompt-eval-rubric-format-drift`
|
|
28
|
+
|
|
29
|
+
Reuse a name when the same decision continues. Send a short delta summary when resuming:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
We are resuming the same decision. New evidence since the last turn:
|
|
33
|
+
- ...
|
|
34
|
+
- ...
|
|
35
|
+
Please update or revise your prior recommendation.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Start a new name when the user changes the problem, project, or decision criterion. Use `--fork-session` when exploring an alternative line while preserving prior context.
|
|
39
|
+
|
|
40
|
+
## Prompt Template
|
|
41
|
+
|
|
42
|
+
```xml
|
|
43
|
+
<role>
|
|
44
|
+
You are a senior teammate in a joint decision. Evaluate the problem
|
|
45
|
+
independently, inspect additional context when useful, and return a
|
|
46
|
+
decision-useful critique. Do not edit files unless explicitly asked. Treat this
|
|
47
|
+
as a read-only review.
|
|
48
|
+
</role>
|
|
49
|
+
|
|
50
|
+
<decision>
|
|
51
|
+
[The decision to settle.]
|
|
52
|
+
</decision>
|
|
53
|
+
|
|
54
|
+
<current_state>
|
|
55
|
+
[What is already known. Include exact dates for time-sensitive state.]
|
|
56
|
+
</current_state>
|
|
57
|
+
|
|
58
|
+
<constraints>
|
|
59
|
+
[Scope, anti-scope, safety, ownership, deadlines, and any explicit cost cap.]
|
|
60
|
+
</constraints>
|
|
61
|
+
|
|
62
|
+
<evidence>
|
|
63
|
+
[File paths, logs, command outputs, prior findings. Mark unverified claims.]
|
|
64
|
+
</evidence>
|
|
65
|
+
|
|
66
|
+
<context_collection>
|
|
67
|
+
You may gather more context using read-only inspection such as pwd, ls, find,
|
|
68
|
+
rg, sed, awk, wc, git status, git diff, git log, git show, and opening relevant
|
|
69
|
+
files. Avoid commands that modify files, start services, install packages, call
|
|
70
|
+
paid external services, or expose secrets. If needed context is unavailable,
|
|
71
|
+
name the missing evidence and proceed with caveats.
|
|
72
|
+
</context_collection>
|
|
73
|
+
|
|
74
|
+
<questions>
|
|
75
|
+
1. What is your independent diagnosis or architecture framing?
|
|
76
|
+
2. Which option do you recommend, and why?
|
|
77
|
+
3. What are the strongest risks, failure modes, or counterarguments?
|
|
78
|
+
4. What evidence supports your claims? Cite files, commands, or assumptions.
|
|
79
|
+
5. What remains unresolved, and what is the cheapest next validation step?
|
|
80
|
+
</questions>
|
|
81
|
+
|
|
82
|
+
<response_format>
|
|
83
|
+
Return concise markdown with these sections:
|
|
84
|
+
- Position
|
|
85
|
+
- Evidence
|
|
86
|
+
- Risks And Counterarguments
|
|
87
|
+
- Recommendation
|
|
88
|
+
- Unresolved Questions
|
|
89
|
+
</response_format>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Running The Helper
|
|
93
|
+
|
|
94
|
+
Prepare the prompt in a temporary file, then call:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
crossagent --agent claude \
|
|
98
|
+
--name "stable-topic-name" \
|
|
99
|
+
--cwd "$PWD" \
|
|
100
|
+
--model sonnet \
|
|
101
|
+
--prompt-file /tmp/crossagent.md
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Useful variants:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Pure reasoning, no tools (Claude).
|
|
108
|
+
crossagent --name "stable-topic-name" --tools "" --prompt-file /tmp/crossagent.md
|
|
109
|
+
|
|
110
|
+
# Ask a different advisor.
|
|
111
|
+
crossagent --agent codex --name "stable-topic-name" --prompt-file /tmp/crossagent.md
|
|
112
|
+
|
|
113
|
+
# Resume a known session directly.
|
|
114
|
+
crossagent --resume "SESSION_ID_OR_SEARCH_TERM" --prompt-file /tmp/followup.md
|
|
115
|
+
|
|
116
|
+
# Branch the same second-opinion history.
|
|
117
|
+
crossagent --name "stable-topic-name" --fork-session --prompt-file /tmp/variant.md
|
|
118
|
+
|
|
119
|
+
# Add a spend cap only when the user explicitly asks (Claude via --raw-arg).
|
|
120
|
+
crossagent --name "stable-topic-name" --raw-arg --max-budget-usd --raw-arg 0.50 \
|
|
121
|
+
--prompt-file /tmp/capped.md
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Synthesizing The Answer
|
|
125
|
+
|
|
126
|
+
1. Verify file/path/command claims when they matter and are cheap to check.
|
|
127
|
+
2. Separate agreement, disagreement, and new information.
|
|
128
|
+
3. Prefer the recommendation that best satisfies the goal, constraints, and evidence — not the louder or more recent answer.
|
|
129
|
+
4. Run a follow-up only when the conflict changes the decision or a missing fact is cheap to inspect.
|
|
130
|
+
5. Finalize with the chosen path, rationale, risks, and next action.
|
|
131
|
+
|
|
132
|
+
## Failure Handling
|
|
133
|
+
|
|
134
|
+
- If a spend cap makes the advisor stop before the decision settles, remove or raise it deliberately and retry with a tighter context package.
|
|
135
|
+
- For Claude streaming, `stream-json` requires `--verbose` (the helper adds it automatically).
|
|
136
|
+
- If a variadic option like `--tools` precedes the prompt, the helper inserts `--` so the prompt is not swallowed.
|
|
137
|
+
- If a session was created without persistence, do not expect it to resume even if a `session_id` was returned.
|
|
138
|
+
- Experimental advisors (codex, opencode, commandcode, gemini) use best-effort default flags. If your install differs, correct them in `~/.config/crossagent/advisors.json`.
|
|
@@ -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]
|
|
@@ -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())
|
|
@@ -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
|