@ours.network/fleet 1.0.2 → 1.0.4

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 (36) hide show
  1. package/README.md +4 -4
  2. package/dist/application/role-command-service.d.ts +29 -1
  3. package/dist/application/role-command-service.js +41 -2
  4. package/dist/application/role-creation-service.d.ts +32 -1
  5. package/dist/application/role-creation-service.js +56 -10
  6. package/dist/application/role-removal-service.d.ts +19 -0
  7. package/dist/application/role-removal-service.js +13 -3
  8. package/dist/application/session-mutations.d.ts +7 -0
  9. package/dist/application/session-mutations.js +8 -0
  10. package/dist/application/task-room-service.d.ts +227 -0
  11. package/dist/application/task-room-service.js +529 -0
  12. package/dist/build-info.json +4 -4
  13. package/dist/cli.js +39 -15
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +7 -6
  16. package/dist/harness/codex-app-server-proxy.d.ts +1 -1
  17. package/dist/harness/codex-app-server-proxy.js +23 -9
  18. package/dist/harness/types.d.ts +4 -2
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/owner-channel/channel.d.ts +5 -0
  22. package/dist/owner-channel/channel.js +143 -18
  23. package/dist/owner-channel/commands.d.ts +26 -1
  24. package/dist/owner-channel/commands.js +66 -128
  25. package/dist/rooms-tasks/cli.js +181 -462
  26. package/dist/rooms-tasks/provision.js +71 -8
  27. package/dist/rooms-tasks/types.d.ts +2 -0
  28. package/dist/runner.js +19 -44
  29. package/dist/session/acp.d.ts +8 -3
  30. package/dist/session/acp.js +24 -8
  31. package/dist/session/control.d.ts +10 -1
  32. package/dist/session/control.js +22 -21
  33. package/dist/watchdog/query.d.ts +2 -0
  34. package/dist/watchdog/query.js +7 -3
  35. package/dist/web/server.js +2 -2
  36. package/package.json +1 -1
@@ -5,6 +5,9 @@ import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, acti
5
5
  import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
6
6
  import { spawnTemp } from '../spawn.js';
7
7
  import { findRole } from '../config.js';
8
+ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
9
+ import { controlRequest } from '../session/control.js';
10
+ import { SessionControlError } from '../session/types.js';
8
11
  import { closeManagedRoom } from './close.js';
9
12
  import { buildRoomMemberTask, sha256Text } from './member-startup.js';
10
13
  import { agentDir } from '../paths.js';
@@ -18,6 +21,28 @@ export function getBinPath() {
18
21
  return process.argv[1];
19
22
  }
20
23
  }
24
+ async function spawnRoomMember(options, binPath) {
25
+ const stateDir = process.env[FLEET_PROXY_STATE_DIR_ENV];
26
+ if (!stateDir)
27
+ return {
28
+ statePath: await spawnTemp(options, binPath),
29
+ creationActionId: options.creationActionId,
30
+ };
31
+ const response = await controlRequest(stateDir, { command: 'fleet_spawn', spawn: options }, 10 * 60_000);
32
+ if (!response.ok) {
33
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'managed room member spawn failed');
34
+ }
35
+ const result = response.result;
36
+ const expectedCaller = process.env[FLEET_PROXY_CALLER_ENV];
37
+ if (expectedCaller && result.caller !== expectedCaller) {
38
+ throw new Error(`fleet proxy caller mismatch: expected '${expectedCaller}', got '${result.caller}'`);
39
+ }
40
+ return {
41
+ statePath: result.statePath,
42
+ creationActionId: result.creationActionId,
43
+ callerRole: result.caller,
44
+ };
45
+ }
21
46
  function shortId(id) { return id.slice(0, 8); }
22
47
  function expandMembers(template, prefix) {
23
48
  const result = [];
@@ -40,11 +65,15 @@ function settingsFor(member, cfg) {
40
65
  refRole = findRole(cfg, member.roleRef);
41
66
  }
42
67
  catch { /* no ref role */ }
68
+ const permissions = member.overrides?.permissions;
43
69
  return {
44
70
  model: member.overrides?.model ?? refRole?.model,
45
71
  harness: member.overrides?.harness ?? refRole?.harness,
46
72
  cwd: member.overrides?.cwd ?? refRole?.cwd,
47
73
  persona: member.overrides?.persona ?? refRole?.persona,
74
+ approval: permissions?.approval,
75
+ filesystem: permissions?.filesystem,
76
+ unattended: permissions?.unattended,
48
77
  };
49
78
  }
