@ours.network/fleet 1.0.5 → 1.1.0-nightly.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +122 -133
  2. package/dist/agent-recovery-gate.d.ts +17 -0
  3. package/dist/agent-recovery-gate.js +135 -0
  4. package/dist/application/capabilities.d.ts +0 -1
  5. package/dist/application/capabilities.js +3 -13
  6. package/dist/application/fleet-query-service.d.ts +0 -3
  7. package/dist/application/fleet-query-service.js +14 -25
  8. package/dist/application/role-creation-service.d.ts +5 -24
  9. package/dist/application/role-creation-service.js +27 -67
  10. package/dist/application/role-removal-service.js +6 -3
  11. package/dist/application/role-repository.d.ts +1 -2
  12. package/dist/application/role-repository.js +7 -7
  13. package/dist/application/session-control.d.ts +0 -12
  14. package/dist/application/session-control.js +0 -42
  15. package/dist/application/session-mutations.d.ts +5 -5
  16. package/dist/application/task-room-service.d.ts +1 -1
  17. package/dist/application/task-room-service.js +154 -16
  18. package/dist/application/types.d.ts +1 -8
  19. package/dist/briefing.js +12 -11
  20. package/dist/build-info.json +5 -5
  21. package/dist/cli.js +143 -63
  22. package/dist/config-yaml.d.ts +1 -1
  23. package/dist/config.d.ts +66 -6
  24. package/dist/config.js +398 -43
  25. package/dist/creation.d.ts +1 -1
  26. package/dist/creation.js +2 -2
  27. package/dist/daemon-recovery.d.ts +93 -0
  28. package/dist/daemon-recovery.js +328 -0
  29. package/dist/docs.d.ts +1 -1
  30. package/dist/docs.js +103 -127
  31. package/dist/doctor.js +18 -11
  32. package/dist/fleet-command-audit.d.ts +160 -0
  33. package/dist/fleet-command-audit.js +433 -0
  34. package/dist/fleet-proxy.d.ts +3 -1
  35. package/dist/fleet-proxy.js +30 -8
  36. package/dist/generated-agent-source.d.ts +9 -0
  37. package/dist/generated-agent-source.js +53 -0
  38. package/dist/harness/acp-session-transport.d.ts +8 -0
  39. package/dist/harness/acp-session-transport.js +3 -0
  40. package/dist/harness/agent-session.d.ts +51 -0
  41. package/dist/harness/agent-session.js +1 -0
  42. package/dist/harness/claude-code-session.d.ts +24 -0
  43. package/dist/harness/claude-code-session.js +36 -0
  44. package/dist/harness/claude-code.d.ts +2 -1
  45. package/dist/harness/claude-code.js +41 -81
  46. package/dist/harness/codex-session.d.ts +24 -0
  47. package/dist/harness/codex-session.js +36 -0
  48. package/dist/harness/codex.d.ts +2 -1
  49. package/dist/harness/codex.js +55 -45
  50. package/dist/harness/registry.js +4 -0
  51. package/dist/harness/types.d.ts +6 -38
  52. package/dist/index.d.ts +4 -6
  53. package/dist/index.js +2 -4
  54. package/dist/init-guidance.d.ts +1 -0
  55. package/dist/init-guidance.js +6 -0
  56. package/dist/isolation/resources.d.ts +3 -7
  57. package/dist/isolation/resources.js +3 -7
  58. package/dist/model-env.d.ts +4 -6
  59. package/dist/model-env.js +5 -8
  60. package/dist/monitor.d.ts +1 -24
  61. package/dist/monitor.js +5 -140
  62. package/dist/ops.js +4 -2
  63. package/dist/owner-channel/channel.d.ts +58 -2
  64. package/dist/owner-channel/channel.js +269 -29
  65. package/dist/owner-channel/commands.d.ts +2 -0
  66. package/dist/owner-channel/commands.js +31 -13
  67. package/dist/owner-channel/state.d.ts +2 -0
  68. package/dist/owner-channel/state.js +8 -2
  69. package/dist/preset-bootstrap.d.ts +10 -0
  70. package/dist/preset-bootstrap.js +85 -0
  71. package/dist/reports/artifact.d.ts +10 -0
  72. package/dist/reports/artifact.js +32 -0
  73. package/dist/reports/index.d.ts +5 -0
  74. package/dist/reports/index.js +5 -0
  75. package/dist/reports/render.d.ts +6 -0
  76. package/dist/reports/render.js +240 -0
  77. package/dist/reports/service.d.ts +42 -0
  78. package/dist/reports/service.js +57 -0
  79. package/dist/reports/tasks.d.ts +52 -0
  80. package/dist/reports/tasks.js +177 -0
  81. package/dist/reports/types.d.ts +315 -0
  82. package/dist/reports/types.js +6 -0
  83. package/dist/resolved-plan.d.ts +3 -2
  84. package/dist/resolved-plan.js +21 -5
  85. package/dist/rooms-tasks/cli.js +225 -40
  86. package/dist/rooms-tasks/config.js +24 -13
  87. package/dist/rooms-tasks/external-worker.d.ts +1 -0
  88. package/dist/rooms-tasks/external-worker.js +14 -9
  89. package/dist/rooms-tasks/provision.js +98 -36
  90. package/dist/rooms-tasks/templates.d.ts +0 -1
  91. package/dist/rooms-tasks/templates.js +9 -76
  92. package/dist/rooms-tasks/types.d.ts +10 -16
  93. package/dist/rooms-tasks/types.js +1 -4
  94. package/dist/runner.d.ts +13 -16
  95. package/dist/runner.js +110 -122
  96. package/dist/sensitive-config.d.ts +2 -0
  97. package/dist/sensitive-config.js +4 -0
  98. package/dist/session/acp.d.ts +7 -2
  99. package/dist/session/acp.js +29 -6
  100. package/dist/session/activity.d.ts +2 -3
  101. package/dist/session/activity.js +2 -3
  102. package/dist/session/arbiter.d.ts +4 -4
  103. package/dist/session/control.d.ts +17 -4
  104. package/dist/session/control.js +35 -0
  105. package/dist/session/types.d.ts +11 -5
  106. package/dist/spawn.d.ts +8 -33
  107. package/dist/spawn.js +53 -213
  108. package/dist/supervisor/none.d.ts +1 -1
  109. package/dist/supervisor/none.js +2 -2
  110. package/dist/temp-lifecycle.d.ts +1 -1
  111. package/dist/tmux.d.ts +1 -14
  112. package/dist/tmux.js +1 -51
  113. package/dist/watchdog/config.d.ts +10 -7
  114. package/dist/watchdog/config.js +30 -18
  115. package/dist/watchdog/run.js +12 -36
  116. package/dist/web/auth.d.ts +1 -1
  117. package/dist/web/fleet-config-service.d.ts +11 -8
  118. package/dist/web/fleet-config-service.js +384 -183
  119. package/dist/web/runtime.js +4 -15
  120. package/dist/web/server.d.ts +1 -3
  121. package/dist/web/server.js +3 -14
  122. package/dist/web/topology-promote.js +26 -19
  123. package/dist/web/topology.js +2 -2
  124. package/dist/web/yaml-document-edit.js +2 -0
  125. package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
  126. package/dist/web-app/assets/index-BbTc5KtZ.js +10 -0
  127. package/dist/web-app/index.html +2 -2
  128. package/examples/fleet/agents/Alice.yaml +11 -0
  129. package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
  130. package/examples/fleet/brains/claude-default.yaml +5 -0
  131. package/examples/fleet/roles/coordinator.yaml +5 -0
  132. package/examples/fleet/roles/developer.yaml +3 -0
  133. package/examples/fleet.yaml +74 -0
  134. package/package.json +5 -8
  135. package/presets/fleet/agents/Agent.yaml +3 -0
  136. package/presets/fleet/agents/Architect.yaml +3 -0
  137. package/presets/fleet/agents/Critic.yaml +3 -0
  138. package/presets/fleet/agents/Developer.yaml +3 -0
  139. package/presets/fleet/agents/Secretary.yaml +3 -0
  140. package/presets/fleet/agents/Tester.yaml +3 -0
  141. package/presets/fleet/brains/claude-default.yaml +5 -0
  142. package/presets/fleet/roles/Agent.yaml +6 -0
  143. package/presets/fleet/roles/Architect.yaml +6 -0
  144. package/presets/fleet/roles/Critic.yaml +6 -0
  145. package/presets/fleet/roles/Developer.yaml +6 -0
  146. package/presets/fleet/roles/Secretary.yaml +6 -0
  147. package/presets/fleet/roles/Tester.yaml +6 -0
  148. package/presets/fleet/room_templates/pair.yaml +11 -0
  149. package/presets/fleet/room_templates/single.yaml +8 -0
  150. package/presets/fleet/room_templates/team.yaml +12 -0
  151. package/presets/fleet.yaml +7 -0
  152. package/presets/manifest.json +5 -0
  153. package/dist/session/tmux.d.ts +0 -28
  154. package/dist/session/tmux.js +0 -80
  155. package/dist/web/terminal/bridge.d.ts +0 -27
  156. package/dist/web/terminal/bridge.js +0 -317
  157. package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
  158. package/dist/web-app/assets/index-BCBK78hw.js +0 -10
  159. package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
