@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,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
|
+
}
|