mirab 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +223 -3
  3. package/package.json +56 -7
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
  106. package/index.js +0 -1
@@ -0,0 +1,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
+ }