@doguyilmaz/konvoy 0.2.0 → 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,6 +40,7 @@ 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"
44
45
  konvoy new # no goal: named after the directory, like sinkaf-8f3a
45
46
  konvoy send codex "start with the token refresh path"
@@ -58,6 +59,20 @@ konvoy version
58
59
  konvoy dashboard --port 4000 # local page with the same numbers as `usage --chart`
59
60
  ```
60
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
+
61
76
  ## Sample output
62
77
 
63
78
  Captured by running konvoy against a scratch database, not copied from a real project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doguyilmaz/konvoy",
3
- "version": "0.2.0",
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
@@ -22,6 +22,7 @@ import { cmdRename } from './commands/rename'
22
22
  import { cmdUsage } from './commands/usage'
23
23
  import { cmdDashboard } from './commands/dashboard'
24
24
  import { formatCommandList, resolveCommandName, type CommandName } from './commands/table'
25
+ import { runRepl, startSession, type ReplIo } from './commands/repl'
25
26
  import type { AgentId } from './types'
26
27
  import pkg from '../package.json'
27
28
 
@@ -29,6 +30,8 @@ const VERSION = pkg.version
29
30
 
30
31
  export const USAGE = `konvoy ${VERSION}
31
32
 
33
+ konvoy start: resume this directory's session or create one, then talk
34
+
32
35
  ${formatCommandList()}
33
36
 
34
37
  agents: ${agentIds.join(', ')}
@@ -112,20 +115,27 @@ const handlers: Record<CommandName, Handler> = {
112
115
  }),
113
116
  }
114
117
 
115
- 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> {
116
127
  const args = parseArgs(argv)
117
128
  const [command, ...rest] = args._
118
129
  const cwd = process.cwd()
119
130
  const slug = typeof args.flags.session === 'string' ? args.flags.session : undefined
120
131
 
121
- if (!command || command === 'help' || args.flags.help) {
132
+ if (command === 'help' || args.flags.help) {
122
133
  console.log(USAGE)
123
- // asking for help is not a usage error; running konvoy with nothing is
124
- return command || args.flags.help ? 0 : 1
134
+ return 0
125
135
  }
126
136
 
127
137
  try {
128
- return await dispatch(command, rest, cwd, slug, args)
138
+ return command ? await dispatch(command, rest, cwd, slug, args) : await interactive(io, cwd, slug)
129
139
  } catch (error) {
130
140
  if (Bun.env.KONVOY_DEBUG === '1') throw error
131
141
  console.error(`konvoy: ${error instanceof Error ? error.message : String(error)}`)
@@ -150,6 +160,26 @@ async function dispatch(command: string, rest: string[], cwd: string, slug: stri
150
160
  return handlers[name]({ db, cfg, cwd, args, slug }, rest)
151
161
  }
152
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
+
153
183
  if (import.meta.main) {
154
184
  process.exitCode = await main(Bun.argv.slice(2))
155
185
  }
@@ -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`)
@@ -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 setGoal(db: Database, id: string, goal: string): void {
80
+ db.query('UPDATE session SET goal = $goal WHERE id = $id').run({ goal, id })
81
+ }
82
+
79
83
  export function renameSession(db: Database, id: string, slug: string): void {
80
84
  db.query('UPDATE session SET slug = $slug WHERE id = $id').run({ slug, id })
81
85
  }