@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,64 @@
1
+ import type { Binding, KonvoyEvent, SpawnPlan, TurnContext } from '../types'
2
+ import { classifyError, safeJson, stripControlChars, withPrelude, type Adapter } from './types'
3
+
4
+ export const opencodeAdapter: Adapter = {
5
+ id: 'opencode',
6
+ bin: 'opencode',
7
+ supportsPresetSessionId: false,
8
+
9
+ turn(ctx: TurnContext): SpawnPlan {
10
+ const cmd = [ctx.bin ?? 'opencode', 'run', '--standalone', '--format', 'json']
11
+ if (ctx.binding?.foreignId) cmd.push('-s', ctx.binding.foreignId)
12
+ else cmd.push('--title', `konvoy:${ctx.slug}`)
13
+ if (ctx.model) cmd.push('-m', `${ctx.model}#${ctx.effort}`)
14
+ if (ctx.permission === 'yolo') cmd.push('--auto')
15
+ cmd.push('--', withPrelude(ctx))
16
+ return { cmd, cwd: ctx.cwd }
17
+ },
18
+
19
+ parse(line: string): KonvoyEvent[] {
20
+ const o = safeJson(line)
21
+ if (!o) return []
22
+ const events: KonvoyEvent[] = []
23
+ if (typeof o.sessionID === 'string') events.push({ t: 'session', foreignId: stripControlChars(o.sessionID) })
24
+
25
+ const part = o.part as { text?: string; tool?: string; state?: { status?: string } } | undefined
26
+ switch (o.type) {
27
+ case 'text':
28
+ if (part?.text) events.push({ t: 'text', text: part.text })
29
+ break
30
+ case 'reasoning':
31
+ if (part?.text) events.push({ t: 'thinking', text: part.text })
32
+ break
33
+ case 'tool_use':
34
+ events.push({ t: 'tool', name: part?.tool ?? 'tool', status: part?.state?.status === 'error' ? 'error' : 'ok' })
35
+ break
36
+ case 'step_finish': {
37
+ const step = o.part as
38
+ | { cost?: number; tokens?: { input?: number; output?: number; cache?: { read?: number; write?: number } } }
39
+ | undefined
40
+ const tokens = step?.tokens
41
+ if (tokens) {
42
+ // opencode's `input` is the uncached remainder, like claude's and unlike codex's —
43
+ // its own `total` is input + output + cache, which is what settles that. It also
44
+ // reports the turn's cost here, on the same part.
45
+ const input = (tokens.input ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0)
46
+ events.push({ t: 'usage', inputTokens: input, outputTokens: tokens.output, costUsd: step?.cost })
47
+ }
48
+ break
49
+ }
50
+ case 'error': {
51
+ const error = o.error as { message?: string; data?: { message?: string } } | undefined
52
+ const message = error?.data?.message ?? error?.message ?? ''
53
+ events.push({ t: 'error', message, kind: classifyError(message) })
54
+ break
55
+ }
56
+ }
57
+ return events
58
+ },
59
+
60
+ attach(binding: Binding): SpawnPlan {
61
+ if (!binding.foreignId) return { cmd: ['opencode'] }
62
+ return { cmd: ['opencode', '--session', binding.foreignId] }
63
+ },
64
+ }
@@ -0,0 +1,108 @@
1
+ import type { AgentId, Binding, KonvoyEvent, SpawnPlan, TurnContext } from '../types'
2
+
3
+ export interface Adapter {
4
+ id: AgentId
5
+ bin: string
6
+ supportsPresetSessionId: boolean
7
+ turn(ctx: TurnContext): SpawnPlan
8
+ parse(line: string): KonvoyEvent[]
9
+ attach(binding: Binding): SpawnPlan
10
+ prepare?(ctx: TurnContext): Promise<void>
11
+ }
12
+
13
+ // konvoy's own instruction, owned here rather than vendored from any installed skill — a
14
+ // user's own such skill is already reachable via `harness: inherit`. It shapes what the user
15
+ // reads, not what agents exchange, so it asks for omission, never compression.
16
+ export const BRIEF_INSTRUCTION =
17
+ 'Lead with the action. Number multi-step work. End with one concrete next step. Skip preamble, recap, and closing pleasantries.'
18
+
19
+ // konvoy has no model and cannot decide when a turn hands off — only the agent running it
20
+ // knows. This instruction is what asks it to say so. A turn not handing work over must emit
21
+ // nothing: the envelope costs output tokens only on the turns that actually use it.
22
+ export const DELEGATION_INSTRUCTION =
23
+ 'If you are handing work to another agent, end your reply with a block like:\n' +
24
+ '<<<konvoy\n' +
25
+ 'to: <agent id or role>\n' +
26
+ 'task: <imperative, one line>\n' +
27
+ 'open: <optional list>\n' +
28
+ 'decisions: <optional list>\n' +
29
+ '>>>\n' +
30
+ 'If this turn is not handing work over, emit nothing — no block at all.'
31
+
32
+ // One composition point rather than four: the adapters cannot drift in how they join these,
33
+ // and the prelude leads because a stable prefix is what prompt caching discounts. The style
34
+ // and delegation instructions trail the prompt for the same reason — they must never join
35
+ // the cached prefix.
36
+ export function withPrelude(ctx: TurnContext): string {
37
+ const base = ctx.prelude ? `${ctx.prelude}\n\n${ctx.prompt}` : ctx.prompt
38
+ const styled = ctx.style === 'brief' ? `${base}\n\n${BRIEF_INSTRUCTION}` : base
39
+ return ctx.delegation ? `${styled}\n\n${DELEGATION_INSTRUCTION}` : styled
40
+ }
41
+
42
+ export function safeJson(line: string): Record<string, unknown> | null {
43
+ const trimmed = line.trim()
44
+ if (!trimmed.startsWith('{')) return null
45
+ try {
46
+ return JSON.parse(trimmed) as Record<string, unknown>
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ // foreignId, the model a CLI echoes back, and the auth detail string are konvoy's own metadata,
53
+ // read from a CLI's stdout and later printed to the terminal or stored. Control bytes (OSC/SGR
54
+ // escapes) have no legitimate use there, so they are stripped at the parse boundary rather than
55
+ // wherever the value later gets printed.
56
+ // CSI (ESC [ … final), OSC (ESC ] … BEL or ST) and two-byte ESC sequences. Their parameters
57
+ // are printable, so dropping only the ESC byte would leave "[2K" behind in an id or a notice.
58
+ const ANSI = /\x1b\[[0-?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
59
+
60
+ export function stripControlChars(value: string): string {
61
+ return value.replace(ANSI, '').replace(/[\x00-\x1f\x7f]/g, '')
62
+ }
63
+
64
+ // For a notice or a table cell: one line, no control bytes, capped — an agent's own words or a
65
+ // CLI's stderr can run to kilobytes and can carry escapes that rewrite what konvoy printed.
66
+ export function oneLine(value: string, max = 200): string {
67
+ const flat = stripControlChars(value.replace(/\r?\n/g, ' ')).replace(/ {2,}/g, ' ').trim()
68
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat
69
+ }
70
+
71
+ // For an agent's final text: keep newlines and tabs, drop every other control byte — CR
72
+ // included, which would let a line overwrite the one before it.
73
+ export function safeText(value: string): string {
74
+ return value.replace(ANSI, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
75
+ }
76
+
77
+ // Extracted from the four installed binaries on 2026-09-20. Expiry is phrased around
78
+ // "session" or "token" — "Cloud gateway session expired", "AWS session has expired",
79
+ // "Login token is expired", "MCP OAuth access token is expired" — so requiring the literal
80
+ // word "credentials" missed every real expiry. The noun must sit next to the state, or a
81
+ // parser's "Unexpected token" and "Invalid token in JSON" read as auth failures.
82
+ const AUTH =
83
+ /invalid api key|authentication failed|not authenticated|not (?:logged|signed) in|unauthorized|bad credentials|\b401\b|please run \/?login|(?:credential|token|session)s? (?:are |is |has |have )?(?:expired|revoked|invalid|missing)|token refresh failed|unable to refresh token/
84
+ // `hit your <window> limit` and `rate_limit` are captured verbatim from Claude Code on
85
+ // 2026-09-20: "You've hit your weekly limit · resets 7am" and "You've hit your session limit
86
+ // · resets 12:40am", both carrying error type rate_limit / HTTP 429. The remaining
87
+ // alternatives are conjecture from other vendors' wording and have never been observed here.
88
+ // kiro-cli emits no rate-limit prose at all, only AWS exception type names, and those carry
89
+ // no separators once lowercased — hence the optional separators below. codex's five-hour
90
+ // window is worded "5-hour usage limit", which "usage limit" already covers.
91
+ const RATE =
92
+ /hit your \w+ limit|rate[_ ]?limit|quota exceeded|too ?many ?requests|usage limit|weekly limit|\d+[- ]hour (?:usage )?limit|throttl|\b429\b/
93
+ // Captured verbatim from opencode's embedded overload classifier, recovered from its binary on
94
+ // 2026-09-20: "the service is at capacity", "Overloaded", "temporarily unavailable", "503
95
+ // Service Unavailable", "server is busy, try again", "Internal Server Error", and "upstream
96
+ // connect error". These describe an API that is reachable but refusing — the one failure worth
97
+ // retrying before giving up on an agent, unlike a rate limit (checked first: a message that is
98
+ // both rate-limited and mentions 503 is a rate limit, since that window is hours, not seconds).
99
+ const UPSTREAM =
100
+ /service is at capacity|overloaded?|temporarily unavailable|\b503\b|server is busy|internal server error|upstream connect error/
101
+
102
+ export function classifyError(message: string): 'auth' | 'rate' | 'upstream' | 'crash' | 'unknown' {
103
+ const m = message.toLowerCase()
104
+ if (AUTH.test(m)) return 'auth'
105
+ if (RATE.test(m)) return 'rate'
106
+ if (UPSTREAM.test(m)) return 'upstream'
107
+ return 'unknown'
108
+ }
package/src/args.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface Args {
2
+ _: string[]
3
+ flags: Record<string, string | boolean>
4
+ }
5
+
6
+ // Flags that are switches, so `konvoy rm --yes <slug>` keeps its slug. Their consumers are the
7
+ // `=== true` checks in cli.ts; a value flag (--session, --port) is anything not listed here.
8
+ const SWITCHES = new Set(['all', 'yes', 'global', 'chart', 'help'])
9
+
10
+ export function parseArgs(argv: string[]): Args {
11
+ const positional: string[] = []
12
+ const flags: Record<string, string | boolean> = {}
13
+ let flagsEnded = false
14
+
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const token = argv[i]!
17
+ if (flagsEnded || !token.startsWith('-') || token === '-') {
18
+ positional.push(token)
19
+ continue
20
+ }
21
+ // the POSIX convention, and the only way to send a message that starts with a dash
22
+ if (token === '--') {
23
+ flagsEnded = true
24
+ continue
25
+ }
26
+ const name = token.replace(/^--?/, '')
27
+ if (name.includes('=')) {
28
+ const [key, ...rest] = name.split('=')
29
+ flags[key!] = rest.join('=')
30
+ continue
31
+ }
32
+ const next = argv[i + 1]
33
+ if (!SWITCHES.has(name) && next !== undefined && !next.startsWith('-')) {
34
+ flags[name] = next
35
+ i++
36
+ } else {
37
+ flags[name] = true
38
+ }
39
+ }
40
+
41
+ return { _: positional, flags }
42
+ }
package/src/chart.ts ADDED
@@ -0,0 +1,91 @@
1
+ const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
2
+ const DENSITY = ['·', '▫', '▪', '▩', '█'] as const
3
+ const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
4
+
5
+ export function sparkline(values: number[], max?: number): string {
6
+ if (values.length === 0) return ''
7
+ // an explicit max lets several series share one scale (see agentSparklines); omitted, a
8
+ // series scales to its own peak, as a lone sparkline always has
9
+ const m = max ?? Math.max(...values)
10
+ // counts have a fixed baseline of zero, not the series' own minimum — a flat run of busy
11
+ // days must render full, not empty. Guard only the case where there's no signal at all.
12
+ if (m <= 0) return BLOCKS[0]!.repeat(values.length)
13
+ // a nonzero count is never the zero glyph: below m/14 Math.round lands on 0, and a quiet day
14
+ // must still read as a day with turns
15
+ return values
16
+ .map((v) => (v <= 0 ? BLOCKS[0]! : BLOCKS[Math.min(BLOCKS.length - 1, Math.max(1, Math.round((v / m) * (BLOCKS.length - 1))))]!))
17
+ .join('')
18
+ }
19
+
20
+ export function shareBars(rows: { label: string; value: number }[], width = 18): string {
21
+ const total = rows.reduce((sum, r) => sum + r.value, 0)
22
+ const labelWidth = Math.max(...rows.map((r) => r.label.length), 1)
23
+ return (
24
+ rows
25
+ .map((r) => {
26
+ const share = total > 0 ? r.value / total : 0
27
+ const filled = Math.round(share * width)
28
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled)
29
+ return `${r.label.padEnd(labelWidth)} ${bar} ${Math.round(share * 100)}%`
30
+ })
31
+ .join('\n') + '\n'
32
+ )
33
+ }
34
+
35
+ // a lone agent's sparkline can't reveal whether it's the busy one or the quiet one — every
36
+ // agent must be drawn against the same peak, and every row must span the same dense day
37
+ // range (gaps filled with zero) so the columns line up between agents
38
+ export function agentSparklines(rows: { agent: string; day: string; count: number }[]): { agent: string; line: string }[] {
39
+ if (rows.length === 0) return []
40
+ const days = [...new Set(rows.map((r) => r.day))].sort()
41
+ const start = days[0]!
42
+ const end = days[days.length - 1]!
43
+
44
+ const denseDays: string[] = []
45
+ for (
46
+ const cursor = new Date(`${start}T00:00:00Z`);
47
+ cursor <= new Date(`${end}T00:00:00Z`);
48
+ cursor.setUTCDate(cursor.getUTCDate() + 1)
49
+ ) {
50
+ denseDays.push(cursor.toISOString().slice(0, 10))
51
+ }
52
+
53
+ const byAgent = new Map<string, Map<string, number>>()
54
+ for (const r of rows) {
55
+ if (!byAgent.has(r.agent)) byAgent.set(r.agent, new Map())
56
+ byAgent.get(r.agent)!.set(r.day, r.count)
57
+ }
58
+
59
+ const series = [...byAgent.entries()].map(([agent, dayCounts]) => ({
60
+ agent,
61
+ values: denseDays.map((d) => dayCounts.get(d) ?? 0),
62
+ }))
63
+
64
+ const sharedMax = Math.max(...series.flatMap((s) => s.values), 0)
65
+ return series.map(({ agent, values }) => ({ agent, line: sparkline(values, sharedMax) }))
66
+ }
67
+
68
+ export function heatmap(days: { day: string; count: number }[]): string {
69
+ if (days.length === 0) return ''
70
+ const max = Math.max(...days.map((d) => d.count), 1)
71
+ const byDay = new Map(days.map((d) => [d.day, d.count]))
72
+
73
+ const first = new Date(`${days[0]!.day}T00:00:00Z`)
74
+ const last = new Date(`${days[days.length - 1]!.day}T00:00:00Z`)
75
+ const start = new Date(first)
76
+ start.setUTCDate(start.getUTCDate() - start.getUTCDay())
77
+
78
+ const rows: string[] = []
79
+ for (let weekday = 0; weekday < 7; weekday++) {
80
+ let line = `${DAY_LABELS[weekday]} `
81
+ for (const cursor = new Date(start); cursor <= last; cursor.setUTCDate(cursor.getUTCDate() + 7)) {
82
+ const cell = new Date(cursor)
83
+ cell.setUTCDate(cell.getUTCDate() + weekday)
84
+ const key = cell.toISOString().slice(0, 10)
85
+ const count = byDay.get(key) ?? 0
86
+ line += count === 0 ? DENSITY[0] : DENSITY[Math.min(DENSITY.length - 1, Math.ceil((count / max) * (DENSITY.length - 1)))]
87
+ }
88
+ rows.push(line)
89
+ }
90
+ return rows.join('\n')
91
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bun
2
+ import type { Database } from 'bun:sqlite'
3
+ import { parseArgs, type Args } from './args'
4
+ import { loadConfig, resolveAgent } from './config/load'
5
+ import { getSessionBySlug } from './store/queries'
6
+ import { agentIds } from './config/schema'
7
+ import type { Config } from './config/schema'
8
+ import { openDb } from './store/db'
9
+ import { dbPath } from './paths'
10
+ import { cmdNew } from './commands/new'
11
+ import { cmdSend } from './commands/send'
12
+ import { cmdLs } from './commands/ls'
13
+ import { cmdRoster } from './commands/roster'
14
+ import { cmdStatus } from './commands/status'
15
+ import { cmdAttach } from './commands/attach'
16
+ import { cmdDoctor } from './commands/doctor'
17
+ import { cmdUpdate } from './commands/update'
18
+ import { cmdConfig } from './commands/config'
19
+ import { cmdResume } from './commands/resume'
20
+ import { cmdRm } from './commands/rm'
21
+ import { cmdUsage } from './commands/usage'
22
+ import { cmdDashboard } from './commands/dashboard'
23
+ import { formatCommandList, resolveCommandName, type CommandName } from './commands/table'
24
+ import type { AgentId } from './types'
25
+ import pkg from '../package.json'
26
+
27
+ const VERSION = pkg.version
28
+
29
+ export const USAGE = `konvoy ${VERSION}
30
+
31
+ ${formatCommandList()}
32
+
33
+ agents: ${agentIds.join(', ')}
34
+ flags: --session <slug>
35
+ `
36
+
37
+ interface CommandContext {
38
+ db: Database
39
+ cfg: Config
40
+ cwd: string
41
+ args: Args
42
+ slug: string | undefined
43
+ }
44
+
45
+ type Handler = (ctx: CommandContext, rest: string[]) => number | Promise<number>
46
+
47
+ // Every key of CommandName must be handled here — TypeScript's excess/missing property
48
+ // checks on an object literal assigned to Record<CommandName, Handler> make an
49
+ // undocumented-yet-dispatched or dispatched-yet-undocumented command a compile error.
50
+ const handlers: Record<CommandName, Handler> = {
51
+ new: (ctx, rest) => cmdNew(ctx.db, ctx.cfg, ctx.cwd, rest.join(' ')),
52
+ send: (ctx, rest) => {
53
+ const [agent, ...prompt] = rest
54
+ if (!agent || prompt.length === 0) {
55
+ console.error('usage: konvoy send <agent> "<message>"')
56
+ console.error('a message beginning with a dash goes after --, as in: konvoy send codex -- "-1 first"')
57
+ return 2
58
+ }
59
+ return cmdSend(ctx.db, ctx.cfg, ctx.cwd, agent, prompt.join(' '), ctx.slug)
60
+ },
61
+ ls: (ctx) => cmdLs(ctx.db),
62
+ roster: (ctx) => cmdRoster(ctx.db, ctx.cfg, ctx.cwd, ctx.slug),
63
+ status: (ctx) => cmdStatus(ctx.db, ctx.cfg, ctx.cwd, ctx.slug),
64
+ attach: (ctx, rest) => {
65
+ const [agent] = rest
66
+ if (!agent) {
67
+ console.error('usage: konvoy attach <agent> [--id <session-id>]')
68
+ return 2
69
+ }
70
+ const settings = resolveAgent(ctx.cfg, agent as AgentId)
71
+ const id = typeof ctx.args.flags.id === 'string' ? ctx.args.flags.id : undefined
72
+ return cmdAttach(ctx.db, ctx.cwd, agent, { slug: ctx.slug, bin: settings.bin, id, effort: settings.effort, permission: settings.permission })
73
+ },
74
+ doctor: (ctx) => cmdDoctor(ctx.cfg),
75
+ update: (ctx) => cmdUpdate(ctx.cfg, { all: ctx.args.flags.all === true }),
76
+ resume: (ctx, rest) => cmdResume(ctx.db, ctx.cfg, ctx.cwd, rest[0] ?? ctx.slug),
77
+ config: (ctx, rest) => {
78
+ const [action, key, value] = rest
79
+ return cmdConfig(ctx.cfg, ctx.cwd, action ?? 'get', key, value, { global: ctx.args.flags.global === true })
80
+ },
81
+ rm: (ctx, rest) => {
82
+ const [target] = rest
83
+ if (!target) {
84
+ console.error('usage: konvoy rm <session> --yes')
85
+ return 2
86
+ }
87
+ return cmdRm(ctx.db, ctx.cwd, target, { yes: ctx.args.flags.yes === true })
88
+ },
89
+ usage: (ctx) =>
90
+ cmdUsage(ctx.db, ctx.cfg, ctx.cwd, {
91
+ all: ctx.args.flags.all === true,
92
+ slug: ctx.slug,
93
+ chart: ctx.args.flags.chart === true,
94
+ }),
95
+ version: (ctx) => {
96
+ console.log(`konvoy ${VERSION}`)
97
+ return cmdStatus(ctx.db, ctx.cfg, ctx.cwd, ctx.slug, { roster: false })
98
+ },
99
+ dashboard: ({ db, cfg, cwd, args, slug }) =>
100
+ cmdDashboard(db, cfg, cwd, {
101
+ port: typeof args.flags.port === 'string' ? Number(args.flags.port) : undefined,
102
+ slug,
103
+ }),
104
+ }
105
+
106
+ export async function main(argv: string[]): Promise<number> {
107
+ const args = parseArgs(argv)
108
+ const [command, ...rest] = args._
109
+ const cwd = process.cwd()
110
+ const slug = typeof args.flags.session === 'string' ? args.flags.session : undefined
111
+
112
+ if (!command || command === 'help' || args.flags.help) {
113
+ console.log(USAGE)
114
+ // asking for help is not a usage error; running konvoy with nothing is
115
+ return command || args.flags.help ? 0 : 1
116
+ }
117
+
118
+ try {
119
+ return await dispatch(command, rest, cwd, slug, args)
120
+ } catch (error) {
121
+ if (Bun.env.KONVOY_DEBUG === '1') throw error
122
+ console.error(`konvoy: ${error instanceof Error ? error.message : String(error)}`)
123
+ return 1
124
+ }
125
+ }
126
+
127
+ async function dispatch(command: string, rest: string[], cwd: string, slug: string | undefined, args: Args): Promise<number> {
128
+ const db = openDb(dbPath())
129
+ // a session named from another directory carries its own project config, not the shell's:
130
+ // model, effort, roles and the failover chain follow the repository the turn runs in
131
+ const projectCwd = (slug ? getSessionBySlug(db, slug)?.cwd : undefined) ?? cwd
132
+ const cfg = await loadConfig({ cwd: projectCwd })
133
+
134
+ const name = resolveCommandName(command)
135
+ if (!name) {
136
+ console.error(`unknown command "${command}"`)
137
+ console.log(USAGE)
138
+ return 2
139
+ }
140
+
141
+ return handlers[name]({ db, cfg, cwd, args, slug }, rest)
142
+ }
143
+
144
+ if (import.meta.main) {
145
+ process.exitCode = await main(Bun.argv.slice(2))
146
+ }
@@ -0,0 +1,85 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { AgentId, Effort, Permission, Session, SpawnPlan } from '../types'
3
+ import { agentIds, getAdapter } from '../adapters'
4
+ import { currentSession, getBinding, getSessionBySlug, upsertBinding } from '../store/queries'
5
+ import { oneLine } from '../adapters/types'
6
+ import { detect } from '../core/detect'
7
+
8
+ export function attachPlan(db: Database, session: Session, agent: AgentId, bin?: string): SpawnPlan {
9
+ const binding = getBinding(db, session.id, agent)
10
+ const plan = getAdapter(agent).attach(
11
+ binding ?? {
12
+ sessionId: session.id,
13
+ agent,
14
+ foreignId: null,
15
+ model: null,
16
+ effort: 'high',
17
+ permission: 'edit',
18
+ status: 'unbound',
19
+ turns: 0,
20
+ costUsd: 0,
21
+ credits: 0,
22
+ lastSeen: null,
23
+ },
24
+ )
25
+ const cmd = bin ? [bin, ...plan.cmd.slice(1)] : plan.cmd
26
+ return { ...plan, cmd, cwd: session.cwd }
27
+ }
28
+
29
+ // Bind a session the user started outside konvoy — the id a CLI prints for its own resume
30
+ // command. The same shape check as every stream-captured id applies at the write path; the
31
+ // binding is only reported as adopted if it actually holds the id.
32
+ export function adoptForeignSession(
33
+ db: Database,
34
+ session: Session,
35
+ agent: AgentId,
36
+ foreignId: string,
37
+ settings: { effort: Effort; permission: Permission },
38
+ ): boolean {
39
+ upsertBinding(db, { sessionId: session.id, agent, foreignId, effort: settings.effort, permission: settings.permission })
40
+ return getBinding(db, session.id, agent)?.foreignId === foreignId
41
+ }
42
+
43
+ export interface AttachOptions {
44
+ slug?: string
45
+ bin?: string
46
+ /** a session id from the CLI itself, adopted into this konvoy session before opening */
47
+ id?: string
48
+ effort?: Effort
49
+ permission?: Permission
50
+ }
51
+
52
+ export async function cmdAttach(db: Database, cwd: string, agent: string, opts: AttachOptions = {}): Promise<number> {
53
+ const { slug, bin } = opts
54
+ if (!agentIds.includes(agent as AgentId)) {
55
+ console.error(`unknown agent "${agent}" — expected one of ${agentIds.join(', ')}`)
56
+ return 2
57
+ }
58
+ const session = slug ? getSessionBySlug(db, slug) : currentSession(db, cwd)
59
+ if (!session) {
60
+ console.error('no konvoy session here — run `konvoy new "<goal>"` first')
61
+ return 2
62
+ }
63
+
64
+ const detection = await detect(agent as AgentId, { bin })
65
+ if (!detection.installed) {
66
+ console.error(`${agent}: not installed`)
67
+ return 2
68
+ }
69
+
70
+ if (opts.id) {
71
+ const adopted = adoptForeignSession(db, session, agent as AgentId, opts.id, {
72
+ effort: opts.effort ?? 'high',
73
+ permission: opts.permission ?? 'edit',
74
+ })
75
+ if (!adopted) {
76
+ console.error(`konvoy: ${agent} not bound — the id was refused; nothing opened`)
77
+ return 2
78
+ }
79
+ console.error(`konvoy: ${agent} bound to session ${oneLine(opts.id, 60)} — the next turn resumes it; opening it now`)
80
+ }
81
+
82
+ const plan = attachPlan(db, session, agent as AgentId, bin)
83
+ const proc = Bun.spawn(plan.cmd, { cwd: plan.cwd, stdio: ['inherit', 'inherit', 'inherit'] })
84
+ return await proc.exited
85
+ }
@@ -0,0 +1,113 @@
1
+ import type { Config } from '../config/schema'
2
+ import { configSchema } from '../config/schema'
3
+ import { explain, globalConfigPath, projectConfigPath, readLayer, resolveAgent } from '../config/load'
4
+ import { agentIds } from '../adapters'
5
+
6
+ export function coerce(raw: string): string | number | boolean {
7
+ if (raw === 'true') return true
8
+ if (raw === 'false') return false
9
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
10
+ return raw
11
+ }
12
+
13
+ // `__proto__` resolves to Object.prototype through an ordinary property read, so a dotted path
14
+ // containing it writes onto the shared prototype — poisoning every object in the process while
15
+ // the config itself stays empty. `constructor` and `prototype` are blocked for the same reason.
16
+ const RESERVED = new Set(['__proto__', 'constructor', 'prototype'])
17
+
18
+ function assertSafe(dotted: string): void {
19
+ for (const key of dotted.split('.')) {
20
+ if (RESERVED.has(key)) throw new Error(`"${key}" is not a valid config key`)
21
+ }
22
+ }
23
+
24
+ export function getPath(obj: unknown, dotted: string): unknown {
25
+ assertSafe(dotted)
26
+ let node: unknown = obj
27
+ for (const key of dotted.split('.')) {
28
+ if (typeof node !== 'object' || node === null) return undefined
29
+ node = (node as Record<string, unknown>)[key]
30
+ }
31
+ return node
32
+ }
33
+
34
+ export function setPath(
35
+ obj: Record<string, unknown>,
36
+ dotted: string,
37
+ raw: string,
38
+ ): Record<string, unknown> {
39
+ assertSafe(dotted)
40
+ const keys = dotted.split('.')
41
+ const out = structuredClone(obj)
42
+ let node: Record<string, unknown> = out
43
+ for (const key of keys.slice(0, -1)) {
44
+ const child = node[key]
45
+ if (typeof child !== 'object' || child === null) node[key] = {}
46
+ node = node[key] as Record<string, unknown>
47
+ }
48
+ node[keys.at(-1)!] = coerce(raw)
49
+ return out
50
+ }
51
+
52
+ export async function cmdConfig(
53
+ cfg: Config,
54
+ cwd: string,
55
+ action: string,
56
+ key?: string,
57
+ value?: string,
58
+ opts: { global?: boolean } = {},
59
+ ): Promise<number> {
60
+ if (action === 'get') {
61
+ if (key) {
62
+ let found: unknown
63
+ try {
64
+ found = getPath(cfg, key)
65
+ } catch (e) {
66
+ console.error((e as Error).message)
67
+ return 2
68
+ }
69
+ if (found === undefined) {
70
+ console.error(`no such config key: ${key}`)
71
+ return 2
72
+ }
73
+ console.log(typeof found === 'string' ? found : JSON.stringify(found, null, 2))
74
+ return 0
75
+ }
76
+ for (const agent of agentIds) {
77
+ const s = resolveAgent(cfg, agent)
78
+ const effort = explain(cfg, agent, 'effort')
79
+ console.log(
80
+ `${agent}: model=${s.model ?? '-'} effort=${s.effort} (${effort.source}) permission=${s.permission} enabled=${s.enabled}`,
81
+ )
82
+ }
83
+ return 0
84
+ }
85
+
86
+ if (action === 'set') {
87
+ if (!key || value === undefined) {
88
+ console.error('usage: konvoy config set <key> <value> [--global]')
89
+ return 2
90
+ }
91
+ const path = opts.global ? globalConfigPath() : projectConfigPath(cwd)
92
+ const raw = ((await readLayer(path)) ?? {}) as Record<string, unknown>
93
+ let next: Record<string, unknown>
94
+ try {
95
+ next = setPath(raw, key, value)
96
+ } catch (e) {
97
+ console.error((e as Error).message)
98
+ return 2
99
+ }
100
+ const parsed = configSchema.safeParse(next)
101
+ if (!parsed.success) {
102
+ const issue = parsed.error.issues[0]
103
+ console.error(`refusing to write: ${issue?.path.join('.')} — ${issue?.message}`)
104
+ return 2
105
+ }
106
+ await Bun.write(path, JSON.stringify(next, null, 2) + '\n')
107
+ console.log(`${key} = ${value} (${path})`)
108
+ return 0
109
+ }
110
+
111
+ console.error('usage: konvoy config get|set')
112
+ return 2
113
+ }
@@ -0,0 +1,26 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { currentSession, getSessionBySlug } from '../store/queries'
3
+ import type { Config } from '../config/schema'
4
+ import { collect, renderPage } from '../dashboard/page'
5
+
6
+ export async function cmdDashboard(
7
+ db: Database,
8
+ cfg: Config,
9
+ cwd: string,
10
+ opts: { port?: number; slug?: string },
11
+ ): Promise<number> {
12
+ const session = opts.slug ? getSessionBySlug(db, opts.slug) : currentSession(db, cwd)
13
+ const server = Bun.serve({
14
+ hostname: '127.0.0.1',
15
+ port: opts.port ?? 0,
16
+ fetch: () => {
17
+ const data = collect(db, cfg, session?.id)
18
+ return new Response(renderPage({ ...data, title: session?.slug ?? 'all sessions' }), {
19
+ headers: { 'content-type': 'text/html; charset=utf-8' },
20
+ })
21
+ },
22
+ })
23
+ console.log(`konvoy dashboard on http://127.0.0.1:${server.port} — ctrl-c to stop`)
24
+ await new Promise(() => {})
25
+ return 0
26
+ }