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

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 (75) hide show
  1. package/README.md +28 -41
  2. package/dist/application/capabilities.d.ts +0 -1
  3. package/dist/application/capabilities.js +3 -13
  4. package/dist/application/fleet-query-service.d.ts +0 -3
  5. package/dist/application/fleet-query-service.js +1 -24
  6. package/dist/application/role-creation-service.d.ts +5 -5
  7. package/dist/application/role-creation-service.js +5 -7
  8. package/dist/application/role-repository.d.ts +1 -2
  9. package/dist/application/role-repository.js +7 -7
  10. package/dist/application/session-control.d.ts +0 -12
  11. package/dist/application/session-control.js +0 -42
  12. package/dist/application/session-mutations.d.ts +5 -5
  13. package/dist/application/types.d.ts +0 -7
  14. package/dist/briefing.js +1 -4
  15. package/dist/build-info.json +5 -5
  16. package/dist/cli.js +9 -23
  17. package/dist/config.d.ts +1 -4
  18. package/dist/config.js +13 -17
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +20 -26
  21. package/dist/doctor.js +0 -7
  22. package/dist/fleet-proxy.d.ts +1 -1
  23. package/dist/harness/acp-session-transport.d.ts +8 -0
  24. package/dist/harness/acp-session-transport.js +3 -0
  25. package/dist/harness/agent-session.d.ts +33 -0
  26. package/dist/harness/agent-session.js +1 -0
  27. package/dist/harness/claude-code-session.d.ts +18 -0
  28. package/dist/harness/claude-code-session.js +28 -0
  29. package/dist/harness/claude-code.d.ts +2 -1
  30. package/dist/harness/claude-code.js +29 -81
  31. package/dist/harness/codex-session.d.ts +18 -0
  32. package/dist/harness/codex-session.js +28 -0
  33. package/dist/harness/codex.d.ts +2 -1
  34. package/dist/harness/codex.js +35 -45
  35. package/dist/harness/types.d.ts +6 -38
  36. package/dist/index.d.ts +3 -5
  37. package/dist/index.js +1 -3
  38. package/dist/isolation/resources.d.ts +3 -7
  39. package/dist/isolation/resources.js +3 -7
  40. package/dist/model-env.d.ts +2 -3
  41. package/dist/model-env.js +2 -3
  42. package/dist/monitor.d.ts +1 -24
  43. package/dist/monitor.js +5 -140
  44. package/dist/owner-channel/channel.d.ts +2 -2
  45. package/dist/runner.d.ts +5 -15
  46. package/dist/runner.js +26 -102
  47. package/dist/session/acp.d.ts +2 -2
  48. package/dist/session/acp.js +2 -3
  49. package/dist/session/activity.d.ts +2 -3
  50. package/dist/session/activity.js +2 -3
  51. package/dist/session/arbiter.d.ts +4 -4
  52. package/dist/session/control.d.ts +3 -3
  53. package/dist/session/types.d.ts +11 -5
  54. package/dist/spawn.js +8 -9
  55. package/dist/supervisor/none.d.ts +1 -1
  56. package/dist/supervisor/none.js +2 -2
  57. package/dist/tmux.d.ts +1 -14
  58. package/dist/tmux.js +1 -51
  59. package/dist/watchdog/config.js +6 -4
  60. package/dist/watchdog/run.js +8 -23
  61. package/dist/web/auth.d.ts +1 -1
  62. package/dist/web/runtime.js +4 -15
  63. package/dist/web/server.d.ts +1 -3
  64. package/dist/web/server.js +3 -14
  65. package/dist/web/topology-promote.js +2 -5
  66. package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
  67. package/dist/web-app/assets/{index-BCBK78hw.js → index-DhMDVRU1.js} +6 -6
  68. package/dist/web-app/index.html +2 -2
  69. package/package.json +3 -8
  70. package/dist/session/tmux.d.ts +0 -28
  71. package/dist/session/tmux.js +0 -80
  72. package/dist/web/terminal/bridge.d.ts +0 -27
  73. package/dist/web/terminal/bridge.js +0 -317
  74. package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
  75. package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
