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
@@ -0,0 +1,446 @@
1
+ import { z } from 'zod'
2
+ import type { AgentState } from './spawn-events'
3
+
4
+ /** Watchdog nudges: mirab pokes a stalled spawned agent through the ordinary
5
+ * injection queue. Rules come from the agent profile and `mirab spawn --nudge`. */
6
+ export type NudgeRule =
7
+ | {
8
+ spec: string
9
+ kind: 'idle'
10
+ after: string
11
+ ms: number
12
+ limit?: number
13
+ message?: string
14
+ }
15
+ | { spec: string; kind: 'waiting'; after: string; ms: number }
16
+ | { spec: string; kind: 'ci'; repo: string }
17
+ | { spec: string; kind: 'mention-unanswered'; after: string; ms: number }
18
+
19
+ const UNITS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
20
+ const durationText = z
21
+ .string()
22
+ .regex(/^[1-9][0-9]{0,6}[smhd]$/, 'Use a duration like 30s, 20m, 2h or 1d')
23
+ .refine(
24
+ (d) =>
25
+ Number(d.slice(0, -1)) * UNITS[d.at(-1) as keyof typeof UNITS] <=
26
+ 7 * UNITS.d,
27
+ 'Durations are at most 7d',
28
+ )
29
+ const ms = (d: string) =>
30
+ Number(d.slice(0, -1)) * UNITS[d.at(-1) as keyof typeof UNITS]
31
+ const repoText = z
32
+ .string()
33
+ .regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, 'Use owner/repo')
34
+ const limitText = z
35
+ .string()
36
+ .regex(/^[1-9][0-9]{0,2}$/, 'A repeat limit is 1 to 999')
37
+ const messageText = z
38
+ .string()
39
+ .min(1)
40
+ .max(400)
41
+ .refine((s) => !/[\x00-\x1f\x7f]/.test(s), 'Nudge messages are one line')
42
+
43
+ /** `idle:<duration>[:<limit>][:<message>]`, `waiting:<duration>`,
44
+ * `ci:<owner/repo>`, `mention-unanswered:<duration>`. */
45
+ export const nudgeRule = z
46
+ .string()
47
+ .max(500)
48
+ .transform((spec, ctx): NudgeRule => {
49
+ const fail = (message: string) => {
50
+ ctx.addIssue({ code: 'custom', message: `${message} (in "${spec}")` })
51
+ return z.NEVER
52
+ }
53
+ const check = <T>(schema: z.ZodType<T>, value: string | undefined) => {
54
+ const result = schema.safeParse(value)
55
+ return result.success
56
+ ? { ok: true as const, value: result.data }
57
+ : { ok: false as const, error: result.error.issues[0].message }
58
+ }
59
+ const [kind, ...rest] = spec.split(':')
60
+ if (kind === 'ci') {
61
+ const repo = check(repoText, rest.join(':'))
62
+ return repo.ok ? { spec, kind, repo: repo.value } : fail(repo.error)
63
+ }
64
+ if (kind !== 'idle' && kind !== 'waiting' && kind !== 'mention-unanswered')
65
+ return fail(
66
+ 'Use idle:<duration>[:<limit>][:<message>], waiting:<duration>, ci:<owner/repo> or mention-unanswered:<duration>',
67
+ )
68
+ const after = check(durationText, rest.shift())
69
+ if (!after.ok) return fail(after.error)
70
+ if (kind !== 'idle')
71
+ return rest.length
72
+ ? fail(`${kind} takes only a duration`)
73
+ : { spec, kind, after: after.value, ms: ms(after.value) }
74
+ let limit: number | undefined
75
+ if (rest.length && /^[0-9]+$/.test(rest[0])) {
76
+ const parsed = check(limitText, rest.shift())
77
+ if (!parsed.ok) return fail(parsed.error)
78
+ limit = Number(parsed.value)
79
+ }
80
+ let message: string | undefined
81
+ if (rest.length) {
82
+ const parsed = check(messageText, rest.join(':'))
83
+ if (!parsed.ok) return fail(parsed.error)
84
+ message = parsed.value
85
+ }
86
+ return {
87
+ spec,
88
+ kind,
89
+ after: after.value,
90
+ ms: ms(after.value),
91
+ ...(limit ? { limit } : {}),
92
+ ...(message ? { message } : {}),
93
+ }
94
+ })
95
+ /** The stored form: the spec text, checked by the same grammar. */
96
+ export const nudgeSpec = z.string().superRefine((spec, ctx) => {
97
+ const result = nudgeRule.safeParse(spec)
98
+ if (!result.success)
99
+ ctx.addIssue({ code: 'custom', message: result.error.issues[0].message })
100
+ })
101
+ export function parseNudge(spec: string): NudgeRule {
102
+ const result = nudgeRule.safeParse(spec)
103
+ if (!result.success) throw Error(result.error.issues[0].message)
104
+ return result.data
105
+ }
106
+
107
+ export const IDLE_PROMPT =
108
+ "Report status: what's done, what's blocked, and next step. If background work finished, read its results now."
109
+
110
+ /** Local events-stream record; it never changes the agent's busy/idle state. */
111
+ export type NudgeEvent = {
112
+ type: 'nudge'
113
+ rule: string
114
+ session: string
115
+ /** Queued as a prompt; false for `waiting`, where a human must answer. */
116
+ queued: boolean
117
+ fired: number
118
+ ts: string
119
+ repo?: string
120
+ message?: string
121
+ run?: number
122
+ error?: string
123
+ }
124
+ export type NudgeStatus = {
125
+ rule: string
126
+ fired: number
127
+ /** ISO time the rule is next due, or null while it is not armed. */
128
+ nextDue: string | null
129
+ limit?: number
130
+ lastFired?: string
131
+ }
132
+ export type Clock = {
133
+ now(): number
134
+ setTimeout(fn: () => void, ms: number): unknown
135
+ clearTimeout(handle: unknown): void
136
+ }
137
+ const realClock: Clock = {
138
+ now: () => Date.now(),
139
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
140
+ clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
141
+ }
142
+
143
+ const pullEvent = z.object({
144
+ pull_request: z.object({
145
+ user: z.object({ login: z.string() }),
146
+ head: z.object({ ref: z.string() }),
147
+ }),
148
+ })
149
+ const runEvent = z.object({
150
+ action: z.literal('completed'),
151
+ workflow_run: z.object({
152
+ id: z.number(),
153
+ name: z.string().nullish(),
154
+ head_branch: z.string(),
155
+ event: z.string().nullish(),
156
+ conclusion: z.string().nullish(),
157
+ completed_at: z.string().nullish(),
158
+ updated_at: z.string().nullish(),
159
+ html_url: z.string().nullish(),
160
+ actor: z.object({ login: z.string() }).nullish(),
161
+ }),
162
+ })
163
+ /** Mention records from the bridge: only delivered ones start a clock. */
164
+ type Delivered = {
165
+ type: string
166
+ stage?: string
167
+ repo?: string
168
+ message?: string
169
+ id?: string
170
+ author?: string
171
+ }
172
+
173
+ type Slot = {
174
+ rule: NudgeRule
175
+ fired: number
176
+ /** Idle fires since the agent last started a turn of its own. */
177
+ streak: number
178
+ armed: boolean
179
+ due?: number
180
+ timer?: unknown
181
+ last?: number
182
+ pending: Map<string, { due: number; timer: unknown }>
183
+ }
184
+
185
+ export class Watchdog {
186
+ private readonly slots: Slot[]
187
+ private readonly clock: Clock
188
+ private readonly started: number
189
+ private current: AgentState = 'unknown'
190
+ /** A nudge was queued, so the next turn is the agent answering it. */
191
+ private nudgeTurn = false
192
+ private readonly branches = new Map<string, Set<string>>()
193
+ private readonly seenRuns = new Set<number>()
194
+ private closed = false
195
+ constructor(
196
+ private readonly o: {
197
+ rules: NudgeRule[]
198
+ session: string
199
+ /** The server identity (`sponsor/label`) whose PRs and replies count. */
200
+ agent?: string
201
+ enqueue: (text: string) => void
202
+ emit: (event: NudgeEvent) => void
203
+ /** Posts a channel note; `repo` is the rule's own repo when it has one. */
204
+ note?: (text: string, repo?: string) => Promise<unknown>
205
+ /** Whether the agent posted in `repo` after channel message `cursor`. */
206
+ replied?: (repo: string, cursor: string) => Promise<boolean>
207
+ clock?: Clock
208
+ },
209
+ ) {
210
+ this.clock = o.clock ?? realClock
211
+ this.started = this.clock.now()
212
+ this.slots = o.rules.map((rule) => ({
213
+ rule,
214
+ fired: 0,
215
+ streak: 0,
216
+ armed: true,
217
+ pending: new Map(),
218
+ }))
219
+ }
220
+ /** Repos the `ci` rules read from the existing events subscription. */
221
+ get repos() {
222
+ return [
223
+ ...new Set(
224
+ this.slots.flatMap((s) => (s.rule.kind === 'ci' ? [s.rule.repo] : [])),
225
+ ),
226
+ ]
227
+ }
228
+ /** Every published agent event; only transitions matter. */
229
+ state(next: AgentState) {
230
+ const previous = this.current
231
+ if (next === previous || this.closed) return
232
+ this.current = next
233
+ if (next !== 'idle' && next !== 'unknown') {
234
+ // Only work re-arms an idle rule; a permission wait is not a turn.
235
+ const working = next === 'busy' || next === 'tool_start'
236
+ if (working && (previous === 'idle' || previous === 'unknown')) {
237
+ // A turn the agent started itself resets the idle repeat limit.
238
+ if (this.nudgeTurn) this.nudgeTurn = false
239
+ else for (const s of this.slots) s.streak = 0
240
+ }
241
+ for (const s of this.slots)
242
+ if (s.rule.kind === 'idle') {
243
+ this.cancel(s)
244
+ if (working) s.armed = true
245
+ }
246
+ }
247
+ for (const s of this.slots)
248
+ if (s.rule.kind === 'waiting') {
249
+ this.cancel(s)
250
+ if (next === 'waiting') this.arm(s, s.rule.ms, () => this.waiting(s))
251
+ }
252
+ if (next === 'idle')
253
+ for (const s of this.slots)
254
+ if (s.rule.kind === 'idle' && s.armed)
255
+ this.arm(s, s.rule.ms, () => this.idle(s))
256
+ }
257
+ /** Bridge records; a delivered mention starts the `mention-unanswered` clocks. */
258
+ bridge(event: Delivered) {
259
+ if (
260
+ this.closed ||
261
+ event.type !== 'mention' ||
262
+ event.stage !== 'delivered' ||
263
+ !event.repo ||
264
+ !event.message ||
265
+ !event.id
266
+ )
267
+ return
268
+ const { repo, message, id } = event
269
+ for (const s of this.slots) {
270
+ if (s.rule.kind !== 'mention-unanswered' || s.pending.has(id)) continue
271
+ const due = this.clock.now() + s.rule.ms
272
+ const timer = this.clock.setTimeout(() => {
273
+ void this.unanswered(s, id, repo, message, event.author)
274
+ }, s.rule.ms)
275
+ s.pending.set(id, { due, timer })
276
+ }
277
+ }
278
+ /** Pages from the events subscription the bridge already holds. */
279
+ events(repo: string, events: { event: string; payload: unknown }[]) {
280
+ if (this.closed || !this.o.agent) return
281
+ const agent = this.o.agent.toLowerCase()
282
+ const slots = this.slots.filter(
283
+ (s) =>
284
+ s.rule.kind === 'ci' &&
285
+ s.rule.repo.toLowerCase() === repo.toLowerCase(),
286
+ )
287
+ if (!slots.length) return
288
+ const key = repo.toLowerCase()
289
+ const branches = this.branches.get(key) ?? new Set<string>()
290
+ this.branches.set(key, branches)
291
+ for (const event of events) {
292
+ if (event.event === 'pull_request') {
293
+ const pull = pullEvent.safeParse(event.payload)
294
+ if (
295
+ pull.success &&
296
+ pull.data.pull_request.user.login.toLowerCase() === agent
297
+ )
298
+ branches.add(pull.data.pull_request.head.ref)
299
+ continue
300
+ }
301
+ if (event.event !== 'workflow_run') continue
302
+ const parsed = runEvent.safeParse(event.payload)
303
+ if (!parsed.success) continue
304
+ const run = parsed.data.workflow_run
305
+ const finished = Date.parse(run.completed_at ?? run.updated_at ?? '')
306
+ // A replayed page must not nudge for runs that ended before the session.
307
+ if (!(finished >= this.started - 1000) || this.seenRuns.has(run.id))
308
+ continue
309
+ const ours =
310
+ branches.has(run.head_branch) ||
311
+ (run.event === 'pull_request' &&
312
+ run.actor?.login.toLowerCase() === agent)
313
+ if (!ours) continue
314
+ this.seenRuns.add(run.id)
315
+ // Only an idle agent is stalled; a busy one sees CI in its own turn.
316
+ if (this.current !== 'idle') continue
317
+ for (const s of slots) {
318
+ const what = `workflow ${JSON.stringify(run.name ?? 'run')} ${run.conclusion ?? 'finished'} on ${run.head_branch} (run #${run.id}${run.html_url ? `, ${run.html_url}` : ''})`
319
+ this.fire(s, {
320
+ prompt: `[mirab] nudge: ci ${repo}: ${what}. Read the result and continue.`,
321
+ note: `ci ${repo}: ${what}; nudged the agent`,
322
+ repo,
323
+ run: run.id,
324
+ })
325
+ }
326
+ }
327
+ }
328
+ status(): NudgeStatus[] {
329
+ return this.slots.map((s) => {
330
+ const due =
331
+ s.rule.kind === 'mention-unanswered'
332
+ ? Math.min(...[...s.pending.values()].map((p) => p.due))
333
+ : s.due
334
+ return {
335
+ rule: s.rule.spec,
336
+ fired: s.fired,
337
+ nextDue:
338
+ due !== undefined && Number.isFinite(due)
339
+ ? new Date(due).toISOString()
340
+ : null,
341
+ ...(s.rule.kind === 'idle' && s.rule.limit
342
+ ? { limit: s.rule.limit }
343
+ : {}),
344
+ ...(s.last !== undefined
345
+ ? { lastFired: new Date(s.last).toISOString() }
346
+ : {}),
347
+ }
348
+ })
349
+ }
350
+ close() {
351
+ this.closed = true
352
+ for (const s of this.slots) {
353
+ this.cancel(s)
354
+ for (const p of s.pending.values()) this.clock.clearTimeout(p.timer)
355
+ s.pending.clear()
356
+ }
357
+ }
358
+ private arm(s: Slot, after: number, run: () => void) {
359
+ if (s.rule.kind === 'idle' && s.rule.limit && s.streak >= s.rule.limit)
360
+ return
361
+ s.due = this.clock.now() + after
362
+ s.timer = this.clock.setTimeout(() => {
363
+ s.timer = undefined
364
+ s.due = undefined
365
+ run()
366
+ }, after)
367
+ }
368
+ private cancel(s: Slot) {
369
+ if (s.timer !== undefined) this.clock.clearTimeout(s.timer)
370
+ s.timer = undefined
371
+ s.due = undefined
372
+ }
373
+ private idle(s: Slot) {
374
+ if (s.rule.kind !== 'idle' || this.current !== 'idle') return
375
+ s.armed = false
376
+ s.streak++
377
+ this.fire(s, {
378
+ prompt: `[mirab] nudge: idle ${s.rule.after}. ${s.rule.message ?? IDLE_PROMPT}`,
379
+ note: `idle ${s.rule.after}; asked the agent for a status report`,
380
+ })
381
+ }
382
+ private waiting(s: Slot) {
383
+ s.fired++
384
+ s.last = this.clock.now()
385
+ this.o.emit({
386
+ type: 'nudge',
387
+ rule: s.rule.spec,
388
+ session: this.o.session,
389
+ queued: false,
390
+ fired: s.fired,
391
+ ts: new Date(s.last).toISOString(),
392
+ })
393
+ }
394
+ private async unanswered(
395
+ s: Slot,
396
+ id: string,
397
+ repo: string,
398
+ cursor: string,
399
+ author?: string,
400
+ ) {
401
+ if (s.rule.kind !== 'mention-unanswered' || this.closed) return
402
+ s.pending.delete(id)
403
+ // An unreadable channel counts as unanswered: a spare nudge is cheap.
404
+ const answered = await this.o.replied?.(repo, cursor).catch(() => false)
405
+ if (answered || this.closed) return
406
+ this.fire(s, {
407
+ prompt: `[mirab] nudge: mention unanswered ${s.rule.after}. ${author ? `@${author}` : 'A mention'} in ${repo} (message ${cursor}) has no reply from you yet. Reply in the channel with your status.`,
408
+ note: `mention ${cursor} unanswered for ${s.rule.after}; nudged the agent`,
409
+ repo,
410
+ message: cursor,
411
+ })
412
+ }
413
+ private fire(
414
+ s: Slot,
415
+ f: {
416
+ prompt: string
417
+ note: string
418
+ repo?: string
419
+ message?: string
420
+ run?: number
421
+ },
422
+ ) {
423
+ s.fired++
424
+ s.last = this.clock.now()
425
+ let error: string | undefined
426
+ try {
427
+ this.o.enqueue(f.prompt)
428
+ this.nudgeTurn = true
429
+ } catch (e) {
430
+ error = e instanceof Error ? e.message : String(e)
431
+ }
432
+ this.o.emit({
433
+ type: 'nudge',
434
+ rule: s.rule.spec,
435
+ session: this.o.session,
436
+ queued: !error,
437
+ fired: s.fired,
438
+ ts: new Date(s.last).toISOString(),
439
+ ...(f.repo ? { repo: f.repo } : {}),
440
+ ...(f.message ? { message: f.message } : {}),
441
+ ...(f.run !== undefined ? { run: f.run } : {}),
442
+ ...(error ? { error } : {}),
443
+ })
444
+ void this.o.note?.(`[mirab nudge] ${f.note}`, f.repo)?.catch(() => {})
445
+ }
446
+ }
@@ -0,0 +1,45 @@
1
+ import { write } from 'node:fs'
2
+ /** Async fd writes keep the socket/signal loop responsive when a TTY reader stalls. */
3
+ export class TerminalOutput {
4
+ private pending: Buffer[] = []
5
+ private active = false
6
+ private waiters: (() => void)[] = []
7
+ constructor(
8
+ private readonly fd: number,
9
+ private readonly pause: () => void,
10
+ private readonly resume: () => void,
11
+ private readonly failed: (error: Error) => void,
12
+ ) {}
13
+ push(data: string | Buffer) {
14
+ this.pending.push(Buffer.isBuffer(data) ? data : Buffer.from(data))
15
+ this.pause()
16
+ this.pump()
17
+ }
18
+ flush(): Promise<void> {
19
+ return !this.active && !this.pending.length
20
+ ? Promise.resolve()
21
+ : new Promise((resolve) => this.waiters.push(resolve))
22
+ }
23
+ private pump() {
24
+ if (this.active) return
25
+ const data = this.pending[0]
26
+ if (!data) {
27
+ for (const done of this.waiters.splice(0)) done()
28
+ this.resume()
29
+ return
30
+ }
31
+ this.active = true
32
+ write(this.fd, data, 0, data.length, null, (error, written) => {
33
+ this.active = false
34
+ if (error) {
35
+ this.pending = []
36
+ for (const done of this.waiters.splice(0)) done()
37
+ this.failed(error)
38
+ return
39
+ }
40
+ if (written === data.length) this.pending.shift()
41
+ else this.pending[0] = data.subarray(written)
42
+ this.pump()
43
+ })
44
+ }
45
+ }
@@ -0,0 +1,135 @@
1
+ import { InputLine } from './spawn-input'
2
+ /** No terminal controls may come from a message source. User input is untouched. */
3
+ export function injectedInput(message: string): string {
4
+ const text = message.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '')
5
+ return (text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text) + '\r'
6
+ }
7
+
8
+ /** Sources enqueue text; only this queue decides when to type it. A future
9
+ * channel stream uses enqueue(), and screen-based busy detection uses ready(). */
10
+ export class InjectionQueue {
11
+ readonly input = new InputLine()
12
+ private buffered: Buffer[] = []
13
+ private pending: { data: string; typed?: () => void }[] = []
14
+ private bytes = 0
15
+ private lastInput = Date.now()
16
+ private timer?: ReturnType<typeof setTimeout>
17
+ private escapeTimer?: ReturnType<typeof setTimeout>
18
+ private submitTimer?: ReturnType<typeof setTimeout>
19
+ constructor(
20
+ private readonly write: (data: string | Buffer) => void,
21
+ private readonly idleMs: number,
22
+ private readonly ready: () => boolean = () => true,
23
+ private readonly structured = false,
24
+ private readonly submitted: () => void = () => {},
25
+ private readonly userEnterIsPrompt = true,
26
+ /** The agent reports accepted prompts (Claude's UserPromptSubmit hook):
27
+ * re-press Enter if a typed prompt was taken as a paste, then give up. */
28
+ private readonly confirmSubmit = false,
29
+ private readonly unconfirmed: () => void = () => {},
30
+ ) {}
31
+ private confirmTimer?: ReturnType<typeof setTimeout>
32
+ private awaitConfirm(attempt: number) {
33
+ clearTimeout(this.confirmTimer)
34
+ this.confirmTimer = setTimeout(() => {
35
+ if (attempt < 2) {
36
+ this.write('\r')
37
+ this.awaitConfirm(attempt + 1)
38
+ } else this.unconfirmed()
39
+ }, 1500)
40
+ }
41
+ /** The agent confirmed it accepted the last typed prompt. */
42
+ confirmed() {
43
+ clearTimeout(this.confirmTimer)
44
+ this.confirmTimer = undefined
45
+ }
46
+ userInput(data?: Buffer) {
47
+ if (data && this.submitTimer) {
48
+ this.buffered.push(Buffer.from(data))
49
+ return
50
+ }
51
+ this.lastInput = Date.now()
52
+ clearTimeout(this.escapeTimer)
53
+ if (data) {
54
+ // A user's Enter is not proof of a prompt (it also answers startup
55
+ // dialogs). Agents whose hooks report UserPromptSubmit say so themselves.
56
+ if (this.input.feed(data) && this.structured && this.userEnterIsPrompt)
57
+ this.submitted()
58
+ this.write(data)
59
+ this.escapeTimer = setTimeout(() => {
60
+ this.input.settleEscape()
61
+ this.schedule()
62
+ }, 50)
63
+ }
64
+ this.schedule()
65
+ }
66
+ changed() {
67
+ this.schedule()
68
+ }
69
+ /** Why queued messages are not being typed yet, for `mirab status`. */
70
+ get held() {
71
+ if (!this.pending.length) return undefined
72
+ return {
73
+ queued: this.pending.length,
74
+ reason: this.submitTimer
75
+ ? 'submitting'
76
+ : !this.ready()
77
+ ? 'agent not idle'
78
+ : this.structured && this.input.unsent
79
+ ? `unsent draft ${JSON.stringify(this.input.why)}`
80
+ : 'scheduled',
81
+ }
82
+ }
83
+ /** `typed` runs once the prompt and its Enter have been written to the agent. */
84
+ enqueue(message: string, typed?: () => void) {
85
+ const data = injectedInput(message)
86
+ if (
87
+ this.pending.length >= 128 ||
88
+ this.bytes + Buffer.byteLength(data) > 1024 * 1024
89
+ )
90
+ throw new Error('Session message queue is full')
91
+ this.pending.push({ data, typed })
92
+ this.bytes += Buffer.byteLength(data)
93
+ this.schedule()
94
+ }
95
+ close() {
96
+ clearTimeout(this.confirmTimer)
97
+ clearTimeout(this.timer)
98
+ clearTimeout(this.submitTimer)
99
+ clearTimeout(this.escapeTimer)
100
+ this.buffered = []
101
+ this.pending = []
102
+ this.bytes = 0
103
+ }
104
+ private schedule() {
105
+ clearTimeout(this.timer)
106
+ if (!this.pending.length || this.submitTimer) return
107
+ this.timer = setTimeout(
108
+ () => {
109
+ if (!this.ready() || (this.structured && this.input.unsent)) {
110
+ if (!this.structured) this.schedule()
111
+ return
112
+ }
113
+ const { data, typed } = this.pending.shift()!
114
+ this.bytes -= Buffer.byteLength(data)
115
+ // TUIs detect a burst of text as paste. Enter must arrive as a subsequent
116
+ // input event, otherwise Claude/Codex may paste it instead of submitting.
117
+ this.write(data.slice(0, -1))
118
+ this.submitTimer = setTimeout(() => {
119
+ this.submitTimer = undefined
120
+ this.write('\r')
121
+ if (this.structured) this.submitted()
122
+ if (this.confirmSubmit) this.awaitConfirm(0)
123
+ typed?.()
124
+ for (const input of this.buffered.splice(0)) this.userInput(input)
125
+ this.lastInput = Date.now()
126
+ this.schedule()
127
+ }, 80)
128
+ },
129
+ Math.max(
130
+ 10,
131
+ (this.structured ? 0 : this.idleMs) - (Date.now() - this.lastInput),
132
+ ),
133
+ )
134
+ }
135
+ }
@@ -0,0 +1,69 @@
1
+ /** Terminal text without escape sequences or whitespace, so a dialog's words
2
+ * match however the TUI positioned them on screen. */
3
+ export function screenText(data: string) {
4
+ return data
5
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (titles)
6
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI (cursor, colour)
7
+ .replace(/\x1b[@-_]/g, '')
8
+ .replace(/[\x00-\x20\x7f]+/g, '')
9
+ }
10
+
11
+ /**
12
+ * Readiness for a runtime that reports only the end of a turn (Codex):
13
+ * - a fresh session is idle once its first screen has been quiet for
14
+ * `startupMs`;
15
+ * - a busy state mirab inferred itself (an Enter it typed or saw typed) falls
16
+ * back to idle after `busyMs` of silence, because a working Codex redraws
17
+ * its timer every second, while an Enter that answered a dialog or a prompt
18
+ * the TUI dropped would otherwise hold every later message forever;
19
+ * - never while the text drawn since the last keystroke shows one of the
20
+ * runtime's dialogs: a channel message must not answer "Trust this folder?".
21
+ * The worker also clears that text on every Enter it sees or types.
22
+ */
23
+ export class QuietIdle {
24
+ private timer?: ReturnType<typeof setTimeout>
25
+ private text = ''
26
+ constructor(
27
+ private readonly opts: {
28
+ startupMs: number
29
+ busyMs: number
30
+ dialogs?: RegExp
31
+ /** 'startup', 'busy' or null when the agent's own report is in charge. */
32
+ phase: () => 'startup' | 'busy' | null
33
+ idle: () => void
34
+ },
35
+ ) {}
36
+ output(data: string | Buffer) {
37
+ this.text = (this.text + screenText(data.toString())).slice(-16384)
38
+ this.arm()
39
+ }
40
+ /** A keystroke changes the screen's context; old dialog text no longer applies. */
41
+ input() {
42
+ this.text = ''
43
+ this.arm()
44
+ }
45
+ /** Re-evaluate after a state change (e.g. mirab just typed a prompt). */
46
+ changed() {
47
+ this.arm()
48
+ }
49
+ close() {
50
+ clearTimeout(this.timer)
51
+ this.timer = undefined
52
+ }
53
+ private arm() {
54
+ clearTimeout(this.timer)
55
+ this.timer = undefined
56
+ const phase = this.opts.phase()
57
+ if (!phase) return
58
+ this.timer = setTimeout(
59
+ () => {
60
+ this.timer = undefined
61
+ if (this.opts.phase() !== phase) return
62
+ if (this.opts.dialogs?.test(this.text)) return
63
+ this.opts.idle()
64
+ },
65
+ phase === 'startup' ? this.opts.startupMs : this.opts.busyMs,
66
+ )
67
+ this.timer.unref?.()
68
+ }
69
+ }