@doguyilmaz/konvoy 0.1.2 → 0.3.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
@@ -40,7 +40,9 @@ to download; the npm package is a few kilobytes of source and runs on the Bun yo
40
40
  ## Use
41
41
 
42
42
  ```bash
43
+ konvoy # start: resume this directory's session or create one, then talk
43
44
  konvoy new "refactor the auth layer"
45
+ konvoy new # no goal: named after the directory, like sinkaf-8f3a
44
46
  konvoy send codex "start with the token refresh path"
45
47
  konvoy ls
46
48
  konvoy resume # make a session current again and show its roster
@@ -52,10 +54,25 @@ konvoy attach kiro --id cli_8a1… # adopt a session you started in kiro's own
52
54
  konvoy doctor
53
55
  konvoy update --all # every agent CLI; konvoy itself follows its install channel (see Install)
54
56
  konvoy rm stale-slug --yes
57
+ konvoy rename stale-slug token-refresh # the session's .konvoy folder follows
55
58
  konvoy version
56
59
  konvoy dashboard --port 4000 # local page with the same numbers as `usage --chart`
57
60
  ```
58
61
 
62
+ Bare `konvoy` is the everyday entry: it resumes the session bound to this directory or creates
63
+ one named after it, then reads what you type. Plain text is a turn against the current agent; a
64
+ line starting with `/` runs any command from the list (`/usage --all`, `/rename token-refresh`,
65
+ `/attach`), plus `/use <agent>`, `/goal <text>`, `/help` and `/quit`. Ctrl-D leaves, Ctrl-C stops a
66
+ running turn and leaves, and piped stdin runs one turn per line.
67
+
68
+ ```text
69
+ sinkaf-8f3a claude> fix the token refresh
70
+ · Read src/auth.ts
71
+ Switched the refresh to fire on 401 with a single in-flight retry.
72
+ sinkaf-8f3a claude> /use codex
73
+ sinkaf-8f3a codex> review that diff
74
+ ```
75
+
59
76
  ## Sample output
60
77
 
61
78
  Captured by running konvoy against a scratch database, not copied from a real project.
@@ -164,6 +181,9 @@ Global `~/.config/konvoy/config.jsonc`, per project `.konvoy/config.jsonc`. The
164
181
  file wins. `konvoy config get` shows each agent's resolved settings and whether a value came
165
182
  from that agent, from `defaults`, or from konvoy's own built-in.
166
183
 
184
+ `konvoy new` also writes `.konvoy/.gitignore` (`*`, then `!config.jsonc`), so a session's `CONTEXT.md`
185
+ and `LEDGER.md` never reach git while the project config can be committed.
186
+
167
187
  ```jsonc
168
188
  {
169
189
  "defaults": { "effort": "high", "permission": "edit" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doguyilmaz/konvoy",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
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",
package/src/cli.ts CHANGED
@@ -18,9 +18,11 @@ 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'
25
+ import { runRepl, startSession, type ReplIo } from './commands/repl'
24
26
  import type { AgentId } from './types'
25
27
  import pkg from '../package.json'
26
28
 
@@ -28,6 +30,8 @@ const VERSION = pkg.version
28
30
 
29
31
  export const USAGE = `konvoy ${VERSION}
30
32
 
33
+ konvoy start: resume this directory's session or create one, then talk
34
+
31
35
  ${formatCommandList()}
32
36
 
33
37
  agents: ${agentIds.join(', ')}
@@ -78,6 +82,14 @@ const handlers: Record<CommandName, Handler> = {
78
82
  const [action, key, value] = rest
79
83
  return cmdConfig(ctx.cfg, ctx.cwd, action ?? 'get', key, value, { global: ctx.args.flags.global === true })
80
84
  },
85
+ rename: (ctx, rest) => {
86
+ const [from, to] = rest
87
+ if (!from || !to) {
88
+ console.error('usage: konvoy rename <session> <new-name>')
89
+ return 2
90
+ }
91
+ return cmdRename(ctx.db, from, to)
92
+ },
81
93
  rm: (ctx, rest) => {
82
94
  const [target] = rest
83
95
  if (!target) {
@@ -103,20 +115,27 @@ const handlers: Record<CommandName, Handler> = {
103
115
  }),
104
116
  }
105
117
 
