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,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
fchmodSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeSync,
|
|
13
|
+
} from 'node:fs'
|
|
14
|
+
import { basename, dirname, join } from 'node:path'
|
|
15
|
+
import type { Socket } from 'node:net'
|
|
16
|
+
import type { RecoveryLocation } from './isolation/sync'
|
|
17
|
+
|
|
18
|
+
/** Worker side of `mirab spawn --detach`: output kept while nobody watches,
|
|
19
|
+
* and the viewers of `mirab attach`. The terminal-facing client is spawn-attach.ts. */
|
|
20
|
+
export const DEFAULT_COLS = 120
|
|
21
|
+
export const DEFAULT_ROWS = 40
|
|
22
|
+
export const RING_BYTES = 256 * 1024
|
|
23
|
+
export const LOG_BYTES = 8 * 1024 * 1024
|
|
24
|
+
const MAX_VIEWER_BACKLOG = 1024 * 1024
|
|
25
|
+
/** Ended sessions whose output is kept (newest first); each is at most LOG_BYTES. */
|
|
26
|
+
export const KEPT_LOGS = 10
|
|
27
|
+
const ENDED_LOG = '.output.log'
|
|
28
|
+
|
|
29
|
+
/** Where an ended session's output stays: `<sessions>/<id>.output.log` (0600). */
|
|
30
|
+
export const endedLogPath = (sessions: string, id: string) =>
|
|
31
|
+
join(sessions, id + ENDED_LOG)
|
|
32
|
+
|
|
33
|
+
/** Keep only the newest `keep` ended-session logs in `sessions`. */
|
|
34
|
+
export function pruneEndedLogs(sessions: string, keep = KEPT_LOGS) {
|
|
35
|
+
const logs = readdirSync(sessions)
|
|
36
|
+
.filter((name) => name.endsWith(ENDED_LOG))
|
|
37
|
+
.map((name) => {
|
|
38
|
+
const path = join(sessions, name)
|
|
39
|
+
try {
|
|
40
|
+
return { path, mtime: statSync(path).mtimeMs }
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.filter((log) => log !== undefined)
|
|
46
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
47
|
+
for (const { path } of logs.slice(keep)) rmSync(path, { force: true })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The most recent PTY output, bounded; replayed to whoever attaches. */
|
|
51
|
+
export class OutputRing {
|
|
52
|
+
private chunks: Buffer[] = []
|
|
53
|
+
private size = 0
|
|
54
|
+
constructor(private readonly limit = RING_BYTES) {}
|
|
55
|
+
push(data: Buffer) {
|
|
56
|
+
this.chunks.push(data)
|
|
57
|
+
this.size += data.length
|
|
58
|
+
while (this.size > this.limit) {
|
|
59
|
+
const over = this.size - this.limit
|
|
60
|
+
const first = this.chunks[0]
|
|
61
|
+
if (first.length <= over) {
|
|
62
|
+
this.chunks.shift()
|
|
63
|
+
this.size -= first.length
|
|
64
|
+
} else {
|
|
65
|
+
this.chunks[0] = first.subarray(over)
|
|
66
|
+
this.size -= over
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
snapshot() {
|
|
71
|
+
return Buffer.concat(this.chunks)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Append-only 0600 log; at the cap the file becomes `.1` (replacing the
|
|
76
|
+
* previous one) and a new file starts, so disk use stays bounded. */
|
|
77
|
+
export class OutputLog {
|
|
78
|
+
private fd: number | undefined
|
|
79
|
+
private size = 0
|
|
80
|
+
constructor(
|
|
81
|
+
readonly path: string,
|
|
82
|
+
private readonly cap = LOG_BYTES,
|
|
83
|
+
) {
|
|
84
|
+
this.open()
|
|
85
|
+
}
|
|
86
|
+
private open() {
|
|
87
|
+
this.fd = openSync(this.path, 'a', 0o600)
|
|
88
|
+
fchmodSync(this.fd, 0o600)
|
|
89
|
+
this.size = 0
|
|
90
|
+
}
|
|
91
|
+
write(data: Buffer) {
|
|
92
|
+
if (this.fd === undefined) return
|
|
93
|
+
try {
|
|
94
|
+
if (data.length > this.cap) data = data.subarray(data.length - this.cap)
|
|
95
|
+
if (this.size + data.length > this.cap) {
|
|
96
|
+
closeSync(this.fd)
|
|
97
|
+
renameSync(this.path, this.path + '.1')
|
|
98
|
+
this.open()
|
|
99
|
+
}
|
|
100
|
+
for (let at = 0; at < data.length;)
|
|
101
|
+
at += writeSync(this.fd!, data, at, data.length - at)
|
|
102
|
+
this.size += data.length
|
|
103
|
+
} catch {
|
|
104
|
+
// A full disk must not take the agent down with it.
|
|
105
|
+
this.close()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
close() {
|
|
109
|
+
if (this.fd === undefined) return
|
|
110
|
+
try {
|
|
111
|
+
closeSync(this.fd)
|
|
112
|
+
} catch {}
|
|
113
|
+
this.fd = undefined
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type Viewer = { interactive: boolean; pending: string }
|
|
118
|
+
export type DetachedOptions = {
|
|
119
|
+
cols: number
|
|
120
|
+
rows: number
|
|
121
|
+
ringBytes?: number
|
|
122
|
+
logBytes?: number
|
|
123
|
+
resize: (cols: number, rows: number) => void
|
|
124
|
+
input: (data: Buffer) => void
|
|
125
|
+
}
|
|
126
|
+
const dimension = (value: unknown, fallback: number) =>
|
|
127
|
+
Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 1000
|
|
128
|
+
? (value as number)
|
|
129
|
+
: fallback
|
|
130
|
+
|
|
131
|
+
export class DetachedHost {
|
|
132
|
+
private readonly ring: OutputRing
|
|
133
|
+
private readonly viewers = new Map<Socket, Viewer>()
|
|
134
|
+
private log: OutputLog | undefined
|
|
135
|
+
private directory: string | undefined
|
|
136
|
+
constructor(private readonly options: DetachedOptions) {
|
|
137
|
+
this.ring = new OutputRing(options.ringBytes)
|
|
138
|
+
}
|
|
139
|
+
/** Created after the agent adapter, which owns `mkdir` of this directory. */
|
|
140
|
+
openLog(directory: string) {
|
|
141
|
+
this.directory = directory
|
|
142
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
143
|
+
chmodSync(directory, 0o700)
|
|
144
|
+
this.log = new OutputLog(
|
|
145
|
+
join(directory, 'output.log'),
|
|
146
|
+
this.options.logBytes,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
get info() {
|
|
150
|
+
return {
|
|
151
|
+
detached: true as const,
|
|
152
|
+
viewers: this.viewers.size,
|
|
153
|
+
interactive: [...this.viewers.values()].some((v) => v.interactive),
|
|
154
|
+
cols: this.options.cols,
|
|
155
|
+
rows: this.options.rows,
|
|
156
|
+
...(this.log ? { log: this.log.path } : {}),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
push(data: Buffer) {
|
|
160
|
+
this.ring.push(data)
|
|
161
|
+
this.log?.write(data)
|
|
162
|
+
for (const socket of this.viewers.keys()) {
|
|
163
|
+
if (socket.writableLength + data.length > MAX_VIEWER_BACKLOG)
|
|
164
|
+
socket.destroy()
|
|
165
|
+
else socket.write(data)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
has(socket: Socket) {
|
|
169
|
+
return this.viewers.has(socket)
|
|
170
|
+
}
|
|
171
|
+
/** Replay and registration happen in one tick, so no byte is lost or doubled. */
|
|
172
|
+
attach(socket: Socket, request: Record<string, unknown>) {
|
|
173
|
+
const interactive = request.mode !== 'watch'
|
|
174
|
+
if (request.mode !== 'watch' && request.mode !== 'interactive')
|
|
175
|
+
throw new Error('Invalid attach mode')
|
|
176
|
+
if (interactive && this.info.interactive)
|
|
177
|
+
throw new Error(
|
|
178
|
+
'Another viewer is attached interactively; use mirab attach --watch',
|
|
179
|
+
)
|
|
180
|
+
socket.setTimeout(0)
|
|
181
|
+
socket.write(
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
attached: true,
|
|
184
|
+
mode: request.mode,
|
|
185
|
+
cols: this.options.cols,
|
|
186
|
+
rows: this.options.rows,
|
|
187
|
+
}) + '\n',
|
|
188
|
+
)
|
|
189
|
+
socket.write(this.ring.snapshot())
|
|
190
|
+
this.viewers.set(socket, { interactive, pending: '' })
|
|
191
|
+
socket.once('close', () => this.drop(socket))
|
|
192
|
+
if (interactive)
|
|
193
|
+
this.options.resize(
|
|
194
|
+
dimension(request.cols, this.options.cols),
|
|
195
|
+
dimension(request.rows, this.options.rows),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
/** Viewer frames are JSON lines: {t:'i',d:<base64 keystrokes>} or {t:'r',cols,rows}. */
|
|
199
|
+
feed(socket: Socket, chunk: string) {
|
|
200
|
+
const viewer = this.viewers.get(socket)
|
|
201
|
+
if (!viewer) return
|
|
202
|
+
viewer.pending += chunk
|
|
203
|
+
if (viewer.pending.length > MAX_VIEWER_BACKLOG) return socket.destroy()
|
|
204
|
+
for (let end; (end = viewer.pending.indexOf('\n')) >= 0;) {
|
|
205
|
+
const line = viewer.pending.slice(0, end)
|
|
206
|
+
viewer.pending = viewer.pending.slice(end + 1)
|
|
207
|
+
let frame: { t?: string; d?: string; cols?: number; rows?: number }
|
|
208
|
+
try {
|
|
209
|
+
frame = JSON.parse(line)
|
|
210
|
+
} catch {
|
|
211
|
+
return socket.destroy()
|
|
212
|
+
}
|
|
213
|
+
if (!viewer.interactive) continue
|
|
214
|
+
if (frame.t === 'i' && typeof frame.d === 'string')
|
|
215
|
+
this.options.input(Buffer.from(frame.d, 'base64'))
|
|
216
|
+
else if (frame.t === 'r')
|
|
217
|
+
this.options.resize(
|
|
218
|
+
dimension(frame.cols, this.options.cols),
|
|
219
|
+
dimension(frame.rows, this.options.rows),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
private drop(socket: Socket) {
|
|
224
|
+
const viewer = this.viewers.get(socket)
|
|
225
|
+
if (!viewer) return
|
|
226
|
+
this.viewers.delete(socket)
|
|
227
|
+
// Detaching returns the agent to the fixed size nobody is watching at.
|
|
228
|
+
if (viewer.interactive)
|
|
229
|
+
this.options.resize(this.options.cols, this.options.rows)
|
|
230
|
+
}
|
|
231
|
+
/** Hands the viewers over to be ended in order (see endSocket), not reset. */
|
|
232
|
+
endViewers(): Socket[] {
|
|
233
|
+
const sockets = [...this.viewers.keys()]
|
|
234
|
+
this.viewers.clear()
|
|
235
|
+
return sockets
|
|
236
|
+
}
|
|
237
|
+
/** Removes the session's private files but keeps its latest output as
|
|
238
|
+
* `<sessions>/<id>.output.log`, so it can be read after the session ended
|
|
239
|
+
* (`mirab attach --watch <id>`); only the newest KEPT_LOGS are kept. */
|
|
240
|
+
cleanup() {
|
|
241
|
+
this.log?.close()
|
|
242
|
+
if (!this.directory) return
|
|
243
|
+
const log = this.log?.path
|
|
244
|
+
// A session that never printed (one that failed to start) leaves nothing.
|
|
245
|
+
if (log && existsSync(log) && statSync(log).size > 0) {
|
|
246
|
+
const sessions = dirname(this.directory)
|
|
247
|
+
try {
|
|
248
|
+
renameSync(log, endedLogPath(sessions, basename(this.directory)))
|
|
249
|
+
pruneEndedLogs(sessions)
|
|
250
|
+
} catch {}
|
|
251
|
+
}
|
|
252
|
+
rmSync(this.directory, { recursive: true, force: true })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The last line on the events stream of a session that has ended. */
|
|
257
|
+
export function exitLine(
|
|
258
|
+
session: string,
|
|
259
|
+
code: number,
|
|
260
|
+
recovery?: RecoveryLocation,
|
|
261
|
+
) {
|
|
262
|
+
return (
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
session,
|
|
265
|
+
type: 'exited',
|
|
266
|
+
code,
|
|
267
|
+
...(recovery ? { recovery } : {}),
|
|
268
|
+
ts: new Date().toISOString(),
|
|
269
|
+
}) + '\n'
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
/** Ends a socket and resolves once its last bytes are written (or it died). */
|
|
273
|
+
export function endSocket(socket: Socket, last?: string) {
|
|
274
|
+
return new Promise<void>((resolve) => {
|
|
275
|
+
if (socket.destroyed || socket.writableFinished) return resolve()
|
|
276
|
+
socket.once('finish', () => resolve())
|
|
277
|
+
socket.once('close', () => resolve())
|
|
278
|
+
socket.once('error', () => resolve())
|
|
279
|
+
if (!socket.writableEnded) {
|
|
280
|
+
if (last) socket.end(last)
|
|
281
|
+
else socket.end()
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
export type StartMessage =
|
|
286
|
+
{ type: 'ready'; id: string } | { type: 'failed'; error: string }
|
|
287
|
+
/** The worker tells `mirab spawn --detach` over IPC that it is ready, or why not. */
|
|
288
|
+
export function reportDetached(
|
|
289
|
+
detach: unknown,
|
|
290
|
+
message: StartMessage,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
if (!detach || !process.send || !process.connected) return Promise.resolve()
|
|
293
|
+
// Resolves once the message is handed to the IPC channel, so exiting right after cannot drop it.
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
try {
|
|
296
|
+
process.send!(message, undefined, undefined, () => resolve())
|
|
297
|
+
} catch {
|
|
298
|
+
resolve()
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** The sole local event envelope. Raw payloads never leave the local socket. */
|
|
2
|
+
export type AgentEvent = {
|
|
3
|
+
session: string
|
|
4
|
+
agent: string
|
|
5
|
+
type: 'busy' | 'idle' | 'tool_start' | 'tool_end' | 'waiting' | 'message'
|
|
6
|
+
tool?: string
|
|
7
|
+
text?: string
|
|
8
|
+
ts: string
|
|
9
|
+
raw: unknown
|
|
10
|
+
}
|
|
11
|
+
export type AgentState =
|
|
12
|
+
'unknown' | Exclude<AgentEvent['type'], 'message' | 'tool_end'>
|
|
13
|
+
export type SessionState = {
|
|
14
|
+
state: AgentState
|
|
15
|
+
tool?: string
|
|
16
|
+
lastActivity: string
|
|
17
|
+
agentSessionId?: string
|
|
18
|
+
transcriptPath?: string
|
|
19
|
+
}
|
|
20
|
+
export function applyEvent(state: SessionState, event: AgentEvent): void {
|
|
21
|
+
state.lastActivity = event.ts
|
|
22
|
+
if (event.type !== 'message') {
|
|
23
|
+
state.state = event.type === 'tool_end' ? 'busy' : event.type
|
|
24
|
+
state.tool = event.type === 'tool_start' ? event.tool : undefined
|
|
25
|
+
}
|
|
26
|
+
const raw = event.raw as Record<string, unknown> | null
|
|
27
|
+
if (raw && typeof raw === 'object') {
|
|
28
|
+
if (typeof raw.session_id === 'string')
|
|
29
|
+
state.agentSessionId = raw.session_id
|
|
30
|
+
if (typeof raw.transcript_path === 'string')
|
|
31
|
+
state.transcriptPath = raw.transcript_path
|
|
32
|
+
if (typeof raw['thread-id'] === 'string')
|
|
33
|
+
state.agentSessionId = raw['thread-id']
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createConnection } from 'node:net'
|
|
3
|
+
import type { Readable } from 'node:stream'
|
|
4
|
+
import { socketPath, MAX_MESSAGE_BYTES } from './spawn-sessions'
|
|
5
|
+
/** Deliberately independent of Commander, identity loading, API clients and PTYs. */
|
|
6
|
+
export async function forwardHook(
|
|
7
|
+
args: string[],
|
|
8
|
+
input: Readable = process.stdin,
|
|
9
|
+
schedule: (
|
|
10
|
+
done: () => void,
|
|
11
|
+
ms: number,
|
|
12
|
+
) => ReturnType<typeof setTimeout> = setTimeout,
|
|
13
|
+
) {
|
|
14
|
+
await new Promise<void>((resolve) => {
|
|
15
|
+
let socket: ReturnType<typeof createConnection> | undefined
|
|
16
|
+
let bytes = 0
|
|
17
|
+
const chunks: Buffer[] = []
|
|
18
|
+
const done = () => {
|
|
19
|
+
clearTimeout(timer)
|
|
20
|
+
input.pause()
|
|
21
|
+
input.off('data', data)
|
|
22
|
+
input.off('end', forward)
|
|
23
|
+
input.off('error', done)
|
|
24
|
+
socket?.destroy()
|
|
25
|
+
resolve()
|
|
26
|
+
}
|
|
27
|
+
const timer = schedule(done, 200)
|
|
28
|
+
const data = (chunk: Buffer) => {
|
|
29
|
+
bytes += chunk.length
|
|
30
|
+
if (bytes > MAX_MESSAGE_BYTES - 1024) done()
|
|
31
|
+
else chunks.push(Buffer.from(chunk))
|
|
32
|
+
}
|
|
33
|
+
const forward = () => {
|
|
34
|
+
try {
|
|
35
|
+
const index = args.indexOf('--session')
|
|
36
|
+
if (index < 0) return done()
|
|
37
|
+
const raw = JSON.parse(
|
|
38
|
+
args.at(-1)?.startsWith('{')
|
|
39
|
+
? args.at(-1)!
|
|
40
|
+
: Buffer.concat(chunks).toString('utf8'),
|
|
41
|
+
)
|
|
42
|
+
const agentIndex = args.indexOf('--agent')
|
|
43
|
+
const agent = agentIndex < 0 ? 'claude' : args[agentIndex + 1]
|
|
44
|
+
socket = createConnection(socketPath(args[index + 1]))
|
|
45
|
+
socket.on('error', done)
|
|
46
|
+
socket.on('connect', () =>
|
|
47
|
+
socket!.end(JSON.stringify({ type: 'hook', agent, raw }) + '\n'),
|
|
48
|
+
)
|
|
49
|
+
socket.on('close', done)
|
|
50
|
+
} catch {
|
|
51
|
+
done()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Codex appends notification JSON as argv, Claude sends stdin JSON.
|
|
55
|
+
if (args.at(-1)?.startsWith('{')) forward()
|
|
56
|
+
else {
|
|
57
|
+
input.on('data', data)
|
|
58
|
+
input.once('end', forward)
|
|
59
|
+
input.once('error', done)
|
|
60
|
+
input.resume()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runHook(args: string[]) {
|
|
66
|
+
await forwardHook(args)
|
|
67
|
+
// The forwarding deadline bounds mirab only; the caller's notifier retains its
|
|
68
|
+
// own runtime and receives exactly the payload argument Codex appended.
|
|
69
|
+
const index = args.indexOf('--notify-command')
|
|
70
|
+
if (index >= 0) {
|
|
71
|
+
const command: unknown = JSON.parse(args[index + 1])
|
|
72
|
+
if (
|
|
73
|
+
Array.isArray(command) &&
|
|
74
|
+
command.length &&
|
|
75
|
+
command.every((arg) => typeof arg === 'string')
|
|
76
|
+
) {
|
|
77
|
+
await new Promise<void>((resolve) => {
|
|
78
|
+
const child = spawn(command[0], [...command.slice(1), args.at(-1)!], {
|
|
79
|
+
stdio: 'inherit',
|
|
80
|
+
})
|
|
81
|
+
child.once('error', () => resolve())
|
|
82
|
+
child.once('exit', () => resolve())
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Conservative composer tracking; unknown editing controls never authorize injection. */
|
|
2
|
+
export class InputLine {
|
|
3
|
+
dirty = false
|
|
4
|
+
private cause = ''
|
|
5
|
+
private paste = false
|
|
6
|
+
private escape = ''
|
|
7
|
+
private pasteDirty = false
|
|
8
|
+
// Terminal replies (OSC colour, DCS version, APC…) arrive on stdin when the
|
|
9
|
+
// agent queries the terminal. They are not typing, so they never dirty the
|
|
10
|
+
// line; they end at BEL or ESC \, and a line break ends a malformed one.
|
|
11
|
+
private string: '' | 'body' | 'escape' = ''
|
|
12
|
+
feed(data: Buffer | string): boolean {
|
|
13
|
+
let submitted = false
|
|
14
|
+
for (const byte of typeof data === 'string' ? Buffer.from(data) : data) {
|
|
15
|
+
if (this.string && byte !== 10 && byte !== 13) {
|
|
16
|
+
if (byte === 7 || (this.string === 'escape' && byte === 92))
|
|
17
|
+
this.string = ''
|
|
18
|
+
else this.string = byte === 27 ? 'escape' : 'body'
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
this.string = ''
|
|
22
|
+
if (!this.paste && [3, 21, 10, 13].includes(byte)) {
|
|
23
|
+
this.escape = ''
|
|
24
|
+
this.dirty = false
|
|
25
|
+
if (byte === 10 || byte === 13) submitted = true
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if (this.escape === '\x1b' && [93, 80, 95, 94, 88].includes(byte)) {
|
|
29
|
+
this.escape = ''
|
|
30
|
+
this.string = 'body'
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (this.escape) {
|
|
34
|
+
this.escape += String.fromCharCode(byte)
|
|
35
|
+
if (
|
|
36
|
+
'\x1b[200~'.startsWith(this.escape) ||
|
|
37
|
+
'\x1b[201~'.startsWith(this.escape)
|
|
38
|
+
) {
|
|
39
|
+
if (this.escape === '\x1b[200~') {
|
|
40
|
+
this.paste = true
|
|
41
|
+
this.escape = ''
|
|
42
|
+
this.pasteDirty = false
|
|
43
|
+
} else if (this.escape === '\x1b[201~') {
|
|
44
|
+
this.paste = false
|
|
45
|
+
this.dirty ||= this.pasteDirty
|
|
46
|
+
this.escape = ''
|
|
47
|
+
}
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
// CSI / SS3 cursor and function keys can arrive in separate chunks.
|
|
51
|
+
if (this.escape === '\x1b[' || this.escape === '\x1bO') continue
|
|
52
|
+
// Full CSI grammar: parameter bytes 0x30–0x3F (digits ; < = > ?),
|
|
53
|
+
// intermediates 0x20–0x2F, then one final byte. SGR mouse reports
|
|
54
|
+
// (ESC [ < b ; x ; y M) arrive on every click and are not typing.
|
|
55
|
+
if (/^\x1b\[[\x30-\x3f]*[\x20-\x2f]*$/.test(this.escape)) continue
|
|
56
|
+
const sequence = this.escape
|
|
57
|
+
this.escape = ''
|
|
58
|
+
// Esc cancels dialogs, but does not reliably erase or precede a draft.
|
|
59
|
+
if (
|
|
60
|
+
!sequence.startsWith('\x1b[') &&
|
|
61
|
+
!sequence.startsWith('\x1bO') &&
|
|
62
|
+
byte >= 32 &&
|
|
63
|
+
byte !== 127
|
|
64
|
+
) {
|
|
65
|
+
this.dirty = true
|
|
66
|
+
this.cause = sequence
|
|
67
|
+
}
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (byte === 27) {
|
|
71
|
+
this.escape = '\x1b'
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (this.paste) {
|
|
75
|
+
this.pasteDirty = true
|
|
76
|
+
this.dirty = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (byte === 13 || byte === 10) {
|
|
80
|
+
this.dirty = false
|
|
81
|
+
submitted = true
|
|
82
|
+
} else if (byte === 21 || byte === 3) this.dirty = false
|
|
83
|
+
// Backspace/delete and cursor editing are intentionally conservative.
|
|
84
|
+
else if ((byte >= 32 && byte !== 127) || byte === 9) {
|
|
85
|
+
this.dirty = true
|
|
86
|
+
this.cause = byte === 9 ? 'tab' : 'typed'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return submitted
|
|
90
|
+
}
|
|
91
|
+
settleEscape() {
|
|
92
|
+
if (this.escape === '\x1b') this.escape = ''
|
|
93
|
+
}
|
|
94
|
+
get why() {
|
|
95
|
+
return {
|
|
96
|
+
dirty: this.dirty && this.cause,
|
|
97
|
+
paste: this.paste,
|
|
98
|
+
escape: this.escape,
|
|
99
|
+
string: this.string,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
get unsent() {
|
|
103
|
+
return (
|
|
104
|
+
this.dirty || this.paste || this.escape.length > 0 || this.string !== ''
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { constants } from 'node:os'
|
|
3
|
+
|
|
4
|
+
export function agentEnvironment() {
|
|
5
|
+
const env: Record<string, string> = {}
|
|
6
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
7
|
+
if (
|
|
8
|
+
value === undefined ||
|
|
9
|
+
/^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_CHILD_SESSION|CLAUDE_CODE_SESSION_ID|CLAUDE_CODE_PARENT_SESSION_ID|CLAUDE_CODE_BRIDGE_SESSION_ID|CLAUDE_CODE_MESSAGING_SOCKET|CLAUDE_CODE_MESSAGING_TOKEN|CLAUDE_CODE_SESSION_ATTENDED|CLAUDE_CODE_ENTRYPOINT|CODEX_SESSION_ID|CODEX_THREAD_ID)$/.test(
|
|
10
|
+
name,
|
|
11
|
+
)
|
|
12
|
+
)
|
|
13
|
+
continue
|
|
14
|
+
env[name] = value
|
|
15
|
+
}
|
|
16
|
+
return env
|
|
17
|
+
}
|
|
18
|
+
export function debugFallback(reason: unknown) {
|
|
19
|
+
if (process.env.MIRAB_DEBUG)
|
|
20
|
+
console.error(`mirab spawn: native fallback: ${String(reason)}`)
|
|
21
|
+
}
|
|
22
|
+
export function supportsPty(platform = process.platform) {
|
|
23
|
+
return platform !== 'win32'
|
|
24
|
+
}
|
|
25
|
+
/** The child shares the foreground process group: terminal SIGINT already reaches it. */
|
|
26
|
+
export async function runNative(
|
|
27
|
+
binary: string,
|
|
28
|
+
args: string[],
|
|
29
|
+
cwd = process.cwd(),
|
|
30
|
+
env = agentEnvironment(),
|
|
31
|
+
) {
|
|
32
|
+
const child = spawn(binary, args, { stdio: 'inherit', cwd, env })
|
|
33
|
+
const interrupt = () => {}
|
|
34
|
+
process.on('SIGINT', interrupt)
|
|
35
|
+
const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGHUP']
|
|
36
|
+
const handlers = signals.map((signal) => () => child.kill(signal))
|
|
37
|
+
signals.forEach((signal, i) => process.on(signal, handlers[i]))
|
|
38
|
+
try {
|
|
39
|
+
return await new Promise<number>((resolve, reject) => {
|
|
40
|
+
child.once('error', reject)
|
|
41
|
+
child.once('exit', (code, signal) =>
|
|
42
|
+
resolve(code ?? 128 + (signal ? constants.signals[signal] : 0)),
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
} finally {
|
|
46
|
+
process.off('SIGINT', interrupt)
|
|
47
|
+
signals.forEach((signal, i) => process.off(signal, handlers[i]))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { GildClient } from './api/client'
|
|
2
|
+
import type { BridgeTarget } from './spawn-bridge'
|
|
3
|
+
import { parseNudge, Watchdog, type NudgeEvent } from './spawn-nudge'
|
|
4
|
+
|
|
5
|
+
function pair(repo: string) {
|
|
6
|
+
const [owner, name] = repo.split('/')
|
|
7
|
+
return { owner, repo: name }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The worker's watchdog: channel notes and reply checks use the session's
|
|
11
|
+
* own agent identity. gild-site has no system-note route for clients yet, so a
|
|
12
|
+
* note is an ordinary message from the agent, prefixed `[mirab nudge]`. */
|
|
13
|
+
export function startWatchdog(o: {
|
|
14
|
+
specs: string[]
|
|
15
|
+
session: string
|
|
16
|
+
agent?: string
|
|
17
|
+
channels: string[]
|
|
18
|
+
target?: BridgeTarget
|
|
19
|
+
enqueue: (text: string) => void
|
|
20
|
+
emit: (event: NudgeEvent) => void
|
|
21
|
+
}) {
|
|
22
|
+
if (!o.specs.length) return undefined
|
|
23
|
+
const client = o.target
|
|
24
|
+
? new GildClient(o.target.server + '/api/v1', o.target.token)
|
|
25
|
+
: undefined
|
|
26
|
+
const agent = o.target?.agent ?? o.agent
|
|
27
|
+
return new Watchdog({
|
|
28
|
+
rules: o.specs.map(parseNudge),
|
|
29
|
+
session: o.session,
|
|
30
|
+
agent,
|
|
31
|
+
enqueue: o.enqueue,
|
|
32
|
+
emit: o.emit,
|
|
33
|
+
note:
|
|
34
|
+
client && o.channels.length
|
|
35
|
+
? async (text, repo) => {
|
|
36
|
+
const own = o.channels.find(
|
|
37
|
+
(c) => c.toLowerCase() === repo?.toLowerCase(),
|
|
38
|
+
)
|
|
39
|
+
for (const channel of own ? [own] : o.channels)
|
|
40
|
+
await client.request('channelPost', pair(channel), { body: text })
|
|
41
|
+
}
|
|
42
|
+
: undefined,
|
|
43
|
+
replied:
|
|
44
|
+
client && agent
|
|
45
|
+
? async (repo, cursor) => {
|
|
46
|
+
const page = await client.request(
|
|
47
|
+
'channelMessages',
|
|
48
|
+
pair(repo),
|
|
49
|
+
undefined,
|
|
50
|
+
{ after: cursor, limit: 200 },
|
|
51
|
+
)
|
|
52
|
+
return page.messages.some(
|
|
53
|
+
(m) =>
|
|
54
|
+
m.kind === 'message' &&
|
|
55
|
+
m.author.name.toLowerCase() === agent.toLowerCase() &&
|
|
56
|
+
!m.body.startsWith('[mirab nudge]'),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
: undefined,
|
|
60
|
+
})
|
|
61
|
+
}
|