@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,104 @@
|
|
|
1
|
+
import type { Config } from '../config/schema'
|
|
2
|
+
import { agentIds, getAdapter } from '../adapters'
|
|
3
|
+
import type { AgentId } from '../types'
|
|
4
|
+
import { resolveAgent } from '../config/load'
|
|
5
|
+
import { detect, detectAuth, type DetectDeps } from '../core/detect'
|
|
6
|
+
import { clampEffort } from '../adapters/effort'
|
|
7
|
+
import { loginHint } from './send'
|
|
8
|
+
|
|
9
|
+
// `which -a` prints one line per PATH entry, so a directory listed twice repeats the same path
|
|
10
|
+
export function distinctPaths(stdout: string): string[] {
|
|
11
|
+
return [...new Set(stdout.trim().split('\n').filter(Boolean))]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// engine, policy.maxDelegationDepth and policy.isolation are in the schema and the spec, but
|
|
15
|
+
// nothing reads them yet — the delegation work will. Comparing the parsed policy values against
|
|
16
|
+
// their schema defaults is an approximation (a value set explicitly equal to the default reads
|
|
17
|
+
// as unset), acceptable for an informational line with no behavioural effect.
|
|
18
|
+
export function acceptedButUnusedKeys(cfg: Config): string[] {
|
|
19
|
+
const keys: string[] = []
|
|
20
|
+
if (cfg.policy.maxDelegationDepth !== 3) keys.push('policy.maxDelegationDepth')
|
|
21
|
+
if (cfg.policy.isolation !== 'serial') keys.push('policy.isolation')
|
|
22
|
+
for (const agent of agentIds) {
|
|
23
|
+
if (cfg.agents[agent]?.engine !== undefined) keys.push(`agents.${agent}.engine`)
|
|
24
|
+
}
|
|
25
|
+
return keys
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function cmdDoctor(cfg: Config, deps?: DetectDeps): Promise<number> {
|
|
29
|
+
let problems = 0
|
|
30
|
+
const models = new Map<string, string[]>()
|
|
31
|
+
const required = new Set<AgentId>(
|
|
32
|
+
[cfg.roles.lead ?? 'claude', cfg.roles.implementer, cfg.roles.reviewer, cfg.roles.researcher].filter(
|
|
33
|
+
(a): a is AgentId => a !== undefined,
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
for (const agent of agentIds) {
|
|
38
|
+
const settings = resolveAgent(cfg, agent)
|
|
39
|
+
if (!settings.enabled) {
|
|
40
|
+
if (required.has(agent)) {
|
|
41
|
+
console.log(`x ${agent}: disabled in config but named by a role`)
|
|
42
|
+
problems++
|
|
43
|
+
} else {
|
|
44
|
+
console.log(`- ${agent}: disabled in config`)
|
|
45
|
+
}
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const d = await detect(agent, { model: settings.model, bin: settings.bin, deps })
|
|
50
|
+
if (!d.installed) {
|
|
51
|
+
if (required.has(agent)) {
|
|
52
|
+
console.log(`x ${agent}: not installed`)
|
|
53
|
+
problems++
|
|
54
|
+
} else {
|
|
55
|
+
console.log(`- ${agent}: not installed`)
|
|
56
|
+
}
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const auth = await detectAuth(agent, { bin: settings.bin, deps })
|
|
60
|
+
if (auth.authed === false) {
|
|
61
|
+
if (required.has(agent)) {
|
|
62
|
+
console.log(`x ${agent}: ${auth.detail} — ${loginHint(agent)}`)
|
|
63
|
+
problems++
|
|
64
|
+
} else {
|
|
65
|
+
console.log(`- ${agent}: ${auth.detail} — ${loginHint(agent)}`)
|
|
66
|
+
}
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const clamp = clampEffort(settings.effort, d.efforts)
|
|
71
|
+
if (clamp.clamped) {
|
|
72
|
+
console.log(`! ${agent}: effort "${settings.effort}" is unsupported here, using "${clamp.value}"`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (agent === 'opencode' && !settings.model) {
|
|
76
|
+
console.log(`! opencode: no model configured — it returns HTTP 403 without an explicit -m`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const shadow = Bun.spawnSync(['which', '-a', settings.bin ?? getAdapter(agent).bin])
|
|
80
|
+
const paths = distinctPaths(new TextDecoder().decode(shadow.stdout))
|
|
81
|
+
if (paths.length > 1 && !settings.bin) {
|
|
82
|
+
console.log(`! ${agent}: ${paths.length} binaries on PATH, "${paths[0]}" wins — set agents.${agent}.bin to be explicit`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (settings.model) {
|
|
86
|
+
models.set(settings.model, [...(models.get(settings.model) ?? []), agent])
|
|
87
|
+
}
|
|
88
|
+
console.log(`ok ${agent}: ${d.version}${settings.model ? ` (${settings.model})` : ''}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [model, users] of models) {
|
|
92
|
+
if (users.length > 1) {
|
|
93
|
+
console.log(`! ${users.join(' and ')} both run ${model} — they will not disagree with each other`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const unused = acceptedButUnusedKeys(cfg)
|
|
98
|
+
if (unused.length > 0) {
|
|
99
|
+
console.log(`i ${unused.join(', ')} — accepted but not yet used`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(problems === 0 ? '\nno problems found' : `\n${problems} problem(s) found`)
|
|
103
|
+
return problems === 0 ? 0 : 1
|
|
104
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { boundBindingCounts, listSessions } from '../store/queries'
|
|
3
|
+
|
|
4
|
+
export function cmdLs(db: Database): number {
|
|
5
|
+
const sessions = listSessions(db)
|
|
6
|
+
if (sessions.length === 0) {
|
|
7
|
+
console.log('no konvoy sessions yet')
|
|
8
|
+
return 0
|
|
9
|
+
}
|
|
10
|
+
const bound = boundBindingCounts(db)
|
|
11
|
+
for (const s of sessions) {
|
|
12
|
+
console.log(`${s.slug} ${bound.get(s.id) ?? 0}/4 bound ${s.cwd} ${s.goal}`)
|
|
13
|
+
}
|
|
14
|
+
return 0
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Config } from '../config/schema'
|
|
3
|
+
import { newSession } from '../core/session'
|
|
4
|
+
import { sessionDir } from '../paths'
|
|
5
|
+
|
|
6
|
+
export async function cmdNew(db: Database, cfg: Config, cwd: string, goal: string): Promise<number> {
|
|
7
|
+
const lead = cfg.roles.lead ?? 'claude'
|
|
8
|
+
const session = newSession(db, { cwd, goal: goal || 'untitled', lead })
|
|
9
|
+
const dir = sessionDir(cwd, session.slug)
|
|
10
|
+
// `konvoy rm` frees the slug but leaves these files — they are the user's. A new session under
|
|
11
|
+
// an old slug adds its goal to the context and appends to the ledger; it truncates neither.
|
|
12
|
+
const context = Bun.file(`${dir}/CONTEXT.md`)
|
|
13
|
+
await Bun.write(
|
|
14
|
+
context,
|
|
15
|
+
(await context.exists())
|
|
16
|
+
? `${await context.text()}\n## Goal\n\n${session.goal}\n`
|
|
17
|
+
: `# ${session.slug}\n\n## Goal\n\n${session.goal}\n`,
|
|
18
|
+
)
|
|
19
|
+
const ledger = Bun.file(`${dir}/LEDGER.md`)
|
|
20
|
+
if (!(await ledger.exists())) await Bun.write(ledger, `# Ledger — ${session.slug}\n`)
|
|
21
|
+
console.log(`created session ${session.slug} (lead: ${lead})`)
|
|
22
|
+
console.log(dir)
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Config } from '../config/schema'
|
|
3
|
+
import { currentSession, getSessionBySlug, touchSession } from '../store/queries'
|
|
4
|
+
import { cmdRoster } from './roster'
|
|
5
|
+
|
|
6
|
+
export function cmdResume(db: Database, cfg: Config, cwd: string, slug?: string): number {
|
|
7
|
+
const session = slug ? getSessionBySlug(db, slug) : currentSession(db, cwd)
|
|
8
|
+
if (!session) {
|
|
9
|
+
console.error(slug ? `no konvoy session named "${slug}"` : 'no konvoy session in this directory')
|
|
10
|
+
return 2
|
|
11
|
+
}
|
|
12
|
+
touchSession(db, session.id)
|
|
13
|
+
return cmdRoster(db, cfg, session.cwd, session.slug)
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { deleteSession, getSessionBySlug, listBindings, lockOwner } from '../store/queries'
|
|
3
|
+
|
|
4
|
+
export function cmdRm(db: Database, cwd: string, slug: string, opts: { yes: boolean }): number {
|
|
5
|
+
const session = getSessionBySlug(db, slug)
|
|
6
|
+
if (!session) {
|
|
7
|
+
console.error(`no konvoy session named "${slug}"`)
|
|
8
|
+
return 2
|
|
9
|
+
}
|
|
10
|
+
const busy = lockOwner(db, session.id)
|
|
11
|
+
if (busy) {
|
|
12
|
+
console.error(`"${slug}" has a turn running (${busy}) — wait for it to finish, then retry`)
|
|
13
|
+
return 2
|
|
14
|
+
}
|
|
15
|
+
const bound = listBindings(db, session.id).filter((b) => b.foreignId)
|
|
16
|
+
if (!opts.yes) {
|
|
17
|
+
console.error(`this deletes konvoy session "${slug}" and its ${bound.length} binding(s)`)
|
|
18
|
+
console.error(`the sessions inside each CLI are NOT deleted — re-run with --yes to proceed`)
|
|
19
|
+
return 2
|
|
20
|
+
}
|
|
21
|
+
deleteSession(db, session.id)
|
|
22
|
+
console.log(`removed ${slug}`)
|
|
23
|
+
if (bound.length > 0) {
|
|
24
|
+
console.log(`the following foreign sessions still exist and can be opened directly:`)
|
|
25
|
+
for (const b of bound) console.log(` ${b.agent}: ${b.foreignId}`)
|
|
26
|
+
}
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Config } from '../config/schema'
|
|
3
|
+
import { agentIds } from '../adapters'
|
|
4
|
+
import { resolveAgent } from '../config/load'
|
|
5
|
+
import { currentSession, getSessionBySlug, listBindings } from '../store/queries'
|
|
6
|
+
import { duplicateModels, formatRoster, type RosterRow } from '../format'
|
|
7
|
+
|
|
8
|
+
export function cmdRoster(db: Database, cfg: Config, cwd: string, slug?: string): number {
|
|
9
|
+
const session = slug ? getSessionBySlug(db, slug) : currentSession(db, cwd)
|
|
10
|
+
if (!session) {
|
|
11
|
+
console.error('no konvoy session here — run `konvoy new "<goal>"` first')
|
|
12
|
+
return 2
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const bindings = new Map(listBindings(db, session.id).map((b) => [b.agent, b]))
|
|
16
|
+
const rows: RosterRow[] = agentIds.map((agent) => {
|
|
17
|
+
const settings = resolveAgent(cfg, agent)
|
|
18
|
+
const binding = bindings.get(agent) ?? null
|
|
19
|
+
return {
|
|
20
|
+
agent,
|
|
21
|
+
status: !settings.enabled ? 'disabled' : (binding?.status ?? 'unbound'),
|
|
22
|
+
model: settings.model ?? '',
|
|
23
|
+
effort: settings.effort,
|
|
24
|
+
foreignId: binding?.foreignId ?? null,
|
|
25
|
+
turns: binding?.turns ?? 0,
|
|
26
|
+
costUsd: binding?.costUsd ?? 0,
|
|
27
|
+
credits: binding?.credits ?? 0,
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
console.log(`session ${session.slug} — ${session.goal}`)
|
|
32
|
+
console.log(formatRoster(rows))
|
|
33
|
+
for (const model of duplicateModels(rows)) {
|
|
34
|
+
console.log(`warning: ${model} is used by more than one agent — a second opinion from the same model is not one`)
|
|
35
|
+
}
|
|
36
|
+
return 0
|
|
37
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Config } from '../config/schema'
|
|
3
|
+
import type { AgentId } from '../types'
|
|
4
|
+
import { agentIds } from '../adapters'
|
|
5
|
+
import { currentSession, getSessionBySlug } from '../store/queries'
|
|
6
|
+
import { send } from '../core/session'
|
|
7
|
+
import { oneLine, safeText } from '../adapters/types'
|
|
8
|
+
import type { TurnResult } from '../core/turn'
|
|
9
|
+
|
|
10
|
+
export async function cmdSend(
|
|
11
|
+
db: Database,
|
|
12
|
+
cfg: Config,
|
|
13
|
+
cwd: string,
|
|
14
|
+
agent: string,
|
|
15
|
+
prompt: string,
|
|
16
|
+
slug?: string,
|
|
17
|
+
): Promise<number> {
|
|
18
|
+
if (!agentIds.includes(agent as AgentId)) {
|
|
19
|
+
console.error(`unknown agent "${agent}" — expected one of ${agentIds.join(', ')}`)
|
|
20
|
+
return 2
|
|
21
|
+
}
|
|
22
|
+
const session = slug ? getSessionBySlug(db, slug) : currentSession(db, cwd)
|
|
23
|
+
if (!session) {
|
|
24
|
+
console.error('no konvoy session here — run `konvoy new "<goal>"` first')
|
|
25
|
+
return 2
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let result: TurnResult
|
|
29
|
+
try {
|
|
30
|
+
result = await send({ db, cfg }, session, agent as AgentId, prompt, {
|
|
31
|
+
onEvent: (e) => {
|
|
32
|
+
if (e.t === 'tool') console.error(` · ${oneLine(e.name, 120)}`)
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
37
|
+
return 2
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const outcome = decideOutcome(agent as AgentId, result)
|
|
41
|
+
for (const line of outcome.stderrLines) console.error(line)
|
|
42
|
+
if (outcome.stdout !== null) console.log(outcome.stdout)
|
|
43
|
+
return outcome.code
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SendOutcome {
|
|
47
|
+
code: number
|
|
48
|
+
stdout: string | null
|
|
49
|
+
stderrLines: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The exit code is decided here, once, from the same two facts turn.ts keeps separate: whether
|
|
53
|
+
// output was produced (result.final) and whether the agent is now blocked (result.error.kind).
|
|
54
|
+
// A turn that answered and then hit a limit is both successful and blocked — it prints what it
|
|
55
|
+
// produced, plus the one line saying the agent can't keep going, and exits 0.
|
|
56
|
+
export function decideOutcome(agent: AgentId, result: TurnResult): SendOutcome {
|
|
57
|
+
if (result.error) {
|
|
58
|
+
const blocked = result.error.kind === 'auth' || result.error.kind === 'rate'
|
|
59
|
+
if (blocked && result.final.trim() !== '') {
|
|
60
|
+
const stderrLines = [`${agent} is blocked (${result.error.kind}): ${oneLine(result.error.message)}`]
|
|
61
|
+
if (result.error.kind === 'auth') stderrLines.push(loginHint(agent))
|
|
62
|
+
return { code: 0, stdout: safeText(result.final), stderrLines }
|
|
63
|
+
}
|
|
64
|
+
const stderrLines = [`${agent} failed (${result.error.kind}): ${oneLine(result.error.message)}`]
|
|
65
|
+
if (result.error.kind === 'auth') stderrLines.push(loginHint(agent))
|
|
66
|
+
return { code: 1, stdout: null, stderrLines }
|
|
67
|
+
}
|
|
68
|
+
return { code: 0, stdout: safeText(result.final), stderrLines: [] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loginHint(agent: AgentId): string {
|
|
72
|
+
const hints: Record<AgentId, string> = {
|
|
73
|
+
claude: 'run: claude auth',
|
|
74
|
+
codex: 'run: codex login',
|
|
75
|
+
kiro: 'run: kiro-cli login',
|
|
76
|
+
opencode: 'run: opencode providers',
|
|
77
|
+
}
|
|
78
|
+
return hints[agent]
|
|
79
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Config } from '../config/schema'
|
|
3
|
+
import { agentIds } from '../adapters'
|
|
4
|
+
import { resolveAgent } from '../config/load'
|
|
5
|
+
import { detect, detectAuth } from '../core/detect'
|
|
6
|
+
import { formatVersions, type AgentStatusRow } from '../format'
|
|
7
|
+
import { cmdRoster } from './roster'
|
|
8
|
+
|
|
9
|
+
export async function cmdStatus(
|
|
10
|
+
db: Database,
|
|
11
|
+
cfg: Config,
|
|
12
|
+
cwd: string,
|
|
13
|
+
slug?: string,
|
|
14
|
+
opts: { roster?: boolean } = {},
|
|
15
|
+
): Promise<number> {
|
|
16
|
+
const rows: AgentStatusRow[] = await Promise.all(
|
|
17
|
+
agentIds.map(async (agent) => {
|
|
18
|
+
const settings = resolveAgent(cfg, agent)
|
|
19
|
+
const found = await detect(agent, { model: settings.model, bin: settings.bin })
|
|
20
|
+
const auth = found.installed
|
|
21
|
+
? await detectAuth(agent, { bin: settings.bin })
|
|
22
|
+
: { agent, authed: null, detail: 'not installed' }
|
|
23
|
+
return { agent, installed: found.installed, version: found.version, authed: auth.authed, detail: auth.detail }
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
console.log(formatVersions(rows))
|
|
27
|
+
for (const r of rows) {
|
|
28
|
+
if (!r.installed) console.log(`warning: ${r.agent} is not installed — it will be skipped`)
|
|
29
|
+
else if (r.authed === false) console.log(`warning: ${r.agent}: ${r.detail}`)
|
|
30
|
+
}
|
|
31
|
+
// status reports; it does not judge. A directory with no session is not a failure of the
|
|
32
|
+
// command, and cmdRoster has already said so on stderr.
|
|
33
|
+
if (opts.roster !== false) cmdRoster(db, cfg, cwd, slug)
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Single source of truth for konvoy's command surface: name, aliases, usage line and
|
|
2
|
+
// one-line summary. src/cli.ts derives both its dispatch table and its USAGE text from
|
|
3
|
+
// this array, and tests/docs.test.ts checks README.md against it, so a command can't be
|
|
4
|
+
// documented without existing, or exist without being documented.
|
|
5
|
+
export interface CommandSpec {
|
|
6
|
+
readonly name: string
|
|
7
|
+
readonly aliases: readonly string[]
|
|
8
|
+
readonly usage: string
|
|
9
|
+
readonly summary: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const commandTable = [
|
|
13
|
+
{ name: 'new', aliases: ['start'], usage: 'new "<goal>"', summary: 'create a session in this directory' },
|
|
14
|
+
{ name: 'send', aliases: [], usage: 'send <agent> "<msg>"', summary: 'run one turn against one agent' },
|
|
15
|
+
{ name: 'ls', aliases: ['sessions'], usage: 'ls', summary: 'list sessions' },
|
|
16
|
+
{ name: 'resume', aliases: [], usage: 'resume [session]', summary: 'make a session current and show its roster' },
|
|
17
|
+
{ name: 'config', aliases: [], usage: 'config get|set', summary: 'read or write layered configuration' },
|
|
18
|
+
{
|
|
19
|
+
name: 'rm',
|
|
20
|
+
aliases: [],
|
|
21
|
+
usage: 'rm <session> --yes',
|
|
22
|
+
summary: 'delete a konvoy session (foreign sessions survive)',
|
|
23
|
+
},
|
|
24
|
+
{ name: 'roster', aliases: [], usage: 'roster', summary: 'who is in the convoy' },
|
|
25
|
+
{ name: 'usage', aliases: [], usage: 'usage [--all] [--chart]', summary: 'what this session spent, per agent' },
|
|
26
|
+
{ name: 'status', aliases: [], usage: 'status', summary: 'versions, auth and roster' },
|
|
27
|
+
{
|
|
28
|
+
name: 'attach',
|
|
29
|
+
aliases: [],
|
|
30
|
+
usage: 'attach <agent> [--id <session-id>]',
|
|
31
|
+
summary: "open that agent's own interface, same session",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'doctor',
|
|
35
|
+
aliases: [],
|
|
36
|
+
usage: 'doctor',
|
|
37
|
+
summary: 'check installs, logins, effort and model overlap',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'update',
|
|
41
|
+
aliases: [],
|
|
42
|
+
usage: 'update [--all]',
|
|
43
|
+
summary: 'update konvoy, and with --all the agent CLIs',
|
|
44
|
+
},
|
|
45
|
+
{ name: 'version', aliases: [], usage: 'version', summary: 'konvoy and agent versions' },
|
|
46
|
+
{ name: 'dashboard', aliases: [], usage: 'dashboard [--port N]', summary: 'open a local page with the same numbers' },
|
|
47
|
+
] as const satisfies readonly CommandSpec[]
|
|
48
|
+
|
|
49
|
+
export type CommandName = (typeof commandTable)[number]['name']
|
|
50
|
+
|
|
51
|
+
const aliasToName = new Map<string, CommandName>()
|
|
52
|
+
for (const c of commandTable) {
|
|
53
|
+
aliasToName.set(c.name, c.name)
|
|
54
|
+
for (const alias of c.aliases) aliasToName.set(alias, c.name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolves a typed word (a command name or one of its aliases) to its canonical command
|
|
58
|
+
// name, or undefined if it isn't a known command at all.
|
|
59
|
+
export function resolveCommandName(word: string): CommandName | undefined {
|
|
60
|
+
return aliasToName.get(word)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The two-column command listing used by USAGE, generated from the table so it can't
|
|
64
|
+
// drift from what dispatch actually supports.
|
|
65
|
+
export function formatCommandList(): string {
|
|
66
|
+
const prefixes = commandTable.map((c) => `konvoy ${c.usage}`)
|
|
67
|
+
const width = Math.max(...prefixes.map((p) => p.length))
|
|
68
|
+
return commandTable
|
|
69
|
+
.map((c, i) => {
|
|
70
|
+
const prefix = prefixes[i]!
|
|
71
|
+
const gap = ' '.repeat(Math.max(2, width - prefix.length))
|
|
72
|
+
return ` ${prefix}${gap}${c.summary}`
|
|
73
|
+
})
|
|
74
|
+
.join('\n')
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AgentId } from '../types'
|
|
2
|
+
import { agentIds } from '../adapters'
|
|
3
|
+
import { detect, type Detection, clearDetectCache } from '../core/detect'
|
|
4
|
+
import { resolveAgent } from '../config/load'
|
|
5
|
+
import type { Config } from '../config/schema'
|
|
6
|
+
|
|
7
|
+
const COMMANDS: Record<AgentId, string[]> = {
|
|
8
|
+
claude: ['claude', 'update'],
|
|
9
|
+
codex: ['codex', 'update'],
|
|
10
|
+
kiro: ['kiro-cli', 'update'],
|
|
11
|
+
opencode: ['opencode', 'upgrade'],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function updateCommand(agent: AgentId, bin?: string): string[] {
|
|
15
|
+
const [name, ...rest] = COMMANDS[agent]
|
|
16
|
+
return [bin ?? name!, ...rest]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UpdateDeps {
|
|
20
|
+
detect: (agent: AgentId, opts: { bin?: string }) => Promise<Detection>
|
|
21
|
+
spawn: (argv: string[]) => Promise<number>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const realUpdateDeps: UpdateDeps = {
|
|
25
|
+
detect: (agent, opts) => detect(agent, opts),
|
|
26
|
+
spawn: async (argv) => {
|
|
27
|
+
const proc = Bun.spawn(argv, { stdio: ['inherit', 'inherit', 'inherit'] })
|
|
28
|
+
return proc.exited
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function cmdUpdate(
|
|
33
|
+
cfg: Config,
|
|
34
|
+
opts: { all: boolean },
|
|
35
|
+
deps: UpdateDeps = realUpdateDeps,
|
|
36
|
+
): Promise<number> {
|
|
37
|
+
if (!opts.all) {
|
|
38
|
+
// konvoy does not update itself — it follows the channel it was installed from
|
|
39
|
+
const channel = Bun.isStandaloneExecutable
|
|
40
|
+
? 'brew upgrade --cask konvoy, or download the latest release'
|
|
41
|
+
: 'bun add -g @doguyilmaz/konvoy@latest, or bun run build in a checkout'
|
|
42
|
+
console.log(`to update konvoy: ${channel}`)
|
|
43
|
+
console.log('to update the agent CLIs: konvoy update --all')
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let failures = 0
|
|
48
|
+
for (const agent of agentIds) {
|
|
49
|
+
const settings = resolveAgent(cfg, agent)
|
|
50
|
+
if (!settings.enabled) {
|
|
51
|
+
console.log(`- ${agent}: disabled in config, skipping`)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const before = await deps.detect(agent, { bin: settings.bin })
|
|
55
|
+
if (!before.installed) {
|
|
56
|
+
console.log(`- ${agent}: not installed, skipping`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
console.log(`updating ${agent} (${before.version})...`)
|
|
60
|
+
const code = await deps.spawn(updateCommand(agent, settings.bin))
|
|
61
|
+
if (code !== 0) {
|
|
62
|
+
console.log(`! ${agent}: update exited with ${code}`)
|
|
63
|
+
failures++
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
// the memo would hand back the pre-update detection; the version line must come from a fresh --version
|
|
67
|
+
clearDetectCache()
|
|
68
|
+
const after = await deps.detect(agent, { bin: settings.bin })
|
|
69
|
+
console.log(`ok ${agent}: ${before.version} -> ${after.version}`)
|
|
70
|
+
}
|
|
71
|
+
return failures === 0 ? 0 : 1
|
|
72
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import {
|
|
3
|
+
currentSession,
|
|
4
|
+
getSessionBySlug,
|
|
5
|
+
turnsPerDay,
|
|
6
|
+
turnsPerDayByAgent,
|
|
7
|
+
usageAcrossSessions,
|
|
8
|
+
usageByAgentModel,
|
|
9
|
+
usageForSession,
|
|
10
|
+
type UsageRow,
|
|
11
|
+
} from '../store/queries'
|
|
12
|
+
import { formatUsage } from '../format'
|
|
13
|
+
import { agentSparklines, heatmap, shareBars } from '../chart'
|
|
14
|
+
import { isPricingConfigured } from '../pricing'
|
|
15
|
+
import type { Config } from '../config/schema'
|
|
16
|
+
import type { Session } from '../types'
|
|
17
|
+
|
|
18
|
+
// the SPEND column mixes dollars and credits, so every table that shows it carries this line
|
|
19
|
+
const UNITS = "spend is in each agent's own unit; a dash means the CLI reported none"
|
|
20
|
+
|
|
21
|
+
export function cmdUsage(
|
|
22
|
+
db: Database,
|
|
23
|
+
cfg: Config,
|
|
24
|
+
cwd: string,
|
|
25
|
+
opts: { all: boolean; slug?: string; chart?: boolean },
|
|
26
|
+
): number {
|
|
27
|
+
let session: Session | null = null
|
|
28
|
+
let rows: UsageRow[]
|
|
29
|
+
|
|
30
|
+
if (opts.all) {
|
|
31
|
+
rows = usageAcrossSessions(db)
|
|
32
|
+
if (rows.length === 0) {
|
|
33
|
+
console.log('no turns recorded yet')
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
console.log('all sessions')
|
|
37
|
+
} else {
|
|
38
|
+
session = opts.slug ? getSessionBySlug(db, opts.slug) : currentSession(db, cwd)
|
|
39
|
+
if (!session) {
|
|
40
|
+
console.error('no konvoy session here — run `konvoy new "<goal>"` first')
|
|
41
|
+
return 2
|
|
42
|
+
}
|
|
43
|
+
rows = usageForSession(db, session.id)
|
|
44
|
+
if (rows.length === 0) {
|
|
45
|
+
console.log(`session ${session.slug} — no turns yet`)
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
console.log(`session ${session.slug}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sessionId = opts.all ? undefined : session?.id
|
|
52
|
+
|
|
53
|
+
console.log(formatUsage(rows, cfg.pricing, usageByAgentModel(db, sessionId)))
|
|
54
|
+
console.log(
|
|
55
|
+
isPricingConfigured(cfg.pricing)
|
|
56
|
+
? `${UNITS}; ~USD is estimated from rates configured as of ${cfg.pricing.asOf || 'an unspecified date'}`
|
|
57
|
+
: UNITS,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if (opts.chart) {
|
|
61
|
+
console.log('\nturns per day')
|
|
62
|
+
console.log(heatmap(turnsPerDay(db, sessionId)))
|
|
63
|
+
console.log('\nshare of turns')
|
|
64
|
+
console.log(shareBars(rows.map((r) => ({ label: r.agent, value: r.turns }))))
|
|
65
|
+
|
|
66
|
+
const perAgent = agentSparklines(turnsPerDayByAgent(db, sessionId))
|
|
67
|
+
if (perAgent.length > 0) {
|
|
68
|
+
console.log('\nturns per day by agent')
|
|
69
|
+
const labelWidth = Math.max(...perAgent.map((r) => r.agent.length))
|
|
70
|
+
for (const { agent, line } of perAgent) {
|
|
71
|
+
console.log(`${agent.padEnd(labelWidth)} ${line}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return 0
|
|
77
|
+
}
|