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,766 @@
1
+ import { runNative, debugFallback, supportsPty } from './spawn-native'
2
+ import { promptMirab } from './invocation'
3
+ import { createRequire } from 'node:module'
4
+ import { chmodSync, unlinkSync } from 'node:fs'
5
+ import { chmod } from 'node:fs/promises'
6
+ import { createServer, type Socket } from 'node:net'
7
+ import { dirname, join } from 'node:path'
8
+ import type { IPty } from 'node-pty'
9
+ import { adapterFor } from './spawn-adapters'
10
+ import { shellQuote } from './spawn-adapters/types'
11
+ import { applyEvent, type AgentEvent, type SessionState } from './spawn-events'
12
+ import { realAgent, agentEnvironment } from './spawn-binary'
13
+ import { QuietIdle } from './spawn-quiet'
14
+ import { StateReporter, type ReportTarget } from './spawn-report'
15
+ import {
16
+ MentionBridge,
17
+ type BridgeEvent,
18
+ type BridgeTarget,
19
+ } from './spawn-bridge'
20
+ import { GildClient } from './api/client'
21
+ import { TerminalOutput } from './spawn-output'
22
+ import { InjectionQueue } from './spawn-queue'
23
+ import { startWatchdog } from './spawn-nudge-worker'
24
+ import type { NudgeEvent } from './spawn-nudge'
25
+ import { guestEnvironment, startVmChild, type VmChild } from './spawn-vm'
26
+ import { loadStationConfig } from './station/config'
27
+ import { startMultipassChild, usesMultipass } from './isolation/multipass'
28
+ import {
29
+ describeSync,
30
+ type SyncResult,
31
+ type RecoveryLocation,
32
+ } from './isolation/sync'
33
+ import {
34
+ DetachedHost,
35
+ endSocket,
36
+ exitLine,
37
+ reportDetached,
38
+ } from './spawn-detach'
39
+ import {
40
+ MAX_MESSAGE_BYTES,
41
+ removeDeadSocket,
42
+ privateSessionsDirectory,
43
+ socketPath,
44
+ type LocalSession,
45
+ type SyncSummary,
46
+ } from './spawn-sessions'
47
+
48
+ type Options = {
49
+ agent: string
50
+ args: string[]
51
+ id: string
52
+ idleMs: number
53
+ ptyModule: string
54
+ hookCommand: string[]
55
+ printId?: boolean
56
+ reporting?: boolean
57
+ bridging?: boolean
58
+ identity?: string
59
+ profile?: { name: string; channels?: string[]; on?: string[] }
60
+ envAllowlist?: string[]
61
+ nudges?: string[]
62
+ detach?: { cols: number; rows: number }
63
+ /** `mirab spawn --vm`: run the agent in a microVM; configDir holds isolation.json. */
64
+ vm?: { configDir: string }
65
+ }
66
+ const options: Options = JSON.parse(
67
+ process.argv[process.argv[2] === '__vm-worker' ? 3 : 2],
68
+ )
69
+ /** The station for a VM session, when station.json says where harar is. */
70
+ async function vmStation(configDir: string) {
71
+ const config = await loadStationConfig(configDir, { optional: true })
72
+ return config && { config, auditLog: join(configDir, 'egress.log') }
73
+ }
74
+
75
+ // Scoped credentials travel over IPC, never argv, env, settings or event payloads.
76
+ function credential<T>(type: string, key: string, wanted?: boolean) {
77
+ if (!wanted) return undefined
78
+ return new Promise<T>((resolve, reject) => {
79
+ const timer = setTimeout(
80
+ () => reject(new Error(`Agent ${type} credentials were not delivered`)),
81
+ 3000,
82
+ )
83
+ const receive = (message: Record<string, unknown>) => {
84
+ if (message.type !== type) return
85
+ clearTimeout(timer)
86
+ process.off('message', receive)
87
+ const value = message[key] as { token?: string } | undefined
88
+ if (!value?.token)
89
+ reject(new Error(`Invalid agent ${type} configuration`))
90
+ else resolve(value as T)
91
+ }
92
+ process.on('message', receive)
93
+ })
94
+ }
95
+ const reportConfig = credential<ReportTarget>(
96
+ 'report',
97
+ 'report',
98
+ options.reporting,
99
+ )
100
+ const bridgeConfig = credential<BridgeTarget>(
101
+ 'bridge',
102
+ 'bridge',
103
+ options.bridging,
104
+ )
105
+ let child: IPty | VmChild | undefined
106
+ let queue: InjectionQueue | undefined
107
+ let path: string | undefined
108
+ let ownsSocket = false
109
+ let closing = false
110
+ let host: DetachedHost | undefined
111
+ let adapterReceive: ((raw: unknown) => void) | undefined
112
+ let adapterCleanup: (() => void) | undefined
113
+ // In a Multipass VM the agent's hooks can't reach this host, so the session
114
+ // runs like an unknown agent: input waits for the terminal to go quiet.
115
+ const adapter =
116
+ options.vm && usesMultipass(options.vm.configDir)
117
+ ? undefined
118
+ : adapterFor(options.agent, options.args)
119
+ const state: SessionState = {
120
+ state: 'unknown',
121
+ lastActivity: new Date().toISOString(),
122
+ }
123
+ const subscribers = new Set<Socket>()
124
+ let reporter: StateReporter | undefined
125
+ let bridge: MentionBridge | undefined
126
+ let watchdog: ReturnType<typeof startWatchdog>
127
+ const bridgeAbort = new AbortController()
128
+ function publish(event: AgentEvent) {
129
+ if (event.type !== 'message')
130
+ inferredBusy =
131
+ (event.raw as { source?: string } | null)?.source === 'pty_submit'
132
+ if (
133
+ (event.raw as { hook_event_name?: string } | undefined)?.hook_event_name ===
134
+ 'UserPromptSubmit'
135
+ )
136
+ queue?.confirmed()
137
+ applyEvent(state, event)
138
+ quiet?.changed()
139
+ watchdog?.state(state.state)
140
+ queue?.changed()
141
+ broadcast(event)
142
+ reporter?.event(event)
143
+ }
144
+ /** Local subscribers only; mention records say nothing about the agent's state. */
145
+ function broadcast(event: AgentEvent | BridgeEvent | NudgeEvent) {
146
+ const line = JSON.stringify(event) + '\n'
147
+ for (const socket of subscribers) {
148
+ if (socket.writableLength + Buffer.byteLength(line) > 1024 * 1024)
149
+ socket.destroy()
150
+ else socket.write(line)
151
+ }
152
+ }
153
+ /** True while the busy state is mirab's own inference from an Enter, not a
154
+ * report from the agent (Codex reports only turn ends). */
155
+ let inferredBusy = false
156
+ const quiet = adapter?.quiet
157
+ ? new QuietIdle({
158
+ ...adapter.quiet,
159
+ phase: () =>
160
+ closing
161
+ ? null
162
+ : state.state === 'unknown'
163
+ ? 'startup'
164
+ : state.state === 'busy' && inferredBusy
165
+ ? 'busy'
166
+ : null,
167
+ idle: () =>
168
+ publish({
169
+ session: options.id,
170
+ agent: adapter!.name,
171
+ type: 'idle',
172
+ ts: new Date().toISOString(),
173
+ raw: { source: 'terminal_quiet' },
174
+ }),
175
+ })
176
+ : undefined
177
+ function submitted() {
178
+ // Whatever was on screen before this Enter (a dialog it answered) is gone.
179
+ quiet?.input()
180
+ publish({
181
+ session: options.id,
182
+ agent: adapter!.name,
183
+ type: 'busy',
184
+ ts: new Date().toISOString(),
185
+ raw: { source: 'pty_submit' },
186
+ })
187
+ }
188
+ const sockets = new Set<Socket>()
189
+ const wasRaw = process.stdin.isRaw ?? false
190
+ let rawOwned = false
191
+ const server = createServer()
192
+ const output = new TerminalOutput(
193
+ process.stdout.fd,
194
+ () => child?.pause(),
195
+ () => {
196
+ if (!closing) child?.resume()
197
+ },
198
+ failed,
199
+ )
200
+
201
+ function killGroup(signal: NodeJS.Signals) {
202
+ if (!child) return
203
+ if ('dispose' in child) return child.kill() // the guest hangs up its own group
204
+ // node-pty's forkpty/setsid child owns this group, never mirab's terminal group.
205
+ try {
206
+ process.kill(-child.pid, signal)
207
+ } catch (error) {
208
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
209
+ }
210
+ }
211
+ function quiesce() {
212
+ process.stdin.pause()
213
+ if (rawOwned) {
214
+ process.stdin.setRawMode(wasRaw)
215
+ rawOwned = false
216
+ }
217
+ bridgeAbort.abort()
218
+ watchdog?.close()
219
+ queue?.close()
220
+ adapterCleanup?.()
221
+ adapterCleanup = undefined
222
+ }
223
+ /** Unlinking the path leaves connected viewers and subscribers connected. */
224
+ function unlinkSocket() {
225
+ if (ownsSocket && path) {
226
+ try {
227
+ unlinkSync(path)
228
+ } catch {}
229
+ ownsSocket = false
230
+ }
231
+ }
232
+ function restore() {
233
+ quiesce()
234
+ for (const socket of sockets) socket.destroy()
235
+ unlinkSocket()
236
+ host?.cleanup()
237
+ server.close()
238
+ }
239
+ /** The final event: subscribers (and a `mirab stop` caller) learn the exit
240
+ * code, and are closed in order before the socket goes. */
241
+ let exitFlushed: Promise<unknown> = Promise.resolve()
242
+ function announceExit(code: number, recovery?: RecoveryLocation) {
243
+ const events = [...subscribers]
244
+ const viewers = host?.endViewers() ?? []
245
+ subscribers.clear()
246
+ const line = exitLine(options.id, code, recovery)
247
+ exitFlushed = Promise.race([
248
+ Promise.all([
249
+ ...events.map((socket) => endSocket(socket, line)),
250
+ ...viewers.map((socket) => endSocket(socket)),
251
+ ]),
252
+ new Promise((resolve) => setTimeout(resolve, 500)),
253
+ ])
254
+ for (const socket of [...events, ...viewers]) sockets.delete(socket)
255
+ }
256
+ function finish(code: number, terminate = true) {
257
+ if (closing) return
258
+ closing = true
259
+ quiesce()
260
+ // Also terminate descendants on a normal agent exit; they may hold the PTY.
261
+ try {
262
+ killGroup('SIGHUP')
263
+ } catch {}
264
+ setTimeout(
265
+ () => {
266
+ try {
267
+ killGroup('SIGKILL')
268
+ } catch {}
269
+ // --vm: the guest's last changes come back before the VM is stopped.
270
+ let recovery: RecoveryLocation | undefined
271
+ const vm =
272
+ child && 'dispose' in child
273
+ ? child
274
+ .dispose()
275
+ .then((r) => {
276
+ if (!r) return
277
+ recovery = r.recovery
278
+ if (recovery) code ||= 1
279
+ console.error(
280
+ `mirab: working directory synced from the VM: ${describeSync(r)}`,
281
+ )
282
+ for (const c of r.conflicts)
283
+ console.error(
284
+ `mirab: kept the host copy of ${c.path} (${c.reason})${c.saved ? `; recovery entry: ${c.saved}` : ''}`,
285
+ )
286
+ })
287
+ .catch((error) => {
288
+ console.error(
289
+ `mirab: final VM sync failed: ${(error as Error).message}; recovery workspace retained`,
290
+ )
291
+ code ||= 1
292
+ if (child && 'dispose' in child) recovery = child.recovery
293
+ })
294
+ : undefined
295
+ void Promise.allSettled([reporter?.close(), vm]).then(async () => {
296
+ if (recovery) {
297
+ const notice = `mirab: VM workspace retained for recovery: ${recovery.directory}\r\n`
298
+ host?.push(Buffer.from(notice))
299
+ }
300
+ // Subscribers and stop callers stay connected until final sync/recovery
301
+ // has determined the actual session result. The socket path and the
302
+ // session directory go first (the output log is kept), so a caller
303
+ // that saw the exit never finds them still there.
304
+ unlinkSocket()
305
+ host?.cleanup()
306
+ announceExit(code, recovery)
307
+ await exitFlushed
308
+ restore()
309
+ // A signal exit cannot wait forever for a terminal reader that stopped.
310
+ if (terminate) setTimeout(() => process.exit(code), 250)
311
+ void Promise.all([output.flush(), exitFlushed]).then(() =>
312
+ process.exit(code),
313
+ )
314
+ })
315
+ },
316
+ terminate ? 150 : 10,
317
+ )
318
+ }
319
+ function failed(error: unknown) {
320
+ if (closing) return
321
+ reportDetached(options.detach, {
322
+ type: 'failed',
323
+ error: String((error as Error)?.message ?? error),
324
+ })
325
+ console.error(
326
+ `mirab spawn: ${error instanceof Error ? error.message : String(error)}`,
327
+ )
328
+ finish(1)
329
+ }
330
+ process.on('SIGTERM', () => finish(143))
331
+ process.on('SIGHUP', () => finish(129))
332
+ // Raw-mode Ctrl-C goes down stdin. An externally delivered SIGINT hangs up mirab.
333
+ process.on('SIGINT', () => finish(130))
334
+ // A detached worker outlives the `mirab spawn --detach` that started it.
335
+ if (!options.detach) process.on('disconnect', () => finish(1))
336
+ process.on('uncaughtException', failed)
337
+ process.on('unhandledRejection', failed)
338
+ process.on('exit', () => {
339
+ restore()
340
+ try {
341
+ killGroup('SIGKILL')
342
+ } catch {}
343
+ })
344
+
345
+ async function fallback(error: unknown) {
346
+ if (options.vm) {
347
+ // Never quietly run an agent that was asked to be isolated on the host.
348
+ const message = `--vm failed: ${(error as Error)?.message ?? error}`
349
+ restore()
350
+ // After restore: `mirab spawn --detach` returns on this message, with the session already gone.
351
+ await reportDetached(options.detach, { type: 'failed', error: message })
352
+ console.error(`mirab: ${message}`)
353
+ process.exit(1)
354
+ }
355
+ debugFallback(error)
356
+ // No terminal to fall back to: a detached session either has a PTY or fails.
357
+ if (options.detach) return failed(error)
358
+ restore()
359
+ ownsSocket = false
360
+ closing = true
361
+ // Native stdio belongs to the child; PTY lifecycle handlers must not exit first.
362
+ process.removeAllListeners('SIGINT')
363
+ process.removeAllListeners('SIGTERM')
364
+ process.removeAllListeners('SIGHUP')
365
+ const binary = realAgent(options.agent)
366
+ const code = await runNative(
367
+ binary,
368
+ options.args,
369
+ process.cwd(),
370
+ agentEnvironment(binary, options.envAllowlist),
371
+ )
372
+ process.exit(code)
373
+ }
374
+ async function main() {
375
+ if (
376
+ !options.detach &&
377
+ (!process.stdin.isTTY || !process.stdout.isTTY || !supportsPty())
378
+ )
379
+ return fallback('PTY unavailable')
380
+ let pty: typeof import('node-pty') | undefined
381
+ try {
382
+ // Multipass sessions drive `multipass exec` on a host pty.
383
+ if (!options.vm || usesMultipass(options.vm.configDir)) {
384
+ const nodePty = options.ptyModule
385
+ if (!nodePty)
386
+ throw new Error(
387
+ 'spawn needs the node-pty module (run mirab from source with bun install for now).',
388
+ )
389
+ const require = createRequire(nodePty)
390
+ // Keep the macOS helper executable, including installs with restrictive modes.
391
+ const ptyRoot = dirname(nodePty)
392
+ if (process.platform === 'darwin') {
393
+ const helper = join(
394
+ ptyRoot,
395
+ '..',
396
+ 'prebuilds',
397
+ `${process.platform}-${process.arch}`,
398
+ 'spawn-helper',
399
+ )
400
+ try {
401
+ chmodSync(helper, 0o755)
402
+ } catch (error) {
403
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
404
+ }
405
+ }
406
+ pty = require(nodePty)
407
+ }
408
+ } catch (error) {
409
+ return fallback(error)
410
+ }
411
+ const directory = await privateSessionsDirectory()
412
+ path = socketPath(options.id, directory)
413
+ server.on('connection', (socket) => {
414
+ sockets.add(socket)
415
+ socket.on('close', () => {
416
+ sockets.delete(socket)
417
+ subscribers.delete(socket)
418
+ })
419
+ socket.on('error', () => socket.destroy())
420
+ socket.setTimeout(3000, () => socket.destroy())
421
+ socket.setEncoding('utf8')
422
+ let data = ''
423
+ let replied = false
424
+ socket.on('data', (chunk: string) => {
425
+ if (replied) {
426
+ if (!closing) host?.feed(socket, chunk)
427
+ return
428
+ }
429
+ data += chunk
430
+ if (Buffer.byteLength(data) > MAX_MESSAGE_BYTES) {
431
+ replied = true
432
+ socket.end('{"error":"Message exceeds 64 KiB"}\n')
433
+ return
434
+ }
435
+ if (!data.includes('\n')) return
436
+ replied = true
437
+ try {
438
+ const request = JSON.parse(data.slice(0, data.indexOf('\n')))
439
+ if (
440
+ closing &&
441
+ (['hook', 'send', 'sync'].includes(request.type) ||
442
+ (request.type === 'attach' && request.mode !== 'watch'))
443
+ )
444
+ throw new Error('Session is finishing')
445
+ if (request.type === 'hook') {
446
+ if (adapter && request.agent === adapter.name) {
447
+ const event = adapter.translate(options.id, request.raw)
448
+ if (event) {
449
+ publish(event)
450
+ adapterReceive?.(request.raw)
451
+ }
452
+ }
453
+ socket.end()
454
+ return
455
+ }
456
+ if (request.type === 'sync') {
457
+ if (!child || !('dispose' in child))
458
+ throw new Error('sync applies to mirab spawn --vm sessions only')
459
+ socket.setTimeout(0)
460
+ void child.sync().then(
461
+ (r) => socket.end(JSON.stringify(summary(r)) + '\n'),
462
+ (e: Error) =>
463
+ socket.end(JSON.stringify({ error: e.message }) + '\n'),
464
+ )
465
+ return
466
+ }
467
+ if (request.type === 'info') {
468
+ if (!child) throw new Error('Session is starting')
469
+ const info: LocalSession = {
470
+ id: options.id,
471
+ agent: options.agent,
472
+ ...(options.identity ? { identity: options.identity } : {}),
473
+ ...(options.profile
474
+ ? {
475
+ profile: options.profile.name,
476
+ channels:
477
+ bridge?.channels ??
478
+ options.profile.channels?.map((repo) => ({
479
+ repo,
480
+ state: 'connecting' as const,
481
+ })),
482
+ }
483
+ : {}),
484
+ cwd: process.cwd(),
485
+ pid: process.pid,
486
+ childPid: 'dispose' in child ? -1 : child.pid,
487
+ started,
488
+ ...state,
489
+ held: queue?.held,
490
+ nudges: watchdog?.status(),
491
+ ...host?.info,
492
+ }
493
+ socket.end(JSON.stringify(info) + '\n')
494
+ } else if (
495
+ request.type === 'send' &&
496
+ typeof request.message === 'string'
497
+ ) {
498
+ if (!queue) throw new Error('Session is starting')
499
+ queue.enqueue(request.message)
500
+ socket.end('{"queued":true}\n')
501
+ } else if (request.type === 'attach') {
502
+ if (!host || !child)
503
+ throw new Error(
504
+ host
505
+ ? 'Session is starting'
506
+ : 'Only detached sessions can be attached',
507
+ )
508
+ host.attach(socket, request)
509
+ if (!closing) host.feed(socket, data.slice(data.indexOf('\n') + 1))
510
+ } else if (request.type === 'stop') {
511
+ if (!child) throw new Error('Session is starting')
512
+ subscribers.add(socket)
513
+ socket.setTimeout(0)
514
+ stop(request.graceMs)
515
+ } else if (request.type === 'subscribe') {
516
+ subscribers.add(socket)
517
+ socket.setTimeout(0)
518
+ // Channel problems that happened before subscribing stay visible.
519
+ for (const channel of bridge?.channels ?? [])
520
+ if (channel.state === 'error')
521
+ socket.write(
522
+ JSON.stringify({
523
+ type: 'channel',
524
+ session: options.id,
525
+ repo: channel.repo,
526
+ state: channel.state,
527
+ error: channel.error,
528
+ ts: new Date().toISOString(),
529
+ snapshot: true,
530
+ }) + '\n',
531
+ )
532
+ // An initial snapshot makes subscribing before the next hook useful.
533
+ if (state.state !== 'unknown')
534
+ socket.write(
535
+ JSON.stringify({
536
+ session: options.id,
537
+ agent: adapter?.name ?? options.agent,
538
+ type: state.state,
539
+ tool: state.tool,
540
+ ts: state.lastActivity,
541
+ raw: { source: 'snapshot' },
542
+ }) + '\n',
543
+ )
544
+ } else throw new Error('Invalid session request')
545
+ } catch (error) {
546
+ socket.end(JSON.stringify({ error: (error as Error).message }) + '\n')
547
+ }
548
+ })
549
+ })
550
+ for (let suffix = 1, attempt = 0; ;) {
551
+ path = socketPath(options.id, directory)
552
+ try {
553
+ await new Promise<void>((resolve, reject) => {
554
+ const error = (e: Error) => reject(e)
555
+ server.once('error', error)
556
+ server.listen(path, () => {
557
+ server.off('error', error)
558
+ ownsSocket = true
559
+ resolve()
560
+ })
561
+ })
562
+ break
563
+ } catch (error) {
564
+ if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
565
+ if (attempt === 0 && (await removeDeadSocket(path))) {
566
+ attempt++
567
+ continue
568
+ }
569
+ if (options.profile && suffix < 999999) {
570
+ options.id = `${options.profile.name}-${++suffix}`
571
+ attempt = 0
572
+ continue
573
+ }
574
+ throw new Error(
575
+ `Session ${options.id} already exists; use another --name`,
576
+ )
577
+ }
578
+ }
579
+ await chmod(path, 0o600)
580
+ server.on('error', failed)
581
+ const binary = realAgent(options.agent)
582
+ const env = agentEnvironment(binary, options.envAllowlist)
583
+ const baseEnv = { ...env }
584
+ try {
585
+ const prepared = await adapter?.prepare(
586
+ {
587
+ id: options.id,
588
+ directory,
589
+ command: options.hookCommand,
590
+ emit: publish,
591
+ environment: env,
592
+ onCleanup: (cleanup) => {
593
+ adapterCleanup = cleanup
594
+ if (closing) {
595
+ cleanup()
596
+ throw new Error('Session closed during adapter setup')
597
+ }
598
+ },
599
+ },
600
+ options.args,
601
+ )
602
+ adapterCleanup = prepared?.cleanup
603
+ adapterReceive = prepared?.receive
604
+ if (closing) {
605
+ prepared?.cleanup()
606
+ return
607
+ }
608
+ if (options.detach) {
609
+ host = new DetachedHost({
610
+ ...options.detach,
611
+ resize: (cols, rows) => child?.resize(cols, rows),
612
+ input: (data) => {
613
+ quiet?.input()
614
+ queue?.userInput(data)
615
+ },
616
+ })
617
+ host.openLog(join(directory, options.id))
618
+ }
619
+ child =
620
+ options.vm && usesMultipass(options.vm.configDir)
621
+ ? await startMultipassChild({
622
+ pty: pty!,
623
+ configDir: options.vm.configDir,
624
+ sessionId: options.id,
625
+ cwd: process.cwd(),
626
+ binary,
627
+ // The user's own arguments: adapter hook files live on the host.
628
+ args: options.args,
629
+ env: guestEnvironment(env, baseEnv, options.envAllowlist),
630
+ agent: options.profile?.name ?? options.identity?.split('/')[1],
631
+ cols: options.detach?.cols ?? (process.stdout.columns || 80),
632
+ rows: options.detach?.rows ?? (process.stdout.rows || 24),
633
+ log: (line) => console.error(`mirab: ${line}`),
634
+ })
635
+ : options.vm
636
+ ? await startVmChild({
637
+ configDir: options.vm.configDir,
638
+ station: await vmStation(options.vm.configDir),
639
+ sessionId: options.id,
640
+ cwd: process.cwd(),
641
+ binary,
642
+ args: prepared?.args ?? options.args,
643
+ env: guestEnvironment(env, baseEnv, options.envAllowlist),
644
+ cols: options.detach?.cols ?? (process.stdout.columns || 80),
645
+ rows: options.detach?.rows ?? (process.stdout.rows || 24),
646
+ log: (line) => console.error(`mirab: ${line}`),
647
+ })
648
+ : pty!.spawn(binary, prepared?.args ?? options.args, {
649
+ name: 'xterm-256color',
650
+ cols: options.detach?.cols ?? process.stdout.columns,
651
+ rows: options.detach?.rows ?? process.stdout.rows,
652
+ cwd: process.cwd(),
653
+ env,
654
+ encoding: null,
655
+ })
656
+ // Output from the first byte on, so attach can replay the start.
657
+ if (host) child.onData((data) => host!.push(data as unknown as Buffer))
658
+ child.onData((data) => quiet?.output(data as unknown as Buffer))
659
+ } catch (error) {
660
+ return fallback(error)
661
+ }
662
+ if (reportConfig) reporter = new StateReporter(await reportConfig!, 'unknown')
663
+ if (!options.detach) {
664
+ process.stdin.setRawMode(true)
665
+ rawOwned = true
666
+ }
667
+ if (options.printId && !options.detach) console.error(options.id)
668
+ queue = new InjectionQueue(
669
+ (data) => child!.write(data),
670
+ options.idleMs,
671
+ () => !adapter || state.state === 'idle',
672
+ !!adapter,
673
+ submitted,
674
+ // Claude reports UserPromptSubmit through hooks; Codex only reports turn end.
675
+ adapter?.name !== 'claude',
676
+ adapter?.name === 'claude',
677
+ () =>
678
+ // Never left 'busy' on a guess nothing confirmed: report and fall back.
679
+ publish({
680
+ session: options.id,
681
+ agent: adapter!.name,
682
+ type: 'idle',
683
+ ts: new Date().toISOString(),
684
+ raw: { source: 'unconfirmed_submit' },
685
+ }),
686
+ )
687
+ const target = bridgeConfig ? await bridgeConfig : undefined
688
+ watchdog = startWatchdog({
689
+ specs: options.nudges ?? [],
690
+ session: options.id,
691
+ agent: options.identity,
692
+ channels: options.profile?.channels ?? [],
693
+ target,
694
+ enqueue: (text) => queue!.enqueue(text),
695
+ emit: broadcast,
696
+ })
697
+ if (watchdog) watchdog.state(state.state)
698
+ if (target && options.profile) {
699
+ bridge = new MentionBridge({
700
+ client: new GildClient(target.server + '/api/v1', target.token),
701
+ agent: target.agent,
702
+ label: options.profile.name,
703
+ gild: promptMirab(options.hookCommand),
704
+ session: options.id,
705
+ repos: options.profile.channels ?? [],
706
+ triggers: options.profile.on ?? [],
707
+ file: join(directory, `${options.id}.mentions.json`),
708
+ enqueue: (text, typed) => queue!.enqueue(text, typed),
709
+ watch: watchdog?.repos,
710
+ observe: (repo, events) => watchdog?.events(repo, events),
711
+ emit: (event) => {
712
+ broadcast(event)
713
+ watchdog?.bridge(event)
714
+ },
715
+ })
716
+ void bridge.start(bridgeAbort.signal)
717
+ }
718
+ if (host) {
719
+ child.onExit(({ exitCode, signal }) =>
720
+ finish(signal ? 128 + signal : exitCode, false),
721
+ )
722
+ reportDetached(options.detach, { type: 'ready', id: options.id })
723
+ return
724
+ }
725
+ process.stdin.on('data', (data: Buffer) => {
726
+ quiet?.input()
727
+ queue!.userInput(data)
728
+ })
729
+ process.stdin.on('end', () => finish(0))
730
+ process.stdin.resume()
731
+ process.on('SIGWINCH', () =>
732
+ child!.resize(process.stdout.columns || 80, process.stdout.rows || 24),
733
+ )
734
+ child.onData((data) => output.push(data))
735
+ process.stdout.on('error', failed)
736
+ child.onExit(({ exitCode, signal }) => {
737
+ finish(signal ? 128 + signal : exitCode, false)
738
+ })
739
+ }
740
+ function summary(r: SyncResult): SyncSummary {
741
+ return {
742
+ written: r.written.length,
743
+ deleted: r.deleted.length,
744
+ rejected: r.rejected.length,
745
+ conflicts: r.conflicts.slice(0, 100),
746
+ held: r.held.slice(0, 100),
747
+ }
748
+ }
749
+ /** `mirab stop`: the agent gets the same hangup a closed terminal gives it,
750
+ * then SIGKILL after the grace period; its exit then ends the session. */
751
+ let stopping: ReturnType<typeof setTimeout> | undefined
752
+ function stop(graceMs: unknown) {
753
+ if (stopping || closing) return
754
+ const grace =
755
+ Number.isSafeInteger(graceMs) && (graceMs as number) >= 0
756
+ ? Math.min(graceMs as number, 60000)
757
+ : 3000
758
+ killGroup('SIGHUP')
759
+ stopping = setTimeout(() => {
760
+ killGroup('SIGKILL')
761
+ // Should the PTY never report the exit, end the session anyway.
762
+ setTimeout(() => finish(137), 2000)
763
+ }, grace)
764
+ }
765
+ const started = new Date().toISOString()
766
+ main().catch(failed)