package/dist/monitor.d.ts CHANGED
@@ -26,15 +26,8 @@ export type FetchLike = (url: string, init?: {
26
26
  headers?: Record<string, string>;
27
27
  signal?: AbortSignal;
28
28
  }) => Promise<FetchResponse>;
29
- export interface MonitorTmux {
30
- has(name: string): Promise<boolean>;
31
- capture(name: string, lines?: number): Promise<string>;
32
- sendText(name: string, text: string): Promise<void>;
33
- sendKey(name: string, key: string): Promise<void>;
34
- }
35
29
  export interface MonitorDeps {
36
30
  fetch: FetchLike;
37
- tmux: MonitorTmux;
38
31
  isAlive(pid: number): boolean;
39
32
  sleep(ms: number): Promise<void>;
40
33
  now(): number;
@@ -45,7 +38,7 @@ export interface MonitorDeps {
45
38
  clear(t: ReturnType<typeof setTimeout>): void;
46
39
  };
47
40
  /**
48
- * Structured prompt delivery used by ACP sessions. Tmux remains the fallback.
41
+ * Structured prompt delivery used by agent sessions.
49
42
  * `succeeded` is the turn's TERMINAL result, not merely that the session took
50
43
  * the prompt: a refused or cancelled wake was seen and not acted on, and must
51
44
  * not commit the cursor.
@@ -208,14 +201,6 @@ export declare class Monitor {
208
201
  /** Gather stragglers arriving within batch_ms so a burst lands as one line. */
209
202
  private coalesce;
210
203
  private deliver;
211
- /**
212
- * Watch the pane until the just-triggered turn settles, then fold its outcome
213
- * into the API-error streak and republish `.monitor-status`. A completed turn
214
- * (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
215
- * grows it. Once the streak reaches the threshold the status degrades; a later
216
- * completed turn flips it back to armed. Detection only — no remediation (#19).
217
- */
218
- private observeTurnOutcome;
219
204
  /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
220
205
  private recordTurn;
221
206
  /**
@@ -226,14 +211,6 @@ export declare class Monitor {
226
211
  * the composer is cleared by hand. Best-effort: a dead pane just makes the keys
227
212
  * no-ops (delivery is still verified downstream).
228
213
  */
229
- private clearComposer;
230
- /**
231
- * Block until the console can accept input; classify offline/stopped/ready, or
232
- * `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
233
- * what keeps a modal from wedging delivery silently: we still never `Enter` into
234
- * the dialog, but the give-up is reported instead of retried forever.
235
- */
236
- private awaitInjectable;
237
214
  private doFetch;
238
215
  private advance;
239
216
  private persistCursor;
package/dist/monitor.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { classifyFailureText } from './model-recovery.js';
5
4
  // Code constants rather than user configuration.
6
5
  const DEFAULT_PORT = 3050;
7
6
  // The daemon normally holds for 25s, but that value is operator-configurable
@@ -522,7 +521,11 @@ export class Monitor {
522
521
  }
523
522
  async deliver(pid, batch) {
524
523
  const line = formatNotificationLine(batch);
525
- if (this.deps.delivery) {
524
+ if (!this.deps.delivery) {
525
+ this.degrade('delivery', 'structured agent-session delivery is unavailable');
526
+ return false;
527
+ }
528
+ {
526
529
  const result = await this.deps.delivery.submit(line, { interrupt: this.cfg.interrupt });
527
530
  if (!result.succeeded) {
528
531
  // Name the reason: "refused" and "cancelled" are the agent's answer,
@@ -540,101 +543,6 @@ export class Monitor {
540
543
  this.recordTurn('completed');
541
544
  return true;
542
545
  }
543
- // Tmux exposes no authenticated tool lifecycle. `after_tool` therefore
544
- // degrades to the existing non-cancelling injection path; never guess a
545
- // boundary from pane text and never send C-c for this mode.
546
- if (this.cfg.interrupt === true)
547
- await this.deps.tmux.sendKey(this.name, 'C-c');
548
- if (this.cfg.interrupt === 'after_tool')
549
- this.degrade('safe-boundary', 'after_tool unsupported by tmux; using non-cancelling delivery');
550
- const state = await this.awaitInjectable(pid);
551
- if (state !== 'ready') {
552
- if (state === 'offline')
553
- this.degrade('offline', 'offline during delivery');
554
- else if (state === 'modal')
555
- this.degrade('modal', `modal wedge — pane held a dialog for ` +
556
- `${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
557
- return false;
558
- }
559
- await this.clearComposer(); // start from an empty composer
560
- await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
561
- let delivered = false;
562
- // Verify submission for THIS line even if stop() arrives mid-flight: the text
563
- // is already in the composer and we want it submitted (at-least-once). A truly
564
- // dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
565
- for (let i = 0; i < MAX_ENTER_RETRIES;) {
566
- await this.deps.sleep(POST_VERIFY_MS);
567
- const capture = await safeCapture(this.deps.tmux, this.name);
568
- if (!capture.ok) {
569
- this.degrade('delivery', 'capture failed during injection verification');
570
- return false;
571
- }
572
- // A dialog can appear after the initial send. Never let a verification
573
- // retry confirm it. Wait under the same bounded modal policy as initial
574
- // injection, then re-capture immediately before considering Enter.
575
- if (looksModal(capture.pane)) {
576
- const state = await this.awaitInjectable(pid);
577
- if (state === 'ready')
578
- continue;
579
- if (state === 'offline')
580
- this.degrade('offline', 'offline during injection verification');
581
- else if (state === 'modal')
582
- this.degrade('modal', `modal wedge during injection verification — ` +
583
- `no Enter sent for ${MODAL_GIVE_UP_MS / 1000}s`);
584
- return false;
585
- }
586
- if (!stillInComposer(capture.pane, line)) {
587
- delivered = true;
588
- break;
589
- }
590
- await this.deps.tmux.sendKey(this.name, 'Enter');
591
- i++;
592
- }
593
- if (!delivered) {
594
- this.degrade('delivery', 'injection unverified');
595
- return false;
596
- }
597
- this.recover('delivery', 'modal');
598
- // The wake landed and a turn started; observe how that turn terminates so a
599
- // refusal-wedge (every turn dies with `API Error:` while delivery stays green)
600
- // becomes visible in `.monitor-status` instead of masquerading as armed (#19).
601
- const recoveryTriggered = await this.observeTurnOutcome(pid);
602
- return !recoveryTriggered;
603
- }
604
- /**
605
- * Watch the pane until the just-triggered turn settles, then fold its outcome
606
- * into the API-error streak and republish `.monitor-status`. A completed turn
607
- * (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
608
- * grows it. Once the streak reaches the threshold the status degrades; a later
609
- * completed turn flips it back to armed. Detection only — no remediation (#19).
610
- */
611
- async observeTurnOutcome(pid) {
612
- for (let i = 0; i < TURN_OBSERVE_POLLS; i++) {
613
- if (this.stopped)
614
- return false; // shutting down — leave status
615
- if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
616
- return false; // loop marks offline
617
- const capture = await safeCapture(this.deps.tmux, this.name);
618
- if (!capture.ok) {
619
- this.degrade('delivery', 'capture failed during turn observation');
620
- return false;
621
- }
622
- if (looksApiError(capture.pane)) {
623
- const evidence = classifyFailureText(capture.pane);
624
- const recoveryTriggered = evidence
625
- ? this.deps.onFailureEvidence?.(evidence) === true
626
- : false;
627
- this.recordTurn('api-error');
628
- return recoveryTriggered;
629
- }
630
- if (!looksRunning(capture.pane)) {
631
- this.recordTurn('completed');
632
- return false;
633
- }
634
- await this.deps.sleep(TURN_OBSERVE_INTERVAL_MS);
635
- }
636
- this.recordTurn('inconclusive'); // still running at give-up: hold the streak, don't re-arm
637
- return false;
638
546
  }
639
547
  /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
640
548
  recordTurn(outcome) {
@@ -658,41 +566,6 @@ export class Monitor {
658
566
  * the composer is cleared by hand. Best-effort: a dead pane just makes the keys
659
567
  * no-ops (delivery is still verified downstream).
660
568
  */
661
- async clearComposer() {
662
- for (const key of COMPOSER_CLEAR_KEYS)
663
- await this.deps.tmux.sendKey(this.name, key);
664
- }
665
- /**
666
- * Block until the console can accept input; classify offline/stopped/ready, or
667
- * `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
668
- * what keeps a modal from wedging delivery silently: we still never `Enter` into
669
- * the dialog, but the give-up is reported instead of retried forever.
670
- */
671
- async awaitInjectable(pid) {
672
- let modalWaits = 0;
673
- for (;;) {
674
- if (this.stopped)
675
- return 'stopped';
676
- if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
677
- return 'offline';
678
- const now = this.deps.now();
679
- if (now < this.bootDeadline) {
680
- await this.deps.sleep(this.bootDeadline - now);
681
- continue;
682
- }
683
- const capture = await safeCapture(this.deps.tmux, this.name);
684
- if (!capture.ok) {
685
- this.degrade('delivery', 'capture failed while checking session readiness');
686
- await this.deps.sleep(MODAL_RETRY_MS);
687
- continue;
688
- }
689
- if (!looksModal(capture.pane))
690
- return 'ready';
691
- if (modalWaits++ >= MAX_MODAL_WAITS)
692
- return 'modal';
693
- await this.deps.sleep(MODAL_RETRY_MS);
694
- }
695
- }
696
569
  async doFetch(since, timeoutMs, timeoutKind) {
697
570
  const ctrl = new AbortController();
698
571
  this.currentAbort = ctrl;
@@ -820,12 +693,4 @@ export class Monitor {
820
693
  export function createMonitor(o) {
821
694
  return new Monitor(o);
822
695
  }
823
- async function safeCapture(tmux, name) {
824
- try {
825
- return { ok: true, pane: await tmux.capture(name) };
826
- }
827
- catch {
828
- return { ok: false, pane: '' };
829
- }
830
- }
831
696
  const msg = (e) => e?.message ?? String(e);
@@ -1,5 +1,5 @@
1
1
  import { type OwnerChannelConfig } from '../config.js';
2
- import { type SessionHandle } from '../session/types.js';
2
+ import { type AgentSession } from '../session/types.js';
3
3
  import { type OwnerFleetOps } from './commands.js';
4
4
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
5
  import { type OursOps } from './ours-client.js';
@@ -12,7 +12,7 @@ export interface OwnerChannelOptions {
12
12
  /** Harness id of the role (e.g. 'claude-code', 'codex'); gates which slash commands may be forwarded. */
13
13
  harness: string;
14
14
  config: OwnerChannelConfig;
15
- session: SessionHandle;
15
+ session: AgentSession;
16
16
  stateDir: string;
17
17
  env?: Record<string, string>;
18
18
  log(line: string): void;
package/dist/runner.d.ts CHANGED
@@ -1,15 +1,12 @@
1
1
  import { type ResolvedRole } from './config.js';
2
- import type { Launch } from './harness/types.js';
3
- import { Tmux } from './tmux.js';
4
2
  import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
5
3
  import { type Exec } from './exec.js';
6
- import { type AcpSessionOptions } from './session/acp.js';
7
4
  import { RoleControlServer } from './session/control.js';
8
- import type { ExitRecord, SessionHandle, TurnResult } from './session/types.js';
5
+ import type { AgentSession, ExitRecord, TurnResult } from './session/types.js';
6
+ import type { AgentSessionAdapter, AgentSessionStartOptions } from './harness/agent-session.js';
9
7
  import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
10
8
  import { type OwnerBinderLease } from './owner-channel/binder.js';
11
9
  export interface RunnerDeps {
12
- tmux: Tmux;
13
10
  exec: Exec;
14
11
  cpuDelegated(): boolean;
15
12
  isAlive(pid: number): boolean;
@@ -23,9 +20,10 @@ export interface RunnerDeps {
23
20
  /** Construct trusted owner ingress (injectable for lifecycle tests). */
24
21
  createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
25
22
  /** Start the ACP transport (injectable for deterministic runner lifecycle tests). */
26
- startAcpSession(opts: AcpSessionOptions): Promise<SessionHandle>;
23
+ /** Neutral construction seam for deterministic runner lifecycle tests. */
24
+ startAgentSession(adapter: AgentSessionAdapter, options: AgentSessionStartOptions): Promise<AgentSession>;
27
25
  /** Construct the authenticated role control route (injectable where sockets are unavailable). */
28
- createControlServer(stateDir: string, session: SessionHandle, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
26
+ createControlServer(stateDir: string, session: AgentSession, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
29
27
  /** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
30
28
  acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
31
29
  /** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
@@ -51,14 +49,6 @@ export declare function harnessChildEnv(role: ResolvedRole, launchEnv: Record<st
51
49
  * responsible for.
52
50
  */
53
51
  export declare function recordMonitorOwner(dir: string, owner: 'fleet' | 'native'): boolean;
54
- /**
55
- * Compose the tmux pane shell command: env prefix + argv + exit-status capture.
56
- * `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
57
- * the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
58
- * `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
59
- * still sees the real exit code.
60
- */
61
- export declare function buildPaneCommand(launch: Launch, roleEnv: Record<string, string> | undefined, exitStatusPath: string, paneArgv?: string[]): string;
62
52
  /**
63
53
  * Read the pane's `.exit-status`. Three shapes are accepted: the structured
64
54
  * record written above, a bare number left by a pre-upgrade pane (so an
package/dist/runner.js CHANGED
@@ -5,16 +5,13 @@ 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 { realExec } from './exec.js';
11
10
  import { resolveIsolation } from './isolation/policy.js';
12
11
  import { selectIsolationBackend } from './isolation/registry.js';
13
12
  import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
13
  import { resolveLaunchRuntime } from './isolation/runtime.js';
15
- import { AcpSession } from './session/acp.js';
16
14
  import { controlRequest, RoleControlServer } from './session/control.js';
17
- import { TmuxSession } from './session/tmux.js';
18
15
  import { ACP_CANCEL_DEADLINE_EXCEEDED, classifyShellStatus } from './session/types.js';
19
16
  import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
20
17
  import { rotateWorklog } from './worklog.js';
@@ -28,7 +25,6 @@ import { effectivePermissionMode } from './permissions.js';
28
25
  import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
29
26
  import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
30
27
  const defaultDeps = () => ({
31
- tmux: new Tmux(),
32
28
  exec: realExec,
33
29
  cpuDelegated: () => cpuControllerDelegated(),
34
30
  isAlive: pid => { try {
@@ -44,7 +40,7 @@ const defaultDeps = () => ({
44
40
  fetch: (url, init) => globalThis.fetch(url, init),
45
41
  createMonitor: opts => createMonitor(opts),
46
42
  createOwnerChannel: opts => new OwnerChannel(opts),
47
- startAcpSession: opts => AcpSession.start(opts),
43
+ startAgentSession: (adapter, options) => adapter.start(options),
48
44
  createControlServer: (stateDir, session, log) => new RoleControlServer(stateDir, session, log),
49
45
  acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
50
46
  reportOwnerStartupFailure: async (stateDir) => {
@@ -133,38 +129,10 @@ export function recordMonitorOwner(dir, owner) {
133
129
  catch { /* ownership diagnostics must never take the role down */ }
134
130
  return owner === 'fleet' && previous === 'native';
135
131
  }
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
132
  /** Adapt runner deps and the role's daemon-profile overrides for the monitor. */
164
133
  function monitorDeps(deps, roleEnv) {
165
134
  return {
166
135
  fetch: deps.fetch,
167
- tmux: deps.tmux,
168
136
  isAlive: deps.isAlive,
169
137
  sleep: deps.sleep,
170
138
  now: deps.now,
@@ -484,16 +452,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
484
452
  writeFileSync(bootedFile, `${new Date(deps.now()).toISOString()} ${mode}\n`);
485
453
  const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
486
454
  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.
455
+ const sessionBackend = role.session ?? 'acp';
456
+ let launch = adapter.agentSession.prepareLaunch(role, prep);
457
+ // Isolation is additive: only roles that declare `isolation:` are wrapped.
497
458
  let wrappedArgv = launch.argv;
498
459
  if (role.isolation) {
499
460
  // Start with the same durable context that config validation and doctor judged,
@@ -530,7 +491,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
530
491
  // user unit concurrently on boot; `ours-fleet up`/restart-all bulk-start) does not
531
492
  // hit the harness/API rate limit at once. Time-based via a shared launch gate, so
532
493
  // 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.
494
+ // agent-session start; the cheap monitor prime still runs immediately after.
534
495
  if (staggerMs > 0) {
535
496
  const slot = await reserveLaunchSlot(stateRoot(), staggerMs, deps);
536
497
  const wait = slot - deps.now();
@@ -575,7 +536,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
575
536
  await monitor.prime({ resetCursor: resetMonitorCursor });
576
537
  rmSync(exitFile, { force: true });
577
538
  let pid;
578
- let acpSession;
539
+ let agentSession;
579
540
  let control;
580
541
  let unsubscribeRecovery;
581
542
  let monitorLoop;
@@ -590,7 +551,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
590
551
  let loopGeneration = JSON.stringify((role.loops ?? []).map(loop => [
591
552
  loop.name, loop.definitionHash, loop.promptHash,
592
553
  ]));
593
- if (sessionBackend === 'acp') {
554
+ {
594
555
  const perms = role.permissions ?? resolvePermissions(undefined, undefined);
595
556
  // Say once, at startup, that this role will decide permission requests by
596
557
  // itself. Without it the only trace of an auto-denied tool call is a turn
@@ -598,34 +559,16 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
598
559
  if (perms.unattended === 'deny')
599
560
  deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
600
561
  `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,
562
+ agentSession = await deps.startAgentSession(adapter.agentSession, {
563
+ role, prep,
564
+ launch: { ...launch, argv: wrappedArgv, env: harnessChildEnv(role, launch.env, dir) },
565
+ cwd: runCwd, stateDir: dir, mode, permissions: perms,
566
+ permissionMode: effectivePermissionMode(role), log: deps.log,
624
567
  });
625
- pid = acpSession.pid;
626
- arbiter = new RoleTurnArbiter(acpSession);
568
+ pid = agentSession.pid;
569
+ arbiter = new RoleTurnArbiter(agentSession);
627
570
  sessionHandle = arbiter;
628
- unsubscribeRecovery = acpSession.subscribe(event => {
571
+ unsubscribeRecovery = agentSession.subscribe(event => {
629
572
  if (event.kind !== 'error' || !event.text)
630
573
  return;
631
574
  const evidence = classifyFailureText(event.text, 'acp', new Date(deps.now()).toISOString());
@@ -647,7 +590,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
647
590
  + `${notifyError?.message ?? String(notifyError)}`);
648
591
  }
649
592
  }
650
- await acpSession.close();
593
+ await agentSession.close();
651
594
  unsubscribeRecovery?.();
652
595
  throw new Error(`[${name}] owner channel failed to start: `
653
596
  + `${error?.message ?? String(error)}`);
@@ -659,7 +602,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
659
602
  }
660
603
  catch (error) {
661
604
  ownerBinder?.release();
662
- await acpSession.close();
605
+ await agentSession.close();
663
606
  unsubscribeRecovery?.();
664
607
  throw error;
665
608
  }
@@ -740,7 +683,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
740
683
  const started = await starting;
741
684
  // A temporary role's first turn can be the active turn when an ours wake
742
685
  // needs immediate attention. A typed console/monitor cancellation ends
743
- // only that turn: the already-live ACP session and any queued wake remain
686
+ // only that turn: the already-live agent session and any queued wake remain
744
687
  // valid. Keep every unproven cancellation, refusal, shutdown, and genuine
745
688
  // failure terminal so a role that never accepted its briefing is not
746
689
  // silently reported as healthy.
@@ -749,7 +692,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
749
692
  monitor?.stop();
750
693
  await control.close();
751
694
  ownerBinder?.release();
752
- await acpSession.close();
695
+ await agentSession.close();
753
696
  unsubscribeRecovery?.();
754
697
  if (modelRecovery) {
755
698
  if (monitorLoop)
@@ -795,7 +738,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
795
738
  await ownerChannel.close().catch(() => undefined);
796
739
  await control.close();
797
740
  ownerBinder?.release();
798
- await acpSession.close();
741
+ await agentSession.close();
799
742
  unsubscribeRecovery?.();
800
743
  throw new Error(`[${name}] owner channel failed to start: `
801
744
  + `${error?.message ?? String(error)}`);
@@ -854,20 +797,6 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
854
797
  }
855
798
  }
856
799
  }
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
800
  deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
872
801
  // The monitor loop lives exactly as long as the session: it starts once the
873
802
  // pane pid is known and is stopped when that pid dies (task dies with runner).
@@ -899,7 +828,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
899
828
  else if (presence.state === 'absent' && identityObserved) {
900
829
  identityAbsentSince ??= now;
901
830
  // Require a continuous, time-bounded run of authoritative absence.
902
- // The first positive observation is the readiness gate: cold tmux
831
+ // The first positive observation is the readiness gate: cold harness
903
832
  // starts may spend minutes loading the harness and briefing before the
904
833
  // agent creates/binds its identity, and absence before then is not a
905
834
  // close event. After readiness, debounce a real disappearance.
@@ -948,19 +877,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
948
877
  await monitorLoop;
949
878
  }
950
879
  unsubscribeRecovery?.();
951
- if (acpSession && !sessionClosed)
952
- await acpSession.close();
880
+ if (agentSession && !sessionClosed)
881
+ await agentSession.close();
953
882
  const elapsed = (deps.now() - start) / 1000;
954
883
  // Establish what actually happened before deciding anything. Absence of a
955
884
  // record is `unknown` — except when the console itself is gone, which is a
956
885
  // 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` });
886
+ const exitRecord = agentSession.exitResult()
887
+ ?? { version: 1, class: 'unknown', detail: 'the agent session stopped without reporting an exit' };
964
888
  writeFileSync(exitFile, JSON.stringify({
965
889
  ...exitRecord, at: new Date(deps.now()).toISOString(), elapsedSecs: Number(elapsed.toFixed(1)),
966
890
  }) + '\n');
@@ -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
  /**
@@ -81,7 +81,7 @@ export declare function classifyStopReason(stopReason: string | undefined): Turn
81
81
  * Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
82
82
  * human/automation attachment happens through the fleet role-control protocol.
83
83
  */
84
- export declare class AcpSession implements SessionHandle {
84
+ export declare class AcpSession implements AgentSession {
85
85
  private readonly options;
86
86
  readonly backend: "acp";
87
87
  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}` }
@@ -1476,7 +1475,7 @@ export class AcpSession {
1476
1475
  ...(normalized.adapterMeta ? { adapterMeta: normalized.adapterMeta } : {}),
1477
1476
  });
1478
1477
  }
1479
- // ── conversation ledger access (SessionHandle) ─────────────────────────────
1478
+ // ── conversation ledger access (AgentSession) ─────────────────────────────
1480
1479
  conversationPage(request = {}) {
1481
1480
  const floor = Number(this.conversationStartCursor ?? 0);
1482
1481
  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