@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,211 @@
1
+ import type { AgentId } from '../types'
2
+ import { getAdapter } from '../adapters'
3
+ import { stripControlChars } from '../adapters/types'
4
+ import { home, join } from '../paths'
5
+
6
+ export interface Detection {
7
+ agent: AgentId
8
+ installed: boolean
9
+ version: string | null
10
+ efforts?: readonly string[]
11
+ }
12
+
13
+ export interface AuthState {
14
+ agent: AgentId
15
+ authed: boolean | null
16
+ detail: string
17
+ }
18
+
19
+ export type Runner = (cmd: string[]) => Promise<{ stdout: string; exitCode: number }>
20
+
21
+ export interface DetectDeps {
22
+ run: Runner
23
+ readText: (path: string) => Promise<string | null>
24
+ }
25
+
26
+ export function parseVersion(text: string): string | null {
27
+ return /(\d+\.\d+\.\d+)/.exec(text)?.[1] ?? null
28
+ }
29
+
30
+ interface AuthCheck {
31
+ args: string[]
32
+ ok: (stdout: string, exitCode: number) => boolean | null
33
+ }
34
+
35
+ const AUTH_CHECK: Record<AgentId, (bin?: string) => AuthCheck> = {
36
+ claude: (bin = 'claude') => ({
37
+ args: [bin, 'auth', 'status'],
38
+ ok: (stdout, exitCode) => {
39
+ if (!stdout) return null
40
+ try {
41
+ const json = JSON.parse(stdout) as { loggedIn?: unknown }
42
+ if (typeof json.loggedIn === 'boolean') return json.loggedIn
43
+ } catch {
44
+ // not JSON, fall through
45
+ }
46
+ return null
47
+ },
48
+ }),
49
+ codex: (bin = 'codex') => ({
50
+ args: [bin, 'login', 'status'],
51
+ ok: (stdout, exitCode) => {
52
+ if (!stdout || exitCode !== 0) return null
53
+ return stdout.includes('Logged in')
54
+ },
55
+ }),
56
+ kiro: (bin = 'kiro-cli') => ({
57
+ args: [bin, 'whoami'],
58
+ ok: (stdout, exitCode) => {
59
+ if (!stdout || exitCode !== 0) return null
60
+ return stdout.includes('Logged in')
61
+ },
62
+ }),
63
+ opencode: (bin = 'opencode') => ({
64
+ args: [bin, 'auth', 'list'],
65
+ ok: (stdout, exitCode) => {
66
+ if (exitCode !== 0) return null
67
+ return stdout.trim().length > 0
68
+ },
69
+ }),
70
+ }
71
+
72
+ async function codexEfforts(deps: DetectDeps, model?: string): Promise<readonly string[] | undefined> {
73
+ if (!model) return undefined
74
+ const raw = await deps.readText(join(home(), '.codex', 'models_cache.json'))
75
+ if (!raw) return undefined
76
+ try {
77
+ const cache = JSON.parse(raw) as { models?: unknown }
78
+ if (!Array.isArray(cache.models)) return undefined
79
+ // the real file (codex 0.155.1, 2026-09-21) keys models by `slug` and lists each level as
80
+ // `{ effort, description }` — see tests/fixtures/codex-models-cache.json
81
+ const hit = (cache.models as { slug?: string; supported_reasoning_levels?: unknown }[]).find(
82
+ (m) => m.slug === model,
83
+ )
84
+ if (!Array.isArray(hit?.supported_reasoning_levels)) return undefined
85
+ const levels = hit.supported_reasoning_levels.flatMap((l) =>
86
+ typeof (l as { effort?: unknown } | null)?.effort === 'string' ? [(l as { effort: string }).effort] : [],
87
+ )
88
+ return levels.length > 0 ? levels : undefined
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ function authDetail(stdout: string, exitCode: number): string {
95
+ if (exitCode === 127) return 'not installed'
96
+ const trimmed = stdout.trim()
97
+ if (trimmed.startsWith('{')) {
98
+ try {
99
+ const parsed = JSON.parse(trimmed) as { loggedIn?: unknown; authMethod?: unknown }
100
+ const via = typeof parsed.authMethod === 'string' ? ` via ${stripControlChars(parsed.authMethod)}` : ''
101
+ if (parsed.loggedIn === true) return `logged in${via}`
102
+ if (parsed.loggedIn === false) return 'not logged in'
103
+ } catch {
104
+ // not the shape we expected; the first line is still better than nothing
105
+ }
106
+ }
107
+ return stripControlChars(trimmed.split('\n')[0] ?? '')
108
+ }
109
+
110
+ export async function detectWith(
111
+ deps: DetectDeps,
112
+ agent: AgentId,
113
+ opts: { model?: string; bin?: string } = {},
114
+ ): Promise<Detection> {
115
+ const adapter = getAdapter(agent)
116
+ const result = await deps.run([opts.bin ?? adapter.bin, '--version'])
117
+ const installed = result.exitCode === 0
118
+ const version = installed ? parseVersion(result.stdout) : null
119
+ const efforts = installed && agent === 'codex' ? await codexEfforts(deps, opts.model) : undefined
120
+ return { agent, installed, version, efforts }
121
+ }
122
+
123
+ export async function detectAuthWith(
124
+ deps: DetectDeps,
125
+ agent: AgentId,
126
+ bin?: string,
127
+ ): Promise<AuthState> {
128
+ const check = AUTH_CHECK[agent](bin)
129
+ const result = await deps.run(check.args)
130
+ const detail = authDetail(result.stdout, result.exitCode)
131
+ if (result.exitCode === 127) {
132
+ return { agent, authed: null, detail }
133
+ }
134
+ const authed = check.ok(result.stdout, result.exitCode)
135
+ return { agent, authed, detail }
136
+ }
137
+
138
+ function memo<K, V>(cacheMap: Map<K, Promise<V>>, key: K, f: () => Promise<V>): Promise<V> {
139
+ const hit = cacheMap.get(key)
140
+ if (hit) return hit
141
+ const pending = f()
142
+ .then((v) => {
143
+ cacheMap.set(key, Promise.resolve(v))
144
+ return v
145
+ })
146
+ .catch((e) => {
147
+ cacheMap.delete(key)
148
+ throw e
149
+ })
150
+ cacheMap.set(key, pending)
151
+ return pending
152
+ }
153
+
154
+ function realDeps(): DetectDeps {
155
+ return {
156
+ run: async (cmd) => {
157
+ try {
158
+ const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 })
159
+ const stdout = await new Response(proc.stdout).text()
160
+ const stderr = await new Response(proc.stderr).text()
161
+ const exitCode = await proc.exited
162
+ return { stdout: stdout || stderr, exitCode }
163
+ } catch {
164
+ return { stdout: '', exitCode: 127 }
165
+ }
166
+ },
167
+ readText: async (path) => {
168
+ try {
169
+ const file = Bun.file(path)
170
+ return (await file.exists()) ? file.text() : null
171
+ } catch {
172
+ return null
173
+ }
174
+ },
175
+ }
176
+ }
177
+
178
+ const detectCacheMap = new Map<string, Promise<Detection>>()
179
+ const detectAuthCacheMap = new Map<string, Promise<AuthState>>()
180
+
181
+ // Real detection shares one memo; each injected deps object gets its own, so a fake answering in
182
+ // one place is never handed to another.
183
+ const depsIds = new WeakMap<DetectDeps, number>()
184
+ let nextDepsId = 0
185
+ function scope(deps?: DetectDeps): string {
186
+ if (!deps) return 'real'
187
+ let id = depsIds.get(deps)
188
+ if (id === undefined) depsIds.set(deps, (id = ++nextDepsId))
189
+ return String(id)
190
+ }
191
+
192
+ export function clearDetectCache(): void {
193
+ detectCacheMap.clear()
194
+ detectAuthCacheMap.clear()
195
+ }
196
+
197
+ export interface DetectOptions {
198
+ model?: string
199
+ bin?: string
200
+ deps?: DetectDeps
201
+ }
202
+
203
+ export async function detect(agent: AgentId, opts: DetectOptions = {}): Promise<Detection> {
204
+ const key = `${scope(opts.deps)}\u0000${agent}\u0000${opts.model ?? ''}\u0000${opts.bin ?? ''}`
205
+ return memo(detectCacheMap, key, () => detectWith(opts.deps ?? realDeps(), agent, opts))
206
+ }
207
+
208
+ export async function detectAuth(agent: AgentId, opts: DetectOptions = {}): Promise<AuthState> {
209
+ const key = `${scope(opts.deps)}\u0000${agent}\u0000${opts.bin ?? ''}`
210
+ return memo(detectAuthCacheMap, key, () => detectAuthWith(opts.deps ?? realDeps(), agent, opts.bin))
211
+ }
@@ -0,0 +1,113 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { Session } from '../types'
3
+ import { usageForSession } from '../store/queries'
4
+ import { spend } from '../format'
5
+
6
+ export interface Facts {
7
+ commits: { sha: string; subject: string }[]
8
+ files: { path: string; added: number; removed: number }[]
9
+ agents: { agent: string; turns: number; spend: string }[]
10
+ }
11
+
12
+ export interface FactsDeps {
13
+ git: (args: string[], cwd: string) => Promise<string>
14
+ }
15
+
16
+ function parseLog(output: string): { sha: string; subject: string }[] {
17
+ return output
18
+ .split('\n')
19
+ .map((line) => line.trim())
20
+ .filter(Boolean)
21
+ .map((line) => {
22
+ const sp = line.indexOf(' ')
23
+ return sp === -1 ? { sha: line, subject: '' } : { sha: line.slice(0, sp), subject: line.slice(sp + 1) }
24
+ })
25
+ }
26
+
27
+ const STAT_LINE = /^\s*(.+?)\s*\|\s*(\d+)\s*(\+*)(-*)\s*$/
28
+
29
+ function parseDiffStat(output: string): { path: string; added: number; removed: number }[] {
30
+ const files: { path: string; added: number; removed: number }[] = []
31
+ for (const line of output.split('\n')) {
32
+ const m = STAT_LINE.exec(line)
33
+ if (!m) continue
34
+ const total = Number(m[2])
35
+ const plus = m[3]!.length
36
+ const minus = m[4]!.length
37
+ // the +/- bar is drawn proportionally to the real counts, not one char per change,
38
+ // so the split has to be recovered from the bar's ratio rather than counted directly
39
+ const added = plus + minus === 0 ? total : Math.round((total * plus) / (plus + minus))
40
+ files.push({ path: m[1]!, added, removed: total - added })
41
+ }
42
+ return files
43
+ }
44
+
45
+ export async function collectFacts(deps: FactsDeps, db: Database, session: Session): Promise<Facts> {
46
+ const [logOut, diffOut] = await Promise.all([
47
+ deps.git(['log', '--oneline', '-n', '100', '--since', new Date(session.createdAt).toISOString()], session.cwd),
48
+ deps.git(['diff', '--stat'], session.cwd),
49
+ ])
50
+ const agents = usageForSession(db, session.id).map((r) => ({ agent: r.agent, turns: r.turns, spend: spend(r) }))
51
+ return { commits: parseLog(logOut), files: parseDiffStat(diffOut), agents }
52
+ }
53
+
54
+ // A field is quoted only when it has to be. 48% of this repository's own commit subjects
55
+ // contain a comma, so an unquoted row is the common case, not the edge one — and a shifted
56
+ // row makes every number after it wrong while still looking like a table.
57
+ function cell(value: string): string {
58
+ return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
59
+ }
60
+
61
+ // The turns half of the prelude is capped (prelude.ts); this half must be too, or a busy
62
+ // repository prepends its whole history to every prompt for every agent.
63
+ const FACTS_ROW_CAP = 30
64
+
65
+ function section(name: string, fields: string[], rows: string[][]): string {
66
+ const shown = rows.slice(0, FACTS_ROW_CAP)
67
+ const lines = [`${name}[${shown.length}]{${fields.join(',')}}:`, ...shown.map((r) => r.map(cell).join(','))]
68
+ if (rows.length > shown.length) lines.push(`(+${rows.length - shown.length} more ${name})`)
69
+ return lines.join('\n')
70
+ }
71
+
72
+ export function formatFacts(facts: Facts): string {
73
+ const blocks: string[] = []
74
+ if (facts.commits.length > 0) {
75
+ blocks.push(section('commits', ['sha', 'subject'], facts.commits.map((c) => [c.sha, c.subject])))
76
+ }
77
+ if (facts.files.length > 0) {
78
+ blocks.push(
79
+ section(
80
+ 'files',
81
+ ['path', 'added', 'removed'],
82
+ facts.files.map((f) => [f.path, String(f.added), String(f.removed)]),
83
+ ),
84
+ )
85
+ }
86
+ if (facts.agents.length > 0) {
87
+ blocks.push(
88
+ section(
89
+ 'agents',
90
+ ['agent', 'turns', 'spend'],
91
+ facts.agents.map((a) => [a.agent, String(a.turns), a.spend]),
92
+ ),
93
+ )
94
+ }
95
+ return blocks.join('\n\n')
96
+ }
97
+
98
+ export function realFactsDeps(): FactsDeps {
99
+ return {
100
+ git: async (args, cwd) => {
101
+ try {
102
+ // stderr is never read, so it is not piped — a chatty git would fill the pipe and block;
103
+ // and a git that hangs must not hang the turn
104
+ const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'ignore', timeout: 10_000 })
105
+ const stdout = await new Response(proc.stdout).text()
106
+ await proc.exited
107
+ return stdout
108
+ } catch {
109
+ return ''
110
+ }
111
+ },
112
+ }
113
+ }
@@ -0,0 +1,73 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { Config } from '../config/schema'
3
+ import type { Session } from '../types'
4
+ import { setGateResult, turnExitCode } from '../store/queries'
5
+ import { DEFAULT_KILL_GRACE_MS, escalateKill } from './children'
6
+
7
+ // A quality check the user chose to run — konvoy has no model of its own, so it never
8
+ // grades the work itself. Bounded so a hung suite can't block konvoy forever.
9
+ const GATE_TIMEOUT_MS = 5 * 60 * 1000
10
+
11
+ export async function runGate(
12
+ db: Database,
13
+ cfg: Config,
14
+ session: Session,
15
+ turnId: string,
16
+ opts: { timeoutMs?: number; killGraceMs?: number } = {},
17
+ ): Promise<void> {
18
+ const command = cfg.gate.command
19
+ if (!command) return
20
+ // A turn with nothing for the gate to judge — running it now would blame the block on the work.
21
+ if (turnExitCode(db, turnId) !== 0) return
22
+
23
+ const cmd = splitCommand(command)
24
+
25
+ try {
26
+ const proc = Bun.spawn(cmd, {
27
+ cwd: session.cwd,
28
+ stdout: 'ignore',
29
+ stderr: 'ignore',
30
+ timeout: opts.timeoutMs ?? GATE_TIMEOUT_MS,
31
+ killSignal: 'SIGTERM',
32
+ })
33
+ const cancelEscalation = escalateKill(proc, (opts.timeoutMs ?? GATE_TIMEOUT_MS) + (opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS))
34
+ try {
35
+ const exitCode = await proc.exited
36
+ setGateResult(db, turnId, exitCode === 0)
37
+ } finally {
38
+ cancelEscalation()
39
+ }
40
+ } catch {
41
+ // could not spawn: a misconfiguration, not a verdict on the work, so nothing is recorded
42
+ }
43
+ }
44
+
45
+ // Whitespace outside quotes separates arguments; a quoted run is one argument, quotes removed.
46
+ // No shell is involved — the gate is the user's own command, but it still deserves its quotes.
47
+ export function splitCommand(command: string): string[] {
48
+ const out: string[] = []
49
+ let current = ''
50
+ let quote: string | null = null
51
+ let quoted = false
52
+ for (const ch of command) {
53
+ if (quote) {
54
+ if (ch === quote) quote = null
55
+ else current += ch
56
+ continue
57
+ }
58
+ if (ch === '"' || ch === "'") {
59
+ quote = ch
60
+ quoted = true
61
+ continue
62
+ }
63
+ if (/\s/.test(ch)) {
64
+ if (current || quoted) out.push(current)
65
+ current = ''
66
+ quoted = false
67
+ continue
68
+ }
69
+ current += ch
70
+ }
71
+ if (current || quoted) out.push(current)
72
+ return out
73
+ }
@@ -0,0 +1,121 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { Session } from '../types'
3
+
4
+ export interface Envelope {
5
+ to: string | null
6
+ task: string
7
+ open: string[]
8
+ decisions: string[]
9
+ }
10
+
11
+ // Sentinels rather than JSON: a model wraps JSON in prose or fences, and a missing field
12
+ // should degrade to empty rather than fail a parse.
13
+ const ENVELOPE_RE = /<<<konvoy\n([\s\S]*?)>>>/
14
+ const RECIPIENT = /^[a-z][a-z0-9_-]*$/i
15
+
16
+ export function parseEnvelope(final: string): Envelope | null {
17
+ const match = ENVELOPE_RE.exec(final)
18
+ if (!match) return null
19
+
20
+ const env: Envelope = { to: null, task: '', open: [], decisions: [] }
21
+ let list: string[] | null = null
22
+
23
+ for (const raw of (match[1] ?? '').split('\n')) {
24
+ const line = raw.trim()
25
+ if (line === '') continue
26
+
27
+ const item = /^-\s*(.*)$/.exec(line)
28
+ if (item && list) {
29
+ list.push(item[1]!.trim())
30
+ continue
31
+ }
32
+
33
+ const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line)
34
+ if (!kv) {
35
+ list = null
36
+ continue
37
+ }
38
+ const key = kv[1]!
39
+ const value = kv[2]!.trim()
40
+ switch (key) {
41
+ case 'to':
42
+ // a recipient is an agent id or a role name — a bare identifier. The delegation
43
+ // instruction quotes the format with "<agent id or role>" in this slot, and an agent
44
+ // explaining what it is not doing repeats it; that names nobody and is no handoff.
45
+ env.to = RECIPIENT.test(value) ? value : null
46
+ list = null
47
+ break
48
+ case 'task':
49
+ env.task = value
50
+ list = null
51
+ break
52
+ case 'open':
53
+ list = env.open
54
+ break
55
+ case 'decisions':
56
+ list = env.decisions
57
+ break
58
+ default:
59
+ list = null
60
+ }
61
+ }
62
+
63
+ return env
64
+ }
65
+
66
+ interface TurnRow {
67
+ agent: string
68
+ prompt: string
69
+ final: string
70
+ }
71
+
72
+ function renderPair(t: TurnRow): string {
73
+ return `${t.agent} was asked: ${t.prompt}\n${t.agent} answered: ${t.final}`
74
+ }
75
+
76
+ function renderEnvelope(agent: string, env: Envelope): string {
77
+ const lines = [`${agent} handed off to ${env.to ?? 'the next agent'}:`, `task: ${env.task}`]
78
+ if (env.open.length > 0) lines.push('open:', ...env.open.map((o) => `- ${o}`))
79
+ if (env.decisions.length > 0) lines.push('decisions:', ...env.decisions.map((d) => `- ${d}`))
80
+ return lines.join('\n')
81
+ }
82
+
83
+ // Order is goal, then facts, then recent turns: stable first, volatile last, because prompt
84
+ // caching discounts a stable prefix by roughly an order of magnitude and a prelude that
85
+ // reshuffles itself every turn pays full price for all of it.
86
+ export function buildPrelude(db: Database, session: Session, facts: string, opts: { recent: number }): string {
87
+ const total = (
88
+ db.query('SELECT COUNT(*) AS c FROM turn WHERE session_id = $id').get({ id: session.id }) as { c: number }
89
+ ).c
90
+ if (total === 0) return ''
91
+
92
+ const rows = db
93
+ .query('SELECT agent, prompt, final FROM turn WHERE session_id = $id ORDER BY started_at DESC, rowid DESC LIMIT $limit')
94
+ .all({ id: session.id, limit: opts.recent }) as TurnRow[]
95
+ const oldestFirst = [...rows].reverse()
96
+ const dropped = total - rows.length
97
+
98
+ const last = oldestFirst[oldestFirst.length - 1]
99
+ const envelope = last ? parseEnvelope(last.final) : null
100
+
101
+ const turnBlocks: string[] = []
102
+ for (let i = 0; i < oldestFirst.length - 1; i++) turnBlocks.push(renderPair(oldestFirst[i]!))
103
+
104
+ // the same test followHandoff applies: a block with no recipient handed nothing to anyone,
105
+ // so the answer it sits in is what the next agent must see
106
+ if (!last) {
107
+ // recent: 0 — only the goal, the facts and the count of what was left out
108
+ } else if (envelope?.to) {
109
+ turnBlocks.push(renderEnvelope(last.agent, envelope))
110
+ } else {
111
+ turnBlocks.push(renderPair(last))
112
+ // The cooperative case (section 21) has a sender who can still speak; failover does not.
113
+ // A receiver that believes its context is complete proceeds on half the picture, so the
114
+ // gap is stated plainly instead of silently filled with a prompt-and-answer transcript.
115
+ turnBlocks.push("the previous agent's intent was not recorded here — only what was asked and answered is known.")
116
+ }
117
+
118
+ if (dropped > 0) turnBlocks.push(`(${dropped} earlier turn${dropped === 1 ? '' : 's'} not shown)`)
119
+
120
+ return [`goal: ${session.goal}`, facts, turnBlocks.join('\n\n')].filter(Boolean).join('\n\n')
121
+ }