@doguyilmaz/konvoy 0.1.1 → 0.1.2
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/README.md +20 -20
- package/package.json +2 -2
- package/src/adapters/claude.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/opencode.ts +1 -1
- package/src/adapters/types.ts +10 -10
- package/src/chart.ts +2 -2
- package/src/cli.ts +1 -1
- package/src/commands/attach.ts +5 -5
- package/src/commands/config.ts +2 -2
- package/src/commands/dashboard.ts +1 -1
- package/src/commands/doctor.ts +7 -7
- package/src/commands/new.ts +2 -2
- package/src/commands/rm.ts +2 -2
- package/src/commands/roster.ts +3 -3
- package/src/commands/send.ts +3 -3
- package/src/commands/status.ts +1 -1
- package/src/commands/update.ts +1 -1
- package/src/commands/usage.ts +2 -2
- package/src/config/load.ts +9 -9
- package/src/config/schema.ts +3 -3
- package/src/core/children.ts +1 -1
- package/src/core/detect.ts +2 -3
- package/src/core/facts.ts +2 -2
- package/src/core/gate.ts +3 -3
- package/src/core/prelude.ts +3 -3
- package/src/core/session.ts +18 -18
- package/src/core/turn.ts +6 -6
- package/src/dashboard/page.ts +4 -4
- package/src/format.ts +1 -1
- package/src/pricing.ts +3 -3
- package/src/store/db.ts +1 -1
- package/src/store/queries.ts +6 -7
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ opencode ▁▁▂▁▁▁▁▂▁▂
|
|
|
97
97
|
A failover notice, when codex hits its weekly limit mid-chain:
|
|
98
98
|
|
|
99
99
|
```text
|
|
100
|
-
konvoy: codex is blocked (rate)
|
|
100
|
+
konvoy: codex is blocked (rate) - "You've hit your weekly limit · resets 7am" - claude is taking over
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
A handoff, with `delegation.enabled` on and `roles.reviewer` set to `claude`. codex ends its
|
|
@@ -120,7 +120,7 @@ decisions:
|
|
|
120
120
|
konvoy resolves `reviewer` to claude, runs it, and prints:
|
|
121
121
|
|
|
122
122
|
```text
|
|
123
|
-
konvoy: codex handed off to claude
|
|
123
|
+
konvoy: codex handed off to claude - "check the retry does not loop when the refresh itself 401s"
|
|
124
124
|
```
|
|
125
125
|
|
|
126
126
|
claude's turn runs with codex's task as its prompt, preceded by this prelude:
|
|
@@ -139,7 +139,7 @@ decisions:
|
|
|
139
139
|
## How it works
|
|
140
140
|
|
|
141
141
|
One konvoy session holds a binding per agent, and each binding holds that agent's own
|
|
142
|
-
foreign session id
|
|
142
|
+
foreign session id; konvoy's id and the agent's id are never the same thing. Only claude
|
|
143
143
|
accepts a caller-chosen session id up front; the other three assign their own and hand it
|
|
144
144
|
back after the first turn, which konvoy stores in that agent's binding and resumes on every
|
|
145
145
|
turn after.
|
|
@@ -175,18 +175,18 @@ from that agent, from `defaults`, or from konvoy's own built-in.
|
|
|
175
175
|
}
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
`effort` is one scale
|
|
178
|
+
`effort` is one scale, `low | medium | high | max`, mapped onto each CLI's own dial and
|
|
179
179
|
clamped to what the target model actually supports.
|
|
180
180
|
|
|
181
|
-
If a CLI is not on your `PATH`, point konvoy at it directly and every command
|
|
182
|
-
`status`, `send`, `attach`, `update`
|
|
181
|
+
If a CLI is not on your `PATH`, point konvoy at it directly and every command (`doctor`,
|
|
182
|
+
`status`, `send`, `attach`, `update`) uses that path:
|
|
183
183
|
|
|
184
184
|
```jsonc
|
|
185
185
|
{ "agents": { "opencode": { "bin": "~/.opencode/bin/opencode" } } }
|
|
186
186
|
```
|
|
187
187
|
|
|
188
188
|
`konvoy config set <key> <value> [--global]` rewrites the layer it touches as plain JSON, so
|
|
189
|
-
any comments in that file are lost
|
|
189
|
+
any comments in that file are lost; `--global` targets the global file instead of the
|
|
190
190
|
project one. Hand-edit the file instead when you want to keep them.
|
|
191
191
|
|
|
192
192
|
Name a `failover` chain and konvoy follows it when an agent can't work, instead of asking:
|
|
@@ -198,7 +198,7 @@ Name a `failover` chain and konvoy follows it when an agent can't work, instead
|
|
|
198
198
|
A rate limit or an auth failure moves to the next agent in the chain at once. An agent that failed on auth shows as `auth_required` in the roster until one of its turns succeeds. An upstream
|
|
199
199
|
error (a reachable-but-refusing API) retries the same agent with backoff up to
|
|
200
200
|
`upstreamRetries` times before moving on. A crash, a timeout, or an interrupted turn never
|
|
201
|
-
moves the chain
|
|
201
|
+
moves the chain: the fault is in the work, and the next agent would just fail the same way.
|
|
202
202
|
There is no failback: once konvoy moves, it stays moved. An empty chain (the default) turns
|
|
203
203
|
the feature off.
|
|
204
204
|
|
|
@@ -214,15 +214,15 @@ flowchart TD
|
|
|
214
214
|
```
|
|
215
215
|
|
|
216
216
|
Set `style: "brief"` to have an agent lead with the action, number multi-step work, and skip
|
|
217
|
-
preamble and pleasantries
|
|
217
|
+
preamble and pleasantries. It shapes the answer you read, not what agents send each other:
|
|
218
218
|
|
|
219
219
|
```jsonc
|
|
220
220
|
{ "defaults": { "style": "brief" }, "agents": { "kiro": { "style": null } } }
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
-
Set `delegation.enabled` to have every turn told how to hand work to another agent
|
|
224
|
-
`<<<konvoy ... >>>` block naming `to:` and `task
|
|
225
|
-
lists
|
|
223
|
+
Set `delegation.enabled` to have every turn told how to hand work to another agent, a
|
|
224
|
+
`<<<konvoy ... >>>` block naming `to:` and `task:` with optional `open:` and `decisions:`
|
|
225
|
+
lists, instead of the weaker summary konvoy derives on its own. The agent decides when a
|
|
226
226
|
turn is actually handing off; a turn that isn't emits no block at all, so this costs nothing
|
|
227
227
|
on the turns that don't need it. Off by default: a single-agent session has no handoff to
|
|
228
228
|
describe.
|
|
@@ -231,7 +231,7 @@ describe.
|
|
|
231
231
|
{ "delegation": { "enabled": true } }
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
-
Name a `gate` command
|
|
234
|
+
Name a `gate` command (your test suite, a linter, whatever exits non-zero on bad work) and
|
|
235
235
|
konvoy runs it after each turn that produced something, recording a pass or fail against that
|
|
236
236
|
turn. A command that can't even be spawned records nothing, and a failed turn is never gated:
|
|
237
237
|
|
|
@@ -240,8 +240,8 @@ turn. A command that can't even be spawned records nothing, and a failed turn is
|
|
|
240
240
|
```
|
|
241
241
|
|
|
242
242
|
`harness` decides how much of a CLI's own setup a turn loads. `minimal`, the default, strips
|
|
243
|
-
what konvoy already supplies
|
|
244
|
-
files, codex with `--ignore-user-config`
|
|
243
|
+
what konvoy already supplies (claude runs with no MCP servers, slash commands or settings
|
|
244
|
+
files, codex with `--ignore-user-config`) and measured 2.6× less context per turn than
|
|
245
245
|
`inherit`, which runs the CLI exactly as you would by hand, hooks and skills included. kiro and
|
|
246
246
|
opencode run with their own configuration either way. Privileged: the global config only.
|
|
247
247
|
|
|
@@ -249,7 +249,7 @@ opencode run with their own configuration either way. Privileged: the global con
|
|
|
249
249
|
{ "defaults": { "harness": "inherit" } }
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
-
`gate` is privileged like `bin`, `permission` and `harness
|
|
252
|
+
`gate` is privileged like `bin`, `permission` and `harness`: only the global config may set
|
|
253
253
|
it, since a gate runs on every turn with no per-turn opt-in, unlike an agent binary the user
|
|
254
254
|
chose to run. That also means one gate command serves every project; there's no per-project
|
|
255
255
|
override yet.
|
|
@@ -279,11 +279,11 @@ bun test
|
|
|
279
279
|
bun run typecheck
|
|
280
280
|
bun run mutate # mutation coverage of src/
|
|
281
281
|
bun run verify:claims # checks konvoy's own claims about the four CLIs against what --help says here
|
|
282
|
-
bun run smoke # two real turns per installed, authenticated agent, the second resumed
|
|
282
|
+
bun run smoke # two real turns per installed, authenticated agent, the second resumed; spends quota
|
|
283
283
|
```
|
|
284
284
|
|
|
285
285
|
`verify:claims` is the standing form of a manual check: it re-reads each CLI's own `--help`
|
|
286
|
-
and confirms what this README and the adapters assume
|
|
286
|
+
and confirms what this README and the adapters assume: that every flag an adapter puts on a
|
|
287
287
|
command line still exists, that each agent's update and auth-status subcommands exist, that
|
|
288
288
|
only claude accepts a caller-chosen session id, and that opencode's `--session` continues a
|
|
289
289
|
session rather than creating one. The flag list is built from the adapters' real argv, so a flag
|
|
@@ -293,8 +293,8 @@ quota, and it isn't part of `bun test` since it needs the CLIs installed to mean
|
|
|
293
293
|
`smoke` closes the gap `verify:claims` and the frozen fixtures in `tests/fixtures/streams/`
|
|
294
294
|
both leave open: it runs two turns per installed, logged-in agent through konvoy's real
|
|
295
295
|
`send()`. The first stores a nonce and must return a foreign session id, some text and no
|
|
296
|
-
error; the second is resumed through the binding konvoy captured and must give the nonce back
|
|
296
|
+
error; the second is resumed through the binding konvoy captured and must give the nonce back -
|
|
297
297
|
the one cheap proof that a bound session carries its context, which every unit test of it
|
|
298
298
|
checks with fakes. It skips an agent that isn't installed or isn't logged in, and flags
|
|
299
|
-
when an installed CLI's version has drifted from the one a fixture was captured against
|
|
299
|
+
when an installed CLI's version has drifted from the one a fixture was captured against, the
|
|
300
300
|
moment to re-capture. It spends real quota, so it is opt-in and never part of `bun test`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doguyilmaz/konvoy",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "One session across Claude Code, Codex, Kiro CLI and opencode
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "One session across Claude Code, Codex, Kiro CLI and opencode: bind, hand off, fail over.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
package/src/adapters/claude.ts
CHANGED
|
@@ -50,7 +50,7 @@ export const claudeAdapter: Adapter = {
|
|
|
50
50
|
|
|
51
51
|
if (o.type === 'result') {
|
|
52
52
|
const text = typeof o.result === 'string' ? o.result : ''
|
|
53
|
-
// a result can be is_error with no text at all
|
|
53
|
+
// a result can be is_error with no text at all - claude puts the reason on stderr then, and
|
|
54
54
|
// an invented placeholder here would stand in the way of turn.ts reading it
|
|
55
55
|
if (o.is_error) return [{ t: 'error', message: text, kind: classifyError(text) }]
|
|
56
56
|
const usage = o.usage as
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const kiroAdapter: Adapter = {
|
|
|
60
60
|
|
|
61
61
|
if (o.type === 'runFinished') {
|
|
62
62
|
if (data.status === 'success') {
|
|
63
|
-
// finalText comes with a finalTextTruncated flag
|
|
63
|
+
// finalText comes with a finalTextTruncated flag - kiro truncates it. Only an untruncated
|
|
64
64
|
// copy is authoritative; otherwise no done is emitted and the streamed chunks stand.
|
|
65
65
|
if (typeof data.finalText === 'string' && data.finalTextTruncated !== true) events.push({ t: 'done', final: data.finalText })
|
|
66
66
|
} else {
|
package/src/adapters/opencode.ts
CHANGED
|
@@ -39,7 +39,7 @@ export const opencodeAdapter: Adapter = {
|
|
|
39
39
|
| undefined
|
|
40
40
|
const tokens = step?.tokens
|
|
41
41
|
if (tokens) {
|
|
42
|
-
// opencode's `input` is the uncached remainder, like claude's and unlike codex's
|
|
42
|
+
// opencode's `input` is the uncached remainder, like claude's and unlike codex's -
|
|
43
43
|
// its own `total` is input + output + cache, which is what settles that. It also
|
|
44
44
|
// reports the turn's cost here, on the same part.
|
|
45
45
|
const input = (tokens.input ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0)
|
package/src/adapters/types.ts
CHANGED
|
@@ -10,13 +10,13 @@ export interface Adapter {
|
|
|
10
10
|
prepare?(ctx: TurnContext): Promise<void>
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
// konvoy's own instruction, owned here rather than vendored from any installed skill
|
|
13
|
+
// konvoy's own instruction, owned here rather than vendored from any installed skill - a
|
|
14
14
|
// user's own such skill is already reachable via `harness: inherit`. It shapes what the user
|
|
15
15
|
// reads, not what agents exchange, so it asks for omission, never compression.
|
|
16
16
|
export const BRIEF_INSTRUCTION =
|
|
17
17
|
'Lead with the action. Number multi-step work. End with one concrete next step. Skip preamble, recap, and closing pleasantries.'
|
|
18
18
|
|
|
19
|
-
// konvoy has no model and cannot decide when a turn hands off
|
|
19
|
+
// konvoy has no model and cannot decide when a turn hands off - only the agent running it
|
|
20
20
|
// knows. This instruction is what asks it to say so. A turn not handing work over must emit
|
|
21
21
|
// nothing: the envelope costs output tokens only on the turns that actually use it.
|
|
22
22
|
export const DELEGATION_INSTRUCTION =
|
|
@@ -27,11 +27,11 @@ export const DELEGATION_INSTRUCTION =
|
|
|
27
27
|
'open: <optional list>\n' +
|
|
28
28
|
'decisions: <optional list>\n' +
|
|
29
29
|
'>>>\n' +
|
|
30
|
-
'If this turn is not handing work over, emit nothing
|
|
30
|
+
'If this turn is not handing work over, emit nothing - no block at all.'
|
|
31
31
|
|
|
32
32
|
// One composition point rather than four: the adapters cannot drift in how they join these,
|
|
33
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
|
|
34
|
+
// and delegation instructions trail the prompt for the same reason - they must never join
|
|
35
35
|
// the cached prefix.
|
|
36
36
|
export function withPrelude(ctx: TurnContext): string {
|
|
37
37
|
const base = ctx.prelude ? `${ctx.prelude}\n\n${ctx.prompt}` : ctx.prompt
|
|
@@ -61,22 +61,22 @@ export function stripControlChars(value: string): string {
|
|
|
61
61
|
return value.replace(ANSI, '').replace(/[\x00-\x1f\x7f]/g, '')
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// For a notice or a table cell: one line, no control bytes, capped
|
|
64
|
+
// For a notice or a table cell: one line, no control bytes, capped - an agent's own words or a
|
|
65
65
|
// CLI's stderr can run to kilobytes and can carry escapes that rewrite what konvoy printed.
|
|
66
66
|
export function oneLine(value: string, max = 200): string {
|
|
67
67
|
const flat = stripControlChars(value.replace(/\r?\n/g, ' ')).replace(/ {2,}/g, ' ').trim()
|
|
68
68
|
return flat.length > max ? `${flat.slice(0, max)}…` : flat
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// For an agent's final text: keep newlines and tabs, drop every other control byte
|
|
71
|
+
// For an agent's final text: keep newlines and tabs, drop every other control byte - CR
|
|
72
72
|
// included, which would let a line overwrite the one before it.
|
|
73
73
|
export function safeText(value: string): string {
|
|
74
74
|
return value.replace(ANSI, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// Extracted from the four installed binaries on 2026-09-20. Expiry is phrased around
|
|
78
|
-
// "session" or "token"
|
|
79
|
-
// "Login token is expired", "MCP OAuth access token is expired"
|
|
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
80
|
// word "credentials" missed every real expiry. The noun must sit next to the state, or a
|
|
81
81
|
// parser's "Unexpected token" and "Invalid token in JSON" read as auth failures.
|
|
82
82
|
const AUTH =
|
|
@@ -86,14 +86,14 @@ const AUTH =
|
|
|
86
86
|
// · resets 12:40am", both carrying error type rate_limit / HTTP 429. The remaining
|
|
87
87
|
// alternatives are conjecture from other vendors' wording and have never been observed here.
|
|
88
88
|
// kiro-cli emits no rate-limit prose at all, only AWS exception type names, and those carry
|
|
89
|
-
// no separators once lowercased
|
|
89
|
+
// no separators once lowercased - hence the optional separators below. codex's five-hour
|
|
90
90
|
// window is worded "5-hour usage limit", which "usage limit" already covers.
|
|
91
91
|
const RATE =
|
|
92
92
|
/hit your \w+ limit|rate[_ ]?limit|quota exceeded|too ?many ?requests|usage limit|weekly limit|\d+[- ]hour (?:usage )?limit|throttl|\b429\b/
|
|
93
93
|
// Captured verbatim from opencode's embedded overload classifier, recovered from its binary on
|
|
94
94
|
// 2026-09-20: "the service is at capacity", "Overloaded", "temporarily unavailable", "503
|
|
95
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
|
|
96
|
+
// connect error". These describe an API that is reachable but refusing - the one failure worth
|
|
97
97
|
// retrying before giving up on an agent, unlike a rate limit (checked first: a message that is
|
|
98
98
|
// both rate-limited and mentions 503 is a rate limit, since that window is hours, not seconds).
|
|
99
99
|
const UPSTREAM =
|
package/src/chart.ts
CHANGED
|
@@ -7,7 +7,7 @@ export function sparkline(values: number[], max?: number): string {
|
|
|
7
7
|
// an explicit max lets several series share one scale (see agentSparklines); omitted, a
|
|
8
8
|
// series scales to its own peak, as a lone sparkline always has
|
|
9
9
|
const m = max ?? Math.max(...values)
|
|
10
|
-
// counts have a fixed baseline of zero, not the series' own minimum
|
|
10
|
+
// counts have a fixed baseline of zero, not the series' own minimum - a flat run of busy
|
|
11
11
|
// days must render full, not empty. Guard only the case where there's no signal at all.
|
|
12
12
|
if (m <= 0) return BLOCKS[0]!.repeat(values.length)
|
|
13
13
|
// a nonzero count is never the zero glyph: below m/14 Math.round lands on 0, and a quiet day
|
|
@@ -32,7 +32,7 @@ export function shareBars(rows: { label: string; value: number }[], width = 18):
|
|
|
32
32
|
)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// a lone agent's sparkline can't reveal whether it's the busy one or the quiet one
|
|
35
|
+
// a lone agent's sparkline can't reveal whether it's the busy one or the quiet one - every
|
|
36
36
|
// agent must be drawn against the same peak, and every row must span the same dense day
|
|
37
37
|
// range (gaps filled with zero) so the columns line up between agents
|
|
38
38
|
export function agentSparklines(rows: { agent: string; day: string; count: number }[]): { agent: string; line: string }[] {
|
package/src/cli.ts
CHANGED
|
@@ -44,7 +44,7 @@ interface CommandContext {
|
|
|
44
44
|
|
|
45
45
|
type Handler = (ctx: CommandContext, rest: string[]) => number | Promise<number>
|
|
46
46
|
|
|
47
|
-
// Every key of CommandName must be handled here
|
|
47
|
+
// Every key of CommandName must be handled here - TypeScript's excess/missing property
|
|
48
48
|
// checks on an object literal assigned to Record<CommandName, Handler> make an
|
|
49
49
|
// undocumented-yet-dispatched or dispatched-yet-undocumented command a compile error.
|
|
50
50
|
const handlers: Record<CommandName, Handler> = {
|
package/src/commands/attach.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function attachPlan(db: Database, session: Session, agent: AgentId, bin?:
|
|
|
26
26
|
return { ...plan, cmd, cwd: session.cwd }
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Bind a session the user started outside konvoy
|
|
29
|
+
// Bind a session the user started outside konvoy - the id a CLI prints for its own resume
|
|
30
30
|
// command. The same shape check as every stream-captured id applies at the write path; the
|
|
31
31
|
// binding is only reported as adopted if it actually holds the id.
|
|
32
32
|
export function adoptForeignSession(
|
|
@@ -52,12 +52,12 @@ export interface AttachOptions {
|
|
|
52
52
|
export async function cmdAttach(db: Database, cwd: string, agent: string, opts: AttachOptions = {}): Promise<number> {
|
|
53
53
|
const { slug, bin } = opts
|
|
54
54
|
if (!agentIds.includes(agent as AgentId)) {
|
|
55
|
-
console.error(`unknown agent "${agent}"
|
|
55
|
+
console.error(`unknown agent "${agent}" - expected one of ${agentIds.join(', ')}`)
|
|
56
56
|
return 2
|
|
57
57
|
}
|
|
58
58
|
const session = slug ? getSessionBySlug(db, slug) : currentSession(db, cwd)
|
|
59
59
|
if (!session) {
|
|
60
|
-
console.error('no konvoy session here
|
|
60
|
+
console.error('no konvoy session here - run `konvoy new "<goal>"` first')
|
|
61
61
|
return 2
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -73,10 +73,10 @@ export async function cmdAttach(db: Database, cwd: string, agent: string, opts:
|
|
|
73
73
|
permission: opts.permission ?? 'edit',
|
|
74
74
|
})
|
|
75
75
|
if (!adopted) {
|
|
76
|
-
console.error(`konvoy: ${agent} not bound
|
|
76
|
+
console.error(`konvoy: ${agent} not bound - the id was refused; nothing opened`)
|
|
77
77
|
return 2
|
|
78
78
|
}
|
|
79
|
-
console.error(`konvoy: ${agent} bound to session ${oneLine(opts.id, 60)}
|
|
79
|
+
console.error(`konvoy: ${agent} bound to session ${oneLine(opts.id, 60)} - the next turn resumes it; opening it now`)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
const plan = attachPlan(db, session, agent as AgentId, bin)
|
package/src/commands/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ export function coerce(raw: string): string | number | boolean {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
// `__proto__` resolves to Object.prototype through an ordinary property read, so a dotted path
|
|
14
|
-
// containing it writes onto the shared prototype
|
|
14
|
+
// containing it writes onto the shared prototype - poisoning every object in the process while
|
|
15
15
|
// the config itself stays empty. `constructor` and `prototype` are blocked for the same reason.
|
|
16
16
|
const RESERVED = new Set(['__proto__', 'constructor', 'prototype'])
|
|
17
17
|
|
|
@@ -100,7 +100,7 @@ export async function cmdConfig(
|
|
|
100
100
|
const parsed = configSchema.safeParse(next)
|
|
101
101
|
if (!parsed.success) {
|
|
102
102
|
const issue = parsed.error.issues[0]
|
|
103
|
-
console.error(`refusing to write: ${issue?.path.join('.')}
|
|
103
|
+
console.error(`refusing to write: ${issue?.path.join('.')} - ${issue?.message}`)
|
|
104
104
|
return 2
|
|
105
105
|
}
|
|
106
106
|
await Bun.write(path, JSON.stringify(next, null, 2) + '\n')
|
|
@@ -20,7 +20,7 @@ export async function cmdDashboard(
|
|
|
20
20
|
})
|
|
21
21
|
},
|
|
22
22
|
})
|
|
23
|
-
console.log(`konvoy dashboard on http://127.0.0.1:${server.port}
|
|
23
|
+
console.log(`konvoy dashboard on http://127.0.0.1:${server.port} - ctrl-c to stop`)
|
|
24
24
|
await new Promise(() => {})
|
|
25
25
|
return 0
|
|
26
26
|
}
|
package/src/commands/doctor.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function distinctPaths(stdout: string): string[] {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
// engine, policy.maxDelegationDepth and policy.isolation are in the schema and the spec, but
|
|
15
|
-
// nothing reads them yet
|
|
15
|
+
// nothing reads them yet - the delegation work will. Comparing the parsed policy values against
|
|
16
16
|
// their schema defaults is an approximation (a value set explicitly equal to the default reads
|
|
17
17
|
// as unset), acceptable for an informational line with no behavioural effect.
|
|
18
18
|
export function acceptedButUnusedKeys(cfg: Config): string[] {
|
|
@@ -59,10 +59,10 @@ export async function cmdDoctor(cfg: Config, deps?: DetectDeps): Promise<number>
|
|
|
59
59
|
const auth = await detectAuth(agent, { bin: settings.bin, deps })
|
|
60
60
|
if (auth.authed === false) {
|
|
61
61
|
if (required.has(agent)) {
|
|
62
|
-
console.log(`x ${agent}: ${auth.detail}
|
|
62
|
+
console.log(`x ${agent}: ${auth.detail} - ${loginHint(agent)}`)
|
|
63
63
|
problems++
|
|
64
64
|
} else {
|
|
65
|
-
console.log(`- ${agent}: ${auth.detail}
|
|
65
|
+
console.log(`- ${agent}: ${auth.detail} - ${loginHint(agent)}`)
|
|
66
66
|
}
|
|
67
67
|
continue
|
|
68
68
|
}
|
|
@@ -73,13 +73,13 @@ export async function cmdDoctor(cfg: Config, deps?: DetectDeps): Promise<number>
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
if (agent === 'opencode' && !settings.model) {
|
|
76
|
-
console.log(`! opencode: no model configured
|
|
76
|
+
console.log(`! opencode: no model configured - it returns HTTP 403 without an explicit -m`)
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
const shadow = Bun.spawnSync(['which', '-a', settings.bin ?? getAdapter(agent).bin])
|
|
80
80
|
const paths = distinctPaths(new TextDecoder().decode(shadow.stdout))
|
|
81
81
|
if (paths.length > 1 && !settings.bin) {
|
|
82
|
-
console.log(`! ${agent}: ${paths.length} binaries on PATH, "${paths[0]}" wins
|
|
82
|
+
console.log(`! ${agent}: ${paths.length} binaries on PATH, "${paths[0]}" wins - set agents.${agent}.bin to be explicit`)
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
if (settings.model) {
|
|
@@ -90,13 +90,13 @@ export async function cmdDoctor(cfg: Config, deps?: DetectDeps): Promise<number>
|
|
|
90
90
|
|
|
91
91
|
for (const [model, users] of models) {
|
|
92
92
|
if (users.length > 1) {
|
|
93
|
-
console.log(`! ${users.join(' and ')} both run ${model}
|
|
93
|
+
console.log(`! ${users.join(' and ')} both run ${model} - they will not disagree with each other`)
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const unused = acceptedButUnusedKeys(cfg)
|
|
98
98
|
if (unused.length > 0) {
|
|
99
|
-
console.log(`i ${unused.join(', ')}
|
|
99
|
+
console.log(`i ${unused.join(', ')} - accepted but not yet used`)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
console.log(problems === 0 ? '\nno problems found' : `\n${problems} problem(s) found`)
|
package/src/commands/new.ts
CHANGED
|
@@ -7,7 +7,7 @@ export async function cmdNew(db: Database, cfg: Config, cwd: string, goal: strin
|
|
|
7
7
|
const lead = cfg.roles.lead ?? 'claude'
|
|
8
8
|
const session = newSession(db, { cwd, goal: goal || 'untitled', lead })
|
|
9
9
|
const dir = sessionDir(cwd, session.slug)
|
|
10
|
-
// `konvoy rm` frees the slug but leaves these files
|
|
10
|
+
// `konvoy rm` frees the slug but leaves these files - they are the user's. A new session under
|
|
11
11
|
// an old slug adds its goal to the context and appends to the ledger; it truncates neither.
|
|
12
12
|
const context = Bun.file(`${dir}/CONTEXT.md`)
|
|
13
13
|
await Bun.write(
|
|
@@ -17,7 +17,7 @@ export async function cmdNew(db: Database, cfg: Config, cwd: string, goal: strin
|
|
|
17
17
|
: `# ${session.slug}\n\n## Goal\n\n${session.goal}\n`,
|
|
18
18
|
)
|
|
19
19
|
const ledger = Bun.file(`${dir}/LEDGER.md`)
|
|
20
|
-
if (!(await ledger.exists())) await Bun.write(ledger, `# Ledger
|
|
20
|
+
if (!(await ledger.exists())) await Bun.write(ledger, `# Ledger - ${session.slug}\n`)
|
|
21
21
|
console.log(`created session ${session.slug} (lead: ${lead})`)
|
|
22
22
|
console.log(dir)
|
|
23
23
|
return 0
|
package/src/commands/rm.ts
CHANGED
|
@@ -9,13 +9,13 @@ export function cmdRm(db: Database, cwd: string, slug: string, opts: { yes: bool
|
|
|
9
9
|
}
|
|
10
10
|
const busy = lockOwner(db, session.id)
|
|
11
11
|
if (busy) {
|
|
12
|
-
console.error(`"${slug}" has a turn running (${busy})
|
|
12
|
+
console.error(`"${slug}" has a turn running (${busy}) - wait for it to finish, then retry`)
|
|
13
13
|
return 2
|
|
14
14
|
}
|
|
15
15
|
const bound = listBindings(db, session.id).filter((b) => b.foreignId)
|
|
16
16
|
if (!opts.yes) {
|
|
17
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
|
|
18
|
+
console.error(`the sessions inside each CLI are NOT deleted - re-run with --yes to proceed`)
|
|
19
19
|
return 2
|
|
20
20
|
}
|
|
21
21
|
deleteSession(db, session.id)
|
package/src/commands/roster.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { duplicateModels, formatRoster, type RosterRow } from '../format'
|
|
|
8
8
|
export function cmdRoster(db: Database, cfg: Config, cwd: string, slug?: string): number {
|
|
9
9
|
const session = slug ? getSessionBySlug(db, slug) : currentSession(db, cwd)
|
|
10
10
|
if (!session) {
|
|
11
|
-
console.error('no konvoy session here
|
|
11
|
+
console.error('no konvoy session here - run `konvoy new "<goal>"` first')
|
|
12
12
|
return 2
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -28,10 +28,10 @@ export function cmdRoster(db: Database, cfg: Config, cwd: string, slug?: string)
|
|
|
28
28
|
}
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
console.log(`session ${session.slug}
|
|
31
|
+
console.log(`session ${session.slug} - ${session.goal}`)
|
|
32
32
|
console.log(formatRoster(rows))
|
|
33
33
|
for (const model of duplicateModels(rows)) {
|
|
34
|
-
console.log(`warning: ${model} is used by more than one agent
|
|
34
|
+
console.log(`warning: ${model} is used by more than one agent - a second opinion from the same model is not one`)
|
|
35
35
|
}
|
|
36
36
|
return 0
|
|
37
37
|
}
|
package/src/commands/send.ts
CHANGED
|
@@ -16,12 +16,12 @@ export async function cmdSend(
|
|
|
16
16
|
slug?: string,
|
|
17
17
|
): Promise<number> {
|
|
18
18
|
if (!agentIds.includes(agent as AgentId)) {
|
|
19
|
-
console.error(`unknown agent "${agent}"
|
|
19
|
+
console.error(`unknown agent "${agent}" - expected one of ${agentIds.join(', ')}`)
|
|
20
20
|
return 2
|
|
21
21
|
}
|
|
22
22
|
const session = slug ? getSessionBySlug(db, slug) : currentSession(db, cwd)
|
|
23
23
|
if (!session) {
|
|
24
|
-
console.error('no konvoy session here
|
|
24
|
+
console.error('no konvoy session here - run `konvoy new "<goal>"` first')
|
|
25
25
|
return 2
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -51,7 +51,7 @@ export interface SendOutcome {
|
|
|
51
51
|
|
|
52
52
|
// The exit code is decided here, once, from the same two facts turn.ts keeps separate: whether
|
|
53
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
|
|
54
|
+
// A turn that answered and then hit a limit is both successful and blocked - it prints what it
|
|
55
55
|
// produced, plus the one line saying the agent can't keep going, and exits 0.
|
|
56
56
|
export function decideOutcome(agent: AgentId, result: TurnResult): SendOutcome {
|
|
57
57
|
if (result.error) {
|
package/src/commands/status.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function cmdStatus(
|
|
|
25
25
|
)
|
|
26
26
|
console.log(formatVersions(rows))
|
|
27
27
|
for (const r of rows) {
|
|
28
|
-
if (!r.installed) console.log(`warning: ${r.agent} is not installed
|
|
28
|
+
if (!r.installed) console.log(`warning: ${r.agent} is not installed - it will be skipped`)
|
|
29
29
|
else if (r.authed === false) console.log(`warning: ${r.agent}: ${r.detail}`)
|
|
30
30
|
}
|
|
31
31
|
// status reports; it does not judge. A directory with no session is not a failure of the
|
package/src/commands/update.ts
CHANGED
|
@@ -35,7 +35,7 @@ export async function cmdUpdate(
|
|
|
35
35
|
deps: UpdateDeps = realUpdateDeps,
|
|
36
36
|
): Promise<number> {
|
|
37
37
|
if (!opts.all) {
|
|
38
|
-
// konvoy does not update itself
|
|
38
|
+
// konvoy does not update itself - it follows the channel it was installed from
|
|
39
39
|
const channel = Bun.isStandaloneExecutable
|
|
40
40
|
? 'brew upgrade --cask konvoy, or download the latest release'
|
|
41
41
|
: 'bun add -g @doguyilmaz/konvoy@latest, or bun run build in a checkout'
|
package/src/commands/usage.ts
CHANGED
|
@@ -37,12 +37,12 @@ export function cmdUsage(
|
|
|
37
37
|
} else {
|
|
38
38
|
session = opts.slug ? getSessionBySlug(db, opts.slug) : currentSession(db, cwd)
|
|
39
39
|
if (!session) {
|
|
40
|
-
console.error('no konvoy session here
|
|
40
|
+
console.error('no konvoy session here - run `konvoy new "<goal>"` first')
|
|
41
41
|
return 2
|
|
42
42
|
}
|
|
43
43
|
rows = usageForSession(db, session.id)
|
|
44
44
|
if (rows.length === 0) {
|
|
45
|
-
console.log(`session ${session.slug}
|
|
45
|
+
console.log(`session ${session.slug} - no turns yet`)
|
|
46
46
|
return 0
|
|
47
47
|
}
|
|
48
48
|
console.log(`session ${session.slug}`)
|
package/src/config/load.ts
CHANGED
|
@@ -162,17 +162,17 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// bin, permission and harness decide what konvoy runs and how much it trusts the process it
|
|
165
|
-
// spawns
|
|
165
|
+
// spawns - properties of the machine the person is running konvoy on, not of the repo they
|
|
166
166
|
// cloned. A project layer may not set them at any level they appear; only the global config can.
|
|
167
167
|
const PRIVILEGED_DEFAULTS_KEYS = ['permission', 'harness'] as const
|
|
168
168
|
const PRIVILEGED_AGENT_KEYS = ['bin', 'permission', 'harness'] as const
|
|
169
|
-
// gate names a command konvoy executes automatically after every turn
|
|
169
|
+
// gate names a command konvoy executes automatically after every turn - wider than `bin`,
|
|
170
170
|
// which at least requires the user to already be using that agent. It sits at the top level
|
|
171
171
|
// of the config, not under `defaults` or `agents.<id>`, so it needs its own case here.
|
|
172
172
|
const PRIVILEGED_TOP_LEVEL_KEYS = ['gate'] as const
|
|
173
173
|
|
|
174
174
|
function warnIgnored(path: string): void {
|
|
175
|
-
console.error(`konvoy: ignoring project-level "${path}"
|
|
175
|
+
console.error(`konvoy: ignoring project-level "${path}" - privileged, set it in the global config instead`)
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
function stripProjectPrivileges(layer: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -220,7 +220,7 @@ function stripProjectPrivileges(layer: Record<string, unknown>): Record<string,
|
|
|
220
220
|
|
|
221
221
|
// A leading dash is what makes a model string dangerous: spawned with `--model <value>`, it
|
|
222
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
|
|
223
|
+
// harness, a project may legitimately pin a model - so this is validated, not merge-source
|
|
224
224
|
// restricted, and applies to whichever layer's value survives the merge.
|
|
225
225
|
// `/` because opencode names models provider/model; `#` stays out because the variant after
|
|
226
226
|
// it is konvoy's own effort dial, appended by the adapter
|
|
@@ -238,7 +238,7 @@ function stripInvalidModels(layer: Record<string, unknown>): Record<string, unkn
|
|
|
238
238
|
}
|
|
239
239
|
const cleaned = { ...agentCfg }
|
|
240
240
|
if (typeof cleaned.model === 'string' && !MODEL_PATTERN.test(cleaned.model)) {
|
|
241
|
-
console.error(`konvoy: ignoring invalid agents.${id}.model "${cleaned.model}"
|
|
241
|
+
console.error(`konvoy: ignoring invalid agents.${id}.model "${cleaned.model}" - must match ${MODEL_PATTERN}`)
|
|
242
242
|
delete cleaned.model
|
|
243
243
|
}
|
|
244
244
|
setOwn(cleanedAgents, id, cleaned)
|
|
@@ -247,7 +247,7 @@ function stripInvalidModels(layer: Record<string, unknown>): Record<string, unkn
|
|
|
247
247
|
return out
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
// only a leading `~/`, or a bare `~`, is a home-directory reference
|
|
250
|
+
// only a leading `~/`, or a bare `~`, is a home-directory reference - a tilde anywhere else
|
|
251
251
|
// in the path (e.g. `rel/~/x`) is left alone
|
|
252
252
|
function expandHome(p: string): string {
|
|
253
253
|
if (p === '~') return home()
|
|
@@ -267,7 +267,7 @@ export async function loadConfig(opts: { cwd: string; globalPath?: string }): Pr
|
|
|
267
267
|
// set aside, so konvoy still runs in that directory; the user's own file stays strict
|
|
268
268
|
const globalOnly = configSchema.safeParse(stripInvalidModels(merge(globalLayer, {})))
|
|
269
269
|
if (globalOnly.success) {
|
|
270
|
-
console.error(`konvoy: ignoring project-level config at ${projectConfigPath(opts.cwd)}
|
|
270
|
+
console.error(`konvoy: ignoring project-level config at ${projectConfigPath(opts.cwd)} - invalid at ${where}`)
|
|
271
271
|
return finishConfig(globalOnly.data)
|
|
272
272
|
}
|
|
273
273
|
throw new Error(`invalid konvoy config at ${where}`)
|
|
@@ -294,7 +294,7 @@ export function resolveAgent(cfg: Config, agent: AgentId): AgentSettings {
|
|
|
294
294
|
const ownStyle = a.style
|
|
295
295
|
const defaultStyle = cfg.defaults.style ?? undefined
|
|
296
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
|
|
297
|
+
// where the per-agent value is only ever absent or set - so this can't reuse `??`.
|
|
298
298
|
const style = ownStyle === null ? undefined : (ownStyle ?? defaultStyle)
|
|
299
299
|
return {
|
|
300
300
|
enabled: a.enabled ?? true,
|
|
@@ -310,7 +310,7 @@ export function resolveAgent(cfg: Config, agent: AgentId): AgentSettings {
|
|
|
310
310
|
|
|
311
311
|
const isAgentId = (value: string): value is AgentId => (agentIds as readonly string[]).includes(value)
|
|
312
312
|
|
|
313
|
-
// An envelope's `to` is free text a model wrote, not a validated key
|
|
313
|
+
// An envelope's `to` is free text a model wrote, not a validated key - trimmed and
|
|
314
314
|
// lower-cased before either check runs, since a model may write "Reviewer" or " claude ".
|
|
315
315
|
// An agent id wins over a role of the same name: a role can be reassigned mid-session, an
|
|
316
316
|
// agent id cannot.
|
package/src/config/schema.ts
CHANGED
|
@@ -5,7 +5,7 @@ export const effortSchema = z.enum(['low', 'medium', 'high', 'max'])
|
|
|
5
5
|
export const permissionSchema = z.enum(['safe', 'edit', 'yolo'])
|
|
6
6
|
export const harnessSchema = z.enum(['minimal', 'inherit'])
|
|
7
7
|
export const agentIdSchema = z.enum(agentIds)
|
|
8
|
-
// konvoy's own instruction, not a model capability
|
|
8
|
+
// konvoy's own instruction, not a model capability - `.nullish()` so a per-agent `null` can
|
|
9
9
|
// opt out of a `defaults.style` of 'brief', which a plain `.optional()` cannot express.
|
|
10
10
|
export const styleSchema = z.enum(['brief'])
|
|
11
11
|
|
|
@@ -46,7 +46,7 @@ const MAX_TURN_TIMEOUT_SEC = 24 * 60 * 60
|
|
|
46
46
|
const policyObjectSchema = z
|
|
47
47
|
.object({
|
|
48
48
|
maxDelegationDepth: z.number().int().positive().default(3),
|
|
49
|
-
// A project config sets this, so it is clamped rather than trusted outright
|
|
49
|
+
// A project config sets this, so it is clamped rather than trusted outright - otherwise a
|
|
50
50
|
// hostile repo could make konvoy wait forever on every turn.
|
|
51
51
|
turnTimeoutSec: z
|
|
52
52
|
.number()
|
|
@@ -74,7 +74,7 @@ export const configSchema = z
|
|
|
74
74
|
agents: z.partialRecord(agentIdSchema, agentConfigSchema).default({}),
|
|
75
75
|
roles: rolesObjectSchema.prefault({}),
|
|
76
76
|
policy: policyObjectSchema.prefault({}),
|
|
77
|
-
// The command konvoy runs after a turn to produce a pass/fail verdict on the work
|
|
77
|
+
// The command konvoy runs after a turn to produce a pass/fail verdict on the work - a
|
|
78
78
|
// project layer may never set this; see stripProjectPrivileges in config/load.ts.
|
|
79
79
|
gate: z.object({ command: z.string().nullish() }).strict().prefault({}),
|
|
80
80
|
failover: failoverObjectSchema.prefault({}),
|
package/src/core/children.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function liveCount(): number {
|
|
|
48
48
|
return live.size
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// Bun's own `timeout` sends killSignal once and never follows up
|
|
51
|
+
// Bun's own `timeout` sends killSignal once and never follows up - a child that traps or
|
|
52
52
|
// ignores SIGTERM then hangs forever. SIGKILL cannot be trapped; it goes out once the timeout
|
|
53
53
|
// has had a grace period to work. Every bounded spawn (a turn, a gate) uses this one.
|
|
54
54
|
export const DEFAULT_KILL_GRACE_MS = 2_000
|
package/src/core/detect.ts
CHANGED
|
@@ -77,7 +77,7 @@ async function codexEfforts(deps: DetectDeps, model?: string): Promise<readonly
|
|
|
77
77
|
const cache = JSON.parse(raw) as { models?: unknown }
|
|
78
78
|
if (!Array.isArray(cache.models)) return undefined
|
|
79
79
|
// the real file (codex 0.155.1, 2026-09-21) keys models by `slug` and lists each level as
|
|
80
|
-
// `{ effort, description }`
|
|
80
|
+
// `{ effort, description }` - see tests/fixtures/codex-models-cache.json
|
|
81
81
|
const hit = (cache.models as { slug?: string; supported_reasoning_levels?: unknown }[]).find(
|
|
82
82
|
(m) => m.slug === model,
|
|
83
83
|
)
|
|
@@ -178,8 +178,7 @@ function realDeps(): DetectDeps {
|
|
|
178
178
|
const detectCacheMap = new Map<string, Promise<Detection>>()
|
|
179
179
|
const detectAuthCacheMap = new Map<string, Promise<AuthState>>()
|
|
180
180
|
|
|
181
|
-
//
|
|
182
|
-
// one place is never handed to another.
|
|
181
|
+
// one memo per injected deps object; real detection shares its own
|
|
183
182
|
const depsIds = new WeakMap<DetectDeps, number>()
|
|
184
183
|
let nextDepsId = 0
|
|
185
184
|
function scope(deps?: DetectDeps): string {
|
package/src/core/facts.ts
CHANGED
|
@@ -52,7 +52,7 @@ export async function collectFacts(deps: FactsDeps, db: Database, session: Sessi
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// A field is quoted only when it has to be. 48% of this repository's own commit subjects
|
|
55
|
-
// contain a comma, so an unquoted row is the common case, not the edge one
|
|
55
|
+
// contain a comma, so an unquoted row is the common case, not the edge one - and a shifted
|
|
56
56
|
// row makes every number after it wrong while still looking like a table.
|
|
57
57
|
function cell(value: string): string {
|
|
58
58
|
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
|
@@ -99,7 +99,7 @@ export function realFactsDeps(): FactsDeps {
|
|
|
99
99
|
return {
|
|
100
100
|
git: async (args, cwd) => {
|
|
101
101
|
try {
|
|
102
|
-
// stderr is never read, so it is not piped
|
|
102
|
+
// stderr is never read, so it is not piped - a chatty git would fill the pipe and block;
|
|
103
103
|
// and a git that hangs must not hang the turn
|
|
104
104
|
const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'ignore', timeout: 10_000 })
|
|
105
105
|
const stdout = await new Response(proc.stdout).text()
|
package/src/core/gate.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Session } from '../types'
|
|
|
4
4
|
import { setGateResult, turnExitCode } from '../store/queries'
|
|
5
5
|
import { DEFAULT_KILL_GRACE_MS, escalateKill } from './children'
|
|
6
6
|
|
|
7
|
-
// A quality check the user chose to run
|
|
7
|
+
// A quality check the user chose to run - konvoy has no model of its own, so it never
|
|
8
8
|
// grades the work itself. Bounded so a hung suite can't block konvoy forever.
|
|
9
9
|
const GATE_TIMEOUT_MS = 5 * 60 * 1000
|
|
10
10
|
|
|
@@ -17,7 +17,7 @@ export async function runGate(
|
|
|
17
17
|
): Promise<void> {
|
|
18
18
|
const command = cfg.gate.command
|
|
19
19
|
if (!command) return
|
|
20
|
-
// A turn with nothing for the gate to judge
|
|
20
|
+
// A turn with nothing for the gate to judge - running it now would blame the block on the work.
|
|
21
21
|
if (turnExitCode(db, turnId) !== 0) return
|
|
22
22
|
|
|
23
23
|
const cmd = splitCommand(command)
|
|
@@ -43,7 +43,7 @@ export async function runGate(
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Whitespace outside quotes separates arguments; a quoted run is one argument, quotes removed.
|
|
46
|
-
// No shell is involved
|
|
46
|
+
// No shell is involved - the gate is the user's own command, but it still deserves its quotes.
|
|
47
47
|
export function splitCommand(command: string): string[] {
|
|
48
48
|
const out: string[] = []
|
|
49
49
|
let current = ''
|
package/src/core/prelude.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function parseEnvelope(final: string): Envelope | null {
|
|
|
39
39
|
const value = kv[2]!.trim()
|
|
40
40
|
switch (key) {
|
|
41
41
|
case 'to':
|
|
42
|
-
// a recipient is an agent id or a role name
|
|
42
|
+
// a recipient is an agent id or a role name - a bare identifier. The delegation
|
|
43
43
|
// instruction quotes the format with "<agent id or role>" in this slot, and an agent
|
|
44
44
|
// explaining what it is not doing repeats it; that names nobody and is no handoff.
|
|
45
45
|
env.to = RECIPIENT.test(value) ? value : null
|
|
@@ -104,7 +104,7 @@ export function buildPrelude(db: Database, session: Session, facts: string, opts
|
|
|
104
104
|
// the same test followHandoff applies: a block with no recipient handed nothing to anyone,
|
|
105
105
|
// so the answer it sits in is what the next agent must see
|
|
106
106
|
if (!last) {
|
|
107
|
-
// recent: 0
|
|
107
|
+
// recent: 0 - only the goal, the facts and the count of what was left out
|
|
108
108
|
} else if (envelope?.to) {
|
|
109
109
|
turnBlocks.push(renderEnvelope(last.agent, envelope))
|
|
110
110
|
} else {
|
|
@@ -112,7 +112,7 @@ export function buildPrelude(db: Database, session: Session, facts: string, opts
|
|
|
112
112
|
// The cooperative case (section 21) has a sender who can still speak; failover does not.
|
|
113
113
|
// A receiver that believes its context is complete proceeds on half the picture, so the
|
|
114
114
|
// gap is stated plainly instead of silently filled with a prompt-and-answer transcript.
|
|
115
|
-
turnBlocks.push("the previous agent's intent was not recorded here
|
|
115
|
+
turnBlocks.push("the previous agent's intent was not recorded here - only what was asked and answered is known.")
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
if (dropped > 0) turnBlocks.push(`(${dropped} earlier turn${dropped === 1 ? '' : 's'} not shown)`)
|
package/src/core/session.ts
CHANGED
|
@@ -35,7 +35,7 @@ const RECENT_TURNS = 3
|
|
|
35
35
|
// kiro "error: ACP load_session failed" on stderr, exit 1, no stream events at all
|
|
36
36
|
// (re-measured 2026-09-21 against kiro-cli 2.22.1). The 2026-09-19 note here said
|
|
37
37
|
// kiro reported nothing and silently opened an empty session under whatever id it
|
|
38
|
-
// was handed, and recorded that as an undetectable limit
|
|
38
|
+
// was handed, and recorded that as an undetectable limit - it is detectable, and
|
|
39
39
|
// leaving the old wording in place is what kept the rebind from firing for kiro.
|
|
40
40
|
const STALE =
|
|
41
41
|
/no conversation found|no rollout found|session not found|no such session|unknown session|not found with session|load_session failed/i
|
|
@@ -108,16 +108,16 @@ export async function send(
|
|
|
108
108
|
const lease = inherited ?? crypto.randomUUID()
|
|
109
109
|
const alreadyHeld = inherited !== undefined && lockOwner(deps.db, session.id) === inherited
|
|
110
110
|
if (!alreadyHeld && !acquireLock(deps.db, session.id, lease)) {
|
|
111
|
-
throw new Error(`session "${session.slug}" is busy
|
|
111
|
+
throw new Error(`session "${session.slug}" is busy - another konvoy turn is running`)
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// Set once withLock() has built the primary prelude
|
|
114
|
+
// Set once withLock() has built the primary prelude - followHandoff needs its own, built
|
|
115
115
|
// fresh after the handing-off turn is recorded, so it is read rather than recomputed here.
|
|
116
116
|
let facts = ''
|
|
117
117
|
|
|
118
118
|
try {
|
|
119
119
|
const result = await withLock()
|
|
120
|
-
// The turn just finished writing its own row
|
|
120
|
+
// The turn just finished writing its own row - runGate reads that row itself to decide
|
|
121
121
|
// whether there is anything for it to judge, so it is always safe to call here.
|
|
122
122
|
const turnId = lastTurnId(deps.db, session.id)
|
|
123
123
|
if (turnId) await runGate(deps.db, deps.cfg, session, turnId)
|
|
@@ -133,7 +133,7 @@ export async function send(
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
// One hop per send: this reads the envelope on the turn `send()` was asked to run, resolves
|
|
136
|
-
// it once, and returns whatever that recipient's own turn produces
|
|
136
|
+
// it once, and returns whatever that recipient's own turn produces - including an envelope
|
|
137
137
|
// of its own, which is never fed back in here. A mistaken `to:` pointing back at the sender
|
|
138
138
|
// would otherwise loop until something ran out, and the caller asked for one turn.
|
|
139
139
|
async function followHandoff(result: TurnResult, turnId: string): Promise<TurnResult | null> {
|
|
@@ -145,7 +145,7 @@ export async function send(
|
|
|
145
145
|
const recipient = resolveRecipient(deps.cfg, envelope.to)
|
|
146
146
|
if (!recipient) {
|
|
147
147
|
console.error(
|
|
148
|
-
`konvoy: ${ranAsAgent} handed off to "${oneLine(envelope.to, 80)}"
|
|
148
|
+
`konvoy: ${ranAsAgent} handed off to "${oneLine(envelope.to, 80)}" - no such agent or role, ${ranAsAgent}'s turn stands`,
|
|
149
149
|
)
|
|
150
150
|
return null
|
|
151
151
|
}
|
|
@@ -154,21 +154,21 @@ export async function send(
|
|
|
154
154
|
const recipientDetection = recipientSettings.enabled
|
|
155
155
|
? await detectFor(recipient, { model: recipientSettings.model, bin: recipientSettings.bin })
|
|
156
156
|
: { agent: recipient, installed: false, version: null }
|
|
157
|
-
// Reported, not silently dropped
|
|
157
|
+
// Reported, not silently dropped - the same rule the failover chain already follows for a
|
|
158
158
|
// disabled or uninstalled member.
|
|
159
159
|
if (!recipientSettings.enabled || !recipientDetection.installed) {
|
|
160
160
|
const why = !recipientSettings.enabled ? 'disabled in config' : 'not installed'
|
|
161
161
|
console.error(
|
|
162
|
-
`konvoy: ${ranAsAgent} handed off to ${recipient}, but ${recipient} is ${why}
|
|
162
|
+
`konvoy: ${ranAsAgent} handed off to ${recipient}, but ${recipient} is ${why} - ${ranAsAgent}'s turn stands`,
|
|
163
163
|
)
|
|
164
164
|
return null
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
console.error(`konvoy: ${ranAsAgent} handed off to ${recipient}
|
|
167
|
+
console.error(`konvoy: ${ranAsAgent} handed off to ${recipient} - "${oneLine(envelope.task)}"`)
|
|
168
168
|
|
|
169
169
|
const recipientEffort = clampEffort(recipientSettings.effort, recipientDetection.efforts)
|
|
170
170
|
// Built fresh, after the handing-off turn was recorded: the prelude built at the top of
|
|
171
|
-
// this send() describes the state before that turn ran
|
|
171
|
+
// this send() describes the state before that turn ran - the opposite of what the
|
|
172
172
|
// recipient needs, which is its own cooperative handoff, envelope and all.
|
|
173
173
|
const delegatedPrelude = buildPrelude(deps.db, session, facts, { recent: RECENT_TURNS })
|
|
174
174
|
return runOnce(
|
|
@@ -194,7 +194,7 @@ export async function send(
|
|
|
194
194
|
)
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
// One attempt at one agent: run the turn, and
|
|
197
|
+
// One attempt at one agent: run the turn, and - exactly as before failover existed - rebind
|
|
198
198
|
// a stale foreign session once and retry, never more. This is unchanged by the chain walk;
|
|
199
199
|
// it just now runs once per agent the chain visits instead of once per `send()` call.
|
|
200
200
|
async function runOnce(
|
|
@@ -217,13 +217,13 @@ export async function send(
|
|
|
217
217
|
// Only a crash can be a dead session. An auth failure phrased as "session not found" would
|
|
218
218
|
// otherwise be rebound instead of surfaced, discarding a live session and then failing again
|
|
219
219
|
// identically; a rate limit, a timeout and an interruption say nothing about the session at
|
|
220
|
-
// all. The kinds exist so that failures can be told apart
|
|
220
|
+
// all. The kinds exist so that failures can be told apart - this is where it matters.
|
|
221
221
|
const recoverable = first.error?.kind === 'crash' || first.error?.kind === 'unknown'
|
|
222
222
|
const stale = first.error != null && recoverable && STALE.test(first.error.message) && producedNothing
|
|
223
223
|
if (!stale || !wasResuming) return first
|
|
224
224
|
|
|
225
225
|
// said out loud: a silent rebind looks like continuity and is not
|
|
226
|
-
console.error(`konvoy: ${current} could not load session ${oneLine(resumedId ?? '', 60)}
|
|
226
|
+
console.error(`konvoy: ${current} could not load session ${oneLine(resumedId ?? '', 60)} - starting a new one`)
|
|
227
227
|
clearForeignId(deps.db, session.id, current)
|
|
228
228
|
return runTurn(
|
|
229
229
|
{ db: deps.db, adapter: currentAdapter },
|
|
@@ -262,13 +262,13 @@ export async function send(
|
|
|
262
262
|
? await detectFor(current, { model: currentSettings.model, bin: currentSettings.bin })
|
|
263
263
|
: { agent: current, installed: false, version: null }
|
|
264
264
|
}
|
|
265
|
-
// A chain member the user hasn't actually set up can't take the handoff
|
|
265
|
+
// A chain member the user hasn't actually set up can't take the handoff - skip it
|
|
266
266
|
// rather than aborting the whole chain, since a later member might still work. Say so:
|
|
267
267
|
// a three-agent chain that quietly becomes a two-agent chain is the user not being told.
|
|
268
268
|
if (!currentSettings.enabled || !currentDetection.installed) {
|
|
269
269
|
if (!isHead) {
|
|
270
270
|
const why = !currentSettings.enabled ? 'disabled in config' : 'not installed'
|
|
271
|
-
console.error(`konvoy: skipping ${current} in the failover chain
|
|
271
|
+
console.error(`konvoy: skipping ${current} in the failover chain - ${why}`)
|
|
272
272
|
}
|
|
273
273
|
if (result) continue
|
|
274
274
|
break
|
|
@@ -279,7 +279,7 @@ export async function send(
|
|
|
279
279
|
// said "moving to claude" and then ran kiro when claude turned out to be unusable.
|
|
280
280
|
if (blocked) {
|
|
281
281
|
console.error(
|
|
282
|
-
`konvoy: ${blocked.agent} is blocked (${blocked.kind})
|
|
282
|
+
`konvoy: ${blocked.agent} is blocked (${blocked.kind}) - "${oneLine(blocked.message)}" - ${current} is taking over`,
|
|
283
283
|
)
|
|
284
284
|
blocked = null
|
|
285
285
|
}
|
|
@@ -308,7 +308,7 @@ export async function send(
|
|
|
308
308
|
r = await runOnce(current, currentAdapter, ctxBuild, firstTurnId)
|
|
309
309
|
if (firstTurnId === null) firstTurnId = lastTurnId(deps.db, session.id)
|
|
310
310
|
// upstream is transient and usually returns, so it is worth retrying on the same
|
|
311
|
-
// agent
|
|
311
|
+
// agent - with backoff, since a hammered upstream is the last thing to hammer again.
|
|
312
312
|
if (r.error?.kind === 'upstream' && retries < upstreamRetries) {
|
|
313
313
|
retries++
|
|
314
314
|
await Bun.sleep((deps.upstreamBackoffMs ?? UPSTREAM_BACKOFF_MS) * retries)
|
|
@@ -319,7 +319,7 @@ export async function send(
|
|
|
319
319
|
result = r
|
|
320
320
|
|
|
321
321
|
const kind = result.error?.kind
|
|
322
|
-
// rate and auth switch at once
|
|
322
|
+
// rate and auth switch at once - a rate window is hours and an auth failure needs a
|
|
323
323
|
// human, so retrying either is pointless. upstream only reaches here once its retries
|
|
324
324
|
// are spent. crash, timeout and interrupted never switch: the fault travels with the
|
|
325
325
|
// agent, not with the CLI running it, so a second agent would just fail the same way.
|
package/src/core/turn.ts
CHANGED
|
@@ -142,7 +142,7 @@ export async function runTurn(deps: TurnDeps, ctx: TurnContext, opts: TurnOption
|
|
|
142
142
|
killSignal: 'SIGTERM',
|
|
143
143
|
})
|
|
144
144
|
} catch (error) {
|
|
145
|
-
// the binary exists but cannot start
|
|
145
|
+
// the binary exists but cannot start - no execute bit, a bad interpreter: detection cannot
|
|
146
146
|
// see it, and the turn row recorded above is what keeps the failure from leaving no trace
|
|
147
147
|
result.exitCode = 127
|
|
148
148
|
result.error = { message: `could not start ${plan.cmd[0]}: ${error instanceof Error ? error.message : String(error)}`, kind: 'crash' }
|
|
@@ -160,7 +160,7 @@ export async function runTurn(deps: TurnDeps, ctx: TurnContext, opts: TurnOption
|
|
|
160
160
|
|
|
161
161
|
|
|
162
162
|
// exit_code -1 means konvoy itself died before it could record the turn. Every ordinary
|
|
163
|
-
// ending, including an interruption, replaces it
|
|
163
|
+
// ending, including an interruption, replaces it - so a surviving -1 is a real signal, not
|
|
164
164
|
// a default. Failure is decided by the error field, not by the exit code alone: an interrupted turn
|
|
165
165
|
// carries 130 or 143 and a message, and a codex turn can exit 0 with an informational error.
|
|
166
166
|
|
|
@@ -193,7 +193,7 @@ export async function runTurn(deps: TurnDeps, ctx: TurnContext, opts: TurnOption
|
|
|
193
193
|
|
|
194
194
|
|
|
195
195
|
// every path below must reach finish(): an onEvent callback that throws, a parser crash a
|
|
196
|
-
// wrapper missed, or a signal
|
|
196
|
+
// wrapper missed, or a signal - otherwise the turn row stays at its INSERT placeholder and
|
|
197
197
|
// usage counts it as a free turn.
|
|
198
198
|
const releaseExitHandler = onExit((signal) => {
|
|
199
199
|
if (!result.error) result.error = { message: `konvoy was interrupted by ${signal}`, kind: 'interrupted' }
|
|
@@ -236,10 +236,10 @@ export async function runTurn(deps: TurnDeps, ctx: TurnContext, opts: TurnOption
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
// The exit code says whether the turn produced its output; the error says whether the
|
|
239
|
-
// agent is now blocked. auth and rate never mean "just informational"
|
|
240
|
-
// work until something changes
|
|
239
|
+
// agent is now blocked. auth and rate never mean "just informational" - the agent cannot
|
|
240
|
+
// work until something changes - so they survive even a turn that answered and exited 0.
|
|
241
241
|
// crash and unknown keep the old behaviour: discarded once there was any output at all.
|
|
242
|
-
// an error that stayed wordless through the stream and stderr
|
|
242
|
+
// an error that stayed wordless through the stream and stderr - say so, rather than show
|
|
243
243
|
// the user an empty quote
|
|
244
244
|
if (result.error && !result.error.message.trim()) {
|
|
245
245
|
result.error.message = `${adapter.id} exited ${result.exitCode} and reported an error without a message`
|
package/src/dashboard/page.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface DashboardData {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// collect is a pure read of the same tables `konvoy usage --chart` reads, through the same
|
|
30
|
-
// queries
|
|
30
|
+
// queries - a parallel query here is exactly how the two views would start disagreeing
|
|
31
31
|
export function collect(db: Database, cfg: Config, sessionId?: string): DashboardData {
|
|
32
32
|
const rows = sessionId ? usageForSession(db, sessionId) : usageAcrossSessions(db)
|
|
33
33
|
const modelRows = usageByAgentModel(db, sessionId)
|
|
@@ -64,7 +64,7 @@ function escape(text: string): string {
|
|
|
64
64
|
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// every day between the first and last, inclusive
|
|
67
|
+
// every day between the first and last, inclusive - gaps filled with zero downstream - so
|
|
68
68
|
// bars line up between agents the same way the terminal's sparklines line up columns
|
|
69
69
|
function expandDays(start: string, end: string): string[] {
|
|
70
70
|
const days: string[] = []
|
|
@@ -124,7 +124,7 @@ function agentBarsSvg(rows: { agent: string; day: string; count: number }[]): st
|
|
|
124
124
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
125
125
|
.map(([agent, dayCounts]) => ({ agent, values: range.map((d) => dayCounts.get(d) ?? 0) }))
|
|
126
126
|
|
|
127
|
-
// one shared max across every agent
|
|
127
|
+
// one shared max across every agent - a per-row scale would draw a quiet agent's two
|
|
128
128
|
// turns at a busy agent's height, hiding exactly the comparison this chart is for
|
|
129
129
|
const sharedMax = Math.max(...series.flatMap((s) => s.values), 1)
|
|
130
130
|
|
|
@@ -188,7 +188,7 @@ export function renderPage(data: DashboardData): string {
|
|
|
188
188
|
<head>
|
|
189
189
|
<meta charset="utf-8">
|
|
190
190
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
191
|
-
<title>konvoy dashboard
|
|
191
|
+
<title>konvoy dashboard - ${title}</title>
|
|
192
192
|
<style>${STYLE}</style>
|
|
193
193
|
</head>
|
|
194
194
|
<body>
|
package/src/format.ts
CHANGED
|
@@ -36,7 +36,7 @@ function cost(r: RosterRow): string {
|
|
|
36
36
|
// reimplementing the credits-before-dollars rule and risking the two drifting apart
|
|
37
37
|
export function spend(row: UsageRow): string {
|
|
38
38
|
// credits win when both are non-zero: they're what the agent actually charged, and the
|
|
39
|
-
// cost estimator derives its dollar figure from credits the same way
|
|
39
|
+
// cost estimator derives its dollar figure from credits the same way - the two must
|
|
40
40
|
// never disagree about which number is the real one for a given row.
|
|
41
41
|
if (row.credits > 0) return `${row.credits.toFixed(3)} cr`
|
|
42
42
|
if (row.costUsd > 0) return `$${row.costUsd.toFixed(2)}`
|
package/src/pricing.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function estimateUsd(row: Priced, pricing: Pricing): number | null {
|
|
|
23
23
|
const rate = pricing.models[row.model]
|
|
24
24
|
if (rate) {
|
|
25
25
|
// inputTokens is the whole context sent, cache reads included, and those bill at a
|
|
26
|
-
// tenth of this rate
|
|
26
|
+
// tenth of this rate - so a cache-heavy turn estimates high. Only turns whose CLI
|
|
27
27
|
// reported no cost of its own reach here, which today is never claude's.
|
|
28
28
|
return (row.inputTokens / 1_000_000) * rate.inputPerMTok +
|
|
29
29
|
(row.outputTokens / 1_000_000) * rate.outputPerMTok
|
|
@@ -33,7 +33,7 @@ export function estimateUsd(row: Priced, pricing: Pricing): number | null {
|
|
|
33
33
|
return null
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// pricing defaults to empty, so an unconfigured user must never see a ~USD column of dashes
|
|
36
|
+
// pricing defaults to empty, so an unconfigured user must never see a ~USD column of dashes -
|
|
37
37
|
// the column exists only once there's at least one rate to estimate from
|
|
38
38
|
export function isPricingConfigured(pricing: Pricing): boolean {
|
|
39
39
|
return Object.keys(pricing.models).length > 0 || Object.keys(pricing.credits).length > 0
|
|
@@ -50,7 +50,7 @@ export interface ModelUsage {
|
|
|
50
50
|
|
|
51
51
|
// Summed per model, never priced once on the aggregate: a session that ran half its turns on
|
|
52
52
|
// an expensive model and half on a cheap one must not be priced as though it used either one
|
|
53
|
-
// throughout
|
|
53
|
+
// throughout - that's the whole reason `model` lives on the turn instead of the binding.
|
|
54
54
|
export function estimateAgentUsd(agent: AgentId, rows: ModelUsage[], pricing: Pricing): number | null {
|
|
55
55
|
const mine = rows.filter((r) => r.agent === agent)
|
|
56
56
|
// An agent bills one way or the other. Kiro charges credits and reports token counts beside
|
package/src/store/db.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function openDb(path: string): Database {
|
|
|
42
42
|
try {
|
|
43
43
|
db = new Database(path, { create: true, strict: true })
|
|
44
44
|
if (path !== ':memory:') {
|
|
45
|
-
// konvoy runs concurrently by design
|
|
45
|
+
// konvoy runs concurrently by design - a nested konvoy inside an agent, a dashboard beside
|
|
46
46
|
// a send. Set before anything that takes a lock: switching a fresh file to WAL needs an
|
|
47
47
|
// exclusive one, and without the timeout the losers of that first statement throw
|
|
48
48
|
db.exec('PRAGMA busy_timeout = 5000')
|
package/src/store/queries.ts
CHANGED
|
@@ -87,8 +87,8 @@ export function deleteSession(db: Database, id: string): void {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// Every foreign id konvoy has met: UUIDs, kiro's cli_<uuid>_<suffix>, opencode's ses_…. An id
|
|
90
|
-
// is replayed onto a command line
|
|
91
|
-
// codex
|
|
90
|
+
// is replayed onto a command line - after a flag by three adapters, as a bare positional by
|
|
91
|
+
// codex - so anything else, a leading dash above all, is refused here at the one write path.
|
|
92
92
|
const FOREIGN_ID = /^[A-Za-z0-9][\w.:-]*$/
|
|
93
93
|
|
|
94
94
|
export function upsertBinding(
|
|
@@ -100,14 +100,14 @@ export function upsertBinding(
|
|
|
100
100
|
effort: string
|
|
101
101
|
permission: Permission
|
|
102
102
|
model?: string | null
|
|
103
|
-
/** set by a turn that failed on auth
|
|
103
|
+
/** set by a turn that failed on auth - the roster then says which agent needs a login */
|
|
104
104
|
status?: 'auth_required'
|
|
105
105
|
},
|
|
106
106
|
): void {
|
|
107
107
|
const foreignId = input.foreignId !== null && !FOREIGN_ID.test(input.foreignId) ? null : input.foreignId
|
|
108
108
|
if (foreignId === null && input.foreignId !== null) {
|
|
109
109
|
console.error(
|
|
110
|
-
`konvoy: ignoring foreign session id ${JSON.stringify(input.foreignId.slice(0, 60))} for ${input.agent}
|
|
110
|
+
`konvoy: ignoring foreign session id ${JSON.stringify(input.foreignId.slice(0, 60))} for ${input.agent} - not a shape konvoy places on a command line`,
|
|
111
111
|
)
|
|
112
112
|
}
|
|
113
113
|
db.query(
|
|
@@ -235,7 +235,7 @@ export function lastTurnId(db: Database, sessionId: string): string | null {
|
|
|
235
235
|
return row?.id ?? null
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
// Who actually produced the most recent turn
|
|
238
|
+
// Who actually produced the most recent turn - which, after a failover move, is not
|
|
239
239
|
// necessarily the agent `send()` was originally asked to run.
|
|
240
240
|
export function lastTurnAgent(db: Database, sessionId: string): AgentId | null {
|
|
241
241
|
const row = db.query('SELECT agent FROM turn WHERE session_id = $sessionId ORDER BY rowid DESC LIMIT 1').get({
|
|
@@ -397,8 +397,7 @@ export function usageByAgentModel(db: Database, sessionId?: string): ModelUsage[
|
|
|
397
397
|
}))
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
//
|
|
401
|
-
// libc's zone, which ignores process.env.TZ and needs tzdata on the host.
|
|
400
|
+
// bun:sqlite's 'localtime' follows libc's zone, not process.env.TZ, and needs tzdata on the host
|
|
402
401
|
function localDay(ms: number): string {
|
|
403
402
|
const d = new Date(ms)
|
|
404
403
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
package/src/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type KonvoyEvent =
|
|
|
12
12
|
t: 'error'
|
|
13
13
|
message: string
|
|
14
14
|
kind: 'auth' | 'rate' | 'upstream' | 'crash' | 'timeout' | 'interrupted' | 'unknown'
|
|
15
|
-
/** which wire event carried it, when a CLI has more than one
|
|
15
|
+
/** which wire event carried it, when a CLI has more than one - codex: item vs turn.failed */
|
|
16
16
|
source?: string
|
|
17
17
|
}
|
|
18
18
|
| { t: 'done'; final: string }
|