package/dist/runner.js CHANGED
@@ -5,16 +5,15 @@ import { parse } from 'yaml';
5
5
  import { agentDir, stateRoot } from './paths.js';
6
6
  import { loadConfig, findRole, isolationContextFor, resolveMonitorConfig, resolvePermissions, } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
- import { Tmux } from './tmux.js';
9
8
  import { createMonitor, probeIdentityPresence, } from './monitor.js';
10
- import { realExec, shq } from './exec.js';
9
+ import { DaemonGenerationObserver, RoleRecoveryController, probeDaemonGeneration, } from './daemon-recovery.js';
10
+ import { recoverAgentIdentity } from './agent-recovery-gate.js';
11
+ import { realExec } from './exec.js';
11
12
  import { resolveIsolation } from './isolation/policy.js';
12
13
  import { selectIsolationBackend } from './isolation/registry.js';
13
14
  import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
15
  import { resolveLaunchRuntime } from './isolation/runtime.js';
15
- import { AcpSession } from './session/acp.js';
16
16
  import { controlRequest, RoleControlServer } from './session/control.js';
17
- import { TmuxSession } from './session/tmux.js';
18
17
  import { ACP_CANCEL_DEADLINE_EXCEEDED, classifyShellStatus } from './session/types.js';
19
18
  import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
