@doguyilmaz/konvoy 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +300 -0
  3. package/package.json +52 -0
  4. package/src/adapters/claude.ts +83 -0
  5. package/src/adapters/codex.ts +67 -0
  6. package/src/adapters/effort.ts +16 -0
  7. package/src/adapters/index.ts +20 -0
  8. package/src/adapters/kiro.ts +79 -0
  9. package/src/adapters/opencode.ts +64 -0
  10. package/src/adapters/types.ts +108 -0
  11. package/src/args.ts +42 -0
  12. package/src/chart.ts +91 -0
  13. package/src/cli.ts +146 -0
  14. package/src/commands/attach.ts +85 -0
  15. package/src/commands/config.ts +113 -0
  16. package/src/commands/dashboard.ts +26 -0
  17. package/src/commands/doctor.ts +104 -0
  18. package/src/commands/ls.ts +15 -0
  19. package/src/commands/new.ts +24 -0
  20. package/src/commands/resume.ts +14 -0
  21. package/src/commands/rm.ts +28 -0
  22. package/src/commands/roster.ts +37 -0
  23. package/src/commands/send.ts +79 -0
  24. package/src/commands/status.ts +35 -0
  25. package/src/commands/table.ts +75 -0
  26. package/src/commands/update.ts +72 -0
  27. package/src/commands/usage.ts +77 -0
  28. package/src/config/load.ts +335 -0
  29. package/src/config/schema.ts +100 -0
  30. package/src/core/children.ts +62 -0
  31. package/src/core/detect.ts +211 -0
  32. package/src/core/facts.ts +113 -0
  33. package/src/core/gate.ts +73 -0
  34. package/src/core/prelude.ts +121 -0
  35. package/src/core/session.ts +334 -0
  36. package/src/core/turn.ts +263 -0
  37. package/src/dashboard/page.ts +211 -0
  38. package/src/format.ts +98 -0
  39. package/src/paths.ts +33 -0
  40. package/src/pricing.ts +86 -0
  41. package/src/store/db.ts +78 -0
  42. package/src/store/queries.ts +434 -0
  43. package/src/types.ts +71 -0
