@ours.network/fleet 0.9.4 → 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 +47 -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 +16 -1
- package/dist/monitor.js +121 -24
- 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).
|
|
@@ -242,12 +242,16 @@ function stillInComposer(pane, line) {
|
|
|
242
242
|
}
|
|
243
243
|
export class Monitor {
|
|
244
244
|
name;
|
|
245
|
+
identity;
|
|
245
246
|
cfg;
|
|
246
247
|
deps;
|
|
247
248
|
ep;
|
|
248
249
|
statusPath;
|
|
249
250
|
cursorPath;
|
|
251
|
+
statePath;
|
|
250
252
|
cursor = null;
|
|
253
|
+
deliveredCursor = null;
|
|
254
|
+
pendingState = null;
|
|
251
255
|
fatal = false;
|
|
252
256
|
stopped = false;
|
|
253
257
|
bootDeadline = 0;
|
|
@@ -258,16 +262,25 @@ export class Monitor {
|
|
|
258
262
|
turnFailThreshold;
|
|
259
263
|
constructor(o) {
|
|
260
264
|
this.name = o.name;
|
|
265
|
+
this.identity = o.identity ?? o.name;
|
|
261
266
|
this.cfg = o.cfg;
|
|
262
267
|
this.deps = o.deps;
|
|
263
268
|
this.ep = resolveEndpoint(o.deps.env);
|
|
264
269
|
this.statusPath = join(o.agentDir, '.monitor-status');
|
|
265
270
|
this.cursorPath = join(o.agentDir, '.notify-cursor');
|
|
271
|
+
this.statePath = join(o.agentDir, '.monitor-state.json');
|
|
266
272
|
const n = o.cfg.turn_fail_threshold;
|
|
267
273
|
this.turnFailThreshold = typeof n === 'number' && n >= 1 ? n : DEFAULT_TURN_FAIL_THRESHOLD;
|
|
268
274
|
}
|
|
269
|
-
/**
|
|
275
|
+
/** Resume the last delivered cursor; only a brand-new monitor primes at stream tip. */
|
|
270
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
|
+
}
|
|
271
284
|
try {
|
|
272
285
|
const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
|
|
273
286
|
this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
|
|
@@ -280,7 +293,7 @@ export class Monitor {
|
|
|
280
293
|
this.setStatus(`failed: ${e.message}`);
|
|
281
294
|
}
|
|
282
295
|
else {
|
|
283
|
-
this.cursor =
|
|
296
|
+
this.cursor = null;
|
|
284
297
|
this.setStatus(`degraded: prime failed (${msg(e)})`);
|
|
285
298
|
}
|
|
286
299
|
}
|
|
@@ -291,6 +304,7 @@ export class Monitor {
|
|
|
291
304
|
return;
|
|
292
305
|
this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
|
|
293
306
|
let backoff = 0;
|
|
307
|
+
const pending = [];
|
|
294
308
|
while (!this.stopped) {
|
|
295
309
|
if (!this.deps.isAlive(pid)) {
|
|
296
310
|
this.setStatus('degraded: session offline');
|
|
@@ -314,12 +328,35 @@ export class Monitor {
|
|
|
314
328
|
await this.deps.sleep(backoff);
|
|
315
329
|
continue;
|
|
316
330
|
}
|
|
317
|
-
this.advance(body.cursor);
|
|
331
|
+
this.advance(body.cursor, false);
|
|
318
332
|
const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
|
|
319
|
-
|
|
333
|
+
pending.push(...batch);
|
|
334
|
+
if (pending.length === 0) {
|
|
335
|
+
this.persistCursor();
|
|
320
336
|
continue;
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
}
|
|
323
360
|
}
|
|
324
361
|
}
|
|
325
362
|
stop() {
|
|
@@ -336,12 +373,22 @@ export class Monitor {
|
|
|
336
373
|
return;
|
|
337
374
|
try {
|
|
338
375
|
const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS);
|
|
339
|
-
this.advance(more.cursor);
|
|
376
|
+
this.advance(more.cursor, false);
|
|
340
377
|
batch.push(...filterEvents(more.events ?? [], this.cfg.wake_sources));
|
|
341
378
|
}
|
|
342
379
|
catch { /* no stragglers / abort — deliver what we have */ }
|
|
343
380
|
}
|
|
344
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
|
+
}
|
|
345
392
|
const state = await this.awaitInjectable(pid);
|
|
346
393
|
if (state !== 'ready') {
|
|
347
394
|
if (state === 'offline')
|
|
@@ -349,9 +396,8 @@ export class Monitor {
|
|
|
349
396
|
else if (state === 'modal')
|
|
350
397
|
this.setStatus(`degraded: modal wedge — pane held a dialog for ` +
|
|
351
398
|
`${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
|
|
352
|
-
return;
|
|
399
|
+
return false;
|
|
353
400
|
}
|
|
354
|
-
const line = formatNotificationLine(batch);
|
|
355
401
|
await this.clearComposer(); // start from an empty composer
|
|
356
402
|
await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
|
|
357
403
|
let delivered = false;
|
|
@@ -360,8 +406,12 @@ export class Monitor {
|
|
|
360
406
|
// dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
|
|
361
407
|
for (let i = 0; i < MAX_ENTER_RETRIES; i++) {
|
|
362
408
|
await this.deps.sleep(POST_VERIFY_MS);
|
|
363
|
-
const
|
|
364
|
-
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)) {
|
|
365
415
|
delivered = true;
|
|
366
416
|
break;
|
|
367
417
|
}
|
|
@@ -369,12 +419,13 @@ export class Monitor {
|
|
|
369
419
|
}
|
|
370
420
|
if (!delivered) {
|
|
371
421
|
this.setStatus('degraded: injection unverified');
|
|
372
|
-
return;
|
|
422
|
+
return false;
|
|
373
423
|
}
|
|
374
424
|
// The wake landed and a turn started; observe how that turn terminates so a
|
|
375
425
|
// refusal-wedge (every turn dies with `API Error:` while delivery stays green)
|
|
376
426
|
// becomes visible in `.monitor-status` instead of masquerading as armed (#19).
|
|
377
427
|
await this.observeTurnOutcome(pid);
|
|
428
|
+
return true;
|
|
378
429
|
}
|
|
379
430
|
/**
|
|
380
431
|
* Watch the pane until the just-triggered turn settles, then fold its outcome
|
|
@@ -389,12 +440,16 @@ export class Monitor {
|
|
|
389
440
|
return; // shutting down — leave status
|
|
390
441
|
if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
|
|
391
442
|
return; // loop marks offline
|
|
392
|
-
const
|
|
393
|
-
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)) {
|
|
394
449
|
this.recordTurn('api-error');
|
|
395
450
|
return;
|
|
396
451
|
}
|
|
397
|
-
if (!looksRunning(pane)) {
|
|
452
|
+
if (!looksRunning(capture.pane)) {
|
|
398
453
|
this.recordTurn('completed');
|
|
399
454
|
return;
|
|
400
455
|
}
|
|
@@ -443,8 +498,13 @@ export class Monitor {
|
|
|
443
498
|
await this.deps.sleep(this.bootDeadline - now);
|
|
444
499
|
continue;
|
|
445
500
|
}
|
|
446
|
-
const
|
|
447
|
-
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');
|
|
504
|
+
await this.deps.sleep(MODAL_RETRY_MS);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (!looksModal(capture.pane))
|
|
448
508
|
return 'ready';
|
|
449
509
|
if (modalWaits++ >= MAX_MODAL_WAITS)
|
|
450
510
|
return 'modal';
|
|
@@ -457,7 +517,7 @@ export class Monitor {
|
|
|
457
517
|
const timer = this.deps.timers.set(() => ctrl.abort(), holdMs);
|
|
458
518
|
let resp;
|
|
459
519
|
try {
|
|
460
|
-
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 });
|
|
461
521
|
}
|
|
462
522
|
finally {
|
|
463
523
|
this.deps.timers.clear(timer);
|
|
@@ -469,16 +529,22 @@ export class Monitor {
|
|
|
469
529
|
throw new Error(`daemon returned HTTP ${resp.status}`);
|
|
470
530
|
return resp.json();
|
|
471
531
|
}
|
|
472
|
-
advance(cursor) {
|
|
532
|
+
advance(cursor, persist = true) {
|
|
473
533
|
if (typeof cursor === 'number' && cursor !== this.cursor) {
|
|
474
534
|
this.cursor = cursor;
|
|
475
|
-
|
|
535
|
+
if (persist)
|
|
536
|
+
this.persistCursor();
|
|
537
|
+
else
|
|
538
|
+
this.persistState();
|
|
476
539
|
}
|
|
477
540
|
}
|
|
478
541
|
persistCursor() {
|
|
479
542
|
try {
|
|
480
|
-
if (this.cursor !== null)
|
|
543
|
+
if (this.cursor !== null) {
|
|
544
|
+
this.deliveredCursor = this.cursor;
|
|
481
545
|
writeFileSync(this.cursorPath, `${this.cursor}\n`);
|
|
546
|
+
this.persistState();
|
|
547
|
+
}
|
|
482
548
|
}
|
|
483
549
|
catch (e) {
|
|
484
550
|
this.deps.log(`[${this.name}] monitor: failed to persist cursor: ${msg(e)}`);
|
|
@@ -486,15 +552,46 @@ export class Monitor {
|
|
|
486
552
|
}
|
|
487
553
|
readPersistedCursor() {
|
|
488
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
|
+
}
|
|
489
565
|
if (!existsSync(this.cursorPath))
|
|
490
566
|
return null;
|
|
491
567
|
const n = parseInt(readFileSync(this.cursorPath, 'utf8').trim(), 10);
|
|
568
|
+
if (Number.isFinite(n))
|
|
569
|
+
this.deliveredCursor = n;
|
|
492
570
|
return Number.isFinite(n) ? n : null;
|
|
493
571
|
}
|
|
494
572
|
catch {
|
|
495
573
|
return null;
|
|
496
574
|
}
|
|
497
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
|
+
}
|
|
498
595
|
setStatus(s) {
|
|
499
596
|
try {
|
|
500
597
|
writeFileSync(this.statusPath, `${s}\n`);
|
|
@@ -511,10 +608,10 @@ export function createMonitor(o) {
|
|
|
511
608
|
}
|
|
512
609
|
async function safeCapture(tmux, name) {
|
|
513
610
|
try {
|
|
514
|
-
return await tmux.capture(name);
|
|
611
|
+
return { ok: true, pane: await tmux.capture(name) };
|
|
515
612
|
}
|
|
516
613
|
catch {
|
|
517
|
-
return '';
|
|
614
|
+
return { ok: false, pane: '' };
|
|
518
615
|
}
|
|
519
616
|
}
|
|
520
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
|
+
}
|