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