@doguyilmaz/konvoy 0.1.1 → 0.2.0

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 CHANGED
@@ -41,6 +41,7 @@ to download; the npm package is a few kilobytes of source and runs on the Bun yo
41
41
 
42
42
  ```bash
43
43
  konvoy new "refactor the auth layer"
44
+ konvoy new # no goal: named after the directory, like sinkaf-8f3a
44
45
  konvoy send codex "start with the token refresh path"
45
46
  konvoy ls
46
47
  konvoy resume # make a session current again and show its roster
@@ -52,6 +53,7 @@ konvoy attach kiro --id cli_8a1… # adopt a session you started in kiro's own
52
53
  konvoy doctor
53
54
  konvoy update --all # every agent CLI; konvoy itself follows its install channel (see Install)
54
55
  konvoy rm stale-slug --yes
56
+ konvoy rename stale-slug token-refresh # the session's .konvoy folder follows
55
57
  konvoy version
56
58
  konvoy dashboard --port 4000 # local page with the same numbers as `usage --chart`
57
59
  ```
@@ -97,7 +99,7 @@ opencode ▁▁▂▁▁▁▁▂▁▂
97
99
  A failover notice, when codex hits its weekly limit mid-chain:
98
100
 
99
101
  ```text
100
- konvoy: codex is blocked (rate) "You've hit your weekly limit · resets 7am" claude is taking over
102
+ konvoy: codex is blocked (rate) - "You've hit your weekly limit · resets 7am" - claude is taking over
101
103
  ```
102
104
 
103
105
  A handoff, with `delegation.enabled` on and `roles.reviewer` set to `claude`. codex ends its
@@ -120,7 +122,7 @@ decisions:
120
122
  konvoy resolves `reviewer` to claude, runs it, and prints:
121
123
 
122
124
  ```text
123
- konvoy: codex handed off to claude "check the retry does not loop when the refresh itself 401s"
125
+ konvoy: codex handed off to claude - "check the retry does not loop when the refresh itself 401s"
124
126
  ```
125
127
 
126
128
  claude's turn runs with codex's task as its prompt, preceded by this prelude:
@@ -139,7 +141,7 @@ decisions:
139
141
  ## How it works
140
142
 
141
143
  One konvoy session holds a binding per agent, and each binding holds that agent's own
142
- foreign session id konvoy's id and the agent's id are never the same thing. Only claude
144
+ foreign session id; konvoy's id and the agent's id are never the same thing. Only claude
143
145
  accepts a caller-chosen session id up front; the other three assign their own and hand it
144
146
  back after the first turn, which konvoy stores in that agent's binding and resumes on every
145
147
  turn after.
@@ -164,6 +166,9 @@ Global `~/.config/konvoy/config.jsonc`, per project `.konvoy/config.jsonc`. The
164
166
  file wins. `konvoy config get` shows each agent's resolved settings and whether a value came
165
167
  from that agent, from `defaults`, or from konvoy's own built-in.
166
168
 
169
+ `konvoy new` also writes `.konvoy/.gitignore` (`*`, then `!config.jsonc`), so a session's `CONTEXT.md`
170
+ and `LEDGER.md` never reach git while the project config can be committed.
171
+
167
172
  ```jsonc
168
173
  {
169
174
  "defaults": { "effort": "high", "permission": "edit" },
@@ -175,18 +180,18 @@ from that agent, from `defaults`, or from konvoy's own built-in.
175
180
  }
176
181
  ```
177
182
 
178
- `effort` is one scale `low | medium | high | max` mapped onto each CLI's own dial and
183
+ `effort` is one scale, `low | medium | high | max`, mapped onto each CLI's own dial and
179
184
  clamped to what the target model actually supports.
180
185
 
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:
186
+ If a CLI is not on your `PATH`, point konvoy at it directly and every command (`doctor`,
187
+ `status`, `send`, `attach`, `update`) uses that path:
183
188
 
184
189
  ```jsonc
185
190
  { "agents": { "opencode": { "bin": "~/.opencode/bin/opencode" } } }
186
191
  ```
187
192
 
188
193
  `konvoy config set <key> <value> [--global]` rewrites the layer it touches as plain JSON, so
