@ours.network/fleet 0.9.3 → 0.9.5
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/README.md +52 -30
- package/dist/cli.js +153 -15
- package/dist/config.d.ts +24 -0
- package/dist/config.js +79 -1
- package/dist/docs.d.ts +7 -0
- package/dist/docs.js +177 -0
- package/dist/doctor.js +50 -6
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.js +31 -1
- package/dist/harness/codex.js +40 -2
- package/dist/harness/types.d.ts +12 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/monitor.d.ts +35 -5
- package/dist/monitor.js +187 -33
- package/dist/runner.js +73 -18
- package/dist/session/acp.d.ts +49 -0
- package/dist/session/acp.js +280 -0
- package/dist/session/control.d.ts +41 -0
- package/dist/session/control.js +218 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +20 -0
- package/dist/session/tmux.js +46 -0
- package/dist/session/types.d.ts +47 -0
- package/dist/session/types.js +1 -0
- package/dist/spawn.d.ts +5 -0
- package/dist/spawn.js +24 -1
- package/package.json +7 -2
package/dist/monitor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
// Code constants (not config — YAGNI, design §2).
|
|
@@ -9,6 +9,15 @@ const BOOT_GRACE_MS = 15_000; // hold injection until the TUI is up
|
|
|
9
9
|
const POST_VERIFY_MS = 1_000;
|
|
10
10
|
const MAX_ENTER_RETRIES = 2;
|
|
11
11
|
const MODAL_RETRY_MS = 5_000;
|
|
12
|
+
// How long delivery may wait on a modal before giving up. Unbounded waiting made
|
|
13
|
+
// any modal — real or false-positive — wedge wake delivery forever while
|
|
14
|
+
// `.monitor-status` still read `armed`, so the failure was invisible from outside.
|
|
15
|
+
// 2 minutes: long enough that a human answering a real dialog is not punished with
|
|
16
|
+
// a spurious `degraded`, short enough that a wedge shows up in the status while
|
|
17
|
+
// it still matters. Giving up drops nothing — the events stay covered by
|
|
18
|
+
// unread.json / the SessionStart backlog, and the next wake retries.
|
|
19
|
+
const MODAL_GIVE_UP_MS = 120_000;
|
|
20
|
+
const MAX_MODAL_WAITS = Math.floor(MODAL_GIVE_UP_MS / MODAL_RETRY_MS);
|
|
12
21
|
// Keys that reset the composer to empty before we type a wake, so a human's
|
|
13
22
|
// unsubmitted keystrokes can't concatenate with — or wedge (e.g. via an open
|
|
14
23
|
// slash-command menu that captures Enter) — the injected line. C-e moves to end
|
|
@@ -148,18 +157,57 @@ export function formatNotificationLine(events) {
|
|
|
148
157
|
].filter(Boolean).join(', ');
|
|
149
158
|
return `${PREFIX} ${compact} — run get_messages`;
|
|
150
159
|
}
|
|
160
|
+
// ─── Modal-dialog detection (design §3.2, refined empirically) ────────────────
|
|
161
|
+
//
|
|
162
|
+
// `❯` is Claude Code's ordinary composer prompt, so it is on screen in nearly
|
|
163
|
+
// every capture. Testing for it *anywhere* in the pane therefore says nothing;
|
|
164
|
+
// paired with "a numbered line anywhere", it flagged every prose list ("1) foo
|
|
165
|
+
// 2) bar") and every markdown step list as a dialog. What actually distinguishes
|
|
166
|
+
// a select dialog is that its pointer sits ON one of the numbered options.
|
|
167
|
+
//
|
|
168
|
+
// `[^\S\n]` is horizontal whitespace: unlike `\s` it cannot span a line break,
|
|
169
|
+
// so these patterns can't stitch a `❯` in the composer onto a number ten lines up.
|
|
170
|
+
/** `❯ 1. Yes` — the pointer on a numbered option. The shape of every CC dialog. */
|
|
171
|
+
const POINTED_OPTION = /❯[^\S\n]*\d+[.)][^\S\n]+\S/;
|
|
172
|
+
/** A dialog's own chrome. Neither is sufficient alone — see `looksModal`. */
|
|
173
|
+
const DIALOG_MARKERS = [/\bDo you want\b/i, /\bEnter to confirm\b/i];
|
|
174
|
+
/**
|
|
175
|
+
* A numbered option as a dialog paints it: line start (or a box border), then the
|
|
176
|
+
* number. Anchored so prose can't match mid-sentence ("…rewrote 3. Then we…").
|
|
177
|
+
*/
|
|
178
|
+
const OPTION_LINE = /^[^\S\n]*(?:[│┃|][^\S\n]*)?(?:❯[^\S\n]*)?\d+[.)][^\S\n]+\S/;
|
|
179
|
+
/** How far from a marker line the options may sit (footers trail them, questions lead). */
|
|
180
|
+
const OPTION_WINDOW = 10;
|
|
181
|
+
/** Options rendered inline on the marker's own line: "…? 1. Yes 2. No". */
|
|
182
|
+
const INLINE_OPTION = /\d+[.)][^\S\n]+\S/g;
|
|
151
183
|
/**
|
|
152
184
|
* Heuristic: does the pane show a modal selection dialog we must not `Enter`
|
|
153
|
-
* into?
|
|
154
|
-
*
|
|
155
|
-
*
|
|
185
|
+
* into? Two independent signals, both requiring the *option* shape, not just a
|
|
186
|
+
* loose numbered line:
|
|
187
|
+
*
|
|
188
|
+
* 1. the `❯` pointer sitting on a numbered option — `❯ 1. Use this MCP server`;
|
|
189
|
+
* 2. a dialog marker ("Do you want …", "Enter to confirm") with ≥2 numbered
|
|
190
|
+
* options within `OPTION_WINDOW` lines — this still catches a dialog captured
|
|
191
|
+
* mid-redraw, before its pointer row is painted.
|
|
192
|
+
*
|
|
193
|
+
* A running turn, a prose list, and a markdown step list are all NOT modal.
|
|
194
|
+
* Erring modal is the safe direction (a wake is retried; an `Enter` into a live
|
|
195
|
+
* permission dialog is not undoable), which is why signal 2 is kept — but a bare
|
|
196
|
+
* marker with no options no longer suffices, because Claude Code closes turns
|
|
197
|
+
* with exactly that prose ("Do you want me to open the PR?").
|
|
156
198
|
*/
|
|
157
199
|
export function looksModal(pane) {
|
|
158
|
-
if (
|
|
200
|
+
if (POINTED_OPTION.test(pane))
|
|
159
201
|
return true;
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
202
|
+
const lines = pane.split('\n');
|
|
203
|
+
const marker = lines.findIndex(l => DIALOG_MARKERS.some(re => re.test(l)));
|
|
204
|
+
if (marker < 0)
|
|
205
|
+
return false;
|
|
206
|
+
const from = Math.max(0, marker - OPTION_WINDOW);
|
|
207
|
+
const near = lines.slice(from, marker + OPTION_WINDOW + 1);
|
|
208
|
+
if (near.filter(l => OPTION_LINE.test(l)).length >= 2)
|
|
209
|
+
return true;
|
|
210
|
+
return (lines[marker].match(INLINE_OPTION) ?? []).length >= 2;
|
|
163
211
|
}
|
|
164
212
|
/**
|
|
165
213
|
* Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
|
|
@@ -194,12 +242,16 @@ function stillInComposer(pane, line) {
|
|
|
194
242
|
}
|
|
195
243
|
export class Monitor {
|
|
196
244
|
name;
|
|
245
|
+
identity;
|
|
197
246
|
cfg;
|
|
198
247
|
deps;
|
|
199
248
|
ep;
|
|
200
249
|
statusPath;
|
|
201
250
|
cursorPath;
|
|
251
|
+
statePath;
|
|
202
252
|
cursor = null;
|
|
253
|
+
deliveredCursor = null;
|
|
254
|
+
pendingState = null;
|
|
203
255
|
fatal = false;
|
|
204
256
|
stopped = false;
|
|
205
257
|
bootDeadline = 0;
|
|
@@ -210,16 +262,25 @@ export class Monitor {
|
|
|
210
262
|
turnFailThreshold;
|
|
211
263
|
constructor(o) {
|
|
212
264
|
this.name = o.name;
|
|
265
|
+
this.identity = o.identity ?? o.name;
|
|
213
266
|
this.cfg = o.cfg;
|
|
214
267
|
this.deps = o.deps;
|
|
215
268
|
this.ep = resolveEndpoint(o.deps.env);
|
|
216
269
|
this.statusPath = join(o.agentDir, '.monitor-status');
|
|
217
270
|
this.cursorPath = join(o.agentDir, '.notify-cursor');
|
|
271
|
+
this.statePath = join(o.agentDir, '.monitor-state.json');
|
|
218
272
|
const n = o.cfg.turn_fail_threshold;
|
|
219
273
|
this.turnFailThreshold = typeof n === 'number' && n >= 1 ? n : DEFAULT_TURN_FAIL_THRESHOLD;
|
|
220
274
|
}
|
|
221
|
-
/**
|
|
275
|
+
/** Resume the last delivered cursor; only a brand-new monitor primes at stream tip. */
|
|
222
276
|
async prime() {
|
|
277
|
+
const persisted = this.readPersistedCursor();
|
|
278
|
+
if (persisted !== null) {
|
|
279
|
+
this.cursor = persisted;
|
|
280
|
+
this.deliveredCursor = persisted;
|
|
281
|
+
this.setStatus('armed');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
223
284
|
try {
|
|
224
285
|
const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
|
|
225
286
|
this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
|
|
@@ -232,7 +293,7 @@ export class Monitor {
|
|
|
232
293
|
this.setStatus(`failed: ${e.message}`);
|
|
233
294
|
}
|
|
234
295
|
else {
|
|
235
|
-
this.cursor =
|
|
296
|
+
this.cursor = null;
|
|
236
297
|
this.setStatus(`degraded: prime failed (${msg(e)})`);
|
|
237
298
|
}
|
|
238
299
|
}
|
|
@@ -243,6 +304,7 @@ export class Monitor {
|
|
|
243
304
|
return;
|
|
244
305
|
this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
|
|
245
306
|
let backoff = 0;
|
|
307
|
+
const pending = [];
|
|
246
308
|
while (!this.stopped) {
|
|
247
309
|
if (!this.deps.isAlive(pid)) {
|
|
248
310
|
this.setStatus('degraded: session offline');
|
|
@@ -266,12 +328,35 @@ export class Monitor {
|
|
|
266
328
|
await this.deps.sleep(backoff);
|
|
267
329
|
continue;
|
|
268
330
|
}
|
|
269
|
-
this.advance(body.cursor);
|
|
331
|
+
this.advance(body.cursor, false);
|
|
270
332
|
const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
|
|
271
|
-
|
|
333
|
+
pending.push(...batch);
|
|
334
|
+
if (pending.length === 0) {
|
|
335
|
+
this.persistCursor();
|
|
272
336
|
continue;
|
|
273
|
-
|
|
274
|
-
|
|
337
|
+
}
|
|
338
|
+
this.pendingState = {
|
|
339
|
+
count: pending.length,
|
|
340
|
+
eventTypes: uniq(pending.map(event => event.event ?? 'unknown')),
|
|
341
|
+
attempts: (this.pendingState?.attempts ?? 0) + 1,
|
|
342
|
+
};
|
|
343
|
+
this.persistState();
|
|
344
|
+
await this.coalesce(pending);
|
|
345
|
+
// Do not durably commit this cursor until the session explicitly accepts
|
|
346
|
+
// the wake. If delivery fails or the runner crashes, the daemon replays
|
|
347
|
+
// from the last committed cursor and the wake is attempted again.
|
|
348
|
+
let accepted = false;
|
|
349
|
+
try {
|
|
350
|
+
accepted = await this.deliver(pid, pending);
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
this.setStatus(`degraded: delivery failed (${msg(e)})`);
|
|
354
|
+
}
|
|
355
|
+
if (accepted) {
|
|
356
|
+
pending.length = 0;
|
|
357
|
+
this.pendingState = null;
|
|
358
|
+
this.persistCursor();
|
|
359
|
+
}
|
|
275
360
|
}
|
|
276
361
|
}
|
|
277
362
|
stop() {
|
|
@@ -288,19 +373,31 @@ export class Monitor {
|
|
|
288
373
|
return;
|
|
289
374
|
try {
|
|
290
375
|
const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS);
|
|
291
|
-
this.advance(more.cursor);
|
|
376
|
+
this.advance(more.cursor, false);
|
|
292
377
|
batch.push(...filterEvents(more.events ?? [], this.cfg.wake_sources));
|
|
293
378
|
}
|
|
294
379
|
catch { /* no stragglers / abort — deliver what we have */ }
|
|
295
380
|
}
|
|
296
381
|
async deliver(pid, batch) {
|
|
382
|
+
const line = formatNotificationLine(batch);
|
|
383
|
+
if (this.deps.delivery) {
|
|
384
|
+
const result = await this.deps.delivery.submit(line);
|
|
385
|
+
if (!result.accepted) {
|
|
386
|
+
this.setStatus(`degraded: ACP prompt not accepted${result.detail ? ` (${result.detail})` : ''}`);
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
this.recordTurn('completed');
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
297
392
|
const state = await this.awaitInjectable(pid);
|
|
298
393
|
if (state !== 'ready') {
|
|
299
394
|
if (state === 'offline')
|
|
300
395
|
this.setStatus('degraded: offline during delivery');
|
|
301
|
-
|
|
396
|
+
else if (state === 'modal')
|
|
397
|
+
this.setStatus(`degraded: modal wedge — pane held a dialog for ` +
|
|
398
|
+
`${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
|
|
399
|
+
return false;
|
|
302
400
|
}
|
|
303
|
-
const line = formatNotificationLine(batch);
|
|
304
401
|
await this.clearComposer(); // start from an empty composer
|
|
305
402
|
await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
|
|
306
403
|
let delivered = false;
|
|
@@ -309,8 +406,12 @@ export class Monitor {
|
|
|
309
406
|
// dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
|
|
310
407
|
for (let i = 0; i < MAX_ENTER_RETRIES; i++) {
|
|
311
408
|
await this.deps.sleep(POST_VERIFY_MS);
|
|
312
|
-
const
|
|
313
|
-
if (!
|
|
409
|
+
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
410
|
+
if (!capture.ok) {
|
|
411
|
+
this.setStatus('degraded: capture failed during injection verification');
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
if (!stillInComposer(capture.pane, line)) {
|
|
314
415
|
delivered = true;
|
|
315
416
|
break;
|
|
316
417
|
}
|
|
@@ -318,12 +419,13 @@ export class Monitor {
|
|
|
318
419
|
}
|
|
319
420
|
if (!delivered) {
|
|
320
421
|
this.setStatus('degraded: injection unverified');
|
|
321
|
-
return;
|
|
422
|
+
return false;
|
|
322
423
|
}
|
|
323
424
|
// The wake landed and a turn started; observe how that turn terminates so a
|
|
324
425
|
// refusal-wedge (every turn dies with `API Error:` while delivery stays green)
|
|
325
426
|
// becomes visible in `.monitor-status` instead of masquerading as armed (#19).
|
|
326
427
|
await this.observeTurnOutcome(pid);
|
|
428
|
+
return true;
|
|
327
429
|
}
|
|
328
430
|
/**
|
|
329
431
|
* Watch the pane until the just-triggered turn settles, then fold its outcome
|
|
@@ -338,12 +440,16 @@ export class Monitor {
|
|
|
338
440
|
return; // shutting down — leave status
|
|
339
441
|
if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
|
|
340
442
|
return; // loop marks offline
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
443
|
+
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
444
|
+
if (!capture.ok) {
|
|
445
|
+
this.setStatus('degraded: capture failed during turn observation');
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (looksApiError(capture.pane)) {
|
|
343
449
|
this.recordTurn('api-error');
|
|
344
450
|
return;
|
|
345
451
|
}
|
|
346
|
-
if (!looksRunning(pane)) {
|
|
452
|
+
if (!looksRunning(capture.pane)) {
|
|
347
453
|
this.recordTurn('completed');
|
|
348
454
|
return;
|
|
349
455
|
}
|
|
@@ -374,8 +480,14 @@ export class Monitor {
|
|
|
374
480
|
for (const key of COMPOSER_CLEAR_KEYS)
|
|
375
481
|
await this.deps.tmux.sendKey(this.name, key);
|
|
376
482
|
}
|
|
377
|
-
/**
|
|
483
|
+
/**
|
|
484
|
+
* Block until the console can accept input; classify offline/stopped/ready, or
|
|
485
|
+
* `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
|
|
486
|
+
* what keeps a modal from wedging delivery silently: we still never `Enter` into
|
|
487
|
+
* the dialog, but the give-up is reported instead of retried forever.
|
|
488
|
+
*/
|
|
378
489
|
async awaitInjectable(pid) {
|
|
490
|
+
let modalWaits = 0;
|
|
379
491
|
for (;;) {
|
|
380
492
|
if (this.stopped)
|
|
381
493
|
return 'stopped';
|
|
@@ -386,12 +498,17 @@ export class Monitor {
|
|
|
386
498
|
await this.deps.sleep(this.bootDeadline - now);
|
|
387
499
|
continue;
|
|
388
500
|
}
|
|
389
|
-
const
|
|
390
|
-
if (
|
|
501
|
+
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
502
|
+
if (!capture.ok) {
|
|
503
|
+
this.setStatus('degraded: capture failed while checking session readiness');
|
|
391
504
|
await this.deps.sleep(MODAL_RETRY_MS);
|
|
392
505
|
continue;
|
|
393
506
|
}
|
|
394
|
-
|
|
507
|
+
if (!looksModal(capture.pane))
|
|
508
|
+
return 'ready';
|
|
509
|
+
if (modalWaits++ >= MAX_MODAL_WAITS)
|
|
510
|
+
return 'modal';
|
|
511
|
+
await this.deps.sleep(MODAL_RETRY_MS);
|
|
395
512
|
}
|
|
396
513
|
}
|
|
397
514
|
async doFetch(since, holdMs) {
|
|
@@ -400,7 +517,7 @@ export class Monitor {
|
|
|
400
517
|
const timer = this.deps.timers.set(() => ctrl.abort(), holdMs);
|
|
401
518
|
let resp;
|
|
402
519
|
try {
|
|
403
|
-
resp = await this.deps.fetch(`${this.ep.url(this.
|
|
520
|
+
resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
|
|
404
521
|
}
|
|
405
522
|
finally {
|
|
406
523
|
this.deps.timers.clear(timer);
|
|
@@ -412,16 +529,22 @@ export class Monitor {
|
|
|
412
529
|
throw new Error(`daemon returned HTTP ${resp.status}`);
|
|
413
530
|
return resp.json();
|
|
414
531
|
}
|
|
415
|
-
advance(cursor) {
|
|
532
|
+
advance(cursor, persist = true) {
|
|
416
533
|
if (typeof cursor === 'number' && cursor !== this.cursor) {
|
|
417
534
|
this.cursor = cursor;
|
|
418
|
-
|
|
535
|
+
if (persist)
|
|
536
|
+
this.persistCursor();
|
|
537
|
+
else
|
|
538
|
+
this.persistState();
|
|
419
539
|
}
|
|
420
540
|
}
|
|
421
541
|
persistCursor() {
|
|
422
542
|
try {
|
|
423
|
-
if (this.cursor !== null)
|
|
543
|
+
if (this.cursor !== null) {
|
|
544
|
+
this.deliveredCursor = this.cursor;
|
|
424
545
|
writeFileSync(this.cursorPath, `${this.cursor}\n`);
|
|
546
|
+
this.persistState();
|
|
547
|
+
}
|
|
425
548
|
}
|
|
426
549
|
catch (e) {
|
|
427
550
|
this.deps.log(`[${this.name}] monitor: failed to persist cursor: ${msg(e)}`);
|
|
@@ -429,15 +552,46 @@ export class Monitor {
|
|
|
429
552
|
}
|
|
430
553
|
readPersistedCursor() {
|
|
431
554
|
try {
|
|
555
|
+
if (existsSync(this.statePath)) {
|
|
556
|
+
const state = JSON.parse(readFileSync(this.statePath, 'utf8'));
|
|
557
|
+
if ((state.identity !== undefined && state.identity !== this.identity)
|
|
558
|
+
|| (state.profileKey !== undefined && state.profileKey !== this.ep.origin))
|
|
559
|
+
return null;
|
|
560
|
+
if (typeof state.deliveredCursor === 'number') {
|
|
561
|
+
this.deliveredCursor = state.deliveredCursor;
|
|
562
|
+
return state.deliveredCursor;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
432
565
|
if (!existsSync(this.cursorPath))
|
|
433
566
|
return null;
|
|
434
567
|
const n = parseInt(readFileSync(this.cursorPath, 'utf8').trim(), 10);
|
|
568
|
+
if (Number.isFinite(n))
|
|
569
|
+
this.deliveredCursor = n;
|
|
435
570
|
return Number.isFinite(n) ? n : null;
|
|
436
571
|
}
|
|
437
572
|
catch {
|
|
438
573
|
return null;
|
|
439
574
|
}
|
|
440
575
|
}
|
|
576
|
+
/** Atomically persist body-free delivery state; restart always resumes from deliveredCursor. */
|
|
577
|
+
persistState() {
|
|
578
|
+
try {
|
|
579
|
+
const tmp = `${this.statePath}.${process.pid}.tmp`;
|
|
580
|
+
writeFileSync(tmp, JSON.stringify({
|
|
581
|
+
version: 1,
|
|
582
|
+
identity: this.identity,
|
|
583
|
+
profileKey: this.ep.origin,
|
|
584
|
+
observedCursor: this.cursor,
|
|
585
|
+
deliveredCursor: this.deliveredCursor,
|
|
586
|
+
pending: this.pendingState,
|
|
587
|
+
updatedAt: new Date(this.deps.now()).toISOString(),
|
|
588
|
+
}, null, 2) + '\n', { mode: 0o600 });
|
|
589
|
+
renameSync(tmp, this.statePath);
|
|
590
|
+
}
|
|
591
|
+
catch (e) {
|
|
592
|
+
this.deps.log(`[${this.name}] monitor: failed to persist state: ${msg(e)}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
441
595
|
setStatus(s) {
|
|
442
596
|
try {
|
|
443
597
|
writeFileSync(this.statusPath, `${s}\n`);
|
|
@@ -454,10 +608,10 @@ export function createMonitor(o) {
|
|
|
454
608
|
}
|
|
455
609
|
async function safeCapture(tmux, name) {
|
|
456
610
|
try {
|
|
457
|
-
return await tmux.capture(name);
|
|
611
|
+
return { ok: true, pane: await tmux.capture(name) };
|
|
458
612
|
}
|
|
459
613
|
catch {
|
|
460
|
-
return '';
|
|
614
|
+
return { ok: false, pane: '' };
|
|
461
615
|
}
|
|
462
616
|
}
|
|
463
617
|
const msg = (e) => e?.message ?? String(e);
|
package/dist/runner.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
5
|
import { agentDir, home, stateRoot } from './paths.js';
|
|
6
|
-
import { loadConfig, findRole } from './config.js';
|
|
6
|
+
import { loadConfig, findRole, resolvePermissions } from './config.js';
|
|
7
7
|
import { getAdapter } from './harness/registry.js';
|
|
8
8
|
import { Tmux } from './tmux.js';
|
|
9
9
|
import { createMonitor } from './monitor.js';
|
|
@@ -11,6 +11,9 @@ import { realExec, shq } from './exec.js';
|
|
|
11
11
|
import { resolveIsolation } from './isolation/policy.js';
|
|
12
12
|
import { selectIsolationBackend } from './isolation/registry.js';
|
|
13
13
|
import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
|
|
14
|
+
import { AcpSession } from './session/acp.js';
|
|
15
|
+
import { RoleControlServer } from './session/control.js';
|
|
16
|
+
import { TmuxSession } from './session/tmux.js';
|
|
14
17
|
const defaultDeps = () => ({
|
|
15
18
|
tmux: new Tmux(),
|
|
16
19
|
exec: realExec,
|
|
@@ -182,10 +185,17 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
182
185
|
writeFileSync(bootedFile, '');
|
|
183
186
|
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
184
187
|
const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
|
|
185
|
-
const
|
|
188
|
+
const sessionBackend = role.session ?? 'tmux';
|
|
189
|
+
const launch = sessionBackend === 'acp'
|
|
190
|
+
? (() => {
|
|
191
|
+
if (!adapter.buildAcpLaunch)
|
|
192
|
+
throw new Error(`harness '${role.harness}' does not support the ACP session backend`);
|
|
193
|
+
return adapter.buildAcpLaunch(role, prep);
|
|
194
|
+
})()
|
|
195
|
+
: adapter.buildLaunch(role, mode, { sessionId }, prep);
|
|
186
196
|
// Isolation is additive: only roles that declare `isolation:` are wrapped. The
|
|
187
197
|
// env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
|
|
188
|
-
let
|
|
198
|
+
let wrappedArgv = launch.argv;
|
|
189
199
|
if (role.isolation) {
|
|
190
200
|
const addDirs = role.harness === 'codex'
|
|
191
201
|
? (role.harness_options?.add_dirs ?? [])
|
|
@@ -204,14 +214,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
204
214
|
deps.log(`[${name}] isolation: ${sel.backend.id} (net=${policy.network}) ${sel.detail}`);
|
|
205
215
|
rmSync(degradedMarker, { force: true });
|
|
206
216
|
}
|
|
207
|
-
|
|
217
|
+
wrappedArgv = sel.backend.wrap(launch.argv, policy, ctx);
|
|
208
218
|
// Resource caps wrap the sandbox from OUTSIDE, at the pane's own cgroup scope
|
|
209
219
|
// (§5.4). Applies even when the sandbox degraded to none.
|
|
210
220
|
const { argv: rprefix, warnings } = resourceArgs(policy.resources, deps.cpuDelegated());
|
|
211
221
|
for (const w of warnings)
|
|
212
222
|
deps.log(`[${name}] WARNING ${w}`);
|
|
213
223
|
if (rprefix.length)
|
|
214
|
-
|
|
224
|
+
wrappedArgv = [...rprefix, ...wrappedArgv];
|
|
215
225
|
}
|
|
216
226
|
// Start-stagger: space this launch at least start_stagger_ms after the previous
|
|
217
227
|
// agent launch across the whole host, so a burst of boots (systemd starts every
|
|
@@ -232,34 +242,79 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
232
242
|
// (backlog before the tip is the SessionStart hook's job). Disabled roles keep
|
|
233
243
|
// the legacy in-session watch. Temp snapshots predating `monitor:` are treated
|
|
234
244
|
// as disabled (monitor may be undefined on an old role.yaml).
|
|
245
|
+
const resolvedMonitorDeps = monitorDeps(deps, role.env);
|
|
235
246
|
const monitor = role.monitor?.enabled ? deps.createMonitor({
|
|
236
|
-
name, agentDir: dir, cfg: role.monitor,
|
|
237
|
-
deps:
|
|
247
|
+
name, identity: role.identity, agentDir: dir, cfg: role.monitor,
|
|
248
|
+
deps: resolvedMonitorDeps,
|
|
238
249
|
}) : null;
|
|
239
250
|
if (monitor)
|
|
240
251
|
await monitor.prime();
|
|
241
252
|
rmSync(exitFile, { force: true });
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
let pid;
|
|
254
|
+
let sessionHandle;
|
|
255
|
+
let acpSession;
|
|
256
|
+
let control;
|
|
257
|
+
if (sessionBackend === 'acp') {
|
|
258
|
+
acpSession = await AcpSession.start({
|
|
259
|
+
name,
|
|
260
|
+
argv: wrappedArgv,
|
|
261
|
+
cwd: runCwd,
|
|
262
|
+
env: { ...launch.env, ...(role.env ?? {}) },
|
|
263
|
+
stateDir: dir,
|
|
264
|
+
mode,
|
|
265
|
+
permissions: role.permissions ?? resolvePermissions(undefined, undefined),
|
|
266
|
+
log: deps.log,
|
|
267
|
+
});
|
|
268
|
+
pid = acpSession.pid;
|
|
269
|
+
sessionHandle = acpSession;
|
|
270
|
+
control = new RoleControlServer(dir, acpSession, deps.log);
|
|
271
|
+
await control.start();
|
|
272
|
+
resolvedMonitorDeps.delivery = {
|
|
273
|
+
submit: async (text) => {
|
|
274
|
+
const result = await acpSession.submitPrompt(text);
|
|
275
|
+
return { accepted: result.accepted, detail: result.detail };
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
const firstPrompt = mode === 'fresh'
|
|
279
|
+
? `Read and follow ${join(dir, 'briefing.md')} now.`
|
|
280
|
+
: adapter.vocabulary.restartPrompt(role.identity, join(dir, 'WORKLOG.md'), role);
|
|
281
|
+
const started = await acpSession.submitPrompt(firstPrompt);
|
|
282
|
+
if (!started.accepted) {
|
|
283
|
+
monitor?.stop();
|
|
284
|
+
await control.close();
|
|
285
|
+
await acpSession.close();
|
|
286
|
+
throw new Error(`[${name}] ACP session rejected startup prompt: ${started.detail ?? started.outcome}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
await deps.tmux.kill(name);
|
|
291
|
+
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, wrappedArgv));
|
|
292
|
+
let panePid = null;
|
|
293
|
+
for (let i = 0; i < 40 && panePid === null; i++) {
|
|
294
|
+
panePid = await deps.tmux.panePid(name);
|
|
295
|
+
if (panePid === null)
|
|
296
|
+
await deps.sleep(250);
|
|
297
|
+
}
|
|
298
|
+
if (panePid === null)
|
|
299
|
+
throw new Error(`[${name}] could not resolve tmux pane pid`);
|
|
300
|
+
pid = panePid;
|
|
301
|
+
sessionHandle = new TmuxSession(name, pid, deps.tmux, deps.isAlive);
|
|
249
302
|
}
|
|
250
|
-
|
|
251
|
-
throw new Error(`[${name}] could not resolve tmux pane pid`);
|
|
252
|
-
deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} mode=${mode}`);
|
|
303
|
+
deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
|
|
253
304
|
// The monitor loop lives exactly as long as the session: it starts once the
|
|
254
305
|
// pane pid is known and is stopped when that pid dies (task dies with runner).
|
|
255
306
|
const monitorLoop = monitor?.run(pid);
|
|
256
307
|
const start = deps.now();
|
|
257
|
-
while (
|
|
308
|
+
while (sessionHandle.isAlive())
|
|
258
309
|
await deps.sleep(2000);
|
|
259
310
|
if (monitor) {
|
|
260
311
|
monitor.stop();
|
|
261
312
|
await monitorLoop;
|
|
262
313
|
}
|
|
314
|
+
if (control)
|
|
315
|
+
await control.close();
|
|
316
|
+
if (acpSession)
|
|
317
|
+
await acpSession.close();
|
|
263
318
|
const elapsed = (deps.now() - start) / 1000;
|
|
264
319
|
const code = existsSync(exitFile) ? readFileSync(exitFile, 'utf8').trim() : 'crash';
|
|
265
320
|
const rotate = (why) => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CommonPermissions } from '../config.js';
|
|
2
|
+
import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
|
|
3
|
+
export interface AcpSessionOptions {
|
|
4
|
+
name: string;
|
|
5
|
+
argv: string[];
|
|
6
|
+
cwd: string;
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
stateDir: string;
|
|
9
|
+
mode: 'fresh' | 'resume';
|
|
10
|
+
permissions: CommonPermissions;
|
|
11
|
+
log(line: string): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
|
|
15
|
+
* human/automation attachment happens through the fleet role-control protocol.
|
|
16
|
+
*/
|
|
17
|
+
export declare class AcpSession implements SessionHandle {
|
|
18
|
+
private readonly options;
|
|
19
|
+
readonly backend: "acp";
|
|
20
|
+
readonly pid: number;
|
|
21
|
+
private readonly child;
|
|
22
|
+
private readonly events;
|
|
23
|
+
private readonly sessionFile;
|
|
24
|
+
private readonly pendingPermissions;
|
|
25
|
+
private connection;
|
|
26
|
+
private sessionId?;
|
|
27
|
+
private readiness;
|
|
28
|
+
private lastError?;
|
|
29
|
+
private promptTail;
|
|
30
|
+
private capabilities?;
|
|
31
|
+
private controllerCount;
|
|
32
|
+
private constructor();
|
|
33
|
+
static start(options: AcpSessionOptions): Promise<AcpSession>;
|
|
34
|
+
isAlive(): boolean;
|
|
35
|
+
snapshot(): SessionSnapshot;
|
|
36
|
+
submitPrompt(text: string): Promise<TurnResult>;
|
|
37
|
+
interrupt(): Promise<void>;
|
|
38
|
+
respondPermission(permissionId: string, optionId: string): boolean;
|
|
39
|
+
eventsSince(seq: number): SessionEvent[];
|
|
40
|
+
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
41
|
+
setControllerAttached(attached: boolean): void;
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
private initialize;
|
|
44
|
+
private runPrompt;
|
|
45
|
+
private requestPermission;
|
|
46
|
+
private withinAutomaticBoundary;
|
|
47
|
+
private recordUpdate;
|
|
48
|
+
private fail;
|
|
49
|
+
}
|