@ours.network/fleet 0.11.0 → 0.12.0

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.
@@ -38,6 +38,7 @@ export declare class AcpSession implements SessionHandle {
38
38
  private steeringSupported;
39
39
  private capabilities?;
40
40
  private controllerCount;
41
+ private activeTurn?;
41
42
  private constructor();
42
43
  static start(options: AcpSessionOptions): Promise<AcpSession>;
43
44
  isAlive(): boolean;
@@ -40,6 +40,7 @@ export class AcpSession {
40
40
  steeringSupported = false;
41
41
  capabilities;
42
42
  controllerCount = 0;
43
+ activeTurn;
43
44
  constructor(options, child, connection) {
44
45
  this.options = options;
45
46
  this.child = child;
@@ -162,6 +163,7 @@ export class AcpSession {
162
163
  this.pendingPermissions.delete(permissionId);
163
164
  pending.resolve({ outcome: { outcome: 'selected', optionId } });
164
165
  this.events.emit('permission', {
166
+ turnId: this.activeTurn?.id,
165
167
  permissionId,
166
168
  status: 'completed',
167
169
  decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
@@ -241,6 +243,7 @@ export class AcpSession {
241
243
  if (!this.sessionId || !this.isAlive())
242
244
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
243
245
  this.readiness = 'running';
246
+ this.activeTurn = { id: turnId, output: '' };
244
247
  this.events.emit('state', { turnId, status: 'running' });
245
248
  try {
246
249
  const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
@@ -252,7 +255,7 @@ export class AcpSession {
252
255
  this.events.emit('state', { status: 'idle' });
253
256
  // The prompt was accepted either way — the agent answered. Whether the
254
257
  // turn SUCCEEDED is a separate question, and only `stopReason` answers it.
255
- return turnResult(true, classifyStopReason(response.stopReason), response.stopReason);
258
+ return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
256
259
  }
257
260
  catch (error) {
258
261
  this.lastError = error?.message ?? String(error);
@@ -260,7 +263,11 @@ export class AcpSession {
260
263
  this.events.emit('error', { turnId, text: this.lastError });
261
264
  if (this.isAlive())
262
265
  this.events.emit('state', { status: 'idle' });
263
- return turnResult(false, 'failed', this.lastError);
266
+ return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
267
+ }
268
+ finally {
269
+ if (this.activeTurn?.id === turnId)
270
+ this.activeTurn = undefined;
264
271
  }
265
272
  }
266
273
  async steerPrompt(text) {
@@ -312,6 +319,7 @@ export class AcpSession {
312
319
  const permissionId = randomUUID();
313
320
  this.readiness = 'awaiting_permission';
314
321
  this.events.emit('permission', {
322
+ turnId: this.activeTurn?.id,
315
323
  permissionId,
316
324
  toolCallId: params.toolCall.toolCallId,
317
325
  title: params.toolCall.title ?? 'Permission requested',
@@ -332,6 +340,7 @@ export class AcpSession {
332
340
  settleAutomatically(params, option, decision, policy, reason) {
333
341
  const settled = option ? decision : 'cancelled';
334
342
  this.events.emit('permission', {
343
+ turnId: this.activeTurn?.id,
335
344
  permissionId: randomUUID(),
336
345
  toolCallId: params.toolCall.toolCallId,
337
346
  title: params.toolCall.title ?? 'Permission requested',
@@ -366,17 +375,22 @@ export class AcpSession {
366
375
  recordUpdate(update) {
367
376
  switch (update.sessionUpdate) {
368
377
  case 'agent_message_chunk':
378
+ if (this.activeTurn && update.content.type === 'text')
379
+ this.activeTurn.output += update.content.text;
369
380
  this.events.emit('agent_text', {
381
+ turnId: this.activeTurn?.id,
370
382
  text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
371
383
  });
372
384
  break;
373
385
  case 'agent_thought_chunk':
374
386
  this.events.emit('thought', {
387
+ turnId: this.activeTurn?.id,
375
388
  text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
376
389
  });
377
390
  break;
378
391
  case 'tool_call':
379
392
  this.events.emit('tool_call', {
393
+ turnId: this.activeTurn?.id,
380
394
  toolCallId: update.toolCallId,
381
395
  title: update.title,
382
396
  status: update.status,
@@ -384,6 +398,7 @@ export class AcpSession {
384
398
  break;
385
399
  case 'tool_call_update':
386
400
  this.events.emit('tool_update', {
401
+ turnId: this.activeTurn?.id,
387
402
  toolCallId: update.toolCallId,
388
403
  title: update.title ?? undefined,
389
404
  status: update.status ?? undefined,
@@ -19,6 +19,8 @@ export interface TurnResult {
19
19
  outcome: TurnOutcome;
20
20
  succeeded: boolean;
21
21
  detail?: string;
22
+ /** Final assistant text captured structurally by a backend, when available. */
23
+ output?: string;
22
24
  }
23
25
  /**
24
26
  * Why a control operation failed. The distinctions exist because collapsing
@@ -75,7 +77,7 @@ export declare function classifyChildExit(code: number | null, signal: string |
75
77
  /** The single definition of terminal success. Nothing else may re-derive it. */
76
78
  export declare const isTerminalSuccess: (outcome: TurnOutcome) => boolean;
77
79
  /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
78
- export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string): TurnResult;
80
+ export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string, output?: string): TurnResult;
79
81
  export interface SubmitPromptOptions {
80
82
  /** Cancel active work before delivering this prompt. */
81
83
  interrupt?: boolean;
@@ -37,6 +37,6 @@ export function classifyChildExit(code, signal) {
37
37
  /** The single definition of terminal success. Nothing else may re-derive it. */
38
38
  export const isTerminalSuccess = (outcome) => outcome === 'completed';
39
39
  /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
40
- export function turnResult(accepted, outcome, detail) {
41
- return { accepted, outcome, succeeded: isTerminalSuccess(outcome), detail };
40
+ export function turnResult(accepted, outcome, detail, output) {
41
+ return { accepted, outcome, succeeded: isTerminalSuccess(outcome), detail, output };
42
42
  }
package/dist/spawn.js CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
6
  import { validateIsolationConfig } from './isolation/policy.js';
7
- import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
7
+ import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolveOwnerChannelConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
8
8
  import { applyRole, up } from './ops.js';
9
9
  import { START_STAGGER_FILE } from './runner.js';
10
10
  import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
@@ -186,12 +186,13 @@ export function spawnDryRun(o) {
186
186
  const defaultHarness = cfg.defaults.harness ?? 'claude-code';
187
187
  const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
188
188
  const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
189
+ const session = raw.session ?? cfg.defaults.session ?? 'tmux';
189
190
  const resolvedRole = {
190
191
  ...raw,
191
192
  name: o.name,
192
193
  sourceFile: o.temp ? '(temp dry-run)' : join(fleetDDir(), `${o.name}.yaml`),
193
194
  harness,
194
- session: raw.session ?? cfg.defaults.session ?? 'tmux',
195
+ session,
195
196
  session_options: raw.session_options,
196
197
  permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
197
198
  permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
@@ -203,6 +204,7 @@ export function spawnDryRun(o) {
203
204
  harness_options: Object.keys(harnessOptions).length ? harnessOptions : undefined,
204
205
  isolation: raw.isolation ?? cfg.defaults.isolation,
205
206
  monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
207
+ owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, raw.owner_channel, session),
206
208
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
207
209
  auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy),
208
210
  };
@@ -378,11 +380,12 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
378
380
  const harness = o.harness ?? defaultHarness ?? 'claude-code';
379
381
  const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
380
382
  const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
383
+ const session = o.session ?? cfg.defaults.session ?? 'tmux';
381
384
  const role = {
382
385
  ...fromOpts, // includes `isolation` when --isolation-file was given
383
386
  name: o.name,
384
387
  harness,
385
- session: o.session ?? cfg.defaults.session ?? 'tmux',
388
+ session,
386
389
  identity: o.identity ?? o.name,
387
390
  model,
388
391
  model_chain: resolveModelChain(model, fromOpts.model_chain ?? (inheritsModelDefaults
@@ -393,6 +396,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
393
396
  permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
394
397
  // Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
395
398
  monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
399
+ owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, fromOpts.owner_channel, session),
396
400
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
397
401
  auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy),
398
402
  sourceFile: '(temp)',
@@ -1,4 +1,5 @@
1
1
  import type { FleetConfig, ResolvedRole, SessionBackendId } from '../config.js';
2
+ import type { IsolationConfig } from '../isolation/types.js';
2
3
  export interface WatchdogConfig {
3
4
  coordinator?: string;
4
5
  enabled?: boolean;
@@ -12,6 +13,7 @@ export interface WatchdogConfig {
12
13
  keep_reports?: number;
13
14
  alert_cooldown?: string;
14
15
  prompt_file?: string;
16
+ isolation?: IsolationConfig;
15
17
  }
16
18
  export interface ResolvedWatchdog {
17
19
  name: string;
@@ -19,6 +21,7 @@ export interface ResolvedWatchdog {
19
21
  enabled: boolean;
20
22
  intervalMs: number;
21
23
  watch: string[];
24
+ watchExplicit: boolean;
22
25
  harness: string;
23
26
  session: SessionBackendId;
24
27
  model?: string;
@@ -27,6 +30,7 @@ export interface ResolvedWatchdog {
27
30
  keepReports: number;
28
31
  alertCooldownMs: number;
29
32
  promptFile?: string;
33
+ isolation?: IsolationConfig;
30
34
  sourceFile: string;
31
35
  }
32
36
  export declare const WATCHDOG_DEFAULT_INTERVAL_MS = 600000;
@@ -2,9 +2,10 @@ import { existsSync } from 'node:fs';
2
2
  import { isAbsolute } from 'node:path';
3
3
  import { parseDuration } from '../duration.js';
4
4
  import { ConfigError, ROLE_NAME_RE, resolveRoleModel } from '../config.js';
5
+ import { validateIsolationConfig } from '../isolation/policy.js';
5
6
  const WATCHDOG_KEYS = [
6
7
  'coordinator', 'enabled', 'interval', 'watch', 'harness', 'model', 'session',
7
- 'identity', 'timeout', 'keep_reports', 'alert_cooldown', 'prompt_file',
8
+ 'identity', 'timeout', 'keep_reports', 'alert_cooldown', 'prompt_file', 'isolation',
8
9
  ];
9
10
  export const WATCHDOG_DEFAULT_INTERVAL_MS = 600_000;
10
11
  export const WATCHDOG_MIN_INTERVAL_MS = 60_000;
@@ -69,6 +70,11 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
69
70
  if (!existsSync(w.prompt_file))
70
71
  throw new ConfigError(`${where}: prompt_file not found: ${w.prompt_file}`);
71
72
  }
73
+ if (w.isolation !== undefined) {
74
+ const problems = validateIsolationConfig(w.isolation);
75
+ if (problems.length)
76
+ throw new ConfigError(`${where} ${problems.join('; ')}`);
77
+ }
72
78
  const dur = (v, key, fallback, minMs) => {
73
79
  if (v === undefined)
74
80
  return fallback;
@@ -88,13 +94,13 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
88
94
  name, coordinator: w.coordinator.trim(),
89
95
  enabled: w.enabled ?? true,
90
96
  intervalMs: dur(w.interval, 'interval', WATCHDOG_DEFAULT_INTERVAL_MS, WATCHDOG_MIN_INTERVAL_MS),
91
- watch, harness, session: sessionRaw,
97
+ watch, watchExplicit: w.watch !== undefined, harness, session: sessionRaw,
92
98
  model: resolveRoleModel(w.model, w.harness, defaults),
93
99
  identity,
94
100
  timeoutMs: dur(w.timeout, 'timeout', WATCHDOG_DEFAULT_TIMEOUT_MS),
95
101
  keepReports,
96
102
  alertCooldownMs: dur(w.alert_cooldown, 'alert_cooldown', WATCHDOG_DEFAULT_COOLDOWN_MS),
97
- promptFile: w.prompt_file, sourceFile: baseFile,
103
+ promptFile: w.prompt_file, isolation: w.isolation, sourceFile: baseFile,
98
104
  });
99
105
  }
100
106
  return out;
@@ -21,6 +21,8 @@ export interface WatchdogRunDeps {
21
21
  launchChild?(binPath: string, roleName: string, runDir: string): WatchdogChildHandle;
22
22
  /** Pre-loaded config (defaults inheritance). Falls back to `loadConfig()`. */
23
23
  cfg?: FleetConfig;
24
+ /** Live temporary-role discovery override for focused tests. */
25
+ discoverLiveTemporaryRoles?(): Promise<string[]>;
24
26
  }
25
27
  export interface WatchdogRunOutcome {
26
28
  report: WatchdogReport;
@@ -1,4 +1,4 @@
1
- import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
1
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
2
  import { spawn as spawnChild, execFile } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
@@ -10,10 +10,12 @@ import { computeDigest, reconcileLedger, readLedger, writeLedger } from './alert
10
10
  import { applyRole } from '../ops.js';
11
11
  import { daemonIdentityProvisioner, ensureIdentity, } from '../creation.js';
12
12
  import { runOnce, START_STAGGER_FILE } from '../runner.js';
13
- import { agentDir } from '../paths.js';
14
- import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, } from '../config.js';
13
+ import { agentDir, tmpRoot } from '../paths.js';
14
+ import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, ROLE_NAME_RE, } from '../config.js';
15
15
  import { getAdapter } from '../harness/registry.js';
16
16
  import { redactLogLine } from '../application/log-service.js';
17
+ import { controlRequest, controlSocketPath } from '../session/control.js';
18
+ import { Tmux } from '../tmux.js';
17
19
  const execFileAsync = promisify(execFile);
18
20
  /** How often the deadline loop polls for a completed report or a dead child. */
19
21
  const POLL_MS = 1000;
@@ -92,33 +94,49 @@ function readTail(runDir) {
92
94
  return undefined;
93
95
  }
94
96
  }
95
- /**
96
- * The temp role every watchdog-family run (inspection and notifier alike) launches under.
97
- * `network: 'broker'` keeps ours messaging available; no write binds beyond stateDir/cwd, which
98
- * resolveIsolation adds itself. ~/fleet.yaml and fleet.d are deliberately NOT bound — they're on
99
- * the isolation blocklist, and everything either run flavor needs is written into its own dir.
100
- *
101
- * Read access is scoped to exactly `wd.watch` (finding #3): a watchdog configured to watch one
102
- * role must not be able to read every other role's state dir just because they all live under
103
- * the same agents root. `wd.watch` defaults to every role (watchdog/config.ts's
104
- * resolveWatchdogs), so a watchdog that watches everything still sees everything — this only
105
- * narrows visibility for a watchdog scoped to fewer roles. A watched role whose state dir doesn't
106
- * exist (e.g. a role removed after the watchdog was configured) is simply absent from the
107
- * bwrap ro-bind-try set — the agent reports it unreachable from status evidence, same as any
108
- * other missing state dir.
109
- */
97
+ /** The temp role every watchdog-family run launches under. Isolation is opt-in. */
110
98
  function buildWatchdogRole(wd, cfg) {
111
99
  return {
112
100
  name: wd.identity, sourceFile: '(watchdog)',
113
101
  harness: wd.harness, session: wd.session,
114
102
  identity: wd.identity, model: wd.model,
115
- permissions: { approval: 'allow', filesystem: 'workspace', unattended: 'deny' },
103
+ // Watchdogs are observe-only by contract, but their sanctioned status commands
104
+ // must reach host control sockets. Keep approvals/unattended escalation denied
105
+ // while disabling the harness's native filesystem/network sandbox.
106
+ permissions: { approval: 'deny', filesystem: 'unrestricted', unattended: 'deny' },
116
107
  permissionsDeclared: true,
117
108
  monitor: resolveMonitorConfig(cfg.defaults.monitor, undefined),
118
109
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, undefined),
119
- isolation: { fs: { read: wd.watch.map(r => agentDir(r)) }, network: 'broker' },
110
+ isolation: wd.isolation,
120
111
  };
121
112
  }
113
+ /** Discover temporary sessions that are live now, not merely stale dirs on disk. */
114
+ async function discoverLiveTemporaryRoles() {
115
+ let names;
116
+ try {
117
+ names = readdirSync(tmpRoot(), { withFileTypes: true })
118
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && ROLE_NAME_RE.test(entry.name))
119
+ .map(entry => entry.name);
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ const tmux = new Tmux();
125
+ const live = await Promise.all(names.map(async (name) => {
126
+ const dir = agentDir(name, true);
127
+ const [acpAlive, tmuxAlive] = await Promise.all([
128
+ existsSync(controlSocketPath(dir))
129
+ ? controlRequest(dir, { command: 'status' }, 2_000)
130
+ .then(response => response.ok
131
+ && response.result?.alive === true)
132
+ .catch(() => false)
133
+ : Promise.resolve(false),
134
+ tmux.has(name).catch(() => false),
135
+ ]);
136
+ return acpAlive || tmuxAlive ? name : undefined;
137
+ }));
138
+ return live.filter((name) => name !== undefined);
139
+ }
122
140
  /**
123
141
  * Run one watchdog agent end-to-end in a clean-context temp state dir: provision
124
142
  * identity, materialize the run's contract (briefing/manifest/role snapshot),
@@ -147,14 +165,20 @@ export async function executeWatchdogRun(wd, deps) {
147
165
  try {
148
166
  const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
149
167
  const cfg = deps.cfg ?? loadConfig();
150
- const role = buildWatchdogRole(wd, cfg);
168
+ const discovered = wd.watchExplicit
169
+ ? []
170
+ : await (deps.discoverLiveTemporaryRoles ?? discoverLiveTemporaryRoles)();
171
+ const watch = [...new Set([...wd.watch, ...discovered])]
172
+ .filter(name => name !== wd.identity);
173
+ const runWd = { ...wd, watch };
174
+ const role = buildWatchdogRole(runWd, cfg);
151
175
  const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
152
176
  const reportPath = join(dir, 'report.json');
153
177
  const manifestPath = join(dir, 'watch.json');
154
178
  const promptFocus = wd.promptFile ? readFileSync(wd.promptFile, 'utf8') : undefined;
155
179
  // applyRole wrote the generic role briefing; the watchdog contract replaces it.
156
180
  writeFileSync(join(dir, 'briefing.md'), generateWatchdogBriefing({
157
- wd, manifestPath, reportPath,
181
+ wd: runWd, manifestPath, reportPath,
158
182
  vocabulary: getAdapter(wd.harness).vocabulary,
159
183
  identityGuarantee: guarantee.state,
160
184
  promptFocus,
@@ -163,7 +187,10 @@ export async function executeWatchdogRun(wd, deps) {
163
187
  writeFileSync(join(dir, 'role.yaml'), stringify(role));
164
188
  const manifest = {
165
189
  watchdog: wd.name, run_id: runId, coordinator: wd.coordinator, started_at: startedAt,
166
- roles: wd.watch.map(r => ({ name: r, stateDir: agentDir(r) })),
190
+ roles: runWd.watch.map(r => ({
191
+ name: r,
192
+ stateDir: wd.watch.includes(r) ? agentDir(r) : agentDir(r, true),
193
+ })),
167
194
  digest: computeDigest(ledger, wd.alertCooldownMs, now()),
168
195
  };
169
196
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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",