mirab 0.0.0-stage → 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 (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -2
  3. package/package.json +58 -4
  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
@@ -0,0 +1,504 @@
1
+ import { readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { z } from 'zod'
3
+ import type { GildClient } from './api/client'
4
+ import { tailEvents, waitForEvents } from './events-tail'
5
+ import { parseTrigger, type AgentTrigger } from './agent-profiles'
6
+ import type { ChannelStatus } from './spawn-sessions'
7
+
8
+ /** The agent identity and server a session listens as. It reaches the worker
9
+ * over IPC, never argv or env, the same rule spawn-report follows. */
10
+ export type BridgeTarget = { server: string; token: string; agent: string }
11
+
12
+ export type MentionStage = 'received' | 'queued' | 'delivered' | 'failed'
13
+ /** Local events-stream records. They never change the agent's busy/idle state. */
14
+ export type BridgeEvent =
15
+ | {
16
+ type: 'mention'
17
+ stage: MentionStage
18
+ session: string
19
+ repo: string
20
+ message: string
21
+ id: string
22
+ author: string
23
+ ts: string
24
+ error?: string
25
+ }
26
+ | {
27
+ type: 'trigger'
28
+ stage: MentionStage
29
+ session: string
30
+ repo: string
31
+ /** The spec that fired, e.g. issues.labeled:triage. */
32
+ trigger: string
33
+ event: string
34
+ action: string
35
+ /** Stream event id; namespaced before persisting. */
36
+ id: string
37
+ number?: number
38
+ title?: string
39
+ label?: string
40
+ actor: string
41
+ ts: string
42
+ error?: string
43
+ }
44
+ | {
45
+ type: 'channel'
46
+ session: string
47
+ repo: string
48
+ state: ChannelStatus['state']
49
+ error?: string
50
+ ts: string
51
+ }
52
+
53
+ const mention = z.object({
54
+ repository: z.object({ full_name: z.string() }),
55
+ message: z.object({
56
+ cursor: z.string(),
57
+ id: z.string(),
58
+ body: z.string(),
59
+ author: z.object({ name: z.string() }),
60
+ }),
61
+ agent: z.string(),
62
+ })
63
+ type Mention = z.output<typeof mention>
64
+
65
+ // GitHub-shaped webhook payloads (gild-site lib/events/payloads.ts). Only the
66
+ // fields a trigger needs are parsed; everything else passes through unseen.
67
+ const subject = z.object({
68
+ number: z.number(),
69
+ title: z.string(),
70
+ labels: z.array(z.object({ name: z.string() })),
71
+ user: z.object({ login: z.string() }).optional(),
72
+ })
73
+ const triggerPayload = z.object({
74
+ action: z.string().optional(),
75
+ sender: z.object({ login: z.string() }).optional(),
76
+ label: z.object({ name: z.string() }).optional(),
77
+ issue: subject.optional(),
78
+ pull_request: subject.optional(),
79
+ })
80
+ type TriggerPayload = z.output<typeof triggerPayload>
81
+
82
+ const SEEN_LIMIT = 500
83
+
84
+ /** One short prompt; the agent reads the channel history itself. */
85
+ export function mentionPrompt(
86
+ repo: string,
87
+ label: string,
88
+ m: Mention,
89
+ gild = 'gild',
90
+ ) {
91
+ const { cursor, body, author } = m.message
92
+ return [
93
+ `[gild] @${author.name} mentioned you in ${repo} (message ${cursor}):`,
94
+ body,
95
+ `Context: ${gild} chat history ${repo} --agent ${label} --before ${Number(cursor) + 1} --limit 30`,
96
+ `Reply: ${gild} chat send ${repo} --agent ${label} --reply-to ${cursor} "<your reply>"`,
97
+ MENTION_ETIQUETTE,
98
+ workflowPrompt(repo, label, gild),
99
+ ].join('\n')
100
+ }
101
+
102
+ /** Every @tag wakes that agent, so a courtesy tag ("@bob says 42") makes it
103
+ * answer again and two agents can ping-pong. The rehearsal on 10 Oct did. */
104
+ export const MENTION_ETIQUETTE =
105
+ 'Note: an @tag wakes that agent. Tag only whoever must act next; name others without @. If this needs nothing from you, do not reply.'
106
+
107
+ /** One short prompt per fired trigger; names the read and hand-off commands. */
108
+ export function triggerPrompt(
109
+ repo: string,
110
+ trigger: AgentTrigger,
111
+ issue: { number: number; title: string },
112
+ actor: string,
113
+ label: string | undefined,
114
+ gild: string,
115
+ agent: string,
116
+ ) {
117
+ const kind =
118
+ trigger.event === 'pull_request'
119
+ ? 'pull request'
120
+ : trigger.event === 'issue_comment'
121
+ ? 'issue comment'
122
+ : 'issue'
123
+ const what = label ? `${trigger.action} ${label}` : trigger.action
124
+ return [
125
+ `[gild] ${kind} #${issue.number} "${issue.title}" ${what} in ${repo} by ${actor}`,
126
+ `Read: ${gild} ${trigger.event === 'pull_request' ? 'pr' : 'issue'} view ${repo}#${issue.number} --agent ${agent}`,
127
+ `Post: ${gild} chat send ${repo} --agent ${agent} "@<agent> <message>"`,
128
+ workflowPrompt(repo, agent, gild),
129
+ ].join('\n')
130
+ }
131
+
132
+ function workflowPrompt(repo: string, agent: string, gild: string) {
133
+ return `Work: ${gild} clone ${repo} --agent ${agent}; git switch -c <branch>, commit, push; ${gild} issue create|comment|close|label and ${gild} pr create|list|view|diff|checks|comment|review|merge. Use --agent ${agent} on every forge command; reads support --json.`
134
+ }
135
+
136
+ type Saved = { cursors: Record<string, string>; delivered: string[] }
137
+ const savedSchema = z.object({
138
+ cursors: z.record(z.string(), z.string()).default({}),
139
+ delivered: z.array(z.string()).default([]),
140
+ })
141
+
142
+ type StreamRecord = {
143
+ id: string
144
+ cursor: string
145
+ event: string
146
+ payload: unknown
147
+ }
148
+ type Trigger = { spec: string } & AgentTrigger
149
+
150
+ /** Turns `channel.mention` events and profile triggers for one agent into
151
+ * queued prompts, once each, over the same events subscription. */
152
+ export class MentionBridge {
153
+ readonly channels: ChannelStatus[]
154
+ private readonly watched: ChannelStatus[]
155
+ private saved: Saved = { cursors: {}, delivered: [] }
156
+ private readonly seen = new Set<string>()
157
+ private readonly inflight = new Map<string, number>()
158
+ private readonly head = new Map<string, string>()
159
+ private readonly triggers: Trigger[]
160
+ private writing: Promise<void> = Promise.resolve()
161
+ constructor(
162
+ private readonly opts: {
163
+ client: Pick<GildClient, 'request'>
164
+ /** sponsor/label, as the server addresses the mention. */
165
+ agent: string
166
+ /** The local profile label used in `--agent <label>`. */
167
+ label: string
168
+ /** The gild that spawned this session; a `gild` on PATH may be older. */
169
+ gild?: string
170
+ session: string
171
+ repos: string[]
172
+ /** More repos to read events from without delivering their mentions. */
173
+ watch?: string[]
174
+ /** Sees every page of every repo before mentions are handled. */
175
+ observe?: (
176
+ repo: string,
177
+ events: { event: string; payload: unknown }[],
178
+ ) => void
179
+ /** Trigger specs from the profile (`--on`), matched on the same repos. */
180
+ triggers?: string[]
181
+ file: string
182
+ enqueue: (text: string, typed: () => void) => void
183
+ emit: (event: BridgeEvent) => void
184
+ pause?: typeof waitForEvents
185
+ /** Waits between reconnects; tests pass an instant one. */
186
+ backoff?: (ms: number, signal: AbortSignal) => Promise<void>
187
+ },
188
+ ) {
189
+ this.channels = opts.repos.map((repo) => ({ repo, state: 'connecting' }))
190
+ const own = new Set(opts.repos.map((r) => r.toLowerCase()))
191
+ this.watched = [...new Set(opts.watch ?? [])]
192
+ .filter((repo) => !own.has(repo.toLowerCase()))
193
+ .map((repo) => ({ repo, state: 'connecting' }))
194
+ this.triggers = (opts.triggers ?? [])
195
+ .map((spec) => ({ spec, ...parseTrigger(spec) }))
196
+ .filter((t): t is Trigger => t.event !== undefined)
197
+ }
198
+ async start(signal: AbortSignal) {
199
+ try {
200
+ this.saved = savedSchema.parse(
201
+ JSON.parse(await readFile(this.opts.file, 'utf8')),
202
+ )
203
+ } catch {
204
+ // No file yet, or an unreadable one: start from now rather than replay.
205
+ }
206
+ for (const id of this.saved.delivered) this.seen.add(id)
207
+ void this.backlog().catch(() => {})
208
+ await Promise.all(
209
+ [...this.channels, ...this.watched].map((channel) =>
210
+ this.run(channel, signal),
211
+ ),
212
+ )
213
+ }
214
+ private set(
215
+ channel: ChannelStatus,
216
+ state: ChannelStatus['state'],
217
+ error?: string,
218
+ ) {
219
+ if (channel.state === state && channel.error === error) return
220
+ channel.state = state
221
+ if (error) channel.error = error
222
+ else delete channel.error
223
+ this.opts.emit({
224
+ type: 'channel',
225
+ session: this.opts.session,
226
+ repo: channel.repo,
227
+ state,
228
+ ...(error ? { error } : {}),
229
+ ts: new Date().toISOString(),
230
+ })
231
+ }
232
+ private async run(channel: ChannelStatus, signal: AbortSignal) {
233
+ // A listener never gives up while the agent runs: any failure (a network
234
+ // drop, a 5xx, or a read check answered while the repo's access settings
235
+ // were briefly pending) shows in status, then it resumes from the last
236
+ // page it saw. Before 10 Oct one transient 404 silenced an agent for good.
237
+ for (let attempt = 0; !signal.aborted; attempt++) {
238
+ try {
239
+ await tailEvents(
240
+ this.opts.client,
241
+ {
242
+ repo: channel.repo,
243
+ since:
244
+ this.head.get(channel.repo) ?? this.saved.cursors[channel.repo],
245
+ onPage: (page) => {
246
+ attempt = 0
247
+ this.page(channel, page)
248
+ },
249
+ },
250
+ signal,
251
+ () => {},
252
+ () => {},
253
+ this.opts.pause,
254
+ )
255
+ return
256
+ } catch (error) {
257
+ if (signal.aborted) return
258
+ this.set(
259
+ channel,
260
+ 'error',
261
+ error instanceof Error ? error.message : String(error),
262
+ )
263
+ await (this.opts.backoff ?? sleep)(
264
+ Math.min(30_000, 1_000 * 2 ** Math.min(attempt, 5)),
265
+ signal,
266
+ )
267
+ }
268
+ }
269
+ }
270
+ private stage(stage: MentionStage, repo: string, m: Mention, error?: string) {
271
+ this.opts.emit({
272
+ type: 'mention',
273
+ stage,
274
+ session: this.opts.session,
275
+ repo,
276
+ message: m.message.cursor,
277
+ id: m.message.id,
278
+ author: m.message.author.name,
279
+ ts: new Date().toISOString(),
280
+ ...(error ? { error } : {}),
281
+ })
282
+ }
283
+ private stageTrigger(
284
+ stage: MentionStage,
285
+ repo: string,
286
+ t: {
287
+ spec: string
288
+ event: string
289
+ action: string
290
+ id: string
291
+ number?: number
292
+ title?: string
293
+ label?: string
294
+ actor: string
295
+ },
296
+ error?: string,
297
+ ) {
298
+ this.opts.emit({
299
+ type: 'trigger',
300
+ stage,
301
+ session: this.opts.session,
302
+ repo,
303
+ trigger: t.spec,
304
+ event: t.event,
305
+ action: t.action,
306
+ id: t.id,
307
+ ...(t.number !== undefined ? { number: t.number } : {}),
308
+ ...(t.title !== undefined ? { title: t.title } : {}),
309
+ ...(t.label !== undefined ? { label: t.label } : {}),
310
+ actor: t.actor,
311
+ ts: new Date().toISOString(),
312
+ ...(error ? { error } : {}),
313
+ })
314
+ }
315
+ private page(
316
+ channel: ChannelStatus,
317
+ page: { events: StreamRecord[]; cursor: string },
318
+ ) {
319
+ this.set(channel, 'listening')
320
+ const repo = channel.repo
321
+ this.opts.observe?.(repo, page.events)
322
+ if (this.watched.includes(channel)) return this.save(repo, page.cursor)
323
+ for (const event of page.events) {
324
+ if (event.event === 'channel.mention') this.mention(repo, event)
325
+ else this.trigger(repo, event)
326
+ }
327
+ this.head.set(repo, page.cursor)
328
+ // A cursor may only pass an enqueued prompt once it has been typed, so a
329
+ // crash replays it; delivered ids keep typed ones from repeating.
330
+ if (!this.inflight.get(repo)) this.save(repo, page.cursor)
331
+ }
332
+ private mention(repo: string, record: StreamRecord) {
333
+ const parsed = mention.safeParse(record.payload)
334
+ if (!parsed.success) return
335
+ const m = parsed.data
336
+ // The server already addresses mentions; still never type someone else's.
337
+ if (
338
+ m.agent.toLowerCase() !== this.opts.agent.toLowerCase() ||
339
+ m.repository.full_name.toLowerCase() !== repo.toLowerCase() ||
340
+ this.seen.has(m.message.id)
341
+ )
342
+ return
343
+ this.seen.add(m.message.id)
344
+ this.stage('received', repo, m)
345
+ this.inflight.set(repo, (this.inflight.get(repo) ?? 0) + 1)
346
+ try {
347
+ this.opts.enqueue(
348
+ mentionPrompt(repo, this.opts.label, m, this.opts.gild),
349
+ () => {
350
+ this.stage('delivered', repo, m)
351
+ this.settle(repo, m.message.id)
352
+ },
353
+ )
354
+ this.stage('queued', repo, m)
355
+ } catch (error) {
356
+ // A full queue drops this mention; say so rather than lose it silently.
357
+ this.seen.delete(m.message.id)
358
+ this.inflight.set(repo, this.inflight.get(repo)! - 1)
359
+ this.stage('failed', repo, m, (error as Error).message)
360
+ }
361
+ }
362
+ /** Open issues already carrying a `labeled:<label>` trigger's label are work
363
+ * the agent missed while offline: deliver each once, like a fresh label. */
364
+ private async backlog() {
365
+ const repos = [
366
+ ...new Set([...this.channels, ...this.watched].map((c) => c.repo)),
367
+ ]
368
+ for (const t of this.triggers) {
369
+ if (t.event !== 'issues' || t.action !== 'labeled' || !t.label) continue
370
+ for (const repo of repos) {
371
+ const [owner, name] = repo.split('/')
372
+ const issues = (await this.opts.client.request(
373
+ 'issues' as never,
374
+ { owner, repo: name } as never,
375
+ undefined as never,
376
+ { state: 'open', labels: t.label, per_page: 50 } as never,
377
+ )) as unknown as {
378
+ number: number
379
+ title: string
380
+ labels: { name: string }[]
381
+ user: { login: string }
382
+ }[]
383
+ for (const issue of issues.slice().reverse())
384
+ this.trigger(repo, {
385
+ event: 'issues',
386
+ id: `backlog:${repo}#${issue.number}`,
387
+ payload: {
388
+ action: 'labeled',
389
+ label: { name: t.label },
390
+ issue,
391
+ sender: { login: issue.user.login },
392
+ },
393
+ } as never)
394
+ }
395
+ }
396
+ }
397
+ private matchTrigger(
398
+ event: string,
399
+ p: TriggerPayload,
400
+ ): (Trigger & { labelHit?: string }) | null {
401
+ for (const t of this.triggers) {
402
+ // `labeled:<label>` means "a subject carrying this label exists": it also
403
+ // fires when one is opened or reopened already carrying the label.
404
+ const carries =
405
+ t.action === 'labeled' &&
406
+ t.label !== undefined &&
407
+ (p.action === 'opened' || p.action === 'reopened')
408
+ if (t.event !== event || (t.action !== p.action && !carries)) continue
409
+ // `labeled`/`unlabeled` carry the label on the event itself; other
410
+ // actions match against the subject's current labels.
411
+ if (t.label !== undefined) {
412
+ const names =
413
+ (t.action === 'labeled' || t.action === 'unlabeled') && !carries
414
+ ? [p.label?.name]
415
+ : ((p.issue ?? p.pull_request)?.labels.map((l) => l.name) ?? [])
416
+ if (!names.includes(t.label)) continue
417
+ }
418
+ return { ...t, labelHit: t.label ?? p.label?.name }
419
+ }
420
+ return null
421
+ }
422
+ private trigger(repo: string, record: StreamRecord) {
423
+ if (!this.triggers.length) return
424
+ const parsed = triggerPayload.safeParse(record.payload)
425
+ if (!parsed.success) return
426
+ const p = parsed.data
427
+ const match = this.matchTrigger(record.event, p)
428
+ if (!match) return
429
+ const subject = p.issue ?? p.pull_request
430
+ // Events without a subject (push, workflow_run) name no issue to read.
431
+ if (!subject) return
432
+ // Namespaced so a trigger id can never collide with a mention message id.
433
+ // Label backlog subscriptions intentionally represent one assignment per
434
+ // subject. Other subscriptions represent individual durable stream events.
435
+ const key =
436
+ match.action === 'labeled'
437
+ ? `trigger:${repo}#${subject.number}:${match.spec}`
438
+ : `trigger:${repo}:${record.event}:${record.id}:${match.spec}`
439
+ if (this.seen.has(key)) return
440
+ this.seen.add(key)
441
+ const t = {
442
+ spec: match.spec,
443
+ event: match.event,
444
+ action: match.action,
445
+ id: record.id,
446
+ number: subject.number,
447
+ title: subject.title,
448
+ actor: p.sender?.login ?? 'someone',
449
+ ...(match.labelHit !== undefined ? { label: match.labelHit } : {}),
450
+ }
451
+ this.stageTrigger('received', repo, t)
452
+ this.inflight.set(repo, (this.inflight.get(repo) ?? 0) + 1)
453
+ try {
454
+ this.opts.enqueue(
455
+ triggerPrompt(
456
+ repo,
457
+ match,
458
+ subject,
459
+ t.actor,
460
+ match.labelHit,
461
+ this.opts.gild ?? 'gild',
462
+ this.opts.label,
463
+ ),
464
+ () => {
465
+ this.stageTrigger('delivered', repo, t)
466
+ this.settle(repo, key)
467
+ },
468
+ )
469
+ this.stageTrigger('queued', repo, t)
470
+ } catch (error) {
471
+ this.seen.delete(key)
472
+ this.inflight.set(repo, this.inflight.get(repo)! - 1)
473
+ this.stageTrigger('failed', repo, t, (error as Error).message)
474
+ }
475
+ }
476
+ private settle(repo: string, id: string) {
477
+ this.inflight.set(repo, this.inflight.get(repo)! - 1)
478
+ this.saved.delivered = [...this.saved.delivered, id].slice(-SEEN_LIMIT)
479
+ this.save(repo, this.inflight.get(repo) ? undefined : this.head.get(repo))
480
+ }
481
+ private save(repo: string, cursor?: string) {
482
+ if (cursor) this.saved.cursors[repo] = cursor
483
+ const text = JSON.stringify(this.saved)
484
+ const partial = `${this.opts.file}.${process.pid}.tmp`
485
+ this.writing = this.writing
486
+ .then(async () => {
487
+ await writeFile(partial, text, { mode: 0o600 })
488
+ await rename(partial, this.opts.file)
489
+ })
490
+ .catch(() => {})
491
+ }
492
+ flush() {
493
+ return this.writing
494
+ }
495
+ }
496
+
497
+ function sleep(ms: number, signal: AbortSignal) {
498
+ return new Promise<void>((done) => {
499
+ const timer = setTimeout(done, ms)
500
+ signal.addEventListener('abort', () => (clearTimeout(timer), done()), {
501
+ once: true,
502
+ })
503
+ })
504
+ }
@@ -0,0 +1,16 @@
1
+ /** Bundle the Node companion into the standalone CLI, without native addons. */
2
+ export async function spawnWorkerSource(): Promise<string> {
3
+ const result = Bun.spawnSync([
4
+ process.execPath,
5
+ 'build',
6
+ new URL('./spawn-worker.ts', import.meta.url).pathname,
7
+ '--target=node',
8
+ // rixse's gateway runs only in the VM worker, which Bun runs; inline its
9
+ // source (the `bun` export) rather than the stub it gives Node.
10
+ '--conditions=bun',
11
+ '--format=esm',
12
+ ])
13
+ if (result.exitCode !== 0)
14
+ throw new Error(`PTY companion build failed: ${result.stderr.toString()}`)
15
+ return result.stdout.toString()
16
+ }