@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,335 @@
1
+ import {
2
+ agentIds,
3
+ configSchema,
4
+ type AgentId,
5
+ type Config,
6
+ type Effort,
7
+ type Harness,
8
+ type Permission,
9
+ type Style,
10
+ } from './schema'
11
+ import { configDir, home, join } from '../paths'
12
+
13
+ export const globalConfigPath = (): string => join(configDir(), 'config.jsonc')
14
+ export const projectConfigPath = (cwd: string): string => join(cwd, '.konvoy', 'config.jsonc')
15
+
16
+ export interface AgentSettings {
17
+ enabled: boolean
18
+ model?: string
19
+ effort: Effort
20
+ permission: Permission
21
+ harness: Harness
22
+ bin?: string
23
+ subagentEffort?: Effort
24
+ style?: Style
25
+ }
26
+
27
+ export function stripJsonc(text: string): string {
28
+ let result = ''
29
+ let inString = false
30
+ let i = 0
31
+
32
+ function skipWhitespaceAndComments(start: number): number {
33
+ let j = start
34
+ while (j < text.length) {
35
+ if (/\s/.test(text[j]!)) {
36
+ j++
37
+ continue
38
+ }
39
+ if (text[j] === '/' && text[j + 1] === '/') {
40
+ while (j < text.length && text[j] !== '\n') j++
41
+ if (j < text.length) j++
42
+ continue
43
+ }
44
+ if (text[j] === '/' && text[j + 1] === '*') {
45
+ j += 2
46
+ while (j < text.length - 1) {
47
+ if (text[j] === '*' && text[j + 1] === '/') {
48
+ j += 2
49
+ break
50
+ }
51
+ j++
52
+ }
53
+ continue
54
+ }
55
+ break
56
+ }
57
+ return j
58
+ }
59
+
60
+ while (i < text.length) {
61
+ const char = text[i]
62
+ const next = text[i + 1]
63
+
64
+ if (inString) {
65
+ result += char
66
+ if (char === '\\' && next) {
67
+ result += next
68
+ i += 2
69
+ continue
70
+ }
71
+ if (char === '"') {
72
+ inString = false
73
+ }
74
+ i++
75
+ continue
76
+ }
77
+
78
+ if (char === '"') {
79
+ inString = true
80
+ result += char
81
+ i++
82
+ continue
83
+ }
84
+
85
+ if (char === '/' && next === '/') {
86
+ let j = i
87
+ while (j < text.length && text[j] !== '\n') j++
88
+ if (j < text.length) result += '\n'
89
+ i = j + 1
90
+ continue
91
+ }
92
+
93
+ if (char === '/' && next === '*') {
94
+ let j = i + 2
95
+ while (j < text.length - 1) {
96
+ if (text[j] === '*' && text[j + 1] === '/') {
97
+ j += 2
98
+ break
99
+ }
100
+ j++
101
+ }
102
+ i = j
103
+ continue
104
+ }
105
+
106
+ if (char === ',' && !inString) {
107
+ const j = skipWhitespaceAndComments(i + 1)
108
+ if (j < text.length && (text[j] === '}' || text[j] === ']')) {
109
+ i = j
110
+ continue
111
+ }
112
+ result += char
113
+ i++
114
+ continue
115
+ }
116
+
117
+ result += char
118
+ i++
119
+ }
120
+
121
+ return result
122
+ }
123
+
124
+ function describeJsonError(path: string, error: unknown): Error {
125
+ const message = error instanceof Error ? error.message : String(error)
126
+ return new Error(`malformed config at ${path}: ${message}`)
127
+ }
128
+
129
+ export async function readLayer(path: string): Promise<unknown> {
130
+ const file = Bun.file(path)
131
+ if (!(await file.exists())) return {}
132
+ const text = await file.text()
133
+ try {
134
+ return JSON.parse(stripJsonc(text))
135
+ } catch (error) {
136
+ throw describeJsonError(path, error)
137
+ }
138
+ }
139
+
140
+ function merge(base: Record<string, unknown>, top: Record<string, unknown>): Record<string, unknown> {
141
+ const out: Record<string, unknown> = { ...base }
142
+ for (const [key, value] of Object.entries(top)) {
143
+ const existing = out[key]
144
+ const mergedValue =
145
+ value && typeof value === 'object' && !Array.isArray(value) && existing && typeof existing === 'object'
146
+ ? merge(existing as Record<string, unknown>, value as Record<string, unknown>)
147
+ : value
148
+ Object.defineProperty(out, key, { value: mergedValue, enumerable: true, configurable: true, writable: true })
149
+ }
150
+ return out
151
+ }
152
+
153
+ // `defineProperty` rather than `out[id] = value`: `id` comes from an attacker-controlled JSON
154
+ // key (an agent id in a cloned repo's config), and plain bracket assignment on a plain object
155
+ // triggers the `__proto__` setter instead of creating an own property.
156
+ function setOwn(obj: Record<string, unknown>, key: string, value: unknown): void {
157
+ Object.defineProperty(obj, key, { value, enumerable: true, configurable: true, writable: true })
158
+ }
159
+
160
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
161
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
162
+ }
163
+
164
+ // bin, permission and harness decide what konvoy runs and how much it trusts the process it
165
+ // spawns — properties of the machine the person is running konvoy on, not of the repo they
166
+ // cloned. A project layer may not set them at any level they appear; only the global config can.
167
+ const PRIVILEGED_DEFAULTS_KEYS = ['permission', 'harness'] as const
168
+ const PRIVILEGED_AGENT_KEYS = ['bin', 'permission', 'harness'] as const
169
+ // gate names a command konvoy executes automatically after every turn — wider than `bin`,
170
+ // which at least requires the user to already be using that agent. It sits at the top level
171
+ // of the config, not under `defaults` or `agents.<id>`, so it needs its own case here.
172
+ const PRIVILEGED_TOP_LEVEL_KEYS = ['gate'] as const
173
+
174
+ function warnIgnored(path: string): void {
175
+ console.error(`konvoy: ignoring project-level "${path}" — privileged, set it in the global config instead`)
176
+ }
177
+
178
+ function stripProjectPrivileges(layer: Record<string, unknown>): Record<string, unknown> {
179
+ const out: Record<string, unknown> = { ...layer }
180
+
181
+ for (const key of PRIVILEGED_TOP_LEVEL_KEYS) {
182
+ if (key in out) {
183
+ warnIgnored(key)
184
+ delete out[key]
185
+ }
186
+ }
187
+
188
+ if (isPlainObject(out.defaults)) {
189
+ const cleaned = { ...out.defaults }
190
+ for (const key of PRIVILEGED_DEFAULTS_KEYS) {
191
+ if (key in cleaned) {
192
+ warnIgnored(`defaults.${key}`)
193
+ delete cleaned[key]
194
+ }
195
+ }
196
+ out.defaults = cleaned
197
+ }
198
+
199
+ if (isPlainObject(out.agents)) {
200
+ const cleanedAgents: Record<string, unknown> = {}
201
+ for (const [id, agentCfg] of Object.entries(out.agents)) {
202
+ if (!isPlainObject(agentCfg)) {
203
+ setOwn(cleanedAgents, id, agentCfg)
204
+ continue
205
+ }
206
+ const cleaned = { ...agentCfg }
207
+ for (const key of PRIVILEGED_AGENT_KEYS) {
208
+ if (key in cleaned) {
209
+ warnIgnored(`agents.${id}.${key}`)
210
+ delete cleaned[key]
211
+ }
212
+ }
213
+ setOwn(cleanedAgents, id, cleaned)
214
+ }
215
+ out.agents = cleanedAgents
216
+ }
217
+
218
+ return out
219
+ }
220
+
221
+ // A leading dash is what makes a model string dangerous: spawned with `--model <value>`, it
222
+ // lands as the next argv token with no `--` guard, unlike the prompt. Unlike bin/permission/
223
+ // harness, a project may legitimately pin a model — so this is validated, not merge-source
224
+ // restricted, and applies to whichever layer's value survives the merge.
225
+ // `/` because opencode names models provider/model; `#` stays out because the variant after
226
+ // it is konvoy's own effort dial, appended by the adapter
227
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:\/-]*$/
228
+
229
+ function stripInvalidModels(layer: Record<string, unknown>): Record<string, unknown> {
230
+ const out: Record<string, unknown> = { ...layer }
231
+ if (!isPlainObject(out.agents)) return out
232
+
233
+ const cleanedAgents: Record<string, unknown> = {}
234
+ for (const [id, agentCfg] of Object.entries(out.agents)) {
235
+ if (!isPlainObject(agentCfg)) {
236
+ setOwn(cleanedAgents, id, agentCfg)
237
+ continue
238
+ }
239
+ const cleaned = { ...agentCfg }
240
+ if (typeof cleaned.model === 'string' && !MODEL_PATTERN.test(cleaned.model)) {
241
+ console.error(`konvoy: ignoring invalid agents.${id}.model "${cleaned.model}" — must match ${MODEL_PATTERN}`)
242
+ delete cleaned.model
243
+ }
244
+ setOwn(cleanedAgents, id, cleaned)
245
+ }
246
+ out.agents = cleanedAgents
247
+ return out
248
+ }
249
+
250
+ // only a leading `~/`, or a bare `~`, is a home-directory reference — a tilde anywhere else
251
+ // in the path (e.g. `rel/~/x`) is left alone
252
+ function expandHome(p: string): string {
253
+ if (p === '~') return home()
254
+ if (p.startsWith('~/')) return join(home(), p.slice(2))
255
+ return p
256
+ }
257
+
258
+ export async function loadConfig(opts: { cwd: string; globalPath?: string }): Promise<Config> {
259
+ const globalPath = opts.globalPath ?? globalConfigPath()
260
+ const globalLayer = (await readLayer(globalPath)) as Record<string, unknown>
261
+ const projectLayer = stripProjectPrivileges((await readLayer(projectConfigPath(opts.cwd))) as Record<string, unknown>)
262
+ const merged = stripInvalidModels(merge(globalLayer, projectLayer))
263
+ const parsed = configSchema.safeParse(merged)
264
+ if (!parsed.success) {
265
+ const where = describeIssue(parsed.error.issues[0])
266
+ // the project layer is the lower-trust one: a repository's mistake is reported and the layer
267
+ // set aside, so konvoy still runs in that directory; the user's own file stays strict
268
+ const globalOnly = configSchema.safeParse(stripInvalidModels(merge(globalLayer, {})))
269
+ if (globalOnly.success) {
270
+ console.error(`konvoy: ignoring project-level config at ${projectConfigPath(opts.cwd)} — invalid at ${where}`)
271
+ return finishConfig(globalOnly.data)
272
+ }
273
+ throw new Error(`invalid konvoy config at ${where}`)
274
+ }
275
+ return finishConfig(parsed.data)
276
+ }
277
+
278
+ function describeIssue(issue: { path: PropertyKey[]; code: string; message: string; keys?: unknown } | undefined): string {
279
+ let pathStr = issue?.path.map(String).join('.')
280
+ const keys = issue?.code === 'unrecognized_keys' ? (issue.keys as string[] | undefined) : undefined
281
+ if (keys && keys.length > 0) pathStr = [...(issue?.path ?? []).map(String), keys[0]].join('.')
282
+ return `${pathStr}: ${issue?.message}`
283
+ }
284
+
285
+ function finishConfig(data: Config): Config {
286
+ for (const agentCfg of Object.values(data.agents)) {
287
+ if (agentCfg?.bin) agentCfg.bin = expandHome(agentCfg.bin)
288
+ }
289
+ return data
290
+ }
291
+
292
+ export function resolveAgent(cfg: Config, agent: AgentId): AgentSettings {
293
+ const a = cfg.agents[agent] ?? {}
294
+ const ownStyle = a.style
295
+ const defaultStyle = cfg.defaults.style ?? undefined
296
+ // an explicit `null` opts an agent out of a `defaults.style`, unlike `effort`/`permission`
297
+ // where the per-agent value is only ever absent or set — so this can't reuse `??`.
298
+ const style = ownStyle === null ? undefined : (ownStyle ?? defaultStyle)
299
+ return {
300
+ enabled: a.enabled ?? true,
301
+ model: a.model,
302
+ effort: a.effort ?? cfg.defaults.effort,
303
+ permission: a.permission ?? cfg.defaults.permission,
304
+ harness: a.harness ?? cfg.defaults.harness,
305
+ bin: a.bin,
306
+ subagentEffort: a.subagentEffort,
307
+ style,
308
+ }
309
+ }
310
+
311
+ const isAgentId = (value: string): value is AgentId => (agentIds as readonly string[]).includes(value)
312
+
313
+ // An envelope's `to` is free text a model wrote, not a validated key — trimmed and
314
+ // lower-cased before either check runs, since a model may write "Reviewer" or " claude ".
315
+ // An agent id wins over a role of the same name: a role can be reassigned mid-session, an
316
+ // agent id cannot.
317
+ export function resolveRecipient(cfg: Config, to: string): AgentId | null {
318
+ const norm = to.trim().toLowerCase()
319
+ if (isAgentId(norm)) return norm
320
+ if (norm === 'lead' || norm === 'implementer' || norm === 'reviewer' || norm === 'researcher') {
321
+ return cfg.roles[norm] ?? null
322
+ }
323
+ return null
324
+ }
325
+
326
+ export function explain(
327
+ cfg: Config,
328
+ agent: AgentId,
329
+ key: 'effort' | 'permission' | 'model',
330
+ ): { value: string | undefined; source: 'agent' | 'defaults' | 'built-in' } {
331
+ const a = cfg.agents[agent] ?? {}
332
+ if (a[key] !== undefined) return { value: a[key], source: 'agent' }
333
+ if (key === 'model') return { value: undefined, source: 'built-in' }
334
+ return { value: cfg.defaults[key], source: 'defaults' }
335
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod'
2
+
3
+ export const agentIds = ['claude', 'codex', 'kiro', 'opencode'] as const
4
+ export const effortSchema = z.enum(['low', 'medium', 'high', 'max'])
5
+ export const permissionSchema = z.enum(['safe', 'edit', 'yolo'])
6
+ export const harnessSchema = z.enum(['minimal', 'inherit'])
7
+ export const agentIdSchema = z.enum(agentIds)
8
+ // konvoy's own instruction, not a model capability — `.nullish()` so a per-agent `null` can
9
+ // opt out of a `defaults.style` of 'brief', which a plain `.optional()` cannot express.
10
+ export const styleSchema = z.enum(['brief'])
11
+
12
+ const agentConfigSchema = z
13
+ .object({
14
+ enabled: z.boolean().optional(),
15
+ model: z.string().optional(),
16
+ effort: effortSchema.optional(),
17
+ permission: permissionSchema.optional(),
18
+ harness: harnessSchema.optional(),
19
+ bin: z.string().optional(),
20
+ subagentEffort: effortSchema.optional(),
21
+ engine: z.string().optional(),
22
+ style: styleSchema.nullish(),
23
+ })
24
+ .strict()
25
+
26
+ const defaultsObjectSchema = z
27
+ .object({
28
+ effort: effortSchema.default('high'),
29
+ permission: permissionSchema.default('edit'),
30
+ harness: harnessSchema.default('minimal'),
31
+ style: styleSchema.nullish(),
32
+ })
33
+ .strict()
34
+
35
+ const rolesObjectSchema = z
36
+ .object({
37
+ lead: agentIdSchema.optional(),
38
+ implementer: agentIdSchema.optional(),
39
+ reviewer: agentIdSchema.optional(),
40
+ researcher: agentIdSchema.optional(),
41
+ })
42
+ .strict()
43
+
44
+ const MAX_TURN_TIMEOUT_SEC = 24 * 60 * 60
45
+
46
+ const policyObjectSchema = z
47
+ .object({
48
+ maxDelegationDepth: z.number().int().positive().default(3),
49
+ // A project config sets this, so it is clamped rather than trusted outright — otherwise a
50
+ // hostile repo could make konvoy wait forever on every turn.
51
+ turnTimeoutSec: z
52
+ .number()
53
+ .int()
54
+ .positive()
55
+ .default(900)
56
+ .transform((v) => Math.min(v, MAX_TURN_TIMEOUT_SEC)),
57
+ isolation: z.enum(['serial', 'parallel']).default('serial'),
58
+ })
59
+ .strict()
60
+
61
+ // An empty chain means the feature is off, which is the default. A project layer may set
62
+ // this: naming an ordering among agents the user already enabled grants nothing new, unlike
63
+ // `bin` or `permission`.
64
+ const failoverObjectSchema = z
65
+ .object({
66
+ chain: z.array(agentIdSchema).default([]),
67
+ upstreamRetries: z.number().int().min(0).max(10).default(3),
68
+ })
69
+ .strict()
70
+
71
+ export const configSchema = z
72
+ .object({
73
+ defaults: defaultsObjectSchema.prefault({}),
74
+ agents: z.partialRecord(agentIdSchema, agentConfigSchema).default({}),
75
+ roles: rolesObjectSchema.prefault({}),
76
+ policy: policyObjectSchema.prefault({}),
77
+ // The command konvoy runs after a turn to produce a pass/fail verdict on the work — a
78
+ // project layer may never set this; see stripProjectPrivileges in config/load.ts.
79
+ gate: z.object({ command: z.string().nullish() }).strict().prefault({}),
80
+ failover: failoverObjectSchema.prefault({}),
81
+ // Off by default: a single-agent session has no handoff to describe, and asking for one
82
+ // anyway would cost output tokens on every turn for a format nobody reads.
83
+ delegation: z.object({ enabled: z.boolean().default(false) }).strict().prefault({}),
84
+ pricing: z
85
+ .object({
86
+ asOf: z.string().default(''),
87
+ models: z.record(z.string(), z.object({ inputPerMTok: z.number(), outputPerMTok: z.number() }).strict()).default({}),
88
+ credits: z.record(z.string(), z.object({ usdPerCredit: z.number() }).strict()).default({}),
89
+ })
90
+ .strict()
91
+ .prefault({}),
92
+ })
93
+ .strict()
94
+
95
+ export type Config = z.infer<typeof configSchema>
96
+ export type AgentId = z.infer<typeof agentIdSchema>
97
+ export type Effort = z.infer<typeof effortSchema>
98
+ export type Permission = z.infer<typeof permissionSchema>
99
+ export type Harness = z.infer<typeof harnessSchema>
100
+ export type Style = z.infer<typeof styleSchema>
@@ -0,0 +1,62 @@
1
+ type Child = { kill(): void; exitCode: number | null }
2
+
3
+ const live = new Set<Child>()
4
+ let installed = false
5
+
6
+ function install(): void {
7
+ if (installed) return
8
+ installed = true
9
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
10
+ process.on(signal, () => {
11
+ for (const child of live) {
12
+ if (child.exitCode === null) child.kill()
13
+ }
14
+ live.clear()
15
+ for (const handler of exitHandlers) {
16
+ try {
17
+ handler(signal)
18
+ } catch {
19
+ // a cleanup that fails must not stop the others from running
20
+ }
21
+ }
22
+ exitHandlers.clear()
23
+ process.exit(signal === 'SIGINT' ? 130 : 143)
24
+ })
25
+ }
26
+ }
27
+
28
+ type ExitHandler = (signal: 'SIGINT' | 'SIGTERM') => void
29
+
30
+ const exitHandlers = new Set<ExitHandler>()
31
+
32
+ export function onExit(fn: ExitHandler): () => void {
33
+ install()
34
+ exitHandlers.add(fn)
35
+ return () => exitHandlers.delete(fn)
36
+ }
37
+
38
+ export function track(child: Child): void {
39
+ install()
40
+ live.add(child)
41
+ }
42
+
43
+ export function untrack(child: Child): void {
44
+ live.delete(child)
45
+ }
46
+
47
+ export function liveCount(): number {
48
+ return live.size
49
+ }
50
+
51
+ // Bun's own `timeout` sends killSignal once and never follows up — a child that traps or
52
+ // ignores SIGTERM then hangs forever. SIGKILL cannot be trapped; it goes out once the timeout
53
+ // has had a grace period to work. Every bounded spawn (a turn, a gate) uses this one.
54
+ export const DEFAULT_KILL_GRACE_MS = 2_000
55
+
56
+ export function escalateKill(proc: { exitCode: number | null; kill(signal: 'SIGKILL'): void }, afterMs: number): () => void {
57
+ const timer = setTimeout(() => {
58
+ if (proc.exitCode === null) proc.kill('SIGKILL')
59
+ }, afterMs)
60
+ timer.unref?.()
61
+ return () => clearTimeout(timer)
62
+ }