@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +300 -0
  3. package/package.json +52 -0
  4. package/src/adapters/claude.ts +83 -0
  5. package/src/adapters/codex.ts +67 -0
  6. package/src/adapters/effort.ts +16 -0
  7. package/src/adapters/index.ts +20 -0
  8. package/src/adapters/kiro.ts +79 -0
  9. package/src/adapters/opencode.ts +64 -0
  10. package/src/adapters/types.ts +108 -0
  11. package/src/args.ts +42 -0
  12. package/src/chart.ts +91 -0
  13. package/src/cli.ts +146 -0
  14. package/src/commands/attach.ts +85 -0
  15. package/src/commands/config.ts +113 -0
  16. package/src/commands/dashboard.ts +26 -0
  17. package/src/commands/doctor.ts +104 -0
  18. package/src/commands/ls.ts +15 -0
  19. package/src/commands/new.ts +24 -0
  20. package/src/commands/resume.ts +14 -0
  21. package/src/commands/rm.ts +28 -0
  22. package/src/commands/roster.ts +37 -0
  23. package/src/commands/send.ts +79 -0
  24. package/src/commands/status.ts +35 -0
  25. package/src/commands/table.ts +75 -0
  26. package/src/commands/update.ts +72 -0
  27. package/src/commands/usage.ts +77 -0
  28. package/src/config/load.ts +335 -0
  29. package/src/config/schema.ts +100 -0
  30. package/src/core/children.ts +62 -0
  31. package/src/core/detect.ts +211 -0
  32. package/src/core/facts.ts +113 -0
  33. package/src/core/gate.ts +73 -0
  34. package/src/core/prelude.ts +121 -0
  35. package/src/core/session.ts +334 -0
  36. package/src/core/turn.ts +263 -0
  37. package/src/dashboard/page.ts +211 -0
  38. package/src/format.ts +98 -0
  39. package/src/paths.ts +33 -0
  40. package/src/pricing.ts +86 -0
  41. package/src/store/db.ts +78 -0
  42. package/src/store/queries.ts +434 -0
  43. 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 &lt;id&gt; 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
+ }