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.
- package/LICENSE +201 -0
- package/README.md +226 -2
- package/package.json +58 -4
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- 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)
|