mirab 0.0.0 → 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.
Files changed (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +223 -3
  3. package/package.json +56 -7
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
  106. package/index.js +0 -1
package/src/dev.ts ADDED
@@ -0,0 +1,453 @@
1
+ // `mirab dev`: the agent in a project, running in its own VM on this
2
+ // machine (docs/architecture.md section 3). The VM has no network of its own;
3
+ // its only way out is the station, which lets it reach the manifest's
4
+ // `domains` and adds the model key from harar on the host. The chat is a
5
+ // channel the station hosts (src/channel): the terminal is one client of it,
6
+ // the agent loop in the guest another.
7
+ import { randomBytes } from 'node:crypto'
8
+ import { userInfo } from 'node:os'
9
+ import { clearLine, createInterface, cursorTo } from 'node:readline'
10
+ import { mkdtemp } from 'node:fs/promises'
11
+ import { join, resolve } from 'node:path'
12
+ import { MANIFEST_LINE } from './agent/guest'
13
+ import { bridgeAgent } from './channel/bridge'
14
+ import { projectAgent } from 'rixse'
15
+ import { printable } from './channel/wire'
16
+ import {
17
+ openChannel,
18
+ type LocalChannel,
19
+ type Member,
20
+ type Message,
21
+ } from './channel/local'
22
+ import { withinApproval } from './agent/approved'
23
+ import { parseManifest, type Manifest } from './agent/manifest'
24
+ import { startVm, type Isolation, type VmConfig } from './isolation'
25
+ import { agentImage } from './images'
26
+ import { liveSync, type LiveSync } from './dev-sync'
27
+ import { describeSync, hostManifest, syncBack } from './isolation/sync'
28
+ import type { PtyHandle } from './isolation/session'
29
+ import { finishVmSync, guestEnvironment, requireVm } from './spawn-vm'
30
+ import { privateSessionsDirectory } from './spawn-sessions'
31
+ import type { StationConfig } from './station/config'
32
+ import type { Upstream } from 'rixse'
33
+ import { GUEST_PROXY, vmStation } from './station/gateway'
34
+ import { PLACEHOLDER, anthropicKeyRoute, guestRouteURL } from 'rixse'
35
+ import { hararVault } from './station/vault'
36
+
37
+ /** Where the loop lands in the guest (its tmpfs, never the workspace). */
38
+ export const GUEST_LOOP = '/tmp/mirab/loop.js'
39
+
40
+ let bundle: Promise<string> | undefined
41
+
42
+ /** The agent loop and everything it needs, as one file for the guest's Bun. */
43
+ export function guestLoop(): Promise<string> {
44
+ return (bundle ??= Bun.build({
45
+ entrypoints: [join(import.meta.dir, 'agent/guest.ts')],
46
+ target: 'bun',
47
+ minify: true,
48
+ }).then(async (built) => {
49
+ if (!built.success || built.outputs.length !== 1)
50
+ throw new Error(
51
+ `the agent loop didn't build: ${built.logs.map(String).join('; ')}`,
52
+ )
53
+ return built.outputs[0].text()
54
+ }))
55
+ }
56
+
57
+ export interface Dev {
58
+ pty: PtyHandle
59
+ iso: Isolation
60
+ /** The zone this VM is, in the station's audit log. */
61
+ zone: string
62
+ /** The person's edits into the VM as they save. */
63
+ live: LiveSync
64
+ /** The chat: a channel the station hosts, between the person and the agent. */
65
+ channel: LocalChannel
66
+ /** The person's and the agent's member names. */
67
+ person: string
68
+ agent: string
69
+ /** Says `text` in the channel as the person. */
70
+ say(text: string): Message
71
+ close(): Promise<void>
72
+ }
73
+
74
+ /** The person at this terminal, as a channel member: `station.json`'s
75
+ * `human`, or this login in the agent's org, which nothing has vouched for. */
76
+ export function personMember(
77
+ station: StationConfig,
78
+ org: string,
79
+ login = userInfo().username,
80
+ ): Member {
81
+ if (station.human) return { name: station.human, can: 'speak' }
82
+ const local =
83
+ login
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9-]+/g, '-')
86
+ .slice(0, 63)
87
+ .replace(/^-+|-+$/g, '') || 'person'
88
+ return { name: `${org}.human.${local}`, can: 'speak', unverified: true }
89
+ }
90
+
91
+ /** Reads the manifest the guest printed; the host trusts only what the schema allows. */
92
+ export function manifestFromGuest(lines: string[]): Manifest {
93
+ const line = lines.find((l) => l.startsWith(MANIFEST_LINE))
94
+ if (!line)
95
+ throw new Error(
96
+ `the agent didn't load in its VM: ${printable(lines.slice(-5).join(' ').slice(0, 2000))}`,
97
+ )
98
+ let data: unknown
99
+ try {
100
+ data = JSON.parse(line.slice(MANIFEST_LINE.length))
101
+ } catch {
102
+ throw new Error("the VM's manifest isn't JSON")
103
+ }
104
+ return parseManifest(data, 'the agent in its VM')
105
+ }
106
+
107
+ /** The agent's manifest, read in the VM by the bundled loop: the agent's own
108
+ * files run there, never on the host, which only checks the JSON. */
109
+ export async function readManifestIn(iso: Isolation): Promise<Manifest> {
110
+ const lines: string[] = []
111
+ await iso.exec(
112
+ ['bun', '--no-install', GUEST_LOOP, '--manifest', '/workspace'],
113
+ {
114
+ cwd: '/workspace',
115
+ env: guestEnvironment({}, {}),
116
+ timeoutMs: 60_000,
117
+ signal: new AbortController().signal,
118
+ onLine: async (line) => void lines.push(line),
119
+ },
120
+ )
121
+ return manifestFromGuest(lines)
122
+ }
123
+
124
+ /** The manifest of the agent in `project`, read in a VM that boots only for it. */
125
+ export async function agentManifest(o: {
126
+ project: string
127
+ configDir: string
128
+ log?: (line: string) => void
129
+ }): Promise<{ manifest: Manifest; vm: VmConfig }> {
130
+ const vm = await requireVm(o.configDir, o.log)
131
+ const vmDir = await mkdtemp(
132
+ join(await privateSessionsDirectory(), 'manifest-'),
133
+ )
134
+ const iso = await startVm(
135
+ { ...vm, egress: 'block' },
136
+ resolve(o.project),
137
+ vmDir,
138
+ o.log,
139
+ )
140
+ try {
141
+ await iso.put(GUEST_LOOP, await guestLoop(), 0o600)
142
+ return {
143
+ // Looking, or building an image, approves nothing.
144
+ manifest: withinApproval(
145
+ o.configDir,
146
+ resolve(o.project),
147
+ await readManifestIn(iso),
148
+ { record: false, log: o.log },
149
+ ),
150
+ vm,
151
+ }
152
+ } finally {
153
+ await iso.close()
154
+ }
155
+ }
156
+
157
+ /** `<org>.zone.dev-<random>`, in the agent's org. */
158
+ export function devZone(agentName: string): string {
159
+ const org = /^(.+)\.agent\.[^.]+$/.exec(agentName)?.[1]
160
+ if (!org) throw new Error(`${agentName} isn't an agent name`)
161
+ return `${org}.zone.dev-${randomBytes(6).toString('hex')}`
162
+ }
163
+
164
+ export async function startDev(o: {
165
+ project: string
166
+ configDir: string
167
+ station: StationConfig
168
+ cols: number
169
+ rows: number
170
+ /** Where the egress decisions go (JSON lines). */
171
+ auditLog: string
172
+ log?: (line: string) => void
173
+ /** Where the model route goes; tests point it at a stand-in. */
174
+ upstream?: string
175
+ /** Where the hosts the station opens are; tests point them at a stand-in. */
176
+ opened?: (host: string) => Promise<Upstream>
177
+ }): Promise<Dev> {
178
+ const project = resolve(o.project)
179
+ const anthropic = anthropicKeyRoute(
180
+ hararVault(o.station.harar.socket),
181
+ o.station.models.anthropic?.key,
182
+ o.upstream,
183
+ )
184
+ if (!anthropic)
185
+ throw new Error(
186
+ 'station.json has no models.anthropic.key: the harar secret with the API key',
187
+ )
188
+ const vm = await requireVm(o.configDir, o.log)
189
+ const loop = await guestLoop()
190
+ let vmDir = ''
191
+ const boot = async (rootfs: string) => {
192
+ vmDir = await mkdtemp(join(await privateSessionsDirectory(), 'dev-'))
193
+ // No network of its own: the station is the only way out. The VM works on
194
+ // a clone of the project; nothing it writes comes back.
195
+ const vmIso = await startVm(
196
+ { ...vm, rootfs, egress: 'block' },
197
+ project,
198
+ vmDir,
199
+ o.log,
200
+ )
201
+ try {
202
+ await vmIso.put(GUEST_LOOP, loop, 0o600)
203
+ } catch (e) {
204
+ await vmIso.close()
205
+ throw e
206
+ }
207
+ return vmIso
208
+ }
209
+ // What the VM starts from: the baseline live sync and sync-back compare against.
210
+ const baseline = await hostManifest(project)
211
+ let iso = await boot(vm.rootfs)
212
+ let closeStation = async () => {}
213
+ try {
214
+ // The agent's own files run in the VM, never here.
215
+ // As the agent's code says it now, within what the person approved.
216
+ const manifest = withinApproval(
217
+ o.configDir,
218
+ project,
219
+ await readManifestIn(iso),
220
+ { record: true, log: o.log },
221
+ )
222
+ if (!manifest.model.startsWith('anthropic/'))
223
+ throw new Error(
224
+ `mirab dev talks to anthropic models for now; ${manifest.model} isn't one`,
225
+ )
226
+ // An agent with packages runs on its own image: cached, or built now.
227
+ if (manifest.packages.length) {
228
+ await iso.close()
229
+ const rootfs = await agentImage({
230
+ configDir: o.configDir,
231
+ vm,
232
+ packages: manifest.packages,
233
+ log: o.log,
234
+ })
235
+ iso = await boot(rootfs)
236
+ }
237
+ const zone = devZone(manifest.name)
238
+ const org = manifest.name.replace(/\.agent\.[^.]+$/, '')
239
+ const person = personMember(o.station, org)
240
+ const channel = openChannel({
241
+ dir: join(await privateSessionsDirectory(), 'channels'),
242
+ org,
243
+ members: [person, { name: manifest.name, can: 'speak' }],
244
+ })
245
+ const station = await vmStation({
246
+ project,
247
+ station: o.station,
248
+ configDir: o.configDir,
249
+ zone,
250
+ auditLog: o.auditLog,
251
+ routes: { anthropic },
252
+ allow: manifest.domains,
253
+ // Its rules and keys as the person approved them (withinApproval).
254
+ config: manifest.gateway,
255
+ // Whose grants (secrets, hosts asked for): the project folder on this
256
+ // machine, as spawn --vm has it. Never the manifest's name, which the
257
+ // agent's own code says in the VM: a cloned repo could claim a name the
258
+ // person trusted.
259
+ agent: projectAgent(project),
260
+ upstream: o.opened,
261
+ })
262
+ for (const note of station.notes) o.log?.(note)
263
+ await station.attach(iso)
264
+ closeStation = () => station.close()
265
+ const pty = await iso.pty!({
266
+ // No auto-install: what the agent runs is in its image (its manifest's packages).
267
+ argv: [
268
+ 'bun',
269
+ '--no-install',
270
+ GUEST_LOOP,
271
+ '/workspace',
272
+ `${guestRouteURL(GUEST_PROXY, 'anthropic')}/v1`,
273
+ PLACEHOLDER,
274
+ ],
275
+ env: { ...guestEnvironment({}, {}), ...station.env },
276
+ cwd: '/workspace',
277
+ cols: o.cols,
278
+ rows: o.rows,
279
+ })
280
+ const exited = new Promise<void>((r) => pty.onExit(() => r()))
281
+ const unbridge = bridgeAgent({
282
+ channel,
283
+ agent: manifest.name,
284
+ pty,
285
+ log: o.log,
286
+ })
287
+ const live = liveSync({ project, iso, baseline, log: o.log })
288
+ const close = async () => {
289
+ // The agent stops first, so what comes back is what it finished; a
290
+ // push under way ends before sync-back starts.
291
+ try {
292
+ pty.close()
293
+ } catch {}
294
+ await Promise.race([exited, Bun.sleep(5_000)])
295
+ unbridge()
296
+ await live.stop()
297
+ const conflictDir = join(
298
+ await privateSessionsDirectory(),
299
+ `${zone}.conflicts`,
300
+ )
301
+ // What the agent changed comes back; where the person changed the same
302
+ // path, theirs stays and the agent's is saved aside. Anything that
303
+ // couldn't come back keeps the VM's disk for recovery.
304
+ try {
305
+ const synced = await finishVmSync(
306
+ () =>
307
+ syncBack({
308
+ root: project,
309
+ guestRoot: '/workspace',
310
+ files: iso.files!,
311
+ baseline,
312
+ conflictDir,
313
+ }),
314
+ iso,
315
+ vmDir,
316
+ (line) => o.log?.(line),
317
+ )
318
+ o.log?.(`back from the VM: ${describeSync(synced)}`)
319
+ } catch (e) {
320
+ o.log?.(
321
+ `the agent's changes couldn't all come back: ${(e as Error).message}`,
322
+ )
323
+ }
324
+ await closeStation()
325
+ }
326
+ return {
327
+ pty,
328
+ iso,
329
+ zone,
330
+ close,
331
+ live,
332
+ channel,
333
+ person: person.name,
334
+ agent: manifest.name,
335
+ say: (text) => channel.post(person.name, text),
336
+ }
337
+ } catch (e) {
338
+ await closeStation()
339
+ await iso.close().catch(() => {})
340
+ throw e
341
+ }
342
+ }
343
+
344
+ /** Handled while mirab dev runs: being told to stop. The VM is closed either way. */
345
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
346
+
347
+ /**
348
+ * The chat in a terminal: one client of its channel. What the person types is
349
+ * said as them; what the agent says is shown. Ctrl-D, or input that ends,
350
+ * ends the chat once everything said is answered; Ctrl-C leaves at once.
351
+ */
352
+ export function terminalChat(
353
+ d: Pick<Dev, 'channel' | 'person' | 'agent' | 'say'> & {
354
+ pty: Pick<PtyHandle, 'onExit'>
355
+ },
356
+ input: NodeJS.ReadableStream & { isTTY?: boolean },
357
+ out: NodeJS.WritableStream,
358
+ ): { done: Promise<number>; leave(code: number): void } {
359
+ let leave: (code: number) => void = () => {}
360
+ const done = new Promise<number>((r) => (leave = r))
361
+ const local = d.agent.split('.').at(-1)
362
+ const tty = Boolean(input.isTTY)
363
+ const chat = createInterface({
364
+ input,
365
+ output: out,
366
+ prompt: 'you> ',
367
+ terminal: tty,
368
+ })
369
+ const waiting = new Set<string>()
370
+ let ending = false
371
+ const settle = () => ending && !waiting.size && leave(0)
372
+ const off = d.channel.onMessage((m) => {
373
+ if (m.from === d.person) return
374
+ if (m.answers) waiting.delete(m.answers)
375
+ if (tty) {
376
+ clearLine(out, 0)
377
+ cursorTo(out, 0)
378
+ }
379
+ // The agent's text is the VM's: shown, never obeyed by the terminal.
380
+ const text = printable(m.text)
381
+ out.write(m.note ? `(${text})\n` : `${local}> ${text}\n`)
382
+ if (!ending && tty) chat.prompt(true)
383
+ settle()
384
+ })
385
+ chat.on('line', (line) => {
386
+ if (line.trim())
387
+ try {
388
+ waiting.add(d.say(line).id)
389
+ } catch (e) {
390
+ out.write(`(${(e as Error).message})\n`)
391
+ }
392
+ if (!ending && tty) chat.prompt()
393
+ })
394
+ chat.on('close', () => {
395
+ ending = true
396
+ settle()
397
+ })
398
+ chat.on('SIGINT', () => leave(130))
399
+ d.pty.onExit(leave)
400
+ if (tty) chat.prompt()
401
+ void done.then(() => {
402
+ off()
403
+ chat.close()
404
+ })
405
+ return { done, leave: (code) => leave(code) }
406
+ }
407
+
408
+ /** `mirab dev` in a terminal. */
409
+ export async function devInTerminal(
410
+ options: Omit<Parameters<typeof startDev>[0], 'cols' | 'rows'>,
411
+ ): Promise<number> {
412
+ let dev: Dev | undefined
413
+ // Being told to stop works while the VM boots too: the boot finishes and
414
+ // the VM is closed, but the chat never opens.
415
+ let stopped: (code: number) => void = () => {}
416
+ const stopping = new Promise<number>((r) => (stopped = r))
417
+ let leave = stopped
418
+ const onSignal = () => leave(143)
419
+ for (const signal of SIGNALS) process.on(signal, onSignal)
420
+ const starting = startDev({ ...options, cols: 80, rows: 24 })
421
+ try {
422
+ const first = await Promise.race([starting, stopping])
423
+ if (typeof first === 'number') {
424
+ // Stopped while it boots: the VM is closed once it is up. Asked again,
425
+ // mirab leaves without waiting, and the VM stops as mirab exits (vz's
426
+ // helper when its input closes, Firecracker on process exit).
427
+ options.log?.(
428
+ 'stopping the VM once it is up; Ctrl-C again leaves without waiting',
429
+ )
430
+ const again = new Promise<number>((r) => (leave = r))
431
+ return await Promise.race([
432
+ starting
433
+ .then((d) => d.close())
434
+ .then(
435
+ () => first,
436
+ () => first,
437
+ ),
438
+ again,
439
+ ])
440
+ }
441
+ dev = first
442
+ options.log?.(
443
+ `This chat is ${dev.channel.id}, kept at ${dev.channel.path}. Ctrl-D ends it; Ctrl-C leaves at once.`,
444
+ )
445
+ const chat = terminalChat(dev, process.stdin, process.stdout)
446
+ leave = chat.leave
447
+ return await chat.done
448
+ } finally {
449
+ for (const signal of SIGNALS) process.off(signal, onSignal)
450
+ process.stdin.pause()
451
+ await dev?.close()
452
+ }
453
+ }
@@ -0,0 +1,75 @@
1
+ import { ApiRequestError, type GildClient } from './api/client'
2
+ import type { StreamEvent } from './api/events-contract'
3
+
4
+ export function waitForEvents(ms: number, signal: AbortSignal) {
5
+ return new Promise<void>((resolve) => {
6
+ const finish = () => {
7
+ clearTimeout(timer)
8
+ signal.removeEventListener('abort', finish)
9
+ resolve()
10
+ }
11
+ const timer = setTimeout(finish, ms)
12
+ signal.addEventListener('abort', finish, { once: true })
13
+ if (signal.aborted) finish()
14
+ })
15
+ }
16
+ export async function tailEvents(
17
+ client: Pick<GildClient, 'request'>,
18
+ opts: {
19
+ repo?: string
20
+ since?: string
21
+ once?: boolean
22
+ raw?: boolean
23
+ /** Sees every page (even an empty one) before the loop moves on. */
24
+ onPage?: (page: {
25
+ events: StreamEvent[]
26
+ cursor: string
27
+ }) => void | Promise<void>
28
+ },
29
+ signal: AbortSignal,
30
+ output: (line: string) => void = console.log,
31
+ cursorOutput: (line: string) => void = console.error,
32
+ pause: typeof waitForEvents = waitForEvents,
33
+ ) {
34
+ let since = opts.since,
35
+ backoff = 500
36
+ while (!signal.aborted) {
37
+ try {
38
+ const page = await client.request(
39
+ 'events',
40
+ {},
41
+ undefined,
42
+ { repos: opts.repo, since, wait: opts.once ? 0 : 20000 },
43
+ { signal },
44
+ )
45
+ await opts.onPage?.(page)
46
+ for (const event of page.events)
47
+ output(
48
+ JSON.stringify(
49
+ !opts.raw && event.event === 'agent_request'
50
+ ? { ...event, payload: {} }
51
+ : event,
52
+ ),
53
+ )
54
+ since = page.cursor
55
+ if (opts.once) {
56
+ cursorOutput('cursor: ' + since)
57
+ return
58
+ }
59
+ if (page.events.length) {
60
+ backoff = 500
61
+ continue
62
+ }
63
+ } catch (error) {
64
+ if (signal.aborted) return
65
+ if (!(
66
+ error instanceof TypeError ||
67
+ (error instanceof ApiRequestError &&
68
+ (error.status >= 500 || error.status === 429 || error.status === 408))
69
+ ))
70
+ throw error
71
+ }
72
+ await pause(backoff, signal)
73
+ backoff = Math.min(backoff * 2, 10000)
74
+ }
75
+ }