189
- any comments in that file are lost `--global` targets the global file instead of the
194
+ any comments in that file are lost; `--global` targets the global file instead of the
190
195
  project one. Hand-edit the file instead when you want to keep them.
191
196
 
192
197
  Name a `failover` chain and konvoy follows it when an agent can't work, instead of asking:
@@ -198,7 +203,7 @@ Name a `failover` chain and konvoy follows it when an agent can't work, instead
198
203
  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
204
  error (a reachable-but-refusing API) retries the same agent with backoff up to
200
205
  `upstreamRetries` times before moving on. A crash, a timeout, or an interrupted turn never
201
- moves the chain the fault is in the work, and the next agent would just fail the same way.
206
+ moves the chain: the fault is in the work, and the next agent would just fail the same way.
202
207
  There is no failback: once konvoy moves, it stays moved. An empty chain (the default) turns
203
208
  the feature off.
204
209
 
@@ -214,15 +219,15 @@ flowchart TD
214
219
  ```
215
220
 
216
221
  Set `style: "brief"` to have an agent lead with the action, number multi-step work, and skip
217
- preamble and pleasantries it shapes the answer you read, not what agents send each other:
222
+ preamble and pleasantries. It shapes the answer you read, not what agents send each other:
218
223
 
219
224
  ```jsonc
220
225
  { "defaults": { "style": "brief" }, "agents": { "kiro": { "style": null } } }
221
226
  ```
222
227
 
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
228
+ Set `delegation.enabled` to have every turn told how to hand work to another agent, a
229
+ `<<<konvoy ... >>>` block naming `to:` and `task:` with optional `open:` and `decisions:`
230
+ lists, instead of the weaker summary konvoy derives on its own. The agent decides when a
226
231
  turn is actually handing off; a turn that isn't emits no block at all, so this costs nothing
227
232
  on the turns that don't need it. Off by default: a single-agent session has no handoff to
228
233
  describe.
@@ -231,7 +236,7 @@ describe.
231
236
  { "delegation": { "enabled": true } }
232
237
  ```
233
238
 
234
- Name a `gate` command your test suite, a linter, whatever exits non-zero on bad work and
239
+ Name a `gate` command (your test suite, a linter, whatever exits non-zero on bad work) and
235
240
  konvoy runs it after each turn that produced something, recording a pass or fail against that
236
241
  turn. A command that can't even be spawned records nothing, and a failed turn is never gated:
237
242
 
@@ -240,8 +245,8 @@ turn. A command that can't even be spawned records nothing, and a failed turn is
240
245
  ```
241
246
 
242
247
  `harness` decides how much of a CLI's own setup a turn loads. `minimal`, the default, strips
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
248
+ what konvoy already supplies (claude runs with no MCP servers, slash commands or settings
249
+ files, codex with `--ignore-user-config`) and measured 2.6× less context per turn than
245
250
  `inherit`, which runs the CLI exactly as you would by hand, hooks and skills included. kiro and
246
251
  opencode run with their own configuration either way. Privileged: the global config only.
247
252
 
@@ -249,7 +254,7 @@ opencode run with their own configuration either way. Privileged: the global con
249
254
  { "defaults": { "harness": "inherit" } }
250
255
  ```
251
256
 
252
- `gate` is privileged like `bin`, `permission` and `harness` only the global config may set
257
+ `gate` is privileged like `bin`, `permission` and `harness`: only the global config may set
253
258
  it, since a gate runs on every turn with no per-turn opt-in, unlike an agent binary the user
254
259
  chose to run. That also means one gate command serves every project; there's no per-project
255
260
  override yet.
@@ -279,11 +284,11 @@ bun test
279
284
  bun run typecheck
280
285
  bun run mutate # mutation coverage of src/
281
286
  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 spends quota
287
+ bun run smoke # two real turns per installed, authenticated agent, the second resumed; spends quota
283
288
  ```
284
289
 
285
290
  `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 that every flag an adapter puts on a
291
+ and confirms what this README and the adapters assume: that every flag an adapter puts on a
287
292
  command line still exists, that each agent's update and auth-status subcommands exist, that
288
293
  only claude accepts a caller-chosen session id, and that opencode's `--session` continues a