@@ -0,0 +1,434 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { AgentId, Binding, Permission, Session } from '../types'
3
+ import type { ModelUsage } from '../pricing'
4
+
5
+ const now = () => Date.now()
6
+ const id = () => crypto.randomUUID()
7
+
8
+ export function createSession(
9
+ db: Database,
10
+ input: { slug: string; goal: string; cwd: string; lead: AgentId },
11
+ ): Session {
12
+ const row: Session = {
13
+ id: id(),
14
+ slug: input.slug,
15
+ goal: input.goal,
16
+ cwd: input.cwd,
17
+ lead: input.lead,
18
+ status: 'active',
19
+ createdAt: now(),
20
+ updatedAt: now(),
21
+ }
22
+ db.query(
23
+ `INSERT INTO session (id, slug, goal, cwd, lead, status, created_at, updated_at, updated_seq)
24
+ VALUES ($id, $slug, $goal, $cwd, $lead, $status, $createdAt, $updatedAt,
25
+ (SELECT COALESCE(MAX(updated_seq), 0) + 1 FROM session))`,
26
+ ).run({
27
+ id: row.id,
28
+ slug: row.slug,
29
+ goal: row.goal,
30
+ cwd: row.cwd,
31
+ lead: row.lead,
32
+ status: row.status,
33
+ createdAt: row.createdAt,
34
+ updatedAt: row.updatedAt,
35
+ })
36
+ return row
37
+ }
38
+
39
+ const toSession = (r: Record<string, unknown> | null): Session | null =>
40
+ r
41
+ ? {
42
+ id: r.id as string,
43
+ slug: r.slug as string,
44
+ goal: r.goal as string,
45
+ cwd: r.cwd as string,
46
+ lead: r.lead as AgentId,
47
+ status: r.status as Session['status'],
48
+ createdAt: r.created_at as number,
49
+ updatedAt: r.updated_at as number,
50
+ }
51
+ : null
52
+
53
+ export function getSessionBySlug(db: Database, slug: string): Session | null {
54
+ return toSession(db.query('SELECT * FROM session WHERE slug = $slug').get({ slug }) as never)
55
+ }
56
+
57
+ export function listSessions(db: Database): Session[] {
58
+ const rows = db.query('SELECT * FROM session ORDER BY created_at DESC, rowid DESC').all() as never[]
59
+ return rows.map((r) => toSession(r)!).filter(Boolean)
60
+ }
61
+
62
+ export function currentSession(db: Database, cwd: string): Session | null {
63
+ return toSession(
64
+ db.query('SELECT * FROM session WHERE cwd = $cwd AND status = $status ORDER BY updated_seq DESC LIMIT 1').get({
65
+ cwd,
66
+ status: 'active',
67
+ }) as never,
68
+ )
69
+ }
70
+
71
+ export function touchSession(db: Database, id: string): void {
72
+ db.query(
73
+ `UPDATE session SET updated_at = $now,
74
+ updated_seq = (SELECT COALESCE(MAX(updated_seq), 0) + 1 FROM session)
75
+ WHERE id = $id`,
76
+ ).run({ id, now: now() })
77
+ }
78
+
79
+ export function deleteSession(db: Database, id: string): void {
80
+ db.transaction(() => {
81
+ db.query('DELETE FROM event WHERE turn_id IN (SELECT id FROM turn WHERE session_id = $id)').run({ id })
82
+ db.query('DELETE FROM turn WHERE session_id = $id').run({ id })
83
+ db.query('DELETE FROM binding WHERE session_id = $id').run({ id })
84
+ db.query('DELETE FROM lock WHERE session_id = $id').run({ id })
85
+ db.query('DELETE FROM session WHERE id = $id').run({ id })
86
+ })()
87
+ }
88
+
89
+ // Every foreign id konvoy has met: UUIDs, kiro's cli_<uuid>_<suffix>, opencode's ses_…. An id
90
+ // is replayed onto a command line — after a flag by three adapters, as a bare positional by
91
+ // codex — so anything else, a leading dash above all, is refused here at the one write path.
92
+ const FOREIGN_ID = /^[A-Za-z0-9][\w.:-]*$/
93
+
94
+ export function upsertBinding(
95
+ db: Database,
96
+ input: {
97
+ sessionId: string
98
+ agent: AgentId
99
+ foreignId: string | null
100
+ effort: string
101
+ permission: Permission
102
+ model?: string | null
103
+ /** set by a turn that failed on auth — the roster then says which agent needs a login */
104
+ status?: 'auth_required'
105
+ },
106
+ ): void {
107
+ const foreignId = input.foreignId !== null && !FOREIGN_ID.test(input.foreignId) ? null : input.foreignId
108
+ if (foreignId === null && input.foreignId !== null) {
109
+ 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`,
111
+ )
112
+ }
113
+ db.query(
114
+ `INSERT INTO binding (session_id, agent, foreign_id, model, effort, permission, status, last_seen)
115
+ VALUES ($sessionId, $agent, $foreignId, $model, $effort, $permission, $status, $lastSeen)
116
+ ON CONFLICT (session_id, agent) DO UPDATE SET
117
+ foreign_id = COALESCE(excluded.foreign_id, binding.foreign_id),
118
+ model = COALESCE(excluded.model, binding.model),
119
+ effort = excluded.effort,
120
+ permission = excluded.permission,
121
+ status = CASE WHEN excluded.status = 'auth_required' THEN 'auth_required'
122
+ WHEN COALESCE(excluded.foreign_id, binding.foreign_id) IS NOT NULL
123
+ THEN 'bound' ELSE 'unbound' END,
124
+ last_seen = excluded.last_seen`,
125
+ ).run({
126
+ sessionId: input.sessionId,
127
+ agent: input.agent,
128
+ foreignId,
129
+ model: input.model ?? null,
130
+ effort: input.effort,
131
+ permission: input.permission,
132
+ status: input.status ?? (foreignId ? 'bound' : 'unbound'),
133
+ lastSeen: now(),
134
+ })
135
+ }
136
+
137
+ const toBinding = (r: Record<string, unknown> | null): Binding | null =>
138
+ r
139
+ ? {
140
+ sessionId: r.session_id as string,
141
+ agent: r.agent as AgentId,
142
+ foreignId: (r.foreign_id as string | null) ?? null,
143
+ model: (r.model as string | null) ?? null,
144
+ effort: r.effort as string,
145
+ permission: r.permission as Permission,
146
+ status: r.status as Binding['status'],
147
+ turns: r.turns as number,
148
+ costUsd: r.cost_usd as number,
149
+ credits: r.credits as number,
150
+ lastSeen: (r.last_seen as number | null) ?? null,
151
+ }
152
+ : null
153
+
154
+ export function getBinding(db: Database, sessionId: string, agent: AgentId): Binding | null {
155
+ return toBinding(
156
+ db.query('SELECT * FROM binding WHERE session_id = $sessionId AND agent = $agent').get({
157
+ sessionId,
158
+ agent,
159
+ }) as never,
160
+ )
161
+ }
162
+
163
+ export function listBindings(db: Database, sessionId: string): Binding[] {
164
+ const rows = db.query('SELECT * FROM binding WHERE session_id = $sessionId ORDER BY agent').all({
165
+ sessionId,
166
+ }) as never[]
167
+ return rows.map((r) => toBinding(r)!).filter(Boolean)
168
+ }
169
+
170
+ // one query for every session's bound-agent count, instead of one `listBindings` per session
171
+ export function boundBindingCounts(db: Database): Map<string, number> {
172
+ const rows = db
173
+ .query('SELECT session_id, COUNT(*) AS bound FROM binding WHERE foreign_id IS NOT NULL GROUP BY session_id')
174
+ .all() as { session_id: string; bound: number }[]
175
+ return new Map(rows.map((r) => [r.session_id, r.bound]))
176
+ }
177
+
178
+ export function clearForeignId(db: Database, sessionId: string, agent: AgentId): void {
179
+ db.query(
180
+ "UPDATE binding SET foreign_id = NULL, status = 'unbound' WHERE session_id = $sessionId AND agent = $agent",
181
+ ).run({ sessionId, agent })
182
+ }
183
+
184
+ export function recordTurn(
185
+ db: Database,
186
+ input: {
187
+ sessionId: string
188
+ agent: AgentId
189
+ prompt: string
190
+ final: string
191
+ costUsd: number
192
+ exitCode: number
193
+ error?: string | null
194
+ credits?: number
195
+ inputTokens?: number
196
+ outputTokens?: number
197
+ kind?: string | null
198
+ model?: string | null
199
+ parentTurnId?: string | null
200
+ },
201
+ ): string {
202
+ const turnId = id()
203
+ db.query(
204
+ `INSERT INTO turn (id, session_id, agent, prompt, final, cost_usd, credits, input_tokens,
205
+ output_tokens, kind, gate_passed, exit_code, error, error_kind, started_at, ended_at, model,
206
+ parent_turn_id)
207
+ VALUES ($id, $sessionId, $agent, $prompt, $final, $costUsd, $credits, $inputTokens,
208
+ $outputTokens, $kind, NULL, $exitCode, $error, NULL, $startedAt, $endedAt, $model,
209
+ $parentTurnId)`,
210
+ ).run({
211
+ id: turnId,
212
+ sessionId: input.sessionId,
213
+ agent: input.agent,
214
+ prompt: input.prompt,
215
+ final: input.final,
216
+ costUsd: input.costUsd,
217
+ credits: input.credits ?? 0,
218
+ inputTokens: input.inputTokens ?? 0,
219
+ outputTokens: input.outputTokens ?? 0,
220
+ kind: input.kind ?? null,
221
+ exitCode: input.exitCode,
222
+ error: input.error ?? null,
223
+ startedAt: now(),
224
+ endedAt: now(),
225
+ model: input.model ?? null,
226
+ parentTurnId: input.parentTurnId ?? null,
227
+ })
228
+ return turnId
229
+ }
230
+
231
+ export function lastTurnId(db: Database, sessionId: string): string | null {
232
+ const row = db.query('SELECT id FROM turn WHERE session_id = $sessionId ORDER BY rowid DESC LIMIT 1').get({
233
+ sessionId,
234
+ }) as { id: string } | null
235
+ return row?.id ?? null
236
+ }
237
+
238
+ // Who actually produced the most recent turn — which, after a failover move, is not
239
+ // necessarily the agent `send()` was originally asked to run.
240
+ export function lastTurnAgent(db: Database, sessionId: string): AgentId | null {
241
+ const row = db.query('SELECT agent FROM turn WHERE session_id = $sessionId ORDER BY rowid DESC LIMIT 1').get({
242
+ sessionId,
243
+ }) as { agent: AgentId } | null
244
+ return row?.agent ?? null
245
+ }
246
+
247
+ export function recordEvent(db: Database, turnId: string, seq: number, type: string, payload: unknown): void {
248
+ db.query('INSERT INTO event (turn_id, seq, type, payload, ts) VALUES ($turnId, $seq, $type, $payload, $ts)').run({
249
+ turnId,
250
+ seq,
251
+ type,
252
+ payload: JSON.stringify(payload),
253
+ ts: now(),
254
+ })
255
+ }
256
+
257
+ function isAlive(pid: number): boolean {
258
+ try {
259
+ process.kill(pid, 0)
260
+ return true
261
+ } catch {
262
+ return false
263
+ }
264
+ }
265
+
266
+ export function lockOwner(db: Database, sessionId: string): string | null {
267
+ const row = db.query('SELECT owner, pid FROM lock WHERE session_id = $sessionId').get({ sessionId }) as
268
+ | { owner: string; pid: number }
269
+ | null
270
+ if (!row) return null
271
+ return isAlive(row.pid) ? row.owner : null
272
+ }
273
+
274
+ export function reclaimStaleLock(db: Database, sessionId: string, owner: string, pid: number): boolean {
275
+ const res = db
276
+ .query('DELETE FROM lock WHERE session_id = $sessionId AND owner = $owner AND pid = $pid')
277
+ .run({ sessionId, owner, pid })
278
+ return res.changes > 0
279
+ }
280
+
281
+ export function acquireLock(db: Database, sessionId: string, owner: string): boolean {
282
+ const row = db.query('SELECT owner, pid FROM lock WHERE session_id = $sessionId').get({ sessionId }) as
283
+ | { owner: string; pid: number }
284
+ | null
285
+ if (row) {
286
+ if (row.owner === owner) {
287
+ // a lease inherited from a parent that died keeps the parent's pid; adopt this one so no
288
+ // third process reads the lock as stale and reclaims the session mid-turn
289
+ db.query('UPDATE lock SET pid = $pid WHERE session_id = $sessionId').run({ pid: process.pid, sessionId })
290
+ return true
291
+ }
292
+ if (isAlive(row.pid)) return false
293
+ reclaimStaleLock(db, sessionId, row.owner, row.pid)
294
+ }
295
+ const res = db
296
+ .query(
297
+ `INSERT INTO lock (session_id, owner, pid, acquired_at) VALUES ($sessionId, $owner, $pid, $at)
298
+ ON CONFLICT (session_id) DO NOTHING`,
299
+ )
300
+ .run({ sessionId, owner, pid: process.pid, at: now() })
301
+ return res.changes > 0
302
+ }
303
+
304
+ export function releaseLock(db: Database, sessionId: string, owner: string): void {
305
+ db.query('DELETE FROM lock WHERE session_id = $sessionId AND owner = $owner').run({ sessionId, owner })
306
+ }
307
+
308
+ export function setGateResult(db: Database, turnId: string, passed: boolean): void {
309
+ db.query('UPDATE turn SET gate_passed = $passed WHERE id = $turnId').run({ turnId, passed: passed ? 1 : 0 })
310
+ }
311
+
312
+ export function turnExitCode(db: Database, turnId: string): number | null {
313
+ const row = db.query('SELECT exit_code FROM turn WHERE id = $turnId').get({ turnId }) as { exit_code: number } | null
314
+ return row ? row.exit_code : null
315
+ }
316
+
317
+ export function bumpBinding(
318
+ db: Database,
319
+ sessionId: string,
320
+ agent: AgentId,
321
+ costUsd: number,
322
+ credits = 0,
323
+ ): void {
324
+ db.query(
325
+ `UPDATE binding SET turns = turns + 1, cost_usd = cost_usd + $costUsd, credits = credits + $credits,
326
+ last_seen = $lastSeen WHERE session_id = $sessionId AND agent = $agent`,
327
+ ).run({ sessionId, agent, costUsd, credits, lastSeen: now() })
328
+ }
329
+
330
+ export interface UsageRow {
331
+ agent: AgentId
332
+ turns: number
333
+ inputTokens: number
334
+ outputTokens: number
335
+ costUsd: number
336
+ credits: number
337
+ gatePassed: number
338
+ gateKnown: number
339
+ }
340
+
341
+ const USAGE_COLUMNS = `agent,
342
+ COUNT(*) AS turns,
343
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
344
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
345
+ COALESCE(SUM(cost_usd), 0) AS cost_usd,
346
+ COALESCE(SUM(credits), 0) AS credits,
347
+ COALESCE(SUM(CASE WHEN gate_passed = 1 THEN 1 ELSE 0 END), 0) AS gate_passed,
348
+ COALESCE(SUM(CASE WHEN gate_passed IS NULL THEN 0 ELSE 1 END), 0) AS gate_known`
349
+
350
+ const toUsage = (r: Record<string, unknown>): UsageRow => ({
351
+ agent: r.agent as AgentId,
352
+ turns: r.turns as number,
353
+ inputTokens: r.input_tokens as number,
354
+ outputTokens: r.output_tokens as number,
355
+ costUsd: r.cost_usd as number,
356
+ credits: r.credits as number,
357
+ gatePassed: r.gate_passed as number,
358
+ gateKnown: r.gate_known as number,
359
+ })
360
+
361
+ export function usageForSession(db: Database, sessionId: string): UsageRow[] {
362
+ const rows = db
363
+ .query(`SELECT ${USAGE_COLUMNS} FROM turn WHERE session_id = $sessionId GROUP BY agent ORDER BY agent`)
364
+ .all({ sessionId }) as Record<string, unknown>[]
365
+ return rows.map(toUsage)
366
+ }
367
+
368
+ export function usageAcrossSessions(db: Database): UsageRow[] {
369
+ const rows = db
370
+ .query(`SELECT ${USAGE_COLUMNS} FROM turn GROUP BY agent ORDER BY agent`)
371
+ .all() as Record<string, unknown>[]
372
+ return rows.map(toUsage)
373
+ }
374
+
375
+ // same COALESCE/SUM style as USAGE_COLUMNS, on purpose: this and the per-agent totals above
376
+ // must never treat a null differently, or the two views of the same turns would disagree
377
+ const USAGE_BY_MODEL_COLUMNS = `agent,
378
+ model,
379
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
380
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
381
+ COALESCE(SUM(cost_usd), 0) AS cost_usd,
382
+ COALESCE(SUM(credits), 0) AS credits`
383
+
384
+ export function usageByAgentModel(db: Database, sessionId?: string): ModelUsage[] {
385
+ const sql = sessionId
386
+ ? `SELECT ${USAGE_BY_MODEL_COLUMNS} FROM turn
387
+ WHERE session_id = $sessionId GROUP BY agent, model ORDER BY agent, model`
388
+ : `SELECT ${USAGE_BY_MODEL_COLUMNS} FROM turn GROUP BY agent, model ORDER BY agent, model`
389
+ const rows = (sessionId ? db.query(sql).all({ sessionId }) : db.query(sql).all()) as Record<string, unknown>[]
390
+ return rows.map((r) => ({
391
+ agent: r.agent as AgentId,
392
+ model: (r.model as string | null) ?? null,
393
+ inputTokens: r.input_tokens as number,
394
+ outputTokens: r.output_tokens as number,
395
+ costUsd: r.cost_usd as number,
396
+ credits: r.credits as number,
397
+ }))
398
+ }
399
+
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.
402
+ function localDay(ms: number): string {
403
+ const d = new Date(ms)
404
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
405
+ }
406
+
407
+ function startedAt(db: Database, sessionId?: string): { agent: AgentId; started_at: number }[] {
408
+ const sql = sessionId
409
+ ? 'SELECT agent, started_at FROM turn WHERE session_id = $sessionId'
410
+ : 'SELECT agent, started_at FROM turn'
411
+ return (sessionId ? db.query(sql).all({ sessionId }) : db.query(sql).all()) as { agent: AgentId; started_at: number }[]
412
+ }
413
+
414
+ function countBy<K extends string>(keys: K[]): Map<K, number> {
415
+ const counts = new Map<K, number>()
416
+ for (const k of keys) counts.set(k, (counts.get(k) ?? 0) + 1)
417
+ return new Map([...counts].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
418
+ }
419
+
420
+ export function turnsPerDay(db: Database, sessionId?: string): { day: string; count: number }[] {
421
+ const counts = countBy(startedAt(db, sessionId).map((r) => localDay(r.started_at)))
422
+ return [...counts].map(([day, count]) => ({ day, count }))
423
+ }
424
+
425
+ export function turnsPerDayByAgent(
426
+ db: Database,
427
+ sessionId?: string,
428
+ ): { agent: AgentId; day: string; count: number }[] {
429
+ const counts = countBy(startedAt(db, sessionId).map((r) => `${r.agent}\u0000${localDay(r.started_at)}`))
430
+ return [...counts].map(([key, count]) => {
431
+ const [agent, day] = key.split('\u0000') as [AgentId, string]
432
+ return { agent, day, count }
433
+ })
434
+ }
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { AgentId, Effort, Permission, Style } from './config/schema'
2
+
3
+ export type { AgentId, Effort, Permission, Style }
4
+
5
+ export type KonvoyEvent =
6
+ | { t: 'session'; foreignId: string }
7
+ | { t: 'text'; text: string }
8
+ | { t: 'thinking'; text: string }
9
+ | { t: 'tool'; name: string; status: 'start' | 'ok' | 'error' }
10
+ | { t: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; credits?: number }
11
+ | {
12
+ t: 'error'
13
+ message: string
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 */
16
+ source?: string
17
+ }
18
+ | { t: 'done'; final: string }
19
+
20
+ export type BindingStatus = 'unbound' | 'bound' | 'auth_required'
21
+
22
+ export interface Binding {
23
+ sessionId: string
24
+ agent: AgentId
25
+ foreignId: string | null
26
+ model: string | null
27
+ effort: string
28
+ permission: Permission
29
+ status: BindingStatus
30
+ turns: number
31
+ costUsd: number
32
+ credits: number
33
+ lastSeen: number | null
34
+ }
35
+
36
+ export interface Session {
37
+ id: string
38
+ slug: string
39
+ goal: string
40
+ cwd: string
41
+ lead: AgentId
42
+ status: 'active'
43
+ createdAt: number
44
+ updatedAt: number
45
+ }
46
+
47
+ export interface SpawnPlan {
48
+ cmd: string[]
49
+ env?: Record<string, string>
50
+ cwd?: string
51
+ stdin?: string
52
+ }
53
+
54
+ export interface TurnContext {
55
+ sessionId: string
56
+ slug: string
57
+ cwd: string
58
+ sessionDir: string
59
+ prompt: string
60
+ prelude?: string
61
+ binding: Binding | null
62
+ model?: string
63
+ effort: string
64
+ permission: Permission
65
+ harness?: 'minimal' | 'inherit'
66
+ bin?: string
67
+ lease?: string
68
+ kind?: string
69
+ style?: Style
70
+ delegation?: boolean
71
+ }