@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
@@ -0,0 +1,334 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { oneLine } from '../adapters/types'
3
+ import type { AgentId, Session, TurnContext } from '../types'
4
+ import type { Config } from '../config/schema'
5
+ import type { Adapter } from '../adapters/types'
6
+ import { resolveAgent, resolveRecipient, type AgentSettings } from '../config/load'
7
+ import { getAdapter } from '../adapters'
8
+ import { clampEffort } from '../adapters/effort'
9
+ import { detect, type Detection, type DetectOptions } from './detect'
10
+ import {
11
+ acquireLock,
12
+ clearForeignId,
13
+ createSession,
14
+ getBinding,
15
+ getSessionBySlug,
16
+ lastTurnAgent,
17
+ lastTurnId,
18
+ lockOwner,
19
+ releaseLock,
20
+ } from '../store/queries'
21
+ import { runTurn, type TurnOptions, type TurnResult } from './turn'
22
+ import { runGate } from './gate'
23
+ import { collectFacts, formatFacts, realFactsDeps } from './facts'
24
+ import { buildPrelude, parseEnvelope } from './prelude'
25
+ import { sessionDir } from '../paths'
26
+
27
+ // The prelude carries at most this many recent turns; section 28 caps it so a long session
28
+ // cannot let the handover grow until it dominates every turn.
29
+ const RECENT_TURNS = 3
30
+
31
+ // Captured from the real CLIs on 2026-09-19 by resuming an id that does not exist:
32
+ // claude "No conversation found with session ID <uuid>"
33
+ // codex "no rollout found for thread id <uuid>"
34
+ // opencode {"type":"error","error":{"message":"Session not found"}}
35
+ // kiro "error: ACP load_session failed" on stderr, exit 1, no stream events at all
36
+ // (re-measured 2026-09-21 against kiro-cli 2.22.1). The 2026-09-19 note here said
37
+ // kiro reported nothing and silently opened an empty session under whatever id it
38
+ // was handed, and recorded that as an undetectable limit — it is detectable, and
39
+ // leaving the old wording in place is what kept the rebind from firing for kiro.
40
+ const STALE =
41
+ /no conversation found|no rollout found|session not found|no such session|unknown session|not found with session|load_session failed/i
42
+
43
+ export function slugify(goal: string): string {
44
+ const base = goal
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9]+/g, '-')
47
+ .replace(/^-+|-+$/g, '')
48
+ .slice(0, 40)
49
+ .replace(/-+$/g, '')
50
+ return base || 'session'
51
+ }
52
+
53
+ export function uniqueSlug(db: Database, base: string): string {
54
+ if (!getSessionBySlug(db, base)) return base
55
+ for (let n = 2; n < 1000; n++) {
56
+ const candidate = `${base}-${n}`
57
+ if (!getSessionBySlug(db, candidate)) return candidate
58
+ }
59
+ throw new Error(`cannot find a free slug for ${base}`)
60
+ }
61
+
62
+ export function newSession(
63
+ db: Database,
64
+ input: { cwd: string; goal: string; slug?: string; lead: AgentId },
65
+ ): Session {
66
+ const slug = uniqueSlug(db, input.slug ? slugify(input.slug) : slugify(input.goal))
67
+ return createSession(db, { slug, goal: input.goal, cwd: input.cwd, lead: input.lead })
68
+ }
69
+
70
+ export interface SendDeps {
71
+ db: Database
72
+ cfg: Config
73
+ adapterFor?: (agent: AgentId) => Adapter
74
+ detect?: (agent: AgentId, opts: DetectOptions) => Promise<Detection>
75
+ /** base wait between upstream retries; tests that only care about the walk pass 0 */
76
+ upstreamBackoffMs?: number
77
+ }
78
+
79
+ // A chain names an order, not a set: konvoy starts at the requested agent and follows the
80
+ // chain forward from wherever that agent sits in it. An agent the chain never mentions has
81
+ // nothing configured for it, so it runs alone.
82
+ function chainFrom(chain: readonly AgentId[], agent: AgentId): AgentId[] {
83
+ const idx = chain.indexOf(agent)
84
+ return idx === -1 ? [agent] : chain.slice(idx)
85
+ }
86
+
87
+ // a hammered upstream is the last thing to hammer again: a second, times the attempt
88
+ const UPSTREAM_BACKOFF_MS = 1000
89
+
90
+ export async function send(
91
+ deps: SendDeps,
92
+ session: Session,
93
+ agent: AgentId,
94
+ prompt: string,
95
+ opts: TurnOptions = {},
96
+ ): Promise<TurnResult> {
97
+ const settings = resolveAgent(deps.cfg, agent)
98
+ if (!settings.enabled) throw new Error(`${agent} is disabled in this konvoy config`)
99
+
100
+ const adapterFor = (a: AgentId): Adapter => deps.adapterFor?.(a) ?? getAdapter(a)
101
+ const detectFor = deps.detect ?? detect
102
+ const adapter = adapterFor(agent)
103
+ const detection = await detectFor(agent, { model: settings.model, bin: settings.bin })
104
+ if (!detection.installed) throw new Error(`${agent}: not installed`)
105
+
106
+ const timeoutSec = opts.timeoutSec ?? deps.cfg.policy.turnTimeoutSec
107
+ const inherited = Bun.env.KONVOY_LEASE
108
+ const lease = inherited ?? crypto.randomUUID()
109
+ const alreadyHeld = inherited !== undefined && lockOwner(deps.db, session.id) === inherited
110
+ if (!alreadyHeld && !acquireLock(deps.db, session.id, lease)) {
111
+ throw new Error(`session "${session.slug}" is busy — another konvoy turn is running`)
112
+ }
113
+
114
+ // Set once withLock() has built the primary prelude — followHandoff needs its own, built
115
+ // fresh after the handing-off turn is recorded, so it is read rather than recomputed here.
116
+ let facts = ''
117
+
118
+ try {
119
+ const result = await withLock()
120
+ // The turn just finished writing its own row — runGate reads that row itself to decide
121
+ // whether there is anything for it to judge, so it is always safe to call here.
122
+ const turnId = lastTurnId(deps.db, session.id)
123
+ if (turnId) await runGate(deps.db, deps.cfg, session, turnId)
124
+
125
+ const handoff = turnId ? await followHandoff(result, turnId) : null
126
+ if (!handoff) return result
127
+
128
+ const handoffTurnId = lastTurnId(deps.db, session.id)
129
+ if (handoffTurnId) await runGate(deps.db, deps.cfg, session, handoffTurnId)
130
+ return handoff
131
+ } finally {
132
+ if (!alreadyHeld) releaseLock(deps.db, session.id, lease)
133
+ }
134
+
135
+ // One hop per send: this reads the envelope on the turn `send()` was asked to run, resolves
136
+ // it once, and returns whatever that recipient's own turn produces — including an envelope
137
+ // of its own, which is never fed back in here. A mistaken `to:` pointing back at the sender
138
+ // would otherwise loop until something ran out, and the caller asked for one turn.
139
+ async function followHandoff(result: TurnResult, turnId: string): Promise<TurnResult | null> {
140
+ if (!deps.cfg.delegation.enabled || result.error) return null
141
+ const envelope = parseEnvelope(result.final)
142
+ if (!envelope?.to) return null
143
+
144
+ const ranAsAgent = lastTurnAgent(deps.db, session.id) ?? agent
145
+ const recipient = resolveRecipient(deps.cfg, envelope.to)
146
+ if (!recipient) {
147
+ console.error(
148
+ `konvoy: ${ranAsAgent} handed off to "${oneLine(envelope.to, 80)}" — no such agent or role, ${ranAsAgent}'s turn stands`,
149
+ )
150
+ return null
151
+ }
152
+
153
+ const recipientSettings = resolveAgent(deps.cfg, recipient)
154
+ const recipientDetection = recipientSettings.enabled
155
+ ? await detectFor(recipient, { model: recipientSettings.model, bin: recipientSettings.bin })
156
+ : { agent: recipient, installed: false, version: null }
157
+ // Reported, not silently dropped — the same rule the failover chain already follows for a
158
+ // disabled or uninstalled member.
159
+ if (!recipientSettings.enabled || !recipientDetection.installed) {
160
+ const why = !recipientSettings.enabled ? 'disabled in config' : 'not installed'
161
+ console.error(
162
+ `konvoy: ${ranAsAgent} handed off to ${recipient}, but ${recipient} is ${why} — ${ranAsAgent}'s turn stands`,
163
+ )
164
+ return null
165
+ }
166
+
167
+ console.error(`konvoy: ${ranAsAgent} handed off to ${recipient} — "${oneLine(envelope.task)}"`)
168
+
169
+ const recipientEffort = clampEffort(recipientSettings.effort, recipientDetection.efforts)
170
+ // Built fresh, after the handing-off turn was recorded: the prelude built at the top of
171
+ // this send() describes the state before that turn ran — the opposite of what the
172
+ // recipient needs, which is its own cooperative handoff, envelope and all.
173
+ const delegatedPrelude = buildPrelude(deps.db, session, facts, { recent: RECENT_TURNS })
174
+ return runOnce(
175
+ recipient,
176
+ adapterFor(recipient),
177
+ () => ({
178
+ sessionId: session.id,
179
+ slug: session.slug,
180
+ cwd: session.cwd,
181
+ sessionDir: sessionDir(session.cwd, session.slug),
182
+ prompt: envelope.task,
183
+ prelude: delegatedPrelude,
184
+ binding: getBinding(deps.db, session.id, recipient),
185
+ model: recipientSettings.model,
186
+ effort: recipientEffort.value,
187
+ permission: recipientSettings.permission,
188
+ harness: recipientSettings.harness,
189
+ bin: recipientSettings.bin,
190
+ style: recipientSettings.style,
191
+ delegation: deps.cfg.delegation.enabled,
192
+ }),
193
+ turnId,
194
+ )
195
+ }
196
+
197
+ // One attempt at one agent: run the turn, and — exactly as before failover existed — rebind
198
+ // a stale foreign session once and retry, never more. This is unchanged by the chain walk;
199
+ // it just now runs once per agent the chain visits instead of once per `send()` call.
200
+ async function runOnce(
201
+ current: AgentId,
202
+ currentAdapter: Adapter,
203
+ ctxBuild: () => TurnContext,
204
+ parentTurnId: string | null,
205
+ ): Promise<TurnResult> {
206
+ const resumedId = getBinding(deps.db, session.id, current)?.foreignId ?? null
207
+ const wasResuming = resumedId != null
208
+ const first = await runTurn(
209
+ { db: deps.db, adapter: currentAdapter },
210
+ { ...ctxBuild(), lease },
211
+ { ...opts, timeoutSec, parentTurnId },
212
+ )
213
+
214
+ // "produced nothing" has to mean nothing at all, not merely no text: a turn that ran tool
215
+ // calls edited files and plainly reached a live session, even if it never spoke.
216
+ const producedNothing = first.final.trim() === '' && !first.events.some((e) => e.t === 'tool')
217
+ // Only a crash can be a dead session. An auth failure phrased as "session not found" would
218
+ // otherwise be rebound instead of surfaced, discarding a live session and then failing again
219
+ // identically; a rate limit, a timeout and an interruption say nothing about the session at
220
+ // all. The kinds exist so that failures can be told apart — this is where it matters.
221
+ const recoverable = first.error?.kind === 'crash' || first.error?.kind === 'unknown'
222
+ const stale = first.error != null && recoverable && STALE.test(first.error.message) && producedNothing
223
+ if (!stale || !wasResuming) return first
224
+
225
+ // said out loud: a silent rebind looks like continuity and is not
226
+ console.error(`konvoy: ${current} could not load session ${oneLine(resumedId ?? '', 60)} — starting a new one`)
227
+ clearForeignId(deps.db, session.id, current)
228
+ return runTurn(
229
+ { db: deps.db, adapter: currentAdapter },
230
+ { ...ctxBuild(), lease },
231
+ { ...opts, timeoutSec, parentTurnId },
232
+ )
233
+ }
234
+
235
+ async function withLock(): Promise<TurnResult> {
236
+ const chain = chainFrom(deps.cfg.failover.chain, agent)
237
+ const upstreamRetries = deps.cfg.failover.upstreamRetries
238
+ let firstTurnId: string | null = null
239
+ let result: TurnResult | undefined
240
+
241
+ // Built once for the whole send, not per chain member: recomputing would spend git calls
242
+ // and, worse, let the handover shift between attempts at the same question.
243
+ facts = formatFacts(await collectFacts(realFactsDeps(), deps.db, session))
244
+ const prelude = buildPrelude(deps.db, session, facts, { recent: RECENT_TURNS })
245
+
246
+ let blocked: { agent: AgentId; kind: string; message: string } | null = null
247
+ for (let i = 0; i < chain.length; i++) {
248
+ const current = chain[i]!
249
+ const isHead = i === 0
250
+
251
+ let currentSettings: AgentSettings
252
+ let currentAdapter: Adapter
253
+ let currentDetection: Detection
254
+ if (isHead) {
255
+ currentSettings = settings
256
+ currentAdapter = adapter
257
+ currentDetection = detection
258
+ } else {
259
+ currentSettings = resolveAgent(deps.cfg, current)
260
+ currentAdapter = adapterFor(current)
261
+ currentDetection = currentSettings.enabled
262
+ ? await detectFor(current, { model: currentSettings.model, bin: currentSettings.bin })
263
+ : { agent: current, installed: false, version: null }
264
+ }
265
+ // A chain member the user hasn't actually set up can't take the handoff — skip it
266
+ // rather than aborting the whole chain, since a later member might still work. Say so:
267
+ // a three-agent chain that quietly becomes a two-agent chain is the user not being told.
268
+ if (!currentSettings.enabled || !currentDetection.installed) {
269
+ if (!isHead) {
270
+ const why = !currentSettings.enabled ? 'disabled in config' : 'not installed'
271
+ console.error(`konvoy: skipping ${current} in the failover chain — ${why}`)
272
+ }
273
+ if (result) continue
274
+ break
275
+ }
276
+
277
+ // The move is announced here rather than where the block was detected, because only here
278
+ // is the successor known to be the one that actually runs. Naming chain[i + 1] earlier
279
+ // said "moving to claude" and then ran kiro when claude turned out to be unusable.
280
+ if (blocked) {
281
+ console.error(
282
+ `konvoy: ${blocked.agent} is blocked (${blocked.kind}) — "${oneLine(blocked.message)}" — ${current} is taking over`,
283
+ )
284
+ blocked = null
285
+ }
286
+
287
+ const currentEffort = clampEffort(currentSettings.effort, currentDetection.efforts)
288
+ const ctxBuild = (): TurnContext => ({
289
+ sessionId: session.id,
290
+ slug: session.slug,
291
+ cwd: session.cwd,
292
+ sessionDir: sessionDir(session.cwd, session.slug),
293
+ prompt,
294
+ prelude,
295
+ binding: getBinding(deps.db, session.id, current),
296
+ model: currentSettings.model,
297
+ effort: currentEffort.value,
298
+ permission: currentSettings.permission,
299
+ harness: currentSettings.harness,
300
+ bin: currentSettings.bin,
301
+ style: currentSettings.style,
302
+ delegation: deps.cfg.delegation.enabled,
303
+ })
304
+
305
+ let retries = 0
306
+ let r: TurnResult
307
+ for (;;) {
308
+ r = await runOnce(current, currentAdapter, ctxBuild, firstTurnId)
309
+ if (firstTurnId === null) firstTurnId = lastTurnId(deps.db, session.id)
310
+ // upstream is transient and usually returns, so it is worth retrying on the same
311
+ // agent — with backoff, since a hammered upstream is the last thing to hammer again.
312
+ if (r.error?.kind === 'upstream' && retries < upstreamRetries) {
313
+ retries++
314
+ await Bun.sleep((deps.upstreamBackoffMs ?? UPSTREAM_BACKOFF_MS) * retries)
315
+ continue
316
+ }
317
+ break
318
+ }
319
+ result = r
320
+
321
+ const kind = result.error?.kind
322
+ // rate and auth switch at once — a rate window is hours and an auth failure needs a
323
+ // human, so retrying either is pointless. upstream only reaches here once its retries
324
+ // are spent. crash, timeout and interrupted never switch: the fault travels with the
325
+ // agent, not with the CLI running it, so a second agent would just fail the same way.
326
+ const movable = kind === 'rate' || kind === 'auth' || kind === 'upstream'
327
+ if (!movable) return result
328
+
329
+ blocked = { agent: current, kind: kind!, message: result.error!.message }
330
+ }
331
+
332
+ return result!
333
+ }
334
+ }
@@ -0,0 +1,263 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { KonvoyEvent, TurnContext } from '../types'
3
+ import type { Adapter } from '../adapters/types'
4
+ import { bumpBinding, recordEvent, recordTurn, upsertBinding } from '../store/queries'
5
+ import { onExit, track, untrack, DEFAULT_KILL_GRACE_MS, escalateKill } from './children'
6
+
7
+ export interface TurnResult {
8
+ final: string
9
+ foreignId: string | null
10
+ costUsd: number
11
+ credits: number
12
+ inputTokens: number
13
+ outputTokens: number
14
+ exitCode: number
15
+ error: { message: string; kind: string } | null
16
+ events: KonvoyEvent[]
17
+ }
18
+
19
+ export interface TurnDeps {
20
+ db: Database
21
+ adapter: Adapter
22
+ }
23
+
24
+ export interface TurnOptions {
25
+ timeoutSec?: number
26
+ onEvent?: (event: KonvoyEvent) => void
27
+ /** grace period after the timeout's SIGTERM before konvoy escalates to SIGKILL */
28
+ killGraceMs?: number
29
+ drainGraceMs?: number
30
+ /** the first blocked turn this one replaces, when a failover chain moved to a new agent */
31
+ parentTurnId?: string | null
32
+ }
33
+
34
+ // how long a pipe may stay open after the child is gone before the read is cut off
35
+ const DEFAULT_DRAIN_GRACE_MS = 500
36
+
37
+ // `until` ends the read on the caller's clock: a descendant that inherited the pipe would
38
+ // otherwise keep it open, and with it the turn, for as long as it lives
39
+ export async function drain(stream: ReadableStream<Uint8Array>, cap: number, until?: Promise<unknown>): Promise<string> {
40
+ const decoder = new TextDecoder()
41
+ const reader = stream.getReader()
42
+ void until?.then(() => reader.cancel().catch(() => undefined))
43
+ let text = ''
44
+ while (true) {
45
+ const { value, done } = await reader.read()
46
+ if (done) break
47
+ text += decoder.decode(value, { stream: true })
48
+ if (text.length > cap) {
49
+ text = text.slice(-cap)
50
+ const lead = text.charCodeAt(0)
51
+ if (lead >= 0xdc00 && lead <= 0xdfff) text = text.slice(1)
52
+ }
53
+ }
54
+ return text
55
+ }
56
+
57
+ export async function runTurn(deps: TurnDeps, ctx: TurnContext, opts: TurnOptions = {}): Promise<TurnResult> {
58
+ const { db, adapter } = deps
59
+ const timeoutMs = (opts.timeoutSec ?? 900) * 1000
60
+
61
+ await adapter.prepare?.(ctx)
62
+ const plan = adapter.turn(ctx)
63
+
64
+ const result: TurnResult = {
65
+ final: '',
66
+ foreignId: ctx.binding?.foreignId ?? null,
67
+ costUsd: 0,
68
+ credits: 0,
69
+ inputTokens: 0,
70
+ outputTokens: 0,
71
+ exitCode: 0,
72
+ error: null,
73
+ events: [],
74
+ }
75
+
76
+ let accumulated = ''
77
+ let sawDone = false
78
+ let seq = 0
79
+
80
+ const parseLine = (line: string): KonvoyEvent[] => {
81
+ try {
82
+ return adapter.parse(line)
83
+ } catch (error) {
84
+ return [{ t: 'error', message: `${adapter.id} parser failed: ${String(error)}`, kind: 'crash' }]
85
+ }
86
+ }
87
+
88
+ const turnId = recordTurn(db, {
89
+ sessionId: ctx.sessionId,
90
+ agent: adapter.id,
91
+ prompt: ctx.prompt,
92
+ final: '',
93
+ costUsd: 0,
94
+ exitCode: -1,
95
+ kind: ctx.kind ?? null,
96
+ parentTurnId: opts.parentTurnId ?? null,
97
+ })
98
+
99
+ let finished = false
100
+ const finish = (): void => {
101
+ if (finished) return
102
+ finished = true
103
+ db.query(
104
+ `UPDATE turn SET final = $final, cost_usd = $cost, credits = $credits, input_tokens = $inTok,
105
+ output_tokens = $outTok, exit_code = $exit, error = $error, error_kind = $errorKind,
106
+ ended_at = $ended, model = $model WHERE id = $id`,
107
+ ).run({
108
+ id: turnId,
109
+ final: result.final,
110
+ cost: result.costUsd,
111
+ credits: result.credits,
112
+ inTok: result.inputTokens,
113
+ outTok: result.outputTokens,
114
+ exit: result.exitCode,
115
+ error: result.error?.message ?? null,
116
+ errorKind: result.error?.kind ?? null,
117
+ ended: Date.now(),
118
+ model: ctx.model ?? null,
119
+ })
120
+ upsertBinding(db, {
121
+ sessionId: ctx.sessionId,
122
+ agent: adapter.id,
123
+ foreignId: result.foreignId,
124
+ effort: ctx.effort,
125
+ permission: ctx.permission,
126
+ model: ctx.model ?? null,
127
+ status: result.error?.kind === 'auth' ? 'auth_required' : undefined,
128
+ })
129
+ bumpBinding(db, ctx.sessionId, adapter.id, result.costUsd, result.credits)
130
+ }
131
+
132
+ const spawnedAt = Date.now()
133
+ let proc: ReturnType<typeof Bun.spawn>
134
+ try {
135
+ proc = Bun.spawn(plan.cmd, {
136
+ cwd: plan.cwd ?? ctx.cwd,
137
+ env: { ...(process.env as Record<string, string>), ...(plan.env ?? {}), ...(ctx.lease ? { KONVOY_LEASE: ctx.lease } : {}) },
138
+ stdin: plan.stdin ? new Response(plan.stdin) : 'ignore',
139
+ stdout: 'pipe',
140
+ stderr: 'pipe',
141
+ timeout: timeoutMs,
142
+ killSignal: 'SIGTERM',
143
+ })
144
+ } catch (error) {
145
+ // the binary exists but cannot start — no execute bit, a bad interpreter: detection cannot
146
+ // see it, and the turn row recorded above is what keeps the failure from leaving no trace
147
+ result.exitCode = 127
148
+ result.error = { message: `could not start ${plan.cmd[0]}: ${error instanceof Error ? error.message : String(error)}`, kind: 'crash' }
149
+ finish()
150
+ return result
151
+ }
152
+ track(proc)
153
+
154
+ const cancelEscalation = escalateKill(proc, timeoutMs + (opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS))
155
+
156
+ // once the child has exited, whoever still holds its pipes is something it left behind;
157
+ // it gets this long to flush, then both reads end
158
+ const afterExit = proc.exited.then(() => Bun.sleep(opts.drainGraceMs ?? DEFAULT_DRAIN_GRACE_MS))
159
+ const stderrText = drain(proc.stderr as ReadableStream<Uint8Array>, 64 * 1024, afterExit)
160
+
161
+
162
+ // exit_code -1 means konvoy itself died before it could record the turn. Every ordinary
163
+ // ending, including an interruption, replaces it — so a surviving -1 is a real signal, not
164
+ // a default. Failure is decided by the error field, not by the exit code alone: an interrupted turn
165
+ // carries 130 or 143 and a message, and a codex turn can exit 0 with an informational error.
166
+
167
+ const emit = (event: KonvoyEvent): void => {
168
+ result.events.push(event)
169
+ recordEvent(db, turnId, seq++, event.t, event)
170
+ opts.onEvent?.(event)
171
+ switch (event.t) {
172
+ case 'session':
173
+ result.foreignId = event.foreignId
174
+ break
175
+ case 'text':
176
+ accumulated += event.text
177
+ break
178
+ case 'usage':
179
+ result.credits += event.credits ?? 0
180
+ result.costUsd += event.costUsd ?? 0
181
+ result.inputTokens += event.inputTokens ?? 0
182
+ result.outputTokens += event.outputTokens ?? 0
183
+ break
184
+ case 'error':
185
+ result.error = { message: event.message, kind: event.kind }
186
+ break
187
+ case 'done':
188
+ sawDone = true
189
+ result.final = event.final
190
+ break
191
+ }
192
+ }
193
+
194
+
195
+ // every path below must reach finish(): an onEvent callback that throws, a parser crash a
196
+ // wrapper missed, or a signal — otherwise the turn row stays at its INSERT placeholder and
197
+ // usage counts it as a free turn.
198
+ const releaseExitHandler = onExit((signal) => {
199
+ if (!result.error) result.error = { message: `konvoy was interrupted by ${signal}`, kind: 'interrupted' }
200
+ if (result.exitCode === 0) result.exitCode = signal === 'SIGINT' ? 130 : 143
201
+ finish()
202
+ })
203
+
204
+ const decoder = new TextDecoder()
205
+ let buffer = ''
206
+ try {
207
+ try {
208
+ const stdout = (proc.stdout as ReadableStream<Uint8Array>).getReader()
209
+ void afterExit.then(() => stdout.cancel().catch(() => undefined))
210
+ while (true) {
211
+ const { value, done } = await stdout.read()
212
+ if (done) break
213
+ buffer += decoder.decode(value, { stream: true })
214
+ const lines = buffer.split('\n')
215
+ buffer = lines.pop() ?? ''
216
+ for (const line of lines) for (const event of parseLine(line)) emit(event)
217
+ }
218
+ if (buffer.trim()) for (const event of parseLine(buffer)) emit(event)
219
+ } finally {
220
+ if (proc.exitCode === null) proc.kill()
221
+ }
222
+
223
+ result.exitCode = await proc.exited
224
+
225
+ if (!sawDone) result.final = accumulated
226
+
227
+ // an error event that carries no words (claude's is_error result on a dead session id)
228
+ // must not stand in for stderr, which is where that CLI puts the reason
229
+ if (result.exitCode !== 0 && !result.error?.message.trim()) {
230
+ const stderr = await stderrText
231
+ const timedOut = Date.now() - spawnedAt >= timeoutMs
232
+ result.error = {
233
+ message: timedOut ? `turn timed out after ${opts.timeoutSec ?? 900}s` : stderr.trim() || `exit ${result.exitCode}`,
234
+ kind: timedOut ? 'timeout' : 'crash',
235
+ }
236
+ }
237
+
238
+ // The exit code says whether the turn produced its output; the error says whether the
239
+ // agent is now blocked. auth and rate never mean "just informational" — the agent cannot
240
+ // work until something changes — so they survive even a turn that answered and exited 0.
241
+ // crash and unknown keep the old behaviour: discarded once there was any output at all.
242
+ // an error that stayed wordless through the stream and stderr — say so, rather than show
243
+ // the user an empty quote
244
+ if (result.error && !result.error.message.trim()) {
245
+ result.error.message = `${adapter.id} exited ${result.exitCode} and reported an error without a message`
246
+ }
247
+ const failed = result.exitCode !== 0 || (result.error !== null && result.final.trim() === '')
248
+ const blocking = result.error?.kind === 'auth' || result.error?.kind === 'rate'
249
+ if (!failed && !blocking) result.error = null
250
+ } catch (error) {
251
+ result.error = result.error ?? { message: String(error), kind: 'crash' }
252
+ if (result.exitCode === 0) {
253
+ result.exitCode = proc.exitCode ?? (await proc.exited.catch(() => -1))
254
+ }
255
+ } finally {
256
+ cancelEscalation()
257
+ releaseExitHandler()
258
+ untrack(proc)
259
+ finish()
260
+ }
261
+
262
+ return result
263
+ }