@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/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? Markers are the deployed Claude Code trust/permission dialogs a `❯`
154
- * pointer beside numbered options, or a "Do you want …" prompt (design §3.2,
155
- * open question (a): refine empirically). A running turn is NOT modal.
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 (/Do you want\b/i.test(pane))
200
+ if (POINTED_OPTION.test(pane))
159
201
  return true;
160
- const hasPointer = /❯/.test(pane);
161
- const hasNumbered = /(^|\n)\s*[❯>]?\s*\d+[.)]\s+\S/.test(pane);
162
- return hasPointer && hasNumbered;
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
- /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
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 = this.readPersistedCursor();
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
- if (batch.length === 0)
333
+ pending.push(...batch);
334
+ if (pending.length === 0) {
335
+ this.persistCursor();
272
336
  continue;
273
- await this.coalesce(batch);
274
- await this.deliver(pid, batch);
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
- return; // events remain covered by unread.json / SessionStart backlog
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 pane = await safeCapture(this.deps.tmux, this.name);
313
- if (!stillInComposer(pane, line)) {
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 pane = await safeCapture(this.deps.tmux, this.name);
342
- if (looksApiError(pane)) {
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
- /** Block until the console can accept input; classify offline/stopped/ready. */
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 pane = await safeCapture(this.deps.tmux, this.name);
390
- if (looksModal(pane)) {
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
- return 'ready';
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.name)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
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
- this.persistCursor();
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 launch = adapter.buildLaunch(role, mode, { sessionId }, prep);
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 paneArgv = launch.argv;
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
- paneArgv = sel.backend.wrap(launch.argv, policy, ctx);
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
- paneArgv = [...rprefix, ...paneArgv];
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: monitorDeps(deps, role.env),
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
- await deps.tmux.kill(name);
243
- await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, paneArgv));
244
- let pid = null;
245
- for (let i = 0; i < 40 && pid === null; i++) {
246
- pid = await deps.tmux.panePid(name);
247
- if (pid === null)
248
- await deps.sleep(250);
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
- if (pid === null)
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 (deps.isAlive(pid))
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
+ }