50
79
  function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
@@ -96,9 +125,24 @@ async function retainRunningLaunch(input) {
96
125
  .find(candidate => candidate.role_name === member.name);
97
126
  const dir = agentDir(member.name, true);
98
127
  const taskSha = sha256Text(task);
99
- if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched') && existsSync(dir)) {
128
+ if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched'
129
+ || seat.launch?.state === 'failed') && existsSync(dir)) {
100
130
  if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
101
- throw new Error(`existing launch for ${member.name} does not match its durable intent`);
131
+ const provenance = readProvenance(dir);
132
+ const adoptable = (seat.launch.state === 'intent' || seat.launch.state === 'failed')
133
+ && Boolean(seat.launch.caller_role)
134
+ && provenance?.surface === 'agent'
135
+ && provenance.callerRole === seat.launch.caller_role
136
+ && typeof provenance.creationActionId === 'string'
137
+ && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id);
138
+ if (!adoptable)
139
+ throw new Error(`existing launch for ${member.name} does not match its durable intent`);
140
+ updateMemberStartup(provision.roomId, member.name, { launch: {
141
+ ...seat.launch, state: 'intent', action_id: provenance.creationActionId,
142
+ updated_at: new Date().toISOString(),
143
+ } });
144
+ seat = getRoomRecord(provision.roomId).member_seats
145
+ .find(candidate => candidate.role_name === member.name);
102
146
  }
103
147
  const supervisor = readTempSupervisor(dir);
104
148
  if (!supervisor || supervisor.role !== member.name)