289
294
  session rather than creating one. The flag list is built from the adapters' real argv, so a flag
@@ -293,8 +298,8 @@ quota, and it isn't part of `bun test` since it needs the CLIs installed to mean
293
298
  `smoke` closes the gap `verify:claims` and the frozen fixtures in `tests/fixtures/streams/`
294
299
  both leave open: it runs two turns per installed, logged-in agent through konvoy's real
295
300
  `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
301
+ error; the second is resumed through the binding konvoy captured and must give the nonce back -
297
302
  the one cheap proof that a bound session carries its context, which every unit test of it
298
303
  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 the
304
+ when an installed CLI's version has drifted from the one a fixture was captured against, the
300
305
  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.1",
4
- "description": "One session across Claude Code, Codex, Kiro CLI and opencode bind, hand off, fail over",
3
+ "version": "0.2.0",
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": {
@@ -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 claude puts the reason on stderr then, and
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
@@ -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 kiro truncates it. Only an untruncated
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 {
@@ -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)
@@ -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 a
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 only the agent running it
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 no block at all.'
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 they must never join
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 an agent's own words or a
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 CR
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" "Cloud gateway session expired", "AWS session has expired",
79
- // "Login token is expired", "MCP OAuth access token is expired" so requiring the literal
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 hence the optional separators below. codex's five-hour
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 the one failure worth
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 a flat run of busy
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 every
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
@@ -18,6 +18,7 @@ import { cmdUpdate } from './commands/update'
18
18
  import { cmdConfig } from './commands/config'
19
19
  import { cmdResume } from './commands/resume'
20
20
  import { cmdRm } from './commands/rm'
21
+ import { cmdRename } from './commands/rename'
21
22
  import { cmdUsage } from './commands/usage'
22
23
  import { cmdDashboard } from './commands/dashboard'
23
24
  import { formatCommandList, resolveCommandName, type CommandName } from './commands/table'
@@ -44,7 +45,7 @@ interface CommandContext {
44
45
 
45
46
  type Handler = (ctx: CommandContext, rest: string[]) => number | Promise<number>
46
47
 
47
- // Every key of CommandName must be handled here TypeScript's excess/missing property
48
+ // Every key of CommandName must be handled here - TypeScript's excess/missing property
48
49
  // checks on an object literal assigned to Record<CommandName, Handler> make an
49
50
  // undocumented-yet-dispatched or dispatched-yet-undocumented command a compile error.
50
51
  const handlers: Record<CommandName, Handler> = {
@@ -78,6 +79,14 @@ const handlers: Record<CommandName, Handler> = {
78
79
  const [action, key, value] = rest
79
80
  return cmdConfig(ctx.cfg, ctx.cwd, action ?? 'get', key, value, { global: ctx.args.flags.global === true })
80
81
  },
82
+ rename: (ctx, rest) => {
83
+ const [from, to] = rest
84
+ if (!from || !to) {
85
+ console.error('usage: konvoy rename <session> <new-name>')
86
+ return 2
87
+ }
88
+ return cmdRename(ctx.db, from, to)
89
+ },
81
90
  rm: (ctx, rest) => {
82
91
  const [target] = rest
83
92
  if (!target) {
@@ -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 the id a CLI prints for its own resume
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}" expected one of ${agentIds.join(', ')}`)
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 run `konvoy new "<goal>"` first')
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 the id was refused; nothing opened`)
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)} the next turn resumes it; opening it now`)
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)
@@ -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 poisoning every object in the process while
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('.')} ${issue?.message}`)
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} ctrl-c to stop`)
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
  }
@@ -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 the delegation work will. Comparing the parsed policy values against
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} ${loginHint(agent)}`)
62
+ console.log(`x ${agent}: ${auth.detail} - ${loginHint(agent)}`)
63
63
  problems++
64
64
  } else {
65
- console.log(`- ${agent}: ${auth.detail} ${loginHint(agent)}`)
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 it returns HTTP 403 without an explicit -m`)
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 set agents.${agent}.bin to be explicit`)
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} they will not disagree with each other`)
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(', ')} accepted but not yet used`)
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`)
@@ -1,23 +1,26 @@
1
1
  import type { Database } from 'bun:sqlite'
2
2
  import type { Config } from '../config/schema'
