@doguyilmaz/konvoy 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/package.json +52 -0
- package/src/adapters/claude.ts +83 -0
- package/src/adapters/codex.ts +67 -0
- package/src/adapters/effort.ts +16 -0
- package/src/adapters/index.ts +20 -0
- package/src/adapters/kiro.ts +79 -0
- package/src/adapters/opencode.ts +64 -0
- package/src/adapters/types.ts +108 -0
- package/src/args.ts +42 -0
- package/src/chart.ts +91 -0
- package/src/cli.ts +146 -0
- package/src/commands/attach.ts +85 -0
- package/src/commands/config.ts +113 -0
- package/src/commands/dashboard.ts +26 -0
- package/src/commands/doctor.ts +104 -0
- package/src/commands/ls.ts +15 -0
- package/src/commands/new.ts +24 -0
- package/src/commands/resume.ts +14 -0
- package/src/commands/rm.ts +28 -0
- package/src/commands/roster.ts +37 -0
- package/src/commands/send.ts +79 -0
- package/src/commands/status.ts +35 -0
- package/src/commands/table.ts +75 -0
- package/src/commands/update.ts +72 -0
- package/src/commands/usage.ts +77 -0
- package/src/config/load.ts +335 -0
- package/src/config/schema.ts +100 -0
- package/src/core/children.ts +62 -0
- package/src/core/detect.ts +211 -0
- package/src/core/facts.ts +113 -0
- package/src/core/gate.ts +73 -0
- package/src/core/prelude.ts +121 -0
- package/src/core/session.ts +334 -0
- package/src/core/turn.ts +263 -0
- package/src/dashboard/page.ts +211 -0
- package/src/format.ts +98 -0
- package/src/paths.ts +33 -0
- package/src/pricing.ts +86 -0
- package/src/store/db.ts +78 -0
- package/src/store/queries.ts +434 -0
- package/src/types.ts +71 -0
|
@@ -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
|
+
}
|