@doguyilmaz/konvoy 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/package.json +52 -0
- package/src/adapters/claude.ts +83 -0
- package/src/adapters/codex.ts +67 -0
- package/src/adapters/effort.ts +16 -0
- package/src/adapters/index.ts +20 -0
- package/src/adapters/kiro.ts +79 -0
- package/src/adapters/opencode.ts +64 -0
- package/src/adapters/types.ts +108 -0
- package/src/args.ts +42 -0
- package/src/chart.ts +91 -0
- package/src/cli.ts +146 -0
- package/src/commands/attach.ts +85 -0
- package/src/commands/config.ts +113 -0
- package/src/commands/dashboard.ts +26 -0
- package/src/commands/doctor.ts +104 -0
- package/src/commands/ls.ts +15 -0
- package/src/commands/new.ts +24 -0
- package/src/commands/resume.ts +14 -0
- package/src/commands/rm.ts +28 -0
- package/src/commands/roster.ts +37 -0
- package/src/commands/send.ts +79 -0
- package/src/commands/status.ts +35 -0
- package/src/commands/table.ts +75 -0
- package/src/commands/update.ts +72 -0
- package/src/commands/usage.ts +77 -0
- package/src/config/load.ts +335 -0
- package/src/config/schema.ts +100 -0
- package/src/core/children.ts +62 -0
- package/src/core/detect.ts +211 -0
- package/src/core/facts.ts +113 -0
- package/src/core/gate.ts +73 -0
- package/src/core/prelude.ts +121 -0
- package/src/core/session.ts +334 -0
- package/src/core/turn.ts +263 -0
- package/src/dashboard/page.ts +211 -0
- package/src/format.ts +98 -0
- package/src/paths.ts +33 -0
- package/src/pricing.ts +86 -0
- package/src/store/db.ts +78 -0
- package/src/store/queries.ts +434 -0
- package/src/types.ts +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dogu Yilmaz
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# konvoy
|
|
2
|
+
|
|
3
|
+
One session across Claude Code, Codex, Kiro CLI and opencode. konvoy binds a foreign
|
|
4
|
+
session per CLI, keeps them on one shared brief, and lets you move between them without
|
|
5
|
+
re-explaining anything.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
macOS, via the tap (a signed binary; no Bun needed):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
brew install --cask doguyilmaz/tap/konvoy
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Linux, from the release tarball (no Bun needed):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
curl -fsSL https://github.com/doguyilmaz/konvoy/releases/latest/download/konvoy_linux_amd64.tar.gz | tar xz konvoy
|
|
19
|
+
install -m 755 konvoy ~/.local/bin/konvoy
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
With Bun 1.4+ already installed, from npm:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add -g @doguyilmaz/konvoy # or run once: bunx @doguyilmaz/konvoy help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
From a checkout:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bun install
|
|
32
|
+
bun run build # produces ./dist/konvoy
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Updating follows the channel: `brew upgrade --cask konvoy`, `bun add -g @doguyilmaz/konvoy@latest`, or `bun run build`.
|
|
36
|
+
|
|
37
|
+
The brew and tarball binaries carry the Bun runtime, so each is about 60 MB on disk and 25–35 MB
|
|
38
|
+
to download; the npm package is a few kilobytes of source and runs on the Bun you already have.
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
konvoy new "refactor the auth layer"
|
|
44
|
+
konvoy send codex "start with the token refresh path"
|
|
45
|
+
konvoy ls
|
|
46
|
+
konvoy resume # make a session current again and show its roster
|
|
47
|
+
konvoy roster
|
|
48
|
+
konvoy usage --all --chart # GATE reads as a dash until a `gate` command is configured
|
|
49
|
+
konvoy status
|
|
50
|
+
konvoy attach codex # drops you into the real Codex TUI, same session
|
|
51
|
+
konvoy attach kiro --id cli_8a1… # adopt a session you started in kiro's own TUI; the next turn resumes it
|
|
52
|
+
konvoy doctor
|
|
53
|
+
konvoy update --all # every agent CLI; konvoy itself follows its install channel (see Install)
|
|
54
|
+
konvoy rm stale-slug --yes
|
|
55
|
+
konvoy version
|
|
56
|
+
konvoy dashboard --port 4000 # local page with the same numbers as `usage --chart`
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Sample output
|
|
60
|
+
|
|
61
|
+
Captured by running konvoy against a scratch database, not copied from a real project.
|
|
62
|
+
|
|
63
|
+
`konvoy usage --all --chart`:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
all sessions
|
|
67
|
+
AGENT TURNS IN OUT SPEND GATE
|
|
68
|
+
claude 27 25560 5040 $6.18 -
|
|
69
|
+
codex 17 14800 2850 $1.39 -
|
|
70
|
+
kiro 6 4920 930 0.190 cr -
|
|
71
|
+
opencode 3 2400 450 - -
|
|
72
|
+
|
|
73
|
+
spend is in each agent's own unit; a dash means the CLI reported none
|
|
74
|
+
|
|
75
|
+
turns per day
|
|
76
|
+
Sun ·▪█
|
|
77
|
+
Mon ·▩·
|
|
78
|
+
Tue ·▩·
|
|
79
|
+
Wed ·▫·
|
|
80
|
+
Thu ·▩·
|
|
81
|
+
Fri ▪█·
|
|
82
|
+
Sat ▩█·
|
|
83
|
+
|
|
84
|
+
share of turns
|
|
85
|
+
claude █████████░░░░░░░░░ 51%
|
|
86
|
+
codex ██████░░░░░░░░░░░░ 32%
|
|
87
|
+
kiro ██░░░░░░░░░░░░░░░░ 11%
|
|
88
|
+
opencode █░░░░░░░░░░░░░░░░░ 6%
|
|
89
|
+
|
|
90
|
+
turns per day by agent
|
|
91
|
+
claude ▄▅▂▇▄▁▅█▇▅
|
|
92
|
+
codex ▂▂▄▁▅▄▂▄▅▄
|
|
93
|
+
kiro ▁▂▁▂▁▁▂▁▄▂
|
|
94
|
+
opencode ▁▁▂▁▁▁▁▂▁▂
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A failover notice, when codex hits its weekly limit mid-chain:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
konvoy: codex is blocked (rate) — "You've hit your weekly limit · resets 7am" — claude is taking over
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A handoff, with `delegation.enabled` on and `roles.reviewer` set to `claude`. codex ends its
|
|
104
|
+
turn with a `<<<konvoy ... >>>` block naming the `reviewer` role:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
Fixed: the refresh call was firing on a fixed 55-minute timer, so a laptop asleep past
|
|
108
|
+
that mark woke up to a 401. Switched it to refresh on 401 with a single in-flight retry.
|
|
109
|
+
|
|
110
|
+
<<<konvoy
|
|
111
|
+
to: reviewer
|
|
112
|
+
task: check the retry does not loop when the refresh itself 401s
|
|
113
|
+
open:
|
|
114
|
+
- whether a second consecutive 401 should sign the user out instead of retrying again
|
|
115
|
+
decisions:
|
|
116
|
+
- refresh on 401 rather than on a timer, it tracks the actual failure instead of a guess
|
|
117
|
+
>>>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
konvoy resolves `reviewer` to claude, runs it, and prints:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
konvoy: codex handed off to claude — "check the retry does not loop when the refresh itself 401s"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
claude's turn runs with codex's task as its prompt, preceded by this prelude:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
goal: fix the token refresh bug
|
|
130
|
+
|
|
131
|
+
codex handed off to reviewer:
|
|
132
|
+
task: check the retry does not loop when the refresh itself 401s
|
|
133
|
+
open:
|
|
134
|
+
- whether a second consecutive 401 should sign the user out instead of retrying again
|
|
135
|
+
decisions:
|
|
136
|
+
- refresh on 401 rather than on a timer, it tracks the actual failure instead of a guess
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How it works
|
|
140
|
+
|
|
141
|
+
One konvoy session holds a binding per agent, and each binding holds that agent's own
|
|
142
|
+
foreign session id — konvoy's id and the agent's id are never the same thing. Only claude
|
|
143
|
+
accepts a caller-chosen session id up front; the other three assign their own and hand it
|
|
144
|
+
back after the first turn, which konvoy stores in that agent's binding and resumes on every
|
|
145
|
+
turn after.
|
|
146
|
+
|
|
147
|
+
```mermaid
|
|
148
|
+
flowchart LR
|
|
149
|
+
session["konvoy session<br/>(one slug)"]
|
|
150
|
+
session --> bClaude["binding: claude"]
|
|
151
|
+
session --> bCodex["binding: codex"]
|
|
152
|
+
session --> bKiro["binding: kiro"]
|
|
153
|
+
session --> bOpencode["binding: opencode"]
|
|
154
|
+
|
|
155
|
+
bClaude -->|"via --session-id or --resume<br/>← session_id"| claudeCli(["claude session"])
|
|
156
|
+
bCodex -->|"resume <id> subcommand<br/>← thread_id"| codexCli(["codex thread"])
|
|
157
|
+
bKiro -->|"via --resume-id<br/>← sessionId"| kiroCli(["kiro-cli session"])
|
|
158
|
+
bOpencode -->|"via --session<br/>← sessionID"| opencodeCli(["opencode session"])
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Configure
|
|
162
|
+
|
|
163
|
+
Global `~/.config/konvoy/config.jsonc`, per project `.konvoy/config.jsonc`. The project
|
|
164
|
+
file wins. `konvoy config get` shows each agent's resolved settings and whether a value came
|
|
165
|
+
from that agent, from `defaults`, or from konvoy's own built-in.
|
|
166
|
+
|
|
167
|
+
```jsonc
|
|
168
|
+
{
|
|
169
|
+
"defaults": { "effort": "high", "permission": "edit" },
|
|
170
|
+
"agents": {
|
|
171
|
+
"claude": { "model": "opus", "effort": "max" },
|
|
172
|
+
"codex": { "model": "gpt-6-astra" }
|
|
173
|
+
},
|
|
174
|
+
"roles": { "lead": "claude", "reviewer": "kiro" }
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`effort` is one scale — `low | medium | high | max` — mapped onto each CLI's own dial and
|
|
179
|
+
clamped to what the target model actually supports.
|
|
180
|
+
|
|
181
|
+
If a CLI is not on your `PATH`, point konvoy at it directly and every command — `doctor`,
|
|
182
|
+
`status`, `send`, `attach`, `update` — uses that path:
|
|
183
|
+
|
|
184
|
+
```jsonc
|
|
185
|
+
{ "agents": { "opencode": { "bin": "~/.opencode/bin/opencode" } } }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`konvoy config set <key> <value> [--global]` rewrites the layer it touches as plain JSON, so
|
|
189
|
+
any comments in that file are lost — `--global` targets the global file instead of the
|
|
190
|
+
project one. Hand-edit the file instead when you want to keep them.
|
|
191
|
+
|
|
192
|
+
Name a `failover` chain and konvoy follows it when an agent can't work, instead of asking:
|
|
193
|
+
|
|
194
|
+
```jsonc
|
|
195
|
+
{ "failover": { "chain": ["codex", "claude", "kiro"], "upstreamRetries": 3 } }
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A rate limit or an auth failure moves to the next agent in the chain at once. An agent that failed on auth shows as `auth_required` in the roster until one of its turns succeeds. An upstream
|
|
199
|
+
error (a reachable-but-refusing API) retries the same agent with backoff up to
|
|
200
|
+
`upstreamRetries` times before moving on. A crash, a timeout, or an interrupted turn never
|
|
201
|
+
moves the chain — the fault is in the work, and the next agent would just fail the same way.
|
|
202
|
+
There is no failback: once konvoy moves, it stays moved. An empty chain (the default) turns
|
|
203
|
+
the feature off.
|
|
204
|
+
|
|
205
|
+
```mermaid
|
|
206
|
+
flowchart TD
|
|
207
|
+
run["run current agent"] --> check{"error kind"}
|
|
208
|
+
check -->|"rate or auth"| move["move to next chain agent<br/>(no retry)"]
|
|
209
|
+
check -->|upstream| retry{"retries < upstreamRetries?"}
|
|
210
|
+
retry -->|"yes, with backoff"| run
|
|
211
|
+
retry -->|no| move
|
|
212
|
+
check -->|"none, crash, timeout,<br/>interrupted, or other"| stay["return result<br/>chain stops here"]
|
|
213
|
+
move --> run
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Set `style: "brief"` to have an agent lead with the action, number multi-step work, and skip
|
|
217
|
+
preamble and pleasantries — it shapes the answer you read, not what agents send each other:
|
|
218
|
+
|
|
219
|
+
```jsonc
|
|
220
|
+
{ "defaults": { "style": "brief" }, "agents": { "kiro": { "style": null } } }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Set `delegation.enabled` to have every turn told how to hand work to another agent — a
|
|
224
|
+
`<<<konvoy ... >>>` block naming `to:` and `task:`, with optional `open:` and `decisions:`
|
|
225
|
+
lists — instead of the weaker summary konvoy derives on its own. The agent decides when a
|
|
226
|
+
turn is actually handing off; a turn that isn't emits no block at all, so this costs nothing
|
|
227
|
+
on the turns that don't need it. Off by default: a single-agent session has no handoff to
|
|
228
|
+
describe.
|
|
229
|
+
|
|
230
|
+
```jsonc
|
|
231
|
+
{ "delegation": { "enabled": true } }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Name a `gate` command — your test suite, a linter, whatever exits non-zero on bad work — and
|
|
235
|
+
konvoy runs it after each turn that produced something, recording a pass or fail against that
|
|
236
|
+
turn. A command that can't even be spawned records nothing, and a failed turn is never gated:
|
|
237
|
+
|
|
238
|
+
```jsonc
|
|
239
|
+
{ "gate": { "command": "bun test" } }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`harness` decides how much of a CLI's own setup a turn loads. `minimal`, the default, strips
|
|
243
|
+
what konvoy already supplies — claude runs with no MCP servers, slash commands or settings
|
|
244
|
+
files, codex with `--ignore-user-config` — and measured 2.6× less context per turn than
|
|
245
|
+
`inherit`, which runs the CLI exactly as you would by hand, hooks and skills included. kiro and
|
|
246
|
+
opencode run with their own configuration either way. Privileged: the global config only.
|
|
247
|
+
|
|
248
|
+
```jsonc
|
|
249
|
+
{ "defaults": { "harness": "inherit" } }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`gate` is privileged like `bin`, `permission` and `harness` — only the global config may set
|
|
253
|
+
it, since a gate runs on every turn with no per-turn opt-in, unlike an agent binary the user
|
|
254
|
+
chose to run. That also means one gate command serves every project; there's no per-project
|
|
255
|
+
override yet.
|
|
256
|
+
|
|
257
|
+
## Requirements
|
|
258
|
+
|
|
259
|
+
Whichever of `claude`, `codex`, `kiro-cli`, `opencode` you want in the convoy. Bun 1.4+ only for the npm install or a checkout; the brew and tarball binaries carry their own runtime.
|
|
260
|
+
Each authenticates itself; konvoy never handles credentials.
|
|
261
|
+
|
|
262
|
+
## Releasing
|
|
263
|
+
|
|
264
|
+
Bump `version` in `package.json`, then `git tag vX.Y.Z && git push --tags`. The release
|
|
265
|
+
workflow builds four binaries (macOS arm64 and x64, signed and notarized; Linux amd64 and
|
|
266
|
+
arm64), publishes them with checksums, updates `Casks/konvoy.rb` in `doguyilmaz/homebrew-tap`,
|
|
267
|
+
and publishes to npm. It reads these repository secrets, each declared in `.env.schema`:
|
|
268
|
+
`HOMEBREW_TAP_GITHUB_TOKEN`, `MACOS_SIGN_P12`, `MACOS_SIGN_PASSWORD`,
|
|
269
|
+
`MACOS_NOTARY_ISSUER_ID`, `MACOS_NOTARY_KEY_ID`, `MACOS_NOTARY_KEY`. Signing and the tap push are
|
|
270
|
+
skipped when their secret is absent; npm is published through trusted publishing (OIDC), configured
|
|
271
|
+
once on npmjs.com, so there is no npm token. To publish by hand, put the
|
|
272
|
+
values in `.env.local` (gitignored) and run each step through `bunx varlock run -- <command>`,
|
|
273
|
+
which injects and redacts them instead of having them pasted into a terminal.
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
bun test
|
|
279
|
+
bun run typecheck
|
|
280
|
+
bun run mutate # mutation coverage of src/
|
|
281
|
+
bun run verify:claims # checks konvoy's own claims about the four CLIs against what --help says here
|
|
282
|
+
bun run smoke # two real turns per installed, authenticated agent, the second resumed — spends quota
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`verify:claims` is the standing form of a manual check: it re-reads each CLI's own `--help`
|
|
286
|
+
and confirms what this README and the adapters assume — that every flag an adapter puts on a
|
|
287
|
+
command line still exists, that each agent's update and auth-status subcommands exist, that
|
|
288
|
+
only claude accepts a caller-chosen session id, and that opencode's `--session` continues a
|
|
289
|
+
session rather than creating one. The flag list is built from the adapters' real argv, so a flag
|
|
290
|
+
added to an adapter is checked without touching the script. It never sends a prompt or spends
|
|
291
|
+
quota, and it isn't part of `bun test` since it needs the CLIs installed to mean anything.
|
|
292
|
+
|
|
293
|
+
`smoke` closes the gap `verify:claims` and the frozen fixtures in `tests/fixtures/streams/`
|
|
294
|
+
both leave open: it runs two turns per installed, logged-in agent through konvoy's real
|
|
295
|
+
`send()`. The first stores a nonce and must return a foreign session id, some text and no
|
|
296
|
+
error; the second is resumed through the binding konvoy captured and must give the nonce back —
|
|
297
|
+
the one cheap proof that a bound session carries its context, which every unit test of it
|
|
298
|
+
checks with fakes. It skips an agent that isn't installed or isn't logged in, and flags
|
|
299
|
+
when an installed CLI's version has drifted from the one a fixture was captured against — the
|
|
300
|
+
moment to re-capture. It spends real quota, so it is opt-in and never part of `bun test`.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@doguyilmaz/konvoy",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "One session across Claude Code, Codex, Kiro CLI and opencode — bind, hand off, fail over",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/doguyilmaz/konvoy.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/doguyilmaz/konvoy#readme",
|
|
12
|
+
"bugs": "https://github.com/doguyilmaz/konvoy/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"cli",
|
|
15
|
+
"coding-agents",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"codex",
|
|
18
|
+
"kiro",
|
|
19
|
+
"opencode",
|
|
20
|
+
"bun"
|
|
21
|
+
],
|
|
22
|
+
"bin": {
|
|
23
|
+
"konvoy": "./src/cli.ts"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"bun": ">=1.4.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"build": "bun build ./src/cli.ts --compile --format=esm --minify --sourcemap --bytecode --no-compile-autoload-dotenv --no-compile-autoload-bunfig --outfile dist/konvoy",
|
|
39
|
+
"typecheck": "bunx tsc --noEmit",
|
|
40
|
+
"mutate": "bun run tests/mutations/run.ts",
|
|
41
|
+
"verify:claims": "bun run scripts/verify-claims.ts",
|
|
42
|
+
"smoke": "bun run scripts/smoke.ts"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"zod": "^4.6.5"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "latest",
|
|
49
|
+
"typescript": "^5.6.0",
|
|
50
|
+
"varlock": "^1.20.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Binding, KonvoyEvent, Permission, SpawnPlan, TurnContext } from '../types'
|
|
2
|
+
import { classifyError, safeJson, stripControlChars, withPrelude, type Adapter } from './types'
|
|
3
|
+
|
|
4
|
+
const PERMISSION: Record<Permission, string> = {
|
|
5
|
+
safe: 'manual',
|
|
6
|
+
edit: 'acceptEdits',
|
|
7
|
+
yolo: 'bypassPermissions',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const claudeAdapter: Adapter = {
|
|
11
|
+
id: 'claude',
|
|
12
|
+
bin: 'claude',
|
|
13
|
+
supportsPresetSessionId: true,
|
|
14
|
+
|
|
15
|
+
turn(ctx: TurnContext): SpawnPlan {
|
|
16
|
+
const cmd = [ctx.bin ?? 'claude', '-p', '--output-format', 'stream-json', '--verbose']
|
|
17
|
+
if (ctx.binding?.foreignId) cmd.push('--resume', ctx.binding.foreignId)
|
|
18
|
+
else cmd.push('--session-id', ctx.sessionId)
|
|
19
|
+
if (ctx.model) cmd.push('--model', ctx.model)
|
|
20
|
+
cmd.push('--effort', ctx.effort)
|
|
21
|
+
cmd.push('--permission-mode', PERMISSION[ctx.permission])
|
|
22
|
+
if ((ctx.harness ?? 'minimal') === 'minimal') {
|
|
23
|
+
cmd.push('--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--setting-sources', '')
|
|
24
|
+
}
|
|
25
|
+
cmd.push('--', withPrelude(ctx))
|
|
26
|
+
return { cmd, cwd: ctx.cwd }
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
parse(line: string): KonvoyEvent[] {
|
|
30
|
+
const o = safeJson(line)
|
|
31
|
+
if (!o) return []
|
|
32
|
+
|
|
33
|
+
if (o.subtype === 'init' && typeof o.session_id === 'string') {
|
|
34
|
+
return [{ t: 'session', foreignId: stripControlChars(o.session_id) }]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (o.type === 'assistant') {
|
|
38
|
+
const message = o.message as { content?: unknown[] } | undefined
|
|
39
|
+
const blocks = Array.isArray(message?.content) ? message.content : []
|
|
40
|
+
const events: KonvoyEvent[] = []
|
|
41
|
+
for (const raw of blocks) {
|
|
42
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
43
|
+
const block = raw as { type?: string; text?: string; thinking?: string; name?: string }
|
|
44
|
+
if (block.type === 'text' && block.text) events.push({ t: 'text', text: block.text })
|
|
45
|
+
if (block.type === 'thinking' && block.thinking) events.push({ t: 'thinking', text: block.thinking })
|
|
46
|
+
if (block.type === 'tool_use') events.push({ t: 'tool', name: block.name ?? 'tool', status: 'start' })
|
|
47
|
+
}
|
|
48
|
+
return events
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (o.type === 'result') {
|
|
52
|
+
const text = typeof o.result === 'string' ? o.result : ''
|
|
53
|
+
// a result can be is_error with no text at all — claude puts the reason on stderr then, and
|
|
54
|
+
// an invented placeholder here would stand in the way of turn.ts reading it
|
|
55
|
+
if (o.is_error) return [{ t: 'error', message: text, kind: classifyError(text) }]
|
|
56
|
+
const usage = o.usage as
|
|
57
|
+
| { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
58
|
+
| undefined
|
|
59
|
+
// claude splits input across three fields and input_tokens holds only the uncached
|
|
60
|
+
// remainder, so it reads near zero on a cached turn. codex's input_tokens already
|
|
61
|
+
// contains its cached count; summing here is what puts both agents in one unit.
|
|
62
|
+
const input = usage
|
|
63
|
+
? (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)
|
|
64
|
+
: undefined
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
t: 'usage',
|
|
68
|
+
inputTokens: input,
|
|
69
|
+
outputTokens: usage?.output_tokens,
|
|
70
|
+
costUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : undefined,
|
|
71
|
+
},
|
|
72
|
+
{ t: 'done', final: text },
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return []
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
attach(binding: Binding): SpawnPlan {
|
|
80
|
+
if (!binding.foreignId) return { cmd: ['claude'] }
|
|
81
|
+
return { cmd: ['claude', '--resume', binding.foreignId] }
|
|
82
|
+
},
|
|
83
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Binding, KonvoyEvent, Permission, SpawnPlan, TurnContext } from '../types'
|
|
2
|
+
import { classifyError, safeJson, stripControlChars, withPrelude, type Adapter } from './types'
|
|
3
|
+
|
|
4
|
+
const SANDBOX: Record<Permission, string[]> = {
|
|
5
|
+
safe: ['-s', 'read-only'],
|
|
6
|
+
edit: ['-s', 'workspace-write'],
|
|
7
|
+
yolo: ['--dangerously-bypass-approvals-and-sandbox'],
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const codexAdapter: Adapter = {
|
|
11
|
+
id: 'codex',
|
|
12
|
+
bin: 'codex',
|
|
13
|
+
supportsPresetSessionId: false,
|
|
14
|
+
|
|
15
|
+
turn(ctx: TurnContext): SpawnPlan {
|
|
16
|
+
const cmd = [ctx.bin ?? 'codex', 'exec', '--json', '--skip-git-repo-check']
|
|
17
|
+
if ((ctx.harness ?? 'minimal') === 'minimal') cmd.push('--ignore-user-config')
|
|
18
|
+
if (ctx.model) cmd.push('-m', ctx.model)
|
|
19
|
+
cmd.push('-c', `model_reasoning_effort=${ctx.effort}`)
|
|
20
|
+
cmd.push(...SANDBOX[ctx.permission])
|
|
21
|
+
// `resume` is a subcommand of `exec` with its own small flag set; the flags above belong to
|
|
22
|
+
// `exec` and are only parsed when they come first
|
|
23
|
+
if (ctx.binding?.foreignId) cmd.push('resume', ctx.binding.foreignId)
|
|
24
|
+
cmd.push('--', withPrelude(ctx))
|
|
25
|
+
return { cmd, cwd: ctx.cwd }
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
parse(line: string): KonvoyEvent[] {
|
|
29
|
+
const o = safeJson(line)
|
|
30
|
+
if (!o) return []
|
|
31
|
+
|
|
32
|
+
if (o.type === 'thread.started' && typeof o.thread_id === 'string') {
|
|
33
|
+
return [{ t: 'session', foreignId: stripControlChars(o.thread_id) }]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (o.type === 'item.completed') {
|
|
37
|
+
const item = o.item as { type?: string; text?: string; message?: string } | undefined
|
|
38
|
+
if (!item?.type) return []
|
|
39
|
+
if (item.type === 'agent_message') return item.text ? [{ t: 'text', text: item.text }] : []
|
|
40
|
+
if (item.type === 'reasoning') return item.text ? [{ t: 'thinking', text: item.text }] : []
|
|
41
|
+
if (item.type === 'error') {
|
|
42
|
+
const message = item.message ?? ''
|
|
43
|
+
return [{ t: 'error', message, kind: classifyError(message), source: 'item' }]
|
|
44
|
+
}
|
|
45
|
+
return [{ t: 'tool', name: item.type, status: 'ok' }]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (o.type === 'turn.completed') {
|
|
49
|
+
const usage = o.usage as { input_tokens?: number; output_tokens?: number } | undefined
|
|
50
|
+
if (!usage) return []
|
|
51
|
+
return [{ t: 'usage', inputTokens: usage.input_tokens, outputTokens: usage.output_tokens }]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (o.type === 'turn.failed') {
|
|
55
|
+
const error = o.error as { message?: string } | undefined
|
|
56
|
+
const message = error?.message ?? ''
|
|
57
|
+
return [{ t: 'error', message, kind: classifyError(message), source: 'turn' }]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return []
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
attach(binding: Binding): SpawnPlan {
|
|
64
|
+
if (!binding.foreignId) return { cmd: ['codex'] }
|
|
65
|
+
return { cmd: ['codex', 'resume', binding.foreignId] }
|
|
66
|
+
},
|
|
67
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Effort } from '../types'
|
|
2
|
+
|
|
3
|
+
const LADDER = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const
|
|
4
|
+
|
|
5
|
+
export function clampEffort(requested: Effort, supported?: readonly string[]): { value: string; clamped: boolean } {
|
|
6
|
+
if (!supported || supported.length === 0) return { value: requested, clamped: false }
|
|
7
|
+
if (supported.includes(requested)) return { value: requested, clamped: false }
|
|
8
|
+
const wanted = LADDER.indexOf(requested as (typeof LADDER)[number])
|
|
9
|
+
const ranked = supported
|
|
10
|
+
.map((s) => ({ s, i: LADDER.indexOf(s as (typeof LADDER)[number]) }))
|
|
11
|
+
.filter((x) => x.i >= 0)
|
|
12
|
+
.sort((a, b) => a.i - b.i)
|
|
13
|
+
if (ranked.length === 0) return { value: supported[0]!, clamped: true }
|
|
14
|
+
const below = ranked.filter((x) => x.i < wanted).pop()
|
|
15
|
+
return { value: (below ?? ranked[0]!).s, clamped: true }
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AgentId } from '../types'
|
|
2
|
+
import type { Adapter } from './types'
|
|
3
|
+
import { claudeAdapter } from './claude'
|
|
4
|
+
import { codexAdapter } from './codex'
|
|
5
|
+
import { kiroAdapter } from './kiro'
|
|
6
|
+
import { opencodeAdapter } from './opencode'
|
|
7
|
+
|
|
8
|
+
export const adapters: Record<AgentId, Adapter> = {
|
|
9
|
+
claude: claudeAdapter,
|
|
10
|
+
codex: codexAdapter,
|
|
11
|
+
kiro: kiroAdapter,
|
|
12
|
+
opencode: opencodeAdapter,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getAdapter(id: AgentId): Adapter {
|
|
16
|
+
return adapters[id]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { agentIds } from '../config/schema'
|
|
20
|
+
export type { Adapter }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Binding, KonvoyEvent, Permission, SpawnPlan, TurnContext } from '../types'
|
|
2
|
+
import { classifyError, safeJson, stripControlChars, withPrelude, type Adapter } from './types'
|
|
3
|
+
|
|
4
|
+
const TRUST: Record<Permission, string> = {
|
|
5
|
+
safe: '--trust-tools=',
|
|
6
|
+
edit: '--trust-tools=fs_read,fs_write,grep,glob,execute_bash',
|
|
7
|
+
yolo: '--trust-all-tools',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const kiroAdapter: Adapter = {
|
|
11
|
+
id: 'kiro',
|
|
12
|
+
bin: 'kiro-cli',
|
|
13
|
+
supportsPresetSessionId: false,
|
|
14
|
+
|
|
15
|
+
turn(ctx: TurnContext): SpawnPlan {
|
|
16
|
+
const cmd = [ctx.bin ?? 'kiro-cli', 'chat', '--no-interactive', '--output-format', 'stream-json']
|
|
17
|
+
if (ctx.binding?.foreignId) cmd.push('--resume-id', ctx.binding.foreignId)
|
|
18
|
+
if (ctx.model) cmd.push('--model', ctx.model)
|
|
19
|
+
cmd.push('--effort', ctx.effort, TRUST[ctx.permission], '--', withPrelude(ctx))
|
|
20
|
+
return { cmd, cwd: ctx.cwd }
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
parse(line: string): KonvoyEvent[] {
|
|
24
|
+
const o = safeJson(line)
|
|
25
|
+
if (!o) return []
|
|
26
|
+
const data = o.data as Record<string, unknown> | undefined
|
|
27
|
+
if (!data) return []
|
|
28
|
+
|
|
29
|
+
const events: KonvoyEvent[] = []
|
|
30
|
+
if (typeof data.sessionId === 'string') events.push({ t: 'session', foreignId: stripControlChars(data.sessionId) })
|
|
31
|
+
|
|
32
|
+
if (o.type === 'sessionUpdate') {
|
|
33
|
+
const update = data.update as
|
|
34
|
+
| { sessionUpdate?: string; content?: { text?: string }; title?: string; status?: string }
|
|
35
|
+
| undefined
|
|
36
|
+
const text = update?.content?.text
|
|
37
|
+
switch (update?.sessionUpdate) {
|
|
38
|
+
case 'agent_message_chunk':
|
|
39
|
+
if (text) events.push({ t: 'text', text })
|
|
40
|
+
break
|
|
41
|
+
case 'agent_thought_chunk':
|
|
42
|
+
if (text) events.push({ t: 'thinking', text })
|
|
43
|
+
break
|
|
44
|
+
case 'tool_call':
|
|
45
|
+
events.push({ t: 'tool', name: update.title ?? 'tool', status: 'start' })
|
|
46
|
+
break
|
|
47
|
+
case 'tool_call_update':
|
|
48
|
+
events.push({ t: 'tool', name: update.title ?? 'tool', status: update.status === 'failed' ? 'error' : 'ok' })
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (o.type === 'metadata') {
|
|
54
|
+
const metering = Array.isArray(data.meteringUsage)
|
|
55
|
+
? (data.meteringUsage as { value?: number; unit?: string }[])
|
|
56
|
+
: undefined
|
|
57
|
+
const credits = metering?.find((m) => m.unit === 'credit')?.value
|
|
58
|
+
if (typeof credits === 'number') events.push({ t: 'usage', credits })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (o.type === 'runFinished') {
|
|
62
|
+
if (data.status === 'success') {
|
|
63
|
+
// finalText comes with a finalTextTruncated flag — kiro truncates it. Only an untruncated
|
|
64
|
+
// copy is authoritative; otherwise no done is emitted and the streamed chunks stand.
|
|
65
|
+
if (typeof data.finalText === 'string' && data.finalTextTruncated !== true) events.push({ t: 'done', final: data.finalText })
|
|
66
|
+
} else {
|
|
67
|
+
const message = typeof data.stopReason === 'string' ? data.stopReason : ''
|
|
68
|
+
events.push({ t: 'error', message, kind: classifyError(message) })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return events
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
attach(binding: Binding): SpawnPlan {
|
|
76
|
+
if (!binding.foreignId) return { cmd: ['kiro-cli', 'chat'] }
|
|
77
|
+
return { cmd: ['kiro-cli', 'chat', '--resume-id', binding.foreignId] }
|
|
78
|
+
},
|
|
79
|
+
}
|