3
- import { newSession } from '../core/session'
4
- import { sessionDir } from '../paths'
3
+ import { newSession, slugify } from '../core/session'
4
+ import { basename, join, sessionDir } from '../paths'
5
5
 
6
6
  export async function cmdNew(db: Database, cfg: Config, cwd: string, goal: string): Promise<number> {
7
7
  const lead = cfg.roles.lead ?? 'claude'
8
- const session = newSession(db, { cwd, goal: goal || 'untitled', lead })
8
+ const suffix = Bun.randomUUIDv7().slice(-4)
9
+ const slug = goal ? undefined : `${slugify(basename(cwd)).slice(0, 30)}-${suffix}`
10
+ const session = newSession(db, { cwd, goal, lead, slug })
9
11
  const dir = sessionDir(cwd, session.slug)
10
- // `konvoy rm` frees the slug but leaves these files — they are the user's. A new session under
12
+ const ignore = Bun.file(join(cwd, '.konvoy', '.gitignore'))
13
+ if (!(await ignore.exists())) await Bun.write(ignore, '*\n!config.jsonc\n')
14
+ // `konvoy rm` frees the slug but leaves these files - they are the user's. A new session under
11
15
  // an old slug adds its goal to the context and appends to the ledger; it truncates neither.
12
16
  const context = Bun.file(`${dir}/CONTEXT.md`)
17
+ const goalSection = session.goal ? `\n## Goal\n\n${session.goal}\n` : ''
13
18
  await Bun.write(
14
19
  context,
15
- (await context.exists())
16
- ? `${await context.text()}\n## Goal\n\n${session.goal}\n`
17
- : `# ${session.slug}\n\n## Goal\n\n${session.goal}\n`,
20
+ (await context.exists()) ? `${await context.text()}${goalSection}` : `# ${session.slug}\n${goalSection}`,
18
21
  )
19
22
  const ledger = Bun.file(`${dir}/LEDGER.md`)
20
- if (!(await ledger.exists())) await Bun.write(ledger, `# Ledger ${session.slug}\n`)
23
+ if (!(await ledger.exists())) await Bun.write(ledger, `# Ledger - ${session.slug}\n`)
21
24
  console.log(`created session ${session.slug} (lead: ${lead})`)
22
25
  console.log(dir)
23
26
  return 0
@@ -0,0 +1,49 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { slugify } from '../core/session'
3
+ import { sessionDir } from '../paths'
4
+ import { getSessionBySlug, lockOwner, renameSession } from '../store/queries'
5
+
6
+ const isDir = async (path: string): Promise<boolean> => (await Bun.$`test -d ${path}`.quiet().nothrow()).exitCode === 0
7
+
8
+ async function retitle(path: string, from: string, to: string): Promise<void> {
9
+ const file = Bun.file(path)
10
+ if (!(await file.exists())) return
11
+ const text = await file.text()
12
+ if (text.startsWith(from)) await Bun.write(file, to + text.slice(from.length))
13
+ }
14
+
15
+ export async function cmdRename(db: Database, from: string, to: string): Promise<number> {
16
+ const session = getSessionBySlug(db, from)
17
+ if (!session) {
18
+ console.error(`no konvoy session named "${from}"`)
19
+ return 2
20
+ }
21
+ const busy = lockOwner(db, session.id)
22
+ if (busy) {
23
+ console.error(`"${from}" has a turn running (${busy}) - wait for it to finish, then retry`)
24
+ return 2
25
+ }
26
+ const slug = slugify(to)
27
+ if (getSessionBySlug(db, slug)) {
28
+ console.error(`a session named "${slug}" already exists`)
29
+ return 2
30
+ }
31
+ const oldDir = sessionDir(session.cwd, from)
32
+ const newDir = sessionDir(session.cwd, slug)
33
+ if (await isDir(oldDir)) {
34
+ if (await isDir(newDir)) {
35
+ console.error(`${newDir} already exists - move it away first`)
36
+ return 2
37
+ }
38
+ const moved = await Bun.$`mv ${oldDir} ${newDir}`.quiet().nothrow()
39
+ if (moved.exitCode !== 0) {
40
+ console.error(`could not move ${oldDir} to ${newDir}: ${moved.stderr.toString().trim()}`)
41
+ return 1
42
+ }
43
+ await retitle(`${newDir}/CONTEXT.md`, `# ${from}\n`, `# ${slug}\n`)
44
+ await retitle(`${newDir}/LEDGER.md`, `# Ledger - ${from}\n`, `# Ledger - ${slug}\n`)
45
+ }
46
+ renameSession(db, session.id, slug)
47
+ console.log(`renamed ${from} -> ${slug}`)
48
+ return 0
49
+ }
@@ -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}) wait for it to finish, then retry`)
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 re-run with --yes to proceed`)
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)
@@ -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 run `konvoy new "<goal>"` first')
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} ${session.goal}`)
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 a second opinion from the same model is not one`)
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
  }
