@ours.network/fleet 0.17.9 → 0.17.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.17.9",
3
- "buildId": "6f77d2fb4e88",
4
- "commit": "94827734295ad22ecb6691c099e960413a322970",
2
+ "version": "0.17.10",
3
+ "buildId": "1b9e94d03b53",
4
+ "commit": "f9932f0a2c99b8352286a3c4588bdfb57eed08f1",
5
5
  "dirty": false,
6
- "builtAt": "2026-08-19T09:41:14.713Z",
6
+ "builtAt": "2026-08-19T19:43:42.121Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import { agentDir, defaultConfigPath, fleetDDir, home } from './paths.js';
4
4
  import { parseFleetDocument, } from './config-yaml.js';
5
5
  import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
6
6
  import { getAdapter } from './harness/registry.js';
7
+ import { resolveRoleModelEnv } from './model-env.js';
7
8
  import { resolveWatchdogs } from './watchdog/config.js';
8
9
  import { resolveLoops } from './loops/config.js';
9
10
  import { CAPABILITIES, CAP_MONITOR_INTERRUPT_AFTER_TOOL } from './capabilities.js';
@@ -217,17 +218,23 @@ export function loadConfig(configPath, options = {}) {
217
218
  const harness = r.harness ?? defaults.harness ?? 'claude-code';
218
219
  const defaultHarness = defaults.harness ?? 'claude-code';
219
220
  const inheritsModelDefaults = harness === defaultHarness && r.model !== null;
220
- const model = resolveRoleModel(r.model, r.harness, defaults);
221
+ if (authProxy && harness !== 'claude-code')
222
+ throw new ConfigError(`${file}: role '${name}' auth_proxy is supported only by claude-code`);
223
+ // Environment and runtime model are resolved together so `model:` and the
224
+ // harness's model pin can never disagree (see src/model-env.ts).
225
+ const modelEnv = resolveRoleModelEnv({
226
+ harness,
227
+ model: resolveRoleModel(r.model, r.harness, defaults),
228
+ modelWasExplicit: r.model !== undefined,
229
+ defaultsEnv: (defaults.env ?? {}),
230
+ roleEnv: r.env,
231
+ ...(authProxy ? { authProxyBaseUrl: authProxy.base_url } : {}),
232
+ }, message => new ConfigError(`${file}: role '${name}' ${message}`));
233
+ const env = modelEnv.env;
234
+ const model = modelEnv.model;
221
235
  const modelChain = resolveModelChain(model, r.model_chain ?? (inheritsModelDefaults
222
236
  ? defaults.model_chain
223
237
  : undefined), file, name);
224
- if (authProxy && harness !== 'claude-code')
225
- throw new ConfigError(`${file}: role '${name}' auth_proxy is supported only by claude-code`);
226
- const env = {
227
- ...(defaults.env ?? {}),
228
- ...(r.env ?? {}),
229
- ...(authProxy ? { ANTHROPIC_BASE_URL: authProxy.base_url } : {}),
230
- };
231
238
  roles.push({
232
239
  ...r,
233
240
  name,
@@ -0,0 +1,71 @@
1
+ import type { ResolvedRole } from './config.js';
2
+ /**
3
+ * Which environment variable a harness reads to pin the model it RUNS.
4
+ *
5
+ * This is not a convenience: for `claude-code` it is the only channel that
6
+ * reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
7
+ * `buildAcpLaunch` launches the ACP adapter with no model argument, and that
8
+ * adapter resolves its model in this order — ANTHROPIC_MODEL, then
9
+ * `settings.model`, then a resumed session's live model, then its first
10
+ * catalogue entry. A role's declared model was therefore invisible to every
11
+ * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
12
+ * an explicitly requested one.
13
+ */
14
+ export declare const MODEL_ENV_BY_HARNESS: Readonly<Record<string, string>>;
15
+ /** The model-pin variable for a harness, or undefined if it pins no model by env. */
16
+ export declare function modelEnvVar(harness: string | undefined): string | undefined;
17
+ export interface RoleModelEnvInput {
18
+ harness: string;
19
+ /** Already resolved by `resolveRoleModel` — may come from the fleet default. */
20
+ model: string | undefined;
21
+ /** True when the role (or `--model`) named a model, including `model: null`. */
22
+ modelWasExplicit: boolean;
23
+ defaultsEnv?: Record<string, string>;
24
+ roleEnv?: Record<string, string>;
25
+ authProxyBaseUrl?: string;
26
+ }
27
+ export interface RoleModelEnv {
28
+ env: Record<string, string>;
29
+ /**
30
+ * The model the harness will actually run. Equal to `env[pin]` for a harness
31
+ * that pins by env, so anything reporting this value reports the runtime.
32
+ */
33
+ model: string | undefined;
34
+ }
35
+ /**
36
+ * Resolve a role's environment and its runtime model TOGETHER, so the two can
37
+ * never disagree.
38
+ *
39
+ * Precedence, highest first:
40
+ * 1. an explicit `model:` / `--model` on the role
41
+ * 2. the role's own `env:` pin
42
+ * 3. the fleet `defaults.model`
43
+ * 4. the fleet `defaults.env` pin
44
+ *
45
+ * Inheriting the fleet default remains correct when the role names no model
46
+ * (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
47
+ * disagree, there is no defensible winner, so this refuses rather than picking
48
+ * one silently — the silence is what let a day of "Fable" work run on Opus.
49
+ *
50
+ * `model: null` explicitly asks for no fleet-chosen model, so it also clears an
51
+ * inherited pin instead of leaving one in place to act as a hidden default.
52
+ */
53
+ export declare function resolveRoleModelEnv(input: RoleModelEnvInput, describe?: (message: string) => Error): RoleModelEnv;
54
+ /**
55
+ * The model a role will actually run, read back from the environment it was
56
+ * resolved with. Use this wherever a model is reported to a human.
57
+ */
58
+ export declare function effectiveRoleModel(role: ResolvedRole): string | undefined;
59
+ /**
60
+ * Move a role's env pin onto a new model. Anything that changes the model a
61
+ * role runs after resolution — model-chain recovery is the live example — must
62
+ * go through this, or it changes only the label.
63
+ */
64
+ export declare function repinModelEnv(role: ResolvedRole, model: string | undefined): Record<string, string> | undefined;
65
+ /**
66
+ * Last line of defence, at the exact point a child's environment is composed:
67
+ * refuse to launch a role whose child would run a model other than the one the
68
+ * role declares and the banner reports. A spawn that cannot keep those two in
69
+ * agreement must fail loudly, not start and be believed.
70
+ */
71
+ export declare function assertModelPinReachesChild(role: ResolvedRole, childEnv: Record<string, string | undefined>): void;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Which environment variable a harness reads to pin the model it RUNS.
3
+ *
4
+ * This is not a convenience: for `claude-code` it is the only channel that
5
+ * reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
6
+ * `buildAcpLaunch` launches the ACP adapter with no model argument, and that
7
+ * adapter resolves its model in this order — ANTHROPIC_MODEL, then
8
+ * `settings.model`, then a resumed session's live model, then its first
9
+ * catalogue entry. A role's declared model was therefore invisible to every
10
+ * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
11
+ * an explicitly requested one.
12
+ */
13
+ export const MODEL_ENV_BY_HARNESS = {
14
+ 'claude-code': 'ANTHROPIC_MODEL',
15
+ };
16
+ /** The model-pin variable for a harness, or undefined if it pins no model by env. */
17
+ export function modelEnvVar(harness) {
18
+ return harness === undefined ? undefined : MODEL_ENV_BY_HARNESS[harness];
19
+ }
20
+ /**
21
+ * Resolve a role's environment and its runtime model TOGETHER, so the two can
22
+ * never disagree.
23
+ *
24
+ * Precedence, highest first:
25
+ * 1. an explicit `model:` / `--model` on the role
26
+ * 2. the role's own `env:` pin
27
+ * 3. the fleet `defaults.model`
28
+ * 4. the fleet `defaults.env` pin
29
+ *
30
+ * Inheriting the fleet default remains correct when the role names no model
31
+ * (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
32
+ * disagree, there is no defensible winner, so this refuses rather than picking
33
+ * one silently — the silence is what let a day of "Fable" work run on Opus.
34
+ *
35
+ * `model: null` explicitly asks for no fleet-chosen model, so it also clears an
36
+ * inherited pin instead of leaving one in place to act as a hidden default.
37
+ */
38
+ export function resolveRoleModelEnv(input, describe = message => new Error(message)) {
39
+ const env = {
40
+ ...(input.defaultsEnv ?? {}),
41
+ ...(input.roleEnv ?? {}),
42
+ ...(input.authProxyBaseUrl ? { ANTHROPIC_BASE_URL: input.authProxyBaseUrl } : {}),
43
+ };
44
+ const pin = modelEnvVar(input.harness);
45
+ if (!pin)
46
+ return { env, model: input.model };
47
+ const rolePin = input.roleEnv?.[pin];
48
+ if (input.modelWasExplicit) {
49
+ if (rolePin !== undefined && rolePin !== input.model)
50
+ throw describe(`model '${input.model ?? '(none)'}' contradicts env.${pin} '${rolePin}'; `
51
+ + `remove one — ${pin} is what the harness actually runs`);
52
+ if (input.model === undefined)
53
+ delete env[pin];
54
+ else
55
+ env[pin] = input.model;
56
+ return { env, model: input.model };
57
+ }
58
+ // Not explicit: a role-level pin is the most specific thing said about this
59
+ // role, so it decides — and the reported model follows it.
60
+ if (rolePin !== undefined)
61
+ return { env, model: rolePin };
62
+ if (input.model !== undefined)
63
+ env[pin] = input.model;
64
+ return { env, model: input.model ?? env[pin] };
65
+ }
66
+ /**
67
+ * The model a role will actually run, read back from the environment it was
68
+ * resolved with. Use this wherever a model is reported to a human.
69
+ */
70
+ export function effectiveRoleModel(role) {
71
+ const pin = modelEnvVar(role.harness);
72
+ return (pin ? role.env?.[pin] : undefined) ?? role.model;
73
+ }
74
+ /**
75
+ * Move a role's env pin onto a new model. Anything that changes the model a
76
+ * role runs after resolution — model-chain recovery is the live example — must
77
+ * go through this, or it changes only the label.
78
+ */
79
+ export function repinModelEnv(role, model) {
80
+ const pin = modelEnvVar(role.harness);
81
+ if (!pin)
82
+ return role.env;
83
+ const env = { ...(role.env ?? {}) };
84
+ if (model === undefined)
85
+ delete env[pin];
86
+ else
87
+ env[pin] = model;
88
+ return Object.keys(env).length ? env : undefined;
89
+ }
90
+ /**
91
+ * Last line of defence, at the exact point a child's environment is composed:
92
+ * refuse to launch a role whose child would run a model other than the one the
93
+ * role declares and the banner reports. A spawn that cannot keep those two in
94
+ * agreement must fail loudly, not start and be believed.
95
+ */
96
+ export function assertModelPinReachesChild(role, childEnv) {
97
+ const pin = modelEnvVar(role.harness);
98
+ if (!pin || role.model === undefined)
99
+ return;
100
+ const actual = childEnv[pin];
101
+ if (actual === role.model)
102
+ return;
103
+ throw new Error(`[${role.name}] refusing to launch: role model is '${role.model}' but the child's `
104
+ + `${pin} is ${actual === undefined ? 'unset' : `'${actual}'`} — the session would run a `
105
+ + 'different model than the one reported');
106
+ }
package/dist/runner.d.ts CHANGED
@@ -35,6 +35,15 @@ export interface RunnerDeps {
35
35
  }
36
36
  /** Environment injected only into the managed harness process. */
37
37
  export declare function managedFleetProxyEnv(role: ResolvedRole, stateDir: string): Record<string, string>;
38
+ /**
39
+ * The environment a managed harness child actually receives, checked at the one
40
+ * point where it is composed. `role.env` deliberately wins over harness prep,
41
+ * which is exactly how a stale fleet-wide model pin used to outrank the model
42
+ * the role was spawned with — so the model pin is verified here rather than
43
+ * trusted, and a disagreement stops the launch instead of being reported as a
44
+ * success (see src/model-env.ts).
45
+ */
46
+ export declare function harnessChildEnv(role: ResolvedRole, launchEnv: Record<string, string> | undefined, stateDir: string): Record<string, string>;
38
47
  /**
39
48
  * Record who owns wake delivery for this run. Returning true means a fleet
40
49
  * monitor is taking ownership back from a native harness and must start at the
package/dist/runner.js CHANGED
@@ -24,6 +24,7 @@ import { RoleTurnArbiter } from './session/arbiter.js';
24
24
  import { ScheduledLoopManager, } from './loops/manager.js';
25
25
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, inheritCallerSpawnDefaults, } from './fleet-proxy.js';
26
26
  import { effectivePermissionMode } from './permissions.js';
27
+ import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
27
28
  import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
28
29
  const defaultDeps = () => ({
29
30
  tmux: new Tmux(),
@@ -72,6 +73,19 @@ export function managedFleetProxyEnv(role, stateDir) {
72
73
  [FLEET_PROXY_CALLER_ENV]: role.name,
73
74
  };
74
75
  }
76
+ /**
77
+ * The environment a managed harness child actually receives, checked at the one
78
+ * point where it is composed. `role.env` deliberately wins over harness prep,
79
+ * which is exactly how a stale fleet-wide model pin used to outrank the model
80
+ * the role was spawned with — so the model pin is verified here rather than
81
+ * trusted, and a disagreement stops the launch instead of being reported as a
82
+ * success (see src/model-env.ts).
83
+ */
84
+ export function harnessChildEnv(role, launchEnv, stateDir) {
85
+ const env = { ...(launchEnv ?? {}), ...managedFleetProxyEnv(role, stateDir) };
86
+ assertModelPinReachesChild(role, env);
87
+ return env;
88
+ }
75
89
  /**
76
90
  * Execute a typed proxy request in the caller's supervisor. Dynamic imports
77
91
  * avoid a runner↔spawn initialization cycle (spawn imports runner constants).
@@ -109,7 +123,9 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
109
123
  statePath,
110
124
  harness: preview.harness,
111
125
  session: preview.session,
112
- ...(preview.model ? { model: preview.model } : {}),
126
+ // Read back from the resolved environment, not from the request: the banner
127
+ // must name the model the child will run, not the one that was asked for.
128
+ ...(effectiveRoleModel(preview) ? { model: effectiveRoleModel(preview) } : {}),
113
129
  monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
114
130
  permissionMode: effectivePermissionMode(preview),
115
131
  inherited,
@@ -117,6 +133,7 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
117
133
  };
118
134
  log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
119
135
  + `harness=${result.harness} session=${result.session} `
136
+ + `model=${result.model ?? '(harness default)'} `
120
137
  + `permission=${result.permissionMode.fleetMode} `
121
138
  + `native=${result.permissionMode.nativeMode}`);
122
139
  return result;
@@ -458,11 +475,18 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
458
475
  const effectiveModel = effectiveModelForRole(dir, role);
459
476
  if (effectiveModel !== role.model) {
460
477
  deps.log(`[${name}] model recovery drift: declared=${role.model ?? '(none)'} effective=${effectiveModel}`);
461
- role = { ...role, model: effectiveModel };
478
+ // The env pin has to move with it. A down-shift that changed only
479
+ // `role.model` was reported as a model change while the child kept running
480
+ // the model that had just failed, because the pin is what the harness reads.
481
+ role = { ...role, model: effectiveModel, env: repinModelEnv(role, effectiveModel) };
462
482
  }
463
483
  if (modelRecoveryHeld(dir))
464
484
  throw new Error(`[${name}] model chain exhausted — held down until config changes or recovery reset`);
465
485
  const adapter = getAdapter(role.harness);
486
+ // Say the running model out loud, once, from the resolved environment. The
487
+ // spawn banner is a claim made before the process exists; this is the log line
488
+ // that can be checked against the session afterwards.
489
+ deps.log(`[${name}] model: ${effectiveRoleModel(role) ?? '(harness default)'}`);
466
490
  mkdirSync(dir, { recursive: true });
467
491
  const rotation = rotateWorklog(join(dir, 'WORKLOG.md'), role.worklog);
468
492
  if (rotation.deferred)
@@ -603,7 +627,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
603
627
  name,
604
628
  argv: wrappedArgv,
605
629
  cwd: runCwd,
606
- env: { ...launch.env, ...managedFleetProxyEnv(role, dir) },
630
+ env: harnessChildEnv(role, launch.env, dir),
607
631
  stateDir: dir,
608
632
  mode,
609
633
  permissions: perms,
package/dist/spawn.js CHANGED
@@ -5,6 +5,7 @@ import { parse, stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
6
  import { validateIsolationConfig } from './isolation/policy.js';
7
7
  import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolveOwnerChannelConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
8
+ import { resolveRoleModelEnv } from './model-env.js';
8
9
  import { applyRole, up } from './ops.js';
9
10
  import { START_STAGGER_FILE } from './runner.js';
10
11
  import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
@@ -191,7 +192,17 @@ export function spawnDryRun(o) {
191
192
  const harness = raw.harness ?? cfg.defaults.harness ?? 'claude-code';
192
193
  const defaultHarness = cfg.defaults.harness ?? 'claude-code';
193
194
  const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
194
- const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
195
+ const authProxy = resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy);
196
+ // One resolution for the environment and the model it pins (src/model-env.ts).
197
+ const modelEnv = resolveRoleModelEnv({
198
+ harness,
199
+ model: resolveRoleModel(raw.model, raw.harness, cfg.defaults),
200
+ modelWasExplicit: raw.model !== undefined,
201
+ defaultsEnv: (cfg.defaults.env ?? {}),
202
+ roleEnv: raw.env,
203
+ ...(authProxy ? { authProxyBaseUrl: authProxy.base_url } : {}),
204
+ });
205
+ const model = modelEnv.model;
195
206
  const session = raw.session ?? cfg.defaults.session ?? 'tmux';
196
207
  const resolvedRole = {
197
208
  ...raw,
@@ -212,15 +223,9 @@ export function spawnDryRun(o) {
212
223
  monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
213
224
  owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, raw.owner_channel, session),
214
225
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
215
- auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy),
216
- };
217
- resolvedRole.env = {
218
- ...(cfg.defaults.env ?? {}),
219
- ...(raw.env ?? {}),
220
- ...(resolvedRole.auth_proxy
221
- ? { ANTHROPIC_BASE_URL: resolvedRole.auth_proxy.base_url }
222
- : {}),
226
+ auth_proxy: authProxy,
223
227
  };
228
+ resolvedRole.env = modelEnv.env;
224
229
  const adapter = getAdapter(resolvedRole.harness);
225
230
  if (resolvedRole.auth_proxy && resolvedRole.harness !== 'claude-code')
226
231
  throw new Error('auth_proxy is supported only by claude-code');
@@ -403,7 +408,18 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
403
408
  };
404
409
  const harness = o.harness ?? defaultHarness ?? 'claude-code';
405
410
  const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
406
- const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
411
+ const tempAuthProxy = resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy);
412
+ // An explicitly requested --model must reach the child, not just the banner
413
+ // (src/model-env.ts).
414
+ const modelEnv = resolveRoleModelEnv({
415
+ harness,
416
+ model: resolveRoleModel(o.model, o.harness, cfg.defaults),
417
+ modelWasExplicit: o.model !== undefined,
418
+ defaultsEnv: (cfg.defaults.env ?? {}),
419
+ roleEnv: fromOpts.env,
420
+ ...(tempAuthProxy ? { authProxyBaseUrl: tempAuthProxy.base_url } : {}),
421
+ });
422
+ const model = modelEnv.model;
407
423
  const session = o.session ?? cfg.defaults.session ?? 'tmux';
408
424
  const role = {
409
425
  ...fromOpts, // includes `isolation` when --isolation-file was given
@@ -422,14 +438,10 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
422
438
  monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
423
439
  owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, fromOpts.owner_channel, session),
424
440
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
425
- auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy),
441
+ auth_proxy: tempAuthProxy,
426
442
  sourceFile: '(temp)',
427
443
  };
428
- role.env = {
429
- ...(cfg.defaults.env ?? {}),
430
- ...(fromOpts.env ?? {}),
431
- ...(role.auth_proxy ? { ANTHROPIC_BASE_URL: role.auth_proxy.base_url } : {}),
432
- };
444
+ role.env = modelEnv.env;
433
445
  if (role.auth_proxy && role.harness !== 'claude-code')
434
446
  throw new Error('auth_proxy is supported only by claude-code');
435
447
  onStage?.('writing_role');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.17.9",
3
+ "version": "0.17.10",
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",