20
19
  import { rotateWorklog } from './worklog.js';
@@ -27,8 +26,15 @@ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-prox
27
26
  import { effectivePermissionMode } from './permissions.js';
28
27
  import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
29
28
  import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
29
+ export const SUPERVISOR_RECYCLE_REQUIRED = 'OWNER_CHANNEL_SUPERVISOR_RECYCLE_REQUIRED';
30
+ export class SupervisorRecycleRequiredError extends Error {
31
+ code = SUPERVISOR_RECYCLE_REQUIRED;
32
+ constructor() {
33
+ super('owner channel requires a fresh supervisor process after unproven client quiescence');
34
+ this.name = 'SupervisorRecycleRequiredError';
35
+ }
36
+ }
30
37
  const defaultDeps = () => ({
31
- tmux: new Tmux(),
32
38
  exec: realExec,
33
39
  cpuDelegated: () => cpuControllerDelegated(),
34
40
  isAlive: pid => { try {
@@ -42,9 +48,10 @@ const defaultDeps = () => ({
42
48
  now: () => Date.now(),
43
49
  log: line => process.stderr.write(line + '\n'),
44
50
  fetch: (url, init) => globalThis.fetch(url, init),
51
+ probeGeneration: env => probeDaemonGeneration((url, init) => globalThis.fetch(url, init), env),
45
52
  createMonitor: opts => createMonitor(opts),
46
53
  createOwnerChannel: opts => new OwnerChannel(opts),
47
- startAcpSession: opts => AcpSession.start(opts),
54
+ startAgentSession: (adapter, options) => adapter.start(options),
48
55
  createControlServer: (stateDir, session, log) => new RoleControlServer(stateDir, session, log),
49
56
  acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
50
57
  reportOwnerStartupFailure: async (stateDir) => {
@@ -133,38 +140,10 @@ export function recordMonitorOwner(dir, owner) {
133
140
  catch { /* ownership diagnostics must never take the role down */ }
134
141
  return owner === 'fleet' && previous === 'native';
135
142
  }
136
- /**
137
- * Compose the tmux pane shell command: env prefix + argv + exit-status capture.
138
- * `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
139
- * the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
140
- * `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
141
- * still sees the real exit code.
142
- */
143
- export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
144
- const env = {
145
- PATH: process.env.PATH ?? '', COLORTERM: 'truecolor', ...launch.env, ...(roleEnv ?? {}),
146
- };
147
- delete env[OBSOLETE_OURS_AUTOSTART_ENV];
148
- // Interactive panes should advertise colour even when the supervisor itself
149
- // was launched with NO_COLOR. A role may still deliberately opt back in to
150
- // NO_COLOR (or replace COLORTERM) through its explicit env block.
151
- const unsetNoColor = Object.prototype.hasOwnProperty.call(roleEnv ?? {}, 'NO_COLOR')
152
- ? '' : '-u NO_COLOR ';
153
- const envPfx = 'env -u OURS_AUTOSTART ' + unsetNoColor
154
- + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
155
- const cmd = paneArgv.map(shq).join(' ');
156
- // Write a structured record, not a bare number: the wait status alone cannot
157
- // say whether the file is missing because the program never exited or because
158
- // nothing ever wrote it. `printf` is POSIX; no shell branching is needed
159
- // because classification happens in one place, in TypeScript.
160
- const record = `'{"version":1,"backend":"tmux","status":'"$__ofs"'}'`;
161
- return `${envPfx} ${cmd}; __ofs=$?; printf %s ${record} > ${shq(exitStatusPath)}`;
162
- }
163
143
  /** Adapt runner deps and the role's daemon-profile overrides for the monitor. */
164
144
  function monitorDeps(deps, roleEnv) {
165
145
  return {
166
146
  fetch: deps.fetch,
167
- tmux: deps.tmux,
168
147
  isAlive: deps.isAlive,
169
148
  sleep: deps.sleep,
170
149
  now: deps.now,
@@ -484,16 +463,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
484
463
  writeFileSync(bootedFile, `${new Date(deps.now()).toISOString()} ${mode}\n`);
485
464
  const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
486
465
  const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
487
- const sessionBackend = role.session ?? 'tmux';
488
- let launch = sessionBackend === 'acp'
489
- ? (() => {
490
- if (!adapter.buildAcpLaunch)
491
- throw new Error(`harness '${role.harness}' does not support the ACP session backend`);
492
- return adapter.buildAcpLaunch(role, prep);
493
- })()
494
- : adapter.buildLaunch(role, mode, { sessionId }, prep);
495
- // Isolation is additive: only roles that declare `isolation:` are wrapped. The
496
- // env prefix + exit capture in buildPaneCommand stay host-side.
466
+ const sessionBackend = role.session ?? 'acp';
467
+ let launch = adapter.agentSession.prepareLaunch(role, prep);
468
+ // Isolation is additive: only roles that declare `isolation:` are wrapped.
497
469
  let wrappedArgv = launch.argv;
498
470
  if (role.isolation) {
499
471
  // Start with the same durable context that config validation and doctor judged,
@@ -530,7 +502,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
530
502
  // user unit concurrently on boot; `ours-fleet up`/restart-all bulk-start) does not
531
503
  // hit the harness/API rate limit at once. Time-based via a shared launch gate, so
532
504
  // a lone start or a solo crash-restart waits zero. Applied right before the harness
533
- // launch (tmux.newSession); the cheap monitor prime still runs immediately after.
505
+ // agent-session start; the cheap monitor prime still runs immediately after.
534
506
  if (staggerMs > 0) {
535
507
  const slot = await reserveLaunchSlot(stateRoot(), staggerMs, deps);
536
508
  const wait = slot - deps.now();
@@ -571,11 +543,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
571
543
  name, identity: role.identity, agentDir: dir, cfg: role.monitor,
572
544
  deps: resolvedMonitorDeps,
573
545
  }) : null;
546
+ const daemonObserver = new DaemonGenerationObserver();
547
+ const initialDaemon = daemonObserver.observe(await deps.probeGeneration(resolvedMonitorDeps.env));
548
+ let sessionStartedWithoutDaemonBaseline = initialDaemon.kind !== 'baseline';
574
549
  if (monitor)
575
550
  await monitor.prime({ resetCursor: resetMonitorCursor });
576
551
  rmSync(exitFile, { force: true });
577
552
  let pid;
578
- let acpSession;
553
+ let agentSession;
579
554
  let control;
580
555
  let unsubscribeRecovery;
581
556
  let monitorLoop;
@@ -583,14 +558,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
583
558
  let sessionClosed = false;
584
559
  let ownerChannel;
585
560
  let ownerBinder;
586
- const pendingFleetSpawnNotices = [];
587
561
  let loopManager;
588
562
  let arbiter;
563
+ let recoveryController;
589
564
  let reloadLoopConfig;
590
565
  let loopGeneration = JSON.stringify((role.loops ?? []).map(loop => [
591
566
  loop.name, loop.definitionHash, loop.promptHash,
592
567
  ]));
593
- if (sessionBackend === 'acp') {
568
+ {
594
569
  const perms = role.permissions ?? resolvePermissions(undefined, undefined);
595
570
  // Say once, at startup, that this role will decide permission requests by
596
571
  // itself. Without it the only trace of an auto-denied tool call is a turn
@@ -598,34 +573,16 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
598
573
  if (perms.unattended === 'deny')
599
574
  deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
600
575
  `permission requests are automatically denied once each (reject_once) and the turn continues`);
601
- acpSession = await deps.startAcpSession({
602
- name,
603
- argv: wrappedArgv,
604
- cwd: runCwd,
605
- env: harnessChildEnv(role, launch.env, dir),
606
- stateDir: dir,
607
- mode,
608
- permissions: perms,
609
- modeId: adapter.acpPermissionModeId?.(role),
610
- // The role's declared MCP servers, and the bundled agent's `_meta`
611
- // vocabulary for the options it takes no flag for. Both come from the
612
- // ADAPTER and from `prep`: the ACP launch cannot carry `prep.argv`, so this
613
- // is the route by which harness_options that used to be silently dropped
614
- // for an ACP role actually reach the session.
615
- mcpServers: adapter.acpMcpServers?.(role),
616
- sessionMeta: adapter.acpSessionMeta?.(role, prep),
617
- permissionMode: effectivePermissionMode(role),
618
- // Provenance travels with the exact ACP launch. Keeping it out of a
619
- // role-only adapter hook prevents a PATH fallback or resolver skew from
620
- // claiming metadata trust for an argv it did not authenticate.
621
- permissionMetadataSource: launch.permissionMetadataSource,
622
- scrubObsoleteOursAutostart: true,
623
- log: deps.log,
576
+ agentSession = await deps.startAgentSession(adapter.agentSession, {
577
+ role, prep,
578
+ launch: { ...launch, argv: wrappedArgv, env: harnessChildEnv(role, launch.env, dir) },
579
+ cwd: runCwd, stateDir: dir, mode, permissions: perms,
580
+ permissionMode: effectivePermissionMode(role), log: deps.log,
624
581
  });
625
- pid = acpSession.pid;
626
- arbiter = new RoleTurnArbiter(acpSession);
582
+ pid = agentSession.pid;
583
+ arbiter = new RoleTurnArbiter(agentSession);
627
584
  sessionHandle = arbiter;
628
- unsubscribeRecovery = acpSession.subscribe(event => {
585
+ unsubscribeRecovery = agentSession.subscribe(event => {
629
586
  if (event.kind !== 'error' || !event.text)
630
587
  return;
631
588
  const evidence = classifyFailureText(event.text, 'acp', new Date(deps.now()).toISOString());
@@ -647,7 +604,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
647
604
  + `${notifyError?.message ?? String(notifyError)}`);
648
605
  }
649
606
  }
650
- await acpSession.close();
607
+ await agentSession.close();
651
608
  unsubscribeRecovery?.();
652
609
  throw new Error(`[${name}] owner channel failed to start: `
653
610
  + `${error?.message ?? String(error)}`);
@@ -659,25 +616,23 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
659
616
  }
660
617
  catch (error) {
661
618
  ownerBinder?.release();
662
- await acpSession.close();
619
+ await agentSession.close();
663
620
  unsubscribeRecovery?.();
664
621
  throw error;
665
622
  }
666
623
  control.setFleetSpawner(async (requested) => {
667
624
  const event = await executeManagedSpawn(role, configPath, requested, deps.log);
668
- if (!role.owner_channel)
669
- return event;
670
- if (ownerChannel?.notifyFleetSpawn) {
625
+ // Room-member launches are collected with their Task/Room transaction so
626
+ // causal delivery is Task → Room → Agents → Room active → Task active.
627
+ if (!requested.roomMemberStartup && role.owner_channel && ownerChannel?.notifyFleetSpawn) {
671
628
  try {
672
629
  await ownerChannel.notifyFleetSpawn(event);
673
630
  }
674
631
  catch (error) {
675
- deps.log(`[${name}] spawned-agent owner notice failed: `
632
+ deps.log(`[${name}] Agent lifecycle notice delivery failed: `
676
633
  + `${error?.message ?? String(error)}`);
677
634
  }
678
635
  }
679
- else
680
- pendingFleetSpawnNotices.push(event);
681
636
  return event;
682
637
  });
683
638
  resolvedMonitorDeps.delivery = {
@@ -740,7 +695,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
740
695
  const started = await starting;
741
696
  // A temporary role's first turn can be the active turn when an ours wake
742
697
  // needs immediate attention. A typed console/monitor cancellation ends
743
- // only that turn: the already-live ACP session and any queued wake remain
698
+ // only that turn: the already-live agent session and any queued wake remain
744
699
  // valid. Keep every unproven cancellation, refusal, shutdown, and genuine
745
700
  // failure terminal so a role that never accepted its briefing is not
746
701
  // silently reported as healthy.
@@ -749,7 +704,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
749
704
  monitor?.stop();
750
705
  await control.close();
751
706
  ownerBinder?.release();
752
- await acpSession.close();
707
+ await agentSession.close();
753
708
  unsubscribeRecovery?.();
754
709
  if (modelRecovery) {
755
710
  if (monitorLoop)
@@ -795,23 +750,19 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
795
750
  await ownerChannel.close().catch(() => undefined);
796
751
  await control.close();
797
752
  ownerBinder?.release();
798
- await acpSession.close();
753
+ await agentSession.close();
799
754
  unsubscribeRecovery?.();
800
755
  throw new Error(`[${name}] owner channel failed to start: `
801
756
  + `${error?.message ?? String(error)}`);
802
757
  }
803
- for (const event of pendingFleetSpawnNotices.splice(0)) {
804
- try {
805
- await ownerChannel.notifyFleetSpawn?.(event);
806
- }
807
- catch (error) {
808
- deps.log(`[${name}] deferred spawned-agent owner notice failed: `
809
- + `${error?.message ?? String(error)}`);
810
- }
811
- }
812
758
  }
813
- if (ownerChannel)
759
+ if (ownerChannel) {
814
760
  control.setOwnerChannel(ownerChannel);
761
+ control.setFleetAuditor({
762
+ begin: (requestId, argv) => ownerChannel.beginFleetCommandAudit(requestId, argv),
763
+ finish: input => ownerChannel.finishFleetCommandAudit(input),
764
+ });
765
+ }
815
766
  reloadLoopConfig = async () => {
816
767
  const nextRole = findRole(loadConfig(configPath), name);
817
768
  const definitions = nextRole.loops ?? [];
@@ -854,34 +805,53 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
854
805
  }
855
806
  }
856
807
  }
857
- else {
858
- await deps.tmux.kill(name);
859
- await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, wrappedArgv));
860
- let panePid = null;
861
- for (let i = 0; i < 40 && panePid === null; i++) {
862
- panePid = await deps.tmux.panePid(name);
863
- if (panePid === null)
864
- await deps.sleep(250);
865
- }
866
- if (panePid === null)
867
- throw new Error(`[${name}] could not resolve tmux pane pid`);
868
- pid = panePid;
869
- sessionHandle = new TmuxSession(name, pid, deps.tmux, deps.isAlive);
870
- }
871
808
  deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
872
809
  // The monitor loop lives exactly as long as the session: it starts once the
873
810
  // pane pid is known and is stopped when that pid dies (task dies with runner).
874
811
  monitorLoop ??= monitor?.run(pid);
812
+ recoveryController = new RoleRecoveryController({
813
+ role: name, identity: role.identity, stateDir: dir, now: deps.now, sleep: deps.sleep,
814
+ log: deps.log,
815
+ recoverAgent: async () => {
816
+ const evidence = await recoverAgentIdentity(arbiter, role.identity);
817
+ return evidence.ok ? { ok: true } : { ok: false, reason: evidence.reason };
818
+ },
819
+ recoverOwner: async (epoch) => {
820
+ if (!ownerChannel?.recover)
821
+ return { ok: true };
822
+ try {
823
+ await ownerChannel.recover(epoch);
824
+ return { ok: true };
825
+ }
826
+ catch (error) {
827
+ return { ok: false, reason: error instanceof Error
828
+ ? `OWNER_${error.name.toUpperCase()}` : 'OWNER_UNKNOWN_ERROR' };
829
+ }
830
+ },
831
+ });
875
832
  const start = deps.now();
876
833
  let nextLoopReloadAt = deps.now() + 30_000;
877
834
  let nextIdentityPollAt = deps.now();
835
+ let nextDaemonProbeAt = deps.now();
878
836
  let lastReloadError = '';
879
837
  let identityObserved = false;
880
838
  let identityAbsentSince;
881
839
  let retirementReason;
840
+ let supervisorRecycleRequired = false;
882
841
  while (sessionHandle.isAlive()) {
883
842
  await deps.sleep(temp ? 500 : 2000);
884
843
  const now = deps.now();
844
+ if (now >= nextDaemonProbeAt) {
845
+ nextDaemonProbeAt = now + 2_000;
846
+ const observation = daemonObserver.observe(await deps.probeGeneration(resolvedMonitorDeps.env));
847
+ if (observation.kind === 'lost')
848
+ recoveryController.noteLoss(observation.reason);
849
+ if (observation.kind === 'changed' || observation.kind === 'available'
850
+ || (observation.kind === 'baseline' && sessionStartedWithoutDaemonBaseline)) {
851
+ sessionStartedWithoutDaemonBaseline = false;
852
+ void recoveryController.recover(observation.generation).catch(error => deps.log(`[${name}] daemon recovery controller failed: ${error?.name ?? 'Error'}`));
853
+ }
854
+ }
885
855
  if (temp && deps.shouldStop?.()) {
886
856
  retirementReason = requestedTempStopReason(dir) ?? 'supervisor-signal';
887
857
  deps.log(`[${name}] temporary supervisor retirement requested (${retirementReason})`);
@@ -899,7 +869,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
899
869
  else if (presence.state === 'absent' && identityObserved) {
900
870
  identityAbsentSince ??= now;
901
871
  // Require a continuous, time-bounded run of authoritative absence.
902
- // The first positive observation is the readiness gate: cold tmux
872
+ // The first positive observation is the readiness gate: cold harness
903
873
  // starts may spend minutes loading the harness and briefing before the
904
874
  // agent creates/binds its identity, and absence before then is not a
905
875
  // close event. After readiness, debounce a real disappearance.
@@ -932,6 +902,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
932
902
  control?.setLoopManager(undefined);
933
903
  await loopManager.stop();
934
904
  }
905
+ // Let already-settled path operations publish their aggregate result before
906
+ // the shutdown fence invalidates the epoch. This never waits on I/O.
907
+ await Promise.resolve();
908
+ await Promise.resolve();
909
+ recoveryController?.cancel();
935
910
  control?.setConfigReloader(undefined);
936
911
  // Close the authenticated control route before releasing the binder lease;
937
912
  // otherwise the predecessor can unlink the replacement's new socket.
@@ -942,25 +917,25 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
942
917
  }
943
918
  if (ownerChannel)
944
919
  await ownerChannel.close();
945
- ownerBinder?.release();
920
+ if (ownerChannel?.binderReleaseSafe?.() === false) {
921
+ deps.log(`[${name}] owner binder retained because client quiescence was not proven at shutdown`);
922
+ supervisorRecycleRequired = true;
923
+ }
924
+ else
925
+ ownerBinder?.release();
946
926
  if (monitor) {
947
927
  monitor.stop();
948
928
  await monitorLoop;
949
929
  }
950
930
  unsubscribeRecovery?.();
951
- if (acpSession && !sessionClosed)
952
- await acpSession.close();
931
+ if (agentSession && !sessionClosed)
932
+ await agentSession.close();
953
933
  const elapsed = (deps.now() - start) / 1000;
954
934
  // Establish what actually happened before deciding anything. Absence of a
955
935
  // record is `unknown` — except when the console itself is gone, which is a
956
936
  // different event with a different consequence.
957
- const exitRecord = acpSession
958
- ? acpSession.exitResult()
959
- ?? { version: 1, class: 'unknown', detail: 'the ACP agent stopped without reporting an exit' }
960
- : readExitRecord(exitFile)
961
- ?? (await deps.tmux.has(name)
962
- ? { version: 1, class: 'unknown', detail: 'the pane process ended without writing an exit record' }
963
- : { version: 1, class: 'session-destroyed', detail: `the tmux session '${name}' no longer exists` });
937
+ const exitRecord = agentSession.exitResult()
938
+ ?? { version: 1, class: 'unknown', detail: 'the agent session stopped without reporting an exit' };
964
939
  writeFileSync(exitFile, JSON.stringify({
965
940
  ...exitRecord, at: new Date(deps.now()).toISOString(), elapsedSecs: Number(elapsed.toFixed(1)),
966
941
  }) + '\n');
@@ -996,6 +971,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
996
971
  }
997
972
  else
998
973
  deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
974
+ if (supervisorRecycleRequired)
975
+ throw new SupervisorRecycleRequiredError();
999
976
  return {
1000
977
  elapsedSecs: elapsed, exit: exitRecord, rotated, mode, modelRecovery,
1001
978
  ...(retirementReason ? { retirementReason } : {}),
@@ -1072,6 +1049,16 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1072
1049
  result = await attempt(name, { configPath: opts.configPath, allowResumeRotation: !ledger.resumeDiscarded }, deps);
1073
1050
  }
1074
1051
  catch (e) {
1052
+ if (e instanceof SupervisorRecycleRequiredError) {
1053
+ writeRestartLedger(dir, {
1054
+ ...ledger,
1055
+ lastReason: SUPERVISOR_RECYCLE_REQUIRED,
1056
+ nextDelayMs: 0,
1057
+ updatedAt: stamp(),
1058
+ });
1059
+ deps.log(`[${name}] supervisor recycle required: ${SUPERVISOR_RECYCLE_REQUIRED}`);
1060
+ throw e;
1061
+ }
1075
1062
  // A session that could not even start is an immediate failure like any
1076
1063
  // other; it must count, or an unstartable role loops forever.
1077
1064
  result = {
@@ -1165,7 +1152,7 @@ function fastFailSecsFor(name, configPath) {
1165
1152
  }
1166
1153
  }
1167
1154
  /** Temp-agent entrypoint: run once, journal why it ended, then archive its evidence. */
1168
- export async function runTemp(name, deps = {}) {
1155
+ export async function runTemp(name, deps = {}, attempt = runOnce) {
1169
1156
  const dir = agentDir(name, true);
1170
1157
  await markTempSupervisorActive(dir);
1171
1158
  let signal;
@@ -1176,7 +1163,7 @@ export async function runTemp(name, deps = {}) {
1176
1163
  let result;
1177
1164
  let failure;
1178
1165
  try {
1179
- result = await runOnce(name, { temp: true }, {
1166
+ result = await attempt(name, { temp: true }, {
1180
1167
  ...deps,
1181
1168
  shouldStop: () => Boolean(signal) || (deps.shouldStop?.() ?? false),
1182
1169
  });
@@ -1190,6 +1177,7 @@ export async function runTemp(name, deps = {}) {
1190
1177
  const requested = requestedTempStopReason(dir);
1191
1178
  const reason = requested
1192
1179
  ?? result?.retirementReason
1180
+ ?? (failure instanceof SupervisorRecycleRequiredError ? 'supervisor-recycle' : undefined)
1193
1181
  ?? (signal ? 'supervisor-signal' : failure ? 'startup-failure' : 'session-ended');
1194
1182
  // A service-manager stop can make the child connection close before the
1195
1183
  // runner reaches its normal loop. That is still a successful requested
@@ -0,0 +1,2 @@
1
+ /** Shared recursive configuration redaction boundary for operator-facing models. */
2
+ export declare function isSensitiveConfigKey(key: string): boolean;
@@ -0,0 +1,4 @@
1
+ /** Shared recursive configuration redaction boundary for operator-facing models. */
2
+ export function isSensitiveConfigKey(key) {
3
+ return /(?:secret|token|password|credential|private[_-]?key|auth|invite|api[_-]?key)/i.test(key);
4
+ }
@@ -3,7 +3,7 @@ import type { CommonPermissions } from '../config.js';
3
3
  import type { AcpMcpServer } from '../harness/types.js';
4
4
  import { ConversationEventStore } from './conversation-store.js';
5
5
  import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
6
- import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, RuntimeSelectorMetadata, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
6
+ import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, AgentSession, RuntimeSelectorMetadata, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
7
7
  /** Bound safe-boundary waiting without turning a hung tool into cancellation. */
8
8
  export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
9
9
  /**
@@ -36,6 +36,11 @@ export interface AcpSessionOptions {
36
36
  permissions: CommonPermissions;
37
37
  /** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
38
38
  modeId?: string;
39
+ /** Ordered explicit Brain choices that must be applied before readiness. */
40
+ configSelections?: Array<{
41
+ configId: string;
42
+ value: string;
43
+ }>;
39
44
  /** Adapter-resolved live permission policy; separate from ACP agent-specific session modes. */
40
45
  permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
41
46
  /** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
@@ -81,7 +86,7 @@ export declare function classifyStopReason(stopReason: string | undefined): Turn
81
86
  * Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
82
87
  * human/automation attachment happens through the fleet role-control protocol.
83
88
  */
84
- export declare class AcpSession implements SessionHandle {
89
+ export declare class AcpSession implements AgentSession {
85
90
  private readonly options;
86
91
  readonly backend: "acp";
87
92
  readonly pid: number;
@@ -281,8 +281,7 @@ export class AcpSession {
281
281
  clearTimeout(this.cancelForceKill);
282
282
  this.cancelForceKill = undefined;
283
283
  this.releaseSteeringOccupancy('adapter exited');
284
- // Record the child's real exit code/signal. The tmux path can only see a
285
- // shell's `$?`; here the truth is available, so keep it.
284
+ // Record the child's real exit code/signal while the truth is available.
286
285
  const classified = classifyChildExit(code, signal);
287
286
  this.exit = this.cancelRecoveryReason
288
287
  ? { ...classified, detail: `${this.cancelRecoveryReason}; ${classified.detail}` }
@@ -1028,13 +1027,15 @@ export class AcpSession {
1028
1027
  const persisted = this.options.mode === 'resume' && existsSync(this.sessionFile)
1029
1028
  ? readFileSync(this.sessionFile, 'utf8').trim()
1030
1029
  : '';
1030
+ let advertisedConfigOptions;
1031
1031
  if (persisted && this.capabilities?.sessionCapabilities?.resume != null) {
1032
1032
  const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
1033
1033
  sessionId: persisted,
1034
1034
  cwd: this.options.cwd,
1035
1035
  mcpServers: this.declaredMcpServers(),
1036
1036
  });
1037
- this.captureRuntimeMetadata(resumed.configOptions);
1037
+ advertisedConfigOptions = resumed.configOptions;
1038
+ this.captureRuntimeMetadata(advertisedConfigOptions);
1038
1039
  this.sessionId = persisted;
1039
1040
  }
1040
1041
  else if (persisted && this.capabilities?.loadSession) {
@@ -1047,7 +1048,8 @@ export class AcpSession {
1047
1048
  cwd: this.options.cwd,
1048
1049
  mcpServers: this.declaredMcpServers(),
1049
1050
  });
1050
- this.captureRuntimeMetadata(loaded.configOptions);
1051
+ advertisedConfigOptions = loaded.configOptions;
1052
+ this.captureRuntimeMetadata(advertisedConfigOptions);
1051
1053
  }
1052
1054
  finally {
1053
1055
  this.replaying = false;
@@ -1061,8 +1063,29 @@ export class AcpSession {
1061
1063
  ...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
1062
1064
  });
1063
1065
  this.sessionId = created.sessionId;
1064
- this.captureRuntimeMetadata(created.configOptions);
1066
+ advertisedConfigOptions = created.configOptions;
1067
+ this.captureRuntimeMetadata(advertisedConfigOptions);
1065
1068
  }
1069
+ for (const selection of this.options.configSelections ?? []) {
1070
+ if (!advertisedConfigOptions?.some(option => option.id === selection.configId))
1071
+ throw new Error(`ACP agent did not advertise required session config option '${selection.configId}'`);
1072
+ let configured;
1073
+ try {
1074
+ configured = await this.connection.agent.request(acp.methods.agent.session.setConfigOption, { sessionId: this.sessionId, configId: selection.configId, value: selection.value });
1075
+ }
1076
+ catch (error) {
1077
+ throw new Error(`ACP agent refused required session config option '${selection.configId}' value '${selection.value}': `
1078
+ + (error instanceof Error ? error.message : String(error)));
1079
+ }
1080
+ advertisedConfigOptions = configured.configOptions;
1081
+ this.captureRuntimeMetadata(advertisedConfigOptions);
1082
+ const applied = advertisedConfigOptions.find(option => option.id === selection.configId);
1083
+ if (applied?.currentValue !== selection.value)
1084
+ throw new Error(`ACP agent did not apply required session config option '${selection.configId}' value '${selection.value}'`);
1085
+ }
1086
+ // Do not persist a session until every required Brain choice is live. A
1087
+ // failed startup closes the ACP session; persisting its id first would make
1088
+ // a later resume repeatedly target that invalid session.
1066
1089
  writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
1067
1090
  // Deliver the configured permission mode whichever way the session came up
1068
1091
  // (new, resume or load) — the launch flag never reaches an ACP agent. A
@@ -1476,7 +1499,7 @@ export class AcpSession {
1476
1499
  ...(normalized.adapterMeta ? { adapterMeta: normalized.adapterMeta } : {}),
1477
1500
  });
1478
1501
  }
1479
- // ── conversation ledger access (SessionHandle) ─────────────────────────────
1502
+ // ── conversation ledger access (AgentSession) ─────────────────────────────
1480
1503
  conversationPage(request = {}) {
1481
1504
  const floor = Number(this.conversationStartCursor ?? 0);
1482
1505
  const requested = Number(request.after ?? 0);
@@ -18,9 +18,8 @@ export interface ObservedActivity {
18
18
  lastUpdateAt?: string;
19
19
  }
20
20
  /**
21
- * Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
22
- * cannot see the agent (tmux) has no evidence, and no evidence must never be
23
- * reported as "doing nothing".
21
+ * Classify agent-side activity. `unobservable` is NOT `quiet`: absent evidence
22
+ * must never be reported as "doing nothing".
24
23
  */
25
24
  export declare function classifyActivity(activity: SessionActivity | undefined, now?: number): ObservedActivity;
26
25
  /**
@@ -11,9 +11,8 @@
11
11
  */
12
12
  export const ACTIVITY_WINDOW_MS = 60_000;
13
13
  /**
14
- * Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
15
- * cannot see the agent (tmux) has no evidence, and no evidence must never be
16
- * reported as "doing nothing".
14
+ * Classify agent-side activity. `unobservable` is NOT `quiet`: absent evidence
15
+ * must never be reported as "doing nothing".
17
16
  */
18
17
  export function classifyActivity(activity, now = Date.now()) {
19
18
  if (!activity)
@@ -1,4 +1,4 @@
1
- import type { ConversationHandlePage, ExitRecord, InterruptOutcome, PromptOrigin, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
1
+ import type { ConversationHandlePage, ExitRecord, InterruptOutcome, PromptOrigin, QueuedPrompt, SessionEvent, AgentSession, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
2
2
  import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
3
3
  export type ScheduledAttempt = {
4
4
  state: 'started';
@@ -15,9 +15,9 @@ export type ScheduledAttempt = {
15
15
  * producers retain ACP queue semantics while making their unsettled claim
16
16
  * visible before another producer can inspect idle state.
17
17
  */
18
- export declare class RoleTurnArbiter implements SessionHandle {
18
+ export declare class RoleTurnArbiter implements AgentSession {
19
19
  private readonly session;
20
- readonly backend: import("../config.js").SessionBackendId;
20
+ readonly backend: "acp";
21
21
  readonly pid: number;
22
22
  private tail;
23
23
  private unsettled;
@@ -26,7 +26,7 @@ export declare class RoleTurnArbiter implements SessionHandle {
26
26
  private generation;
27
27
  /** Resolves the instant the CURRENT generation is retired. */
28
28
  private retirement;
29
- constructor(session: SessionHandle);
29
+ constructor(session: AgentSession);
30
30
  /**
31
31
  * Waiters race the tail against their own generation's retirement, so a
32
32
  * single operation that never settles cannot own the boundary forever. A