@@ -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}" expected one of ${agentIds.join(', ')}`)
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 run `konvoy new "<goal>"` first')
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 it prints what it
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) {
@@ -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 it will be skipped`)
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
@@ -21,6 +21,7 @@ export const commandTable = [
21
21
  usage: 'rm <session> --yes',
22
22
  summary: 'delete a konvoy session (foreign sessions survive)',
23
23
  },
24
+ { name: 'rename', aliases: [], usage: 'rename <session> <new-name>', summary: 'rename a session; its .konvoy folder follows' },
24
25
  { name: 'roster', aliases: [], usage: 'roster', summary: 'who is in the convoy' },
25
26
  { name: 'usage', aliases: [], usage: 'usage [--all] [--chart]', summary: 'what this session spent, per agent' },
26
27
  { name: 'status', aliases: [], usage: 'status', summary: 'versions, auth and roster' },
@@ -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 it follows the channel it was installed from
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'
@@ -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 run `konvoy new "<goal>"` first')
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} no turns yet`)
45
+ console.log(`session ${session.slug} - no turns yet`)
46
46
  return 0
47
47
  }
48
48
  console.log(`session ${session.slug}`)
@@ -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 properties of the machine the person is running konvoy on, not of the repo they
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 wider than `bin`,
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}" privileged, set it in the global config instead`)
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 so this is validated, not merge-source
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}" must match ${MODEL_PATTERN}`)
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 a tilde anywhere else
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)} invalid at ${where}`)
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 so this can't reuse `??`.
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 trimmed and
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.
@@ -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 `.nullish()` so a per-agent `null` can
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 otherwise a
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 a
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({}),
@@ -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 a child that traps or
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
@@ -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 }` see tests/fixtures/codex-models-cache.json
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
- // Real detection shares one memo; each injected deps object gets its own, so a fake answering in
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 and a shifted
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 a chatty git would fill the pipe and block;
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 konvoy has no model of its own, so it never
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 running it now would blame the block on the work.
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 the gate is the user's own command, but it still deserves its quotes.
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 = ''
@@ -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 a bare identifier. The delegation
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 only the goal, the facts and the count of what was left out
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,10 +112,10 @@ 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 only what was asked and answered is known.")
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)`)
119
119
 
120
- return [`goal: ${session.goal}`, facts, turnBlocks.join('\n\n')].filter(Boolean).join('\n\n')
120
+ return [session.goal ? `goal: ${session.goal}` : '', facts, turnBlocks.join('\n\n')].filter(Boolean).join('\n\n')
121
121
  }
@@ -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 it is detectable, and
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 another konvoy turn is running`)
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 followHandoff needs its own, built
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 runGate reads that row itself to decide
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 including an envelope
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)}" no such agent or role, ${ranAsAgent}'s turn stands`,
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 the same rule the failover chain already follows for a
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} ${ranAsAgent}'s turn stands`,
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} "${oneLine(envelope.task)}"`)
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 the opposite of what the
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 exactly as before failover existed rebind
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 this is where it matters.
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)} starting a new one`)
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 skip it
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 ${why}`)
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}) "${oneLine(blocked.message)}" ${current} is taking over`,
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 with backoff, since a hammered upstream is the last thing to hammer again.
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 a rate window is hours and an auth failure needs a
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 no execute bit, a bad interpreter: detection cannot
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 so a surviving -1 is a real signal, not
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 otherwise the turn row stays at its INSERT placeholder and
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" the agent cannot
240
- // work until something changes so they survive even a turn that answered and exited 0.
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 say so, rather than show
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`
@@ -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 a parallel query here is exactly how the two views would start disagreeing
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
65
65
  }
66
66
 
67
- // every day between the first and last, inclusive gaps filled with zero downstream so
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 a per-row scale would draw a quiet agent's two
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 ${title}</title>
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 the two must
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/paths.ts CHANGED
@@ -14,6 +14,8 @@ export function join(...parts: string[]): string {
14
14
  return (absolute ? '/' : '') + segments.join('/')
15
15
  }
16
16
 
17
+ export const basename = (p: string): string => p.split('/').filter(Boolean).at(-1) ?? ''
18
+
17
19
  export function dirname(p: string): string {
18
20
  const cut = p.lastIndexOf('/')
19
21
  if (cut < 0) return '.'
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 so a cache-heavy turn estimates high. Only turns whose CLI
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 that's the whole reason `model` lives on the turn instead of the binding.
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 a nested konvoy inside an agent, a dashboard beside
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')
@@ -76,6 +76,10 @@ export function touchSession(db: Database, id: string): void {
76
76
  ).run({ id, now: now() })
77
77
  }
78
78
 
79
+ export function renameSession(db: Database, id: string, slug: string): void {
80
+ db.query('UPDATE session SET slug = $slug WHERE id = $id').run({ slug, id })
81
+ }
82
+
79
83
  export function deleteSession(db: Database, id: string): void {
80
84
  db.transaction(() => {
81
85
  db.query('DELETE FROM event WHERE turn_id IN (SELECT id FROM turn WHERE session_id = $id)').run({ id })
@@ -87,8 +91,8 @@ export function deleteSession(db: Database, id: string): void {
87
91
  }
88
92
 
89
93
  // Every foreign id konvoy has met: UUIDs, kiro's cli_<uuid>_<suffix>, opencode's ses_…. An id
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.
94
+ // is replayed onto a command line - after a flag by three adapters, as a bare positional by
95
+ // codex - so anything else, a leading dash above all, is refused here at the one write path.
92
96
  const FOREIGN_ID = /^[A-Za-z0-9][\w.:-]*$/
93
97
 
94
98
  export function upsertBinding(
@@ -100,14 +104,14 @@ export function upsertBinding(
100
104
  effort: string
101
105
  permission: Permission
102
106
  model?: string | null
103
- /** set by a turn that failed on auth the roster then says which agent needs a login */
107
+ /** set by a turn that failed on auth - the roster then says which agent needs a login */
104
108
  status?: 'auth_required'
105
109
  },
106
110
  ): void {
107
111
  const foreignId = input.foreignId !== null && !FOREIGN_ID.test(input.foreignId) ? null : input.foreignId
108
112
  if (foreignId === null && input.foreignId !== null) {
109
113
  console.error(
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`,
114
+ `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
115
  )
112
116
  }
113
117
  db.query(
@@ -235,7 +239,7 @@ export function lastTurnId(db: Database, sessionId: string): string | null {
235
239
  return row?.id ?? null
236
240
  }
237
241
 
238
- // Who actually produced the most recent turn which, after a failover move, is not
242
+ // Who actually produced the most recent turn - which, after a failover move, is not
239
243
  // necessarily the agent `send()` was originally asked to run.
240
244
  export function lastTurnAgent(db: Database, sessionId: string): AgentId | null {
241
245
  const row = db.query('SELECT agent FROM turn WHERE session_id = $sessionId ORDER BY rowid DESC LIMIT 1').get({
@@ -397,8 +401,7 @@ export function usageByAgentModel(db: Database, sessionId?: string): ModelUsage[
397
401
  }))
398
402
  }
399
403
 
400
- // Buckets are cut in JS so the CLI keeps one clock: bun:sqlite's 'localtime' modifier reads
401
- // libc's zone, which ignores process.env.TZ and needs tzdata on the host.
404
+ // bun:sqlite's 'localtime' follows libc's zone, not process.env.TZ, and needs tzdata on the host
402
405
  function localDay(ms: number): string {
403
406
  const d = new Date(ms)
404
407
  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 codex: item vs turn.failed */
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 }