@@ -106,16 +150,17 @@ async function retainRunningLaunch(input) {
106
150
  const live = await tempSupervisorLiveness(dir);
107
151
  if (live === 'unknown')
108
152
  throw new Error(`existing launch for ${member.name} has unknown liveness`);
153
+ const retainedLaunch = seat.launch;
109
154
  if (live === 'running') {
110
155
  updateMemberStartup(provision.roomId, member.name, { launch: {
111
- ...seat.launch, state: 'launched', launch_id: supervisor.launchId,
156
+ ...retainedLaunch, state: 'launched', launch_id: supervisor.launchId,
112
157
  updated_at: new Date().toISOString(),
113
158
  } });
114
159
  return true;
115
160
  }
116
161
  await secureStoppedTempArchive(member.name, supervisor.launchId);
117
162
  updateMemberStartup(provision.roomId, member.name, { launch: {
118
- ...seat.launch, state: 'stopped', launch_id: supervisor.launchId,
163
+ ...retainedLaunch, state: 'stopped', launch_id: supervisor.launchId,
119
164
  updated_at: new Date().toISOString(),
120
165
  } });
121
166
  return false;
@@ -151,14 +196,18 @@ async function launchMember(input) {
151
196
  const seat = getRoomRecord(provision.roomId).member_seats
152
197
  .find(candidate => candidate.role_name === member.name);
153
198
  const actionId = randomUUID();
199
+ let effectiveActionId = actionId;
154
200
  const attempt = (seat.launch?.attempt ?? 0) + 1;
155
201
  const taskSha = sha256Text(startup.task);
202
+ const proxyCaller = process.env[FLEET_PROXY_STATE_DIR_ENV]
203
+ ? process.env[FLEET_PROXY_CALLER_ENV] : undefined;
156
204
  updateMemberStartup(provision.roomId, member.name, { launch: {
157
205
  state: 'intent', attempt, action_id: actionId, mission_sha256: taskSha,
206
+ ...(proxyCaller ? { caller_role: proxyCaller } : {}),
158
207
  updated_at: new Date().toISOString(),
159
208
  } });
160
209
  try {
161
- const launchedDir = await spawnTemp({
210
+ const launched = await spawnRoomMember({
162
211
  name: member.name,
163
212
  temp: true,
164
213
  identity: member.name,
@@ -166,22 +215,36 @@ async function launchMember(input) {
166
215
  model: settings.model,
167
216
  harness: settings.harness,
168
217
  cwd: settings.cwd,
218
+ approval: settings.approval,
219
+ filesystem: settings.filesystem,
220
+ unattended: settings.unattended,
169
221
  surface: 'agent',
170
222
  creationActionId: actionId,
171
223
  roomMemberStartup: startup,
172
224
  }, provision.binPath);
225
+ const launchedDir = launched.statePath;
226
+ effectiveActionId = launched.creationActionId;
227
+ if (launched.creationActionId !== actionId) {
228
+ updateMemberStartup(provision.roomId, member.name, { launch: {
229
+ state: 'intent', attempt, action_id: launched.creationActionId,
230
+ mission_sha256: taskSha, updated_at: new Date().toISOString(),
231
+ ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
232
+ } });
233
+ }
173
234
  const supervisor = readTempSupervisor(launchedDir);
174
- if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, actionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
235
+ if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
175
236
  throw new Error(`new launch for ${member.name} did not persist matching provenance`);
176
237
  }
177
238
  updateMemberStartup(provision.roomId, member.name, { launch: {
178
- state: 'launched', attempt, action_id: actionId, mission_sha256: taskSha,
239
+ state: 'launched', attempt, action_id: launched.creationActionId, mission_sha256: taskSha,
240
+ ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
179
241
  launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
180
242
  } });
181
243
  }
182
244
  catch (error) {
183
245
  updateMemberStartup(provision.roomId, member.name, { launch: {
184
- state: 'failed', attempt, action_id: actionId, mission_sha256: taskSha,
246
+ state: 'failed', attempt, action_id: effectiveActionId, mission_sha256: taskSha,
247
+ ...(proxyCaller ? { caller_role: proxyCaller } : {}),
185
248
  updated_at: new Date().toISOString(),
186
249
  error: error instanceof Error ? error.message : String(error),
187
250
  } });
@@ -87,6 +87,8 @@ export interface RoomMemberLaunchState {
87
87
  state: 'pending' | 'intent' | 'launched' | 'stopped' | 'failed';
88
88
  attempt: number;
89
89
  action_id?: string;
90
+ /** Expected authenticated proxy caller while adopting a post-spawn crash. */
91
+ caller_role?: string;
90
92
  mission_sha256?: string;
91
93
  launch_id?: string;
92
94
  updated_at: string;
package/dist/runner.js CHANGED
@@ -23,7 +23,7 @@ import { OwnerChannel } from './owner-channel/channel.js';
23
23
  import { acquireOwnerBinderLease, OwnerBinderHandoffTimeoutError, } from './owner-channel/binder.js';
24
24
  import { RoleTurnArbiter } from './session/arbiter.js';
25
25
  import { ScheduledLoopManager, } from './loops/manager.js';
26
- import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, inheritCallerSpawnDefaults, } from './fleet-proxy.js';
26
+ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-proxy.js';
27
27
  import { effectivePermissionMode } from './permissions.js';
28
28
  import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
29
29
  import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
@@ -61,15 +61,15 @@ const defaultDeps = () => ({
61
61
  },
62
62
  });
63
63
  const MONITOR_OWNER_FILE = '.monitor-owner';
64
- /** Fleet roles consume the operator-owned daemon; a role session never starts it. */
65
- const FLEET_OURS_AUTOSTART = '0';
64
+ const OBSOLETE_OURS_AUTOSTART_ENV = 'OURS_AUTOSTART';
66
65
  /** Environment injected only into the managed harness process. */
67
66
  export function managedFleetProxyEnv(role, stateDir) {
67
+ const roleEnv = { ...(role.env ?? {}) };
68
+ // ours-mcp 1.0 treats presence (even "0") as a fatal legacy lifecycle mode.
69
+ // Managed children are daemon clients; the proxy itself never starts one.
70
+ delete roleEnv[OBSOLETE_OURS_AUTOSTART_ENV];
68
71
  return {
69
- ...(role.env ?? {}),
70
- // This must win over both inherited/configured auto-start. ACP agents run
71
- // directly rather than through ours-codex, so the runner owns this fence.
72
- OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
72
+ ...roleEnv,
73
73
  [FLEET_PROXY_STATE_DIR_ENV]: stateDir,
74
74
  [FLEET_PROXY_CALLER_ENV]: role.name,
75
75
  };
@@ -92,11 +92,6 @@ export function harnessChildEnv(role, launchEnv, stateDir) {
92
92
  * avoid a runner↔spawn initialization cycle (spawn imports runner constants).
93
93
  */
94
94
  async function executeManagedSpawn(caller, configPath, requested, log) {
95
- const { options, inherited } = inheritCallerSpawnDefaults(caller, requested, configPath);
96
- const creationActionId = randomUUID();
97
- options.creationActionId = creationActionId;
98
- const spawnModule = await import('./spawn.js');
99
- const preview = spawnModule.spawnDryRun(options).resolvedRole;
100
95
  const runtimeBinPath = (() => {
101
96
  try {
102
97
  return realpathSync(process.argv[1]);
@@ -105,33 +100,15 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
105
100
  return process.argv[1];
106
101
  }
107
102
  })();
108
- let statePath;
109
- if (options.temp) {
110
- statePath = await spawnModule.spawnTemp(options, runtimeBinPath);
111
- }
112
- else {
113
- const { pickBackend } = await import('./supervisor/index.js');
114
- const { WatchdogServiceManager } = await import('./watchdog/service.js');
115
- statePath = await spawnModule.spawnPermanent(options, {
116
- backend: pickBackend(), binPath: runtimeBinPath, log,
117
- watchdogService: new WatchdogServiceManager(),
118
- });
119
- }
120
- const result = {
121
- caller: caller.name,
122
- role: options.name,
123
- lifetime: options.temp ? 'temporary' : 'permanent',
124
- statePath,
125
- harness: preview.harness,
126
- session: preview.session,
127
- // Read back from the resolved environment, not from the request: the banner
128
- // must name the model the child will run, not the one that was asked for.
129
- ...(effectiveRoleModel(preview) ? { model: effectiveRoleModel(preview) } : {}),
130
- monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
131
- permissionMode: effectivePermissionMode(preview),
132
- inherited,
133
- creationActionId,
134
- };
103
+ const [{ RoleCreationService }, { pickBackend }, { WatchdogServiceManager }] = await Promise.all([
104
+ import('./application/role-creation-service.js'), import('./supervisor/index.js'),
105
+ import('./watchdog/service.js'),
106
+ ]);
107
+ const service = new RoleCreationService({ configPath,
108
+ ops: { backend: pickBackend(), binPath: runtimeBinPath, log,
109
+ watchdogService: new WatchdogServiceManager() },
110
+ binPath: runtimeBinPath, journal: false });
111
+ const result = await service.createManaged(caller, requested);
135
112
  log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
136
113
  + `harness=${result.harness} session=${result.session} `
137
114
  + `model=${result.model ?? '(harness default)'} `
@@ -166,17 +143,14 @@ export function recordMonitorOwner(dir, owner) {
166
143
  export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
167
144
  const env = {
168
145
  PATH: process.env.PATH ?? '', COLORTERM: 'truecolor', ...launch.env, ...(roleEnv ?? {}),
169
- // Tmux roles have the same daemon-client boundary as ACP roles. Keep this
170
- // last so neither harness preparation nor a role env block can take over
171
- // the shared daemon lifecycle.
172
- OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
173
146
  };
147
+ delete env[OBSOLETE_OURS_AUTOSTART_ENV];
174
148
  // Interactive panes should advertise colour even when the supervisor itself
175
149
  // was launched with NO_COLOR. A role may still deliberately opt back in to
176
150
  // NO_COLOR (or replace COLORTERM) through its explicit env block.
177
151
  const unsetNoColor = Object.prototype.hasOwnProperty.call(roleEnv ?? {}, 'NO_COLOR')
178
152
  ? '' : '-u NO_COLOR ';
179
- const envPfx = 'env ' + unsetNoColor
153
+ const envPfx = 'env -u OURS_AUTOSTART ' + unsetNoColor
180
154
  + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
181
155
  const cmd = paneArgv.map(shq).join(' ');
182
156
  // Write a structured record, not a bare number: the wait status alone cannot
@@ -645,6 +619,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
645
619
  // role-only adapter hook prevents a PATH fallback or resolver skew from
646
620
  // claiming metadata trust for an argv it did not authenticate.
647
621
  permissionMetadataSource: launch.permissionMetadataSource,
622
+ scrubObsoleteOursAutostart: true,
648
623
  log: deps.log,
649
624
  });
650
625
  pid = acpSession.pid;
@@ -21,6 +21,8 @@ export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
21
21
  * live role.
22
22
  */
23
23
  export declare const STEERING_OCCUPANCY_IDLE_MS = 150000;
24
+ /** Consumed only by Fleet's authenticated bundled-Codex app-server proxy. */
25
+ export declare const CODEX_DISABLE_INHERITED_MCP_ENV = "OURS_FLEET_CODEX_DISABLE_INHERITED_MCP";
24
26
  /** Server-generated typed provenance followed by the exact human-authored body. */
25
27
  export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
26
28
  export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
@@ -38,10 +40,13 @@ export interface AcpSessionOptions {
38
40
  permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
39
41
  /** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
40
42
  permissionMetadataSource?: 'codex-acp';
43
+ /** Fleet-managed ours proxies must never receive the obsolete presence-sensitive flag. */
44
+ scrubObsoleteOursAutostart?: boolean;
41
45
  /**
42
46
  * MCP servers the ROLE declares, for every session/new, resume and load.
43
- * Omitted preserves inherited configuration; an explicit empty array disables
44
- * every inherited server.
47
+ * Omitted preserves inherited configuration (encoded as ACP's required `[]`);
48
+ * an explicit empty array disables every inherited server through the
49
+ * authenticated bundled-adapter compatibility path.
45
50
  */
46
51
  mcpServers?: AcpMcpServer[];
47
52
  /**
@@ -258,7 +263,7 @@ export declare class AcpSession implements SessionHandle {
258
263
  private settlePendingAutomatically;
259
264
  exitResult(): ExitRecord | null;
260
265
  close(): Promise<void>;
261
- /** Preserve inheritance on omission; carry even an explicit empty override. */
266
+ /** ACP requires the field. Bundled agents treat [] as no client-added servers. */
262
267
  private declaredMcpServers;
263
268
  private initialize;
264
269
  private captureRuntimeMetadata;
@@ -32,6 +32,8 @@ export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
32
32
  */
33
33
  export const STEERING_OCCUPANCY_IDLE_MS = 150_000;
34
34
  const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
35
+ /** Consumed only by Fleet's authenticated bundled-Codex app-server proxy. */
36
+ export const CODEX_DISABLE_INHERITED_MCP_ENV = 'OURS_FLEET_CODEX_DISABLE_INHERITED_MCP';
35
37
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
36
38
  const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
37
39
  const MAX_CANONICAL_SYMLINK_DEPTH = 40;
@@ -304,9 +306,25 @@ export class AcpSession {
304
306
  static async start(options) {
305
307
  if (!options.argv.length)
306
308
  throw new Error('ACP agent command is empty');
309
+ const disableInheritedCodexMcp = options.permissionMetadataSource === 'codex-acp'
310
+ && options.mcpServers !== undefined && options.mcpServers.length === 0;
311
+ // Obsolete ours-mcp lifecycle flags are presence-sensitive. The shared
312
+ // daemon remains operator-owned; managed ACP children are clients only.
313
+ const childEnv = {
314
+ ...process.env,
315
+ ...options.env,
316
+ };
317
+ if (options.scrubObsoleteOursAutostart)
318
+ delete childEnv.OURS_AUTOSTART;
319
+ Object.assign(childEnv,
320
+ // Write both states for the authenticated proxy: a stale ambient `1`
321
+ // must never leak explicit-empty semantics into a later inherited role.
322
+ options.permissionMetadataSource === 'codex-acp'
323
+ ? { [CODEX_DISABLE_INHERITED_MCP_ENV]: disableInheritedCodexMcp ? '1' : '0' }
324
+ : {});
307
325
  const child = spawn(options.argv[0], options.argv.slice(1), {
308
326
  cwd: options.cwd,
309
- env: { ...process.env, ...options.env },
327
+ env: childEnv,
310
328
  stdio: ['pipe', 'pipe', 'pipe'],
311
329
  });
312
330
  await new Promise((resolve, reject) => {
@@ -991,11 +1009,9 @@ export class AcpSession {
991
1009
  });
992
1010
  this.conversation.close();
993
1011
  }
994
- /** Preserve inheritance on omission; carry even an explicit empty override. */
1012
+ /** ACP requires the field. Bundled agents treat [] as no client-added servers. */
995
1013
  declaredMcpServers() {
996
- return this.options.mcpServers === undefined
997
- ? {}
998
- : { mcpServers: this.options.mcpServers };
1014
+ return this.options.mcpServers ?? [];
999
1015
  }
1000
1016
  async initialize() {
1001
1017
  const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
@@ -1016,7 +1032,7 @@ export class AcpSession {
1016
1032
  const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
1017
1033
  sessionId: persisted,
1018
1034
  cwd: this.options.cwd,
1019
- ...this.declaredMcpServers(),
1035
+ mcpServers: this.declaredMcpServers(),
1020
1036
  });
1021
1037
  this.captureRuntimeMetadata(resumed.configOptions);
1022
1038
  this.sessionId = persisted;
@@ -1029,7 +1045,7 @@ export class AcpSession {
1029
1045
  const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
1030
1046
  sessionId: persisted,
1031
1047
  cwd: this.options.cwd,
1032
- ...this.declaredMcpServers(),
1048
+ mcpServers: this.declaredMcpServers(),
1033
1049
  });
1034
1050
  this.captureRuntimeMetadata(loaded.configOptions);
1035
1051
  }
@@ -1041,7 +1057,7 @@ export class AcpSession {
1041
1057
  else {
1042
1058
  const created = await this.connection.agent.request(acp.methods.agent.session.new, {
1043
1059
  cwd: this.options.cwd,
1044
- ...this.declaredMcpServers(),
1060
+ mcpServers: this.declaredMcpServers(),
1045
1061
  ...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
1046
1062
  });
1047
1063
  this.sessionId = created.sessionId;
@@ -1,5 +1,5 @@
1
1
  import { type Socket } from 'node:net';
2
- import type { ControlFailureKind, SessionHandle } from './types.js';
2
+ import type { ControlFailureKind, SessionEvent, SessionHandle, SessionSnapshot } from './types.js';
3
3
  import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
4
4
  import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
5
5
  import type { SpawnOpts } from '../spawn.js';
@@ -40,6 +40,15 @@ export interface ControlResponse {
40
40
  /** Why it failed, so the caller does not have to guess from the text. */
41
41
  kind?: ControlFailureKind;
42
42
  }
43
+ export interface RetainedEventPage {
44
+ events: SessionEvent[];
45
+ snapshot: SessionSnapshot;
46
+ firstSeq: number;
47
+ lastSeq: number;
48
+ truncated: boolean;
49
+ }
50
+ /** The one retained-range projection shared by polling and live-follow admission. */
51
+ export declare function retainedEventPage(session: SessionHandle, since: number): RetainedEventPage;
43
52
  /**
44
53
  * One line saying what a control failure does — and does not — prove about the
45
54
  * agent. Only `offline` is evidence that it is gone; every other kind used to
@@ -2,13 +2,26 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { createConnection, createServer } from 'node:net';
4
4
  import { join } from 'node:path';
5
- import { SessionControlError, interruptOutcome } from './types.js';
5
+ import { SessionControlError } from './types.js';
6
+ import { interruptSession, queueSessionPrompt, respondSessionPermission, respondSessionPermissionV2, } from '../application/session-mutations.js';
6
7
  const MAX_LINE_BYTES = 64 * 1024;
7
8
  /** Commands that require protocol version 3. */
8
9
  const V3_COMMANDS = new Set([
9
10
  'conversation_page', 'conversation_follow', 'submit_prompt_v2', 'interrupt_v2',
10
11
  'respond_permission_v2',
11
12
  ]);
13
+ /** The one retained-range projection shared by polling and live-follow admission. */
14
+ export function retainedEventPage(session, since) {
15
+ const events = session.eventsSince(since);
16
+ const all = session.eventsSince(0);
17
+ return {
18
+ events,
19
+ snapshot: session.snapshot(),
20
+ firstSeq: all[0]?.seq ?? 0,
21
+ lastSeq: all.at(-1)?.seq ?? 0,
22
+ truncated: Boolean(all[0] && since > 0 && since < all[0].seq - 1),
23
+ };
24
+ }
12
25
  /**
13
26
  * One line saying what a control failure does — and does not — prove about the
14
27
  * agent. Only `offline` is evidence that it is gone; every other kind used to
@@ -230,7 +243,7 @@ export class RoleControlServer {
230
243
  // Answer on QUEUE ACCEPTANCE, not on turn completion. A turn can run
231
244
  // for minutes; blocking here made every `send` into a busy agent time
232
245
  // out, and the timeout was then reported as a dead agent.
233
- const queued = await this.session.queuePrompt(request.text, {
246
+ const queued = await queueSessionPrompt(this.session, request.text, {
234
247
  origin: { kind: 'local-console' },
235
248
  });
236
249
  this.write(socket, {
@@ -244,7 +257,7 @@ export class RoleControlServer {
244
257
  case 'respond_permission': {
245
258
  if (!request.permissionId || !request.optionId)
246
259
  throw new SessionControlError('rejected', 'permissionId and optionId are required');
247
- const accepted = this.session.respondPermission(request.permissionId, request.optionId);
260
+ const accepted = respondSessionPermission(this.session, request.permissionId, request.optionId);
248
261
  this.write(socket, {
249
262
  version: 1, id: request.id, ok: accepted,
250
263
  result: { accepted },
@@ -256,7 +269,7 @@ export class RoleControlServer {
256
269
  case 'interrupt': {
257
270
  // Forced recovery cancelled the turn just as surely as a cooperative
258
271
  // stop did. Report HOW, never as a failed operation.
259
- const outcome = interruptOutcome(await this.session.interrupt('local-console'));
272
+ const outcome = await interruptSession(this.session, 'local-console');
260
273
  this.write(socket, { version: 1, id: request.id, ok: true, result: outcome });
261
274
  return;
262
275
  }
@@ -309,29 +322,17 @@ export class RoleControlServer {
309
322
  }
310
323
  case 'events_since': {
311
324
  const since = Number.isFinite(request.since) ? Number(request.since) : 0;
312
- const events = this.session.eventsSince(since);
313
- const all = this.session.eventsSince(0);
314
325
  this.write(socket, {
315
326
  version: 1, id: request.id, ok: true,
316
- result: {
317
- events, snapshot: this.session.snapshot(),
318
- firstSeq: all[0]?.seq ?? 0, lastSeq: all.at(-1)?.seq ?? 0,
319
- truncated: Boolean(all[0] && since > 0 && since < all[0].seq - 1),
320
- },
327
+ result: retainedEventPage(this.session, since),
321
328
  });
322
329
  return;
323
330
  }
324
331
  case 'follow': {
325
332
  const since = Number.isFinite(request.since) ? Number(request.since) : 0;
326
- const events = this.session.eventsSince(since);
327
- const all = this.session.eventsSince(0);
328
333
  this.write(socket, {
329
334
  version: 1, id: request.id, ok: true,
330
- result: {
331
- events, snapshot: this.session.snapshot(),
332
- firstSeq: all[0]?.seq ?? 0, lastSeq: all.at(-1)?.seq ?? 0,
333
- truncated: Boolean(all[0] && since > 0 && since < all[0].seq - 1),
334
- },
335
+ result: retainedEventPage(this.session, since),
335
336
  });
336
337
  const controller = request.controller !== false;
337
338
  if (controller)
@@ -399,7 +400,7 @@ export class RoleControlServer {
399
400
  this.write(socket, { version: 1, id: request.id, ok: true, result: existing });
400
401
  return;
401
402
  }
402
- const outcome = interruptOutcome(await this.session.interrupt('local-console'));
403
+ const outcome = await interruptSession(this.session, 'local-console');
403
404
  const receipt = {
404
405
  accepted: true, commandId: request.commandId, at: new Date().toISOString(),
405
406
  ...outcome,
@@ -417,9 +418,9 @@ export class RoleControlServer {
417
418
  if (!request.commandId?.trim() || !request.permissionId?.trim()
418
419
  || !request.optionId?.trim() || !request.sessionGeneration?.trim())
419
420
  throw new SessionControlError('rejected', 'commandId, permissionId, optionId and sessionGeneration are required');
420
- if (!this.session.respondPermissionV2)
421
+ const result = respondSessionPermissionV2(this.session, request.permissionId, request.optionId, request.sessionGeneration);
422
+ if (result === 'unavailable')
421
423
  throw new SessionControlError('rejected', 'generation-bound permission responses are unavailable for this role');
422
- const result = this.session.respondPermissionV2(request.permissionId, request.optionId, request.sessionGeneration);
423
424
  if (result === 'stale')
424
425
  throw new SessionControlError('rejected', 'stale_state: permission is settled, expired, invalid, or belongs to another session generation');
425
426
  this.write(socket, {
@@ -6,6 +6,8 @@ export interface WatchdogRoleFinding {
6
6
  status: WatchdogRoleStatus;
7
7
  reason: string;
8
8
  }
9
+ /** Shared configured-or-surviving-history addressability rule. */
10
+ export declare function watchdogAddressable(name: string, configured: readonly string[], historyExists?: (validName: string) => boolean): boolean;
9
11
  /**
10
12
  * Needs-attention integration: worst current finding per role across
11
13
  * every configured watchdog, for FleetQueryService.status() to fold into a
@@ -6,6 +6,12 @@ import { watchdogsRoot } from '../paths.js';
6
6
  import { WATCHDOG_STATUS_RANK } from './alerts.js';
7
7
  import { readSchedulerState } from './scheduler.js';
8
8
  import { listRuns, readReport } from './store.js';
9
+ /** Shared configured-or-surviving-history addressability rule. */
10
+ export function watchdogAddressable(name, configured, historyExists = validName => existsSync(join(watchdogsRoot(), validName))) {
11
+ if (configured.includes(name))
12
+ return true;
13
+ return ROLE_NAME_RE.test(name) && historyExists(name);
14
+ }
9
15
  /**
10
16
  * Needs-attention integration: worst current finding per role across
11
17
  * every configured watchdog, for FleetQueryService.status() to fold into a
@@ -115,9 +121,7 @@ export class WatchdogQueryService {
115
121
  */
116
122
  requireKnown(name) {
117
123
  const cfg = this.cfgProvider();
118
- if (cfg.watchdogs.some(wd => wd.name === name))
119
- return;
120
- if (ROLE_NAME_RE.test(name) && existsSync(join(watchdogsRoot(), name)))
124
+ if (watchdogAddressable(name, cfg.watchdogs.map(wd => wd.name)))
121
125
  return;
122
126
  throw new FleetError('role_not_found', `no such watchdog '${name}'`);
123
127
  }
@@ -209,7 +209,7 @@ export async function buildWebServer(services, boundary, options = {}) {
209
209
  throw new FleetError('invalid_request', 'invalid role name');
210
210
  if (!services.removal)
211
211
  throw new FleetError('capability_unavailable', 'role removal is unavailable');
212
- return services.removal.preview(request.params.id);
212
+ return services.removal.previewWeb(request.params.id);
213
213
  });
214
214
  app.post('/api/v1/roles/:id/remove', async (request) => {
215
215
  const session = auth.authenticate(request, true);
@@ -218,7 +218,7 @@ export async function buildWebServer(services, boundary, options = {}) {
218
218
  if (!services.removal)
219
219
  throw new FleetError('capability_unavailable', 'role removal is unavailable');
220
220
  const body = request.body;
221
- const result = await services.removal.remove({ role: request.params.id, ...body });
221
+ const result = await services.removal.removeWeb({ role: request.params.id, ...body });
222
222
  events.publish('role.removed', { role: result.role }, result.role);
223
223
  await audit.record({ requestId: request.id, browser: session.id, roleId: result.role, action: 'role.remove', result: 'succeeded' });
224
224
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",