106
- export async function main(argv: string[]): Promise<number> {
118
+ const stdio = (): ReplIo => ({
119
+ lines: console as unknown as AsyncIterable<string>,
120
+ write: (text) => {
121
+ process.stdout.write(text)
122
+ },
123
+ tty: Boolean(process.stdin.isTTY),
124
+ })
125
+
126
+ export async function main(argv: string[], io: ReplIo = stdio()): Promise<number> {
107
127
  const args = parseArgs(argv)
108
128
  const [command, ...rest] = args._
109
129
  const cwd = process.cwd()
110
130
  const slug = typeof args.flags.session === 'string' ? args.flags.session : undefined
111
131
 
112
- if (!command || command === 'help' || args.flags.help) {
132
+ if (command === 'help' || args.flags.help) {
113
133
  console.log(USAGE)
114
- // asking for help is not a usage error; running konvoy with nothing is
115
- return command || args.flags.help ? 0 : 1
134
+ return 0
116
135
  }
117
136
 
118
137
  try {
119
- return await dispatch(command, rest, cwd, slug, args)
138
+ return command ? await dispatch(command, rest, cwd, slug, args) : await interactive(io, cwd, slug)
120
139
  } catch (error) {
121
140
  if (Bun.env.KONVOY_DEBUG === '1') throw error
122
141
  console.error(`konvoy: ${error instanceof Error ? error.message : String(error)}`)
@@ -141,6 +160,26 @@ async function dispatch(command: string, rest: string[], cwd: string, slug: stri
141
160
  return handlers[name]({ db, cfg, cwd, args, slug }, rest)
142
161
  }
143
162
 
163
+ async function interactive(io: ReplIo, cwd: string, slug: string | undefined): Promise<number> {
164
+ const db = openDb(dbPath())
165
+ const cfg = await loadConfig({ cwd: (slug ? getSessionBySlug(db, slug)?.cwd : undefined) ?? cwd })
166
+ const session = await startSession(db, cfg, cwd, slug)
167
+ if (!session) {
168
+ console.error(slug ? `no konvoy session named "${slug}"` : 'could not create a session here')
169
+ return 2
170
+ }
171
+ return runRepl(io, db, cfg, cwd, session, (tokens, current) => {
172
+ const a = parseArgs(tokens)
173
+ const [cmd = '', ...r] = a._
174
+ const name = resolveCommandName(cmd)
175
+ if (!name) {
176
+ console.error(`unknown command "/${cmd}" - /help lists them`)
177
+ return 2
178
+ }
179
+ return handlers[name]({ db, cfg, cwd, args: a, slug: current }, r)
180
+ })
181
+ }
182
+
144
183
  if (import.meta.main) {
145
184
  process.exitCode = await main(Bun.argv.slice(2))
146
185
  }
@@ -1,20 +1,23 @@
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)
12
+ const ignore = Bun.file(join(cwd, '.konvoy', '.gitignore'))
13
+ if (!(await ignore.exists())) await Bun.write(ignore, '*\n!config.jsonc\n')
10
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
23
  if (!(await ledger.exists())) await Bun.write(ledger, `# Ledger - ${session.slug}\n`)
@@ -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
+ }
@@ -0,0 +1,125 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { Config } from '../config/schema'
3
+ import type { AgentId, Session } from '../types'
4
+ import { agentIds } from '../adapters'
5
+ import { resolveAgent } from '../config/load'
6
+ import { sessionDir } from '../paths'
7
+ import { currentSession, getSessionBySlug, lastTurnAgent, listSessions, setGoal } from '../store/queries'
8
+ import { cmdNew } from './new'
9
+ import { formatCommandList } from './table'
10
+
11
+ export interface ReplIo {
12
+ lines: AsyncIterable<string>
13
+ write: (text: string) => void
14
+ tty: boolean
15
+ }
16
+
17
+ /** runs one command line through the command table, as `konvoy <tokens>` would, for the given session */
18
+ export type Run = (tokens: string[], slug: string) => Promise<number> | number
19
+
20
+ const INNER = ` /use <agent> talk to this agent from now on
21
+ /goal <text> set the session goal
22
+ /help this list
23
+ /quit leave (Ctrl-D does too)
24
+ `
25
+
26
+ export const replHelp = (): string => INNER + formatCommandList().replaceAll(' konvoy ', ' /') + '\n'
27
+
28
+ export async function startSession(db: Database, cfg: Config, cwd: string, slug?: string): Promise<Session | null> {
29
+ if (slug) return getSessionBySlug(db, slug)
30
+ const current = currentSession(db, cwd)
31
+ if (current) return current
32
+ if ((await cmdNew(db, cfg, cwd, '')) !== 0) return null
33
+ return currentSession(db, cwd)
34
+ }
35
+
36
+ export async function runRepl(
37
+ io: ReplIo,
38
+ db: Database,
39
+ cfg: Config,
40
+ cwd: string,
41
+ start: Session,
42
+ run: Run,
43
+ ): Promise<number> {
44
+ let session = start
45
+ let agent: AgentId = session.lead
46
+ const prompt = (): void => {
47
+ if (io.tty) io.write(`${session.slug} ${agent}> `)
48
+ }
49
+ const refresh = (): boolean => {
50
+ const fresh = listSessions(db).find((s) => s.id === session.id)
51
+ if (fresh) session = fresh
52
+ return fresh !== undefined
53
+ }
54
+
55
+ prompt()
56
+ for await (const raw of io.lines) {
57
+ const line = raw.trim()
58
+ if (line === '') {
59
+ prompt()
60
+ continue
61
+ }
62
+ if (!line.startsWith('/')) {
63
+ await run(['send', agent, line], session.slug)
64
+ // failover never falls back, so the agent that answered is the one to keep talking to
65
+ const moved = lastTurnAgent(db, session.id)
66
+ if (moved && moved !== agent) agent = moved
67
+ prompt()
68
+ continue
69
+ }
70
+
71
+ const [cmd = '', ...rest] = line.slice(1).split(/\s+/)
72
+ if (cmd === 'quit' || cmd === 'exit' || cmd === 'q') break
73
+ if (cmd === 'help') {
74
+ io.write(replHelp())
75
+ } else if (cmd === 'use') {
76
+ const next = rest[0] ?? ''
77
+ if (!agentIds.includes(next as AgentId)) {
78
+ console.error(`unknown agent "${next}" - expected one of ${agentIds.join(', ')}`)
79
+ } else if (!resolveAgent(cfg, next as AgentId).enabled) {
80
+ console.error(`${next} is disabled in this konvoy config`)
81
+ } else {
82
+ agent = next as AgentId
83
+ }
84
+ } else if (cmd === 'goal') {
85
+ const goal = rest.join(' ')
86
+ if (!goal) {
87
+ console.error('usage: /goal <text>')
88
+ } else {
89
+ setGoal(db, session.id, goal)
90
+ const context = Bun.file(`${sessionDir(session.cwd, session.slug)}/CONTEXT.md`)
91
+ if (await context.exists()) await Bun.write(context, `${await context.text()}\n## Goal\n\n${goal}\n`)
92
+ refresh()
93
+ }
94
+ } else if (cmd === 'rename') {
95
+ if (rest.length === 0) console.error('usage: /rename <new-name>')
96
+ else {
97
+ await run(['rename', session.slug, rest.join(' ')], session.slug)
98
+ refresh()
99
+ }
100
+ } else if (cmd === 'attach') {
101
+ await run(['attach', rest[0] ?? agent, ...rest.slice(1)], session.slug)
102
+ } else {
103
+ await run([cmd, ...rest], session.slug)
104
+ if (cmd === 'resume' && rest[0]) {
105
+ const target = getSessionBySlug(db, rest[0])
106
+ if (target) {
107
+ session = target
108
+ agent = target.lead
109
+ }
110
+ } else if (cmd === 'new') {
111
+ const created = currentSession(db, cwd)
112
+ if (created) {
113
+ session = created
114
+ agent = created.lead
115
+ }
116
+ } else if (!refresh()) {
117
+ console.error(`session ${session.slug} is gone`)
118
+ return 0
119
+ }
120
+ }
121
+ prompt()
122
+ }
123
+ if (io.tty) io.write('\n')
124
+ return 0
125
+ }
@@ -28,7 +28,7 @@ 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.goal ? `session ${session.slug} - ${session.goal}` : `session ${session.slug}`)
32
32
  console.log(formatRoster(rows))
33
33
  for (const model of duplicateModels(rows)) {
34
34
  console.log(`warning: ${model} is used by more than one agent - a second opinion from the same model is not one`)
@@ -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' },
@@ -117,5 +117,5 @@ export function buildPrelude(db: Database, session: Session, facts: string, opts
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
  }
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 '.'
@@ -76,6 +76,14 @@ export function touchSession(db: Database, id: string): void {
76
76
  ).run({ id, now: now() })
77
77
  }
78
78
 
79
+ export function setGoal(db: Database, id: string, goal: string): void {
80
+ db.query('UPDATE session SET goal = $goal WHERE id = $id').run({ goal, id })
81
+ }
82
+
83
+ export function renameSession(db: Database, id: string, slug: string): void {
84
+ db.query('UPDATE session SET slug = $slug WHERE id = $id').run({ slug, id })
85
+ }
86
+
79
87
  export function deleteSession(db: Database, id: string): void {
80
88
  db.transaction(() => {
81
89
  db.query('DELETE FROM event WHERE turn_id IN (SELECT id FROM turn WHERE session_id = $id)').run({ id })