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

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 (65) hide show
  1. package/README.md +76 -93
  2. package/dist/application/role-creation-service.d.ts +2 -21
  3. package/dist/application/role-creation-service.js +15 -64
  4. package/dist/application/role-removal-service.js +6 -3
  5. package/dist/application/task-room-service.js +7 -2
  6. package/dist/application/types.d.ts +1 -1
  7. package/dist/briefing.js +5 -5
  8. package/dist/build-info.json +4 -4
  9. package/dist/cli.js +48 -38
  10. package/dist/config.d.ts +65 -2
  11. package/dist/config.js +337 -25
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/creation.js +2 -2
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +48 -97
  16. package/dist/doctor.js +4 -4
  17. package/dist/fleet-proxy.js +30 -8
  18. package/dist/generated-agent-source.d.ts +9 -0
  19. package/dist/generated-agent-source.js +53 -0
  20. package/dist/harness/agent-session.d.ts +13 -0
  21. package/dist/harness/claude-code-session.d.ts +4 -0
  22. package/dist/harness/claude-code-session.js +4 -0
  23. package/dist/harness/claude-code.js +10 -0
  24. package/dist/harness/codex-session.d.ts +4 -0
  25. package/dist/harness/codex-session.js +4 -0
  26. package/dist/harness/codex.js +14 -0
  27. package/dist/harness/registry.js +4 -0
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/init-guidance.d.ts +1 -0
  31. package/dist/init-guidance.js +5 -0
  32. package/dist/model-env.d.ts +2 -3
  33. package/dist/model-env.js +3 -5
  34. package/dist/ops.js +4 -2
  35. package/dist/owner-channel/commands.js +2 -1
  36. package/dist/resolved-plan.d.ts +3 -2
  37. package/dist/resolved-plan.js +21 -5
  38. package/dist/rooms-tasks/cli.js +2 -1
  39. package/dist/rooms-tasks/config.js +24 -9
  40. package/dist/rooms-tasks/provision.js +71 -36
  41. package/dist/rooms-tasks/templates.js +6 -6
  42. package/dist/rooms-tasks/types.d.ts +8 -15
  43. package/dist/rooms-tasks/types.js +1 -4
  44. package/dist/sensitive-config.d.ts +2 -0
  45. package/dist/sensitive-config.js +4 -0
  46. package/dist/spawn.d.ts +8 -33
  47. package/dist/spawn.js +52 -211
  48. package/dist/watchdog/config.d.ts +10 -7
  49. package/dist/watchdog/config.js +29 -19
  50. package/dist/watchdog/run.js +4 -13
  51. package/dist/web/fleet-config-service.d.ts +11 -8
  52. package/dist/web/fleet-config-service.js +383 -183
  53. package/dist/web/topology-promote.js +25 -15
  54. package/dist/web/topology.js +2 -2
  55. package/dist/web/yaml-document-edit.js +2 -0
  56. package/dist/web-app/assets/index-BbTc5KtZ.js +10 -0
  57. package/dist/web-app/index.html +1 -1
  58. package/examples/fleet/agents/Alice.yaml +11 -0
  59. package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
  60. package/examples/fleet/brains/claude-default.yaml +5 -0
  61. package/examples/fleet/roles/coordinator.yaml +5 -0
  62. package/examples/fleet/roles/developer.yaml +3 -0
  63. package/examples/fleet.yaml +74 -0
  64. package/package.json +2 -1
  65. package/dist/web-app/assets/index-DhMDVRU1.js +0 -10
@@ -3,6 +3,8 @@ import type { AcpMcpServer, SessionPrep } from './types.js';
3
3
  import type { AgentSessionAdapter, AgentSessionStartOptions, PreparedAgentSessionLaunch } from './agent-session.js';
4
4
  import { type AcpSessionTransport } from './acp-session-transport.js';
5
5
  export interface ClaudeCodeSessionStrategy {
6
+ resolveBrain: AgentSessionAdapter['resolveBrain'];
7
+ modelEnvironmentVariable: AgentSessionAdapter['modelEnvironmentVariable'];
6
8
  prepareLaunch(role: ResolvedRole, prep: SessionPrep): PreparedAgentSessionLaunch;
7
9
  permissionModeId(role: ResolvedRole): string | undefined;
8
10
  mcpServers(role: ResolvedRole): AcpMcpServer[] | undefined;
@@ -13,6 +15,8 @@ export declare class ClaudeCodeAgentSessionAdapter implements AgentSessionAdapte
13
15
  private readonly strategy;
14
16
  private readonly transport;
15
17
  constructor(strategy: ClaudeCodeSessionStrategy, transport?: AcpSessionTransport);
18
+ resolveBrain(brain: Parameters<AgentSessionAdapter['resolveBrain']>[0]): import("./agent-session.js").ResolvedBrainSelection;
19
+ modelEnvironmentVariable(): string | undefined;
16
20
  prepareLaunch(role: ResolvedRole, prep: SessionPrep): PreparedAgentSessionLaunch;
17
21
  start(options: AgentSessionStartOptions): Promise<import("../index.js").AgentSession>;
18
22
  }
@@ -8,6 +8,10 @@ export class ClaudeCodeAgentSessionAdapter {
8
8
  this.strategy = strategy;
9
9
  this.transport = transport;
10
10
  }
11
+ resolveBrain(brain) {
12
+ return this.strategy.resolveBrain(brain);
13
+ }
14
+ modelEnvironmentVariable() { return this.strategy.modelEnvironmentVariable(); }
11
15
  prepareLaunch(role, prep) {
12
16
  return this.strategy.prepareLaunch(role, prep);
13
17
  }
@@ -228,6 +228,16 @@ export function makeClaudeCodeAdapter(exec = realExec, transport) {
228
228
  return {
229
229
  id: 'claude-code',
230
230
  agentSession: new ClaudeCodeAgentSessionAdapter({
231
+ resolveBrain(brain) {
232
+ if (brain.effort != null
233
+ && (typeof brain.effort !== 'string' || !EFFORT_LEVELS.includes(brain.effort)))
234
+ throw new Error(`Claude Code Brain effort must be one of: ${EFFORT_LEVELS.join(', ')}`);
235
+ return {
236
+ model: brain.model,
237
+ harnessOptions: { ...(brain.harnessOptions ?? {}), ...(brain.effort ? { effort: brain.effort } : {}) },
238
+ };
239
+ },
240
+ modelEnvironmentVariable: () => 'ANTHROPIC_MODEL',
231
241
  prepareLaunch(role, prep) {
232
242
  const configured = role.session_options?.acp?.command;
233
243
  const argv = Array.isArray(configured)
@@ -3,6 +3,8 @@ import type { AcpMcpServer, SessionPrep } from './types.js';
3
3
  import type { AgentSessionAdapter, AgentSessionStartOptions, PreparedAgentSessionLaunch } from './agent-session.js';
4
4
  import { type AcpSessionTransport } from './acp-session-transport.js';
5
5
  export interface CodexSessionStrategy {
6
+ resolveBrain: AgentSessionAdapter['resolveBrain'];
7
+ modelEnvironmentVariable: AgentSessionAdapter['modelEnvironmentVariable'];
6
8
  prepareLaunch(role: ResolvedRole, prep: SessionPrep): PreparedAgentSessionLaunch;
7
9
  permissionModeId(role: ResolvedRole): string | undefined;
8
10
  mcpServers(role: ResolvedRole): AcpMcpServer[] | undefined;
@@ -13,6 +15,8 @@ export declare class CodexAgentSessionAdapter implements AgentSessionAdapter {
13
15
  private readonly strategy;
14
16
  private readonly transport;
15
17
  constructor(strategy: CodexSessionStrategy, transport?: AcpSessionTransport);
18
+ resolveBrain(brain: Parameters<AgentSessionAdapter['resolveBrain']>[0]): import("./agent-session.js").ResolvedBrainSelection;
19
+ modelEnvironmentVariable(): string | undefined;
16
20
  prepareLaunch(role: ResolvedRole, prep: SessionPrep): PreparedAgentSessionLaunch;
17
21
  start(options: AgentSessionStartOptions): Promise<import("../index.js").AgentSession>;
18
22
  }
@@ -8,6 +8,10 @@ export class CodexAgentSessionAdapter {
8
8
  this.strategy = strategy;
9
9
  this.transport = transport;
10
10
  }
11
+ resolveBrain(brain) {
12
+ return this.strategy.resolveBrain(brain);
13
+ }
14
+ modelEnvironmentVariable() { return this.strategy.modelEnvironmentVariable(); }
11
15
  prepareLaunch(role, prep) {
12
16
  return this.strategy.prepareLaunch(role, prep);
13
17
  }
@@ -251,6 +251,20 @@ export function makeCodexAdapter(exec = realExec, transport) {
251
251
  return {
252
252
  id: 'codex',
253
253
  agentSession: new CodexAgentSessionAdapter({
254
+ resolveBrain(brain) {
255
+ const levels = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
256
+ if (brain.effort != null
257
+ && (typeof brain.effort !== 'string' || !levels.includes(brain.effort)))
258
+ throw new Error(`Codex Brain effort must be one of: ${levels.join(', ')}`);
259
+ const harnessOptions = { ...(brain.harnessOptions ?? {}) };
260
+ if (brain.effort)
261
+ harnessOptions.config = {
262
+ ...(harnessOptions.config ?? {}),
263
+ model_reasoning_effort: brain.effort,
264
+ };
265
+ return { model: brain.model, harnessOptions };
266
+ },
267
+ modelEnvironmentVariable: () => undefined,
254
268
  prepareLaunch(role, prep) {
255
269
  const launch = codexAgentLaunch(role, prep);
256
270
  const { permissionMetadataSource, ...neutral } = launch;
@@ -1,5 +1,9 @@
1
1
  const adapters = new Map();
2
2
  export function registerAdapter(a) {
3
+ if (typeof a.agentSession?.resolveBrain !== 'function')
4
+ throw new Error(`harness adapter '${a.id}' must implement agentSession.resolveBrain(): translate neutral model/effort`);
5
+ if (typeof a.agentSession?.modelEnvironmentVariable !== 'function')
6
+ throw new Error(`harness adapter '${a.id}' must implement agentSession.modelEnvironmentVariable()`);
3
7
  // Enforced here rather than left to the type system: an adapter that silently
4
8
  // omits the capability is how the neutral-permission warnings ended up with no
5
9
  // caller and no reader.
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { effectivePermissionMode } from './permissions.js';
13
13
  export { pickBackend } from './supervisor/index.js';
14
14
  export type { SupervisorBackend } from './supervisor/types.js';
15
15
  export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
16
- export { spawnPermanent, spawnTemp, buildRoleConfig, profileValues, validateSpawnOpts, } from './spawn.js';
16
+ export { spawnPermanent, spawnTemp, agentDefinitionFromSpawn, validateSpawnOpts, } from './spawn.js';
17
17
  export { RoleRepository } from './application/role-repository.js';
18
18
  export { FleetQueryService } from './application/fleet-query-service.js';
19
19
  export { AcpRoleSessionAdapter } from './application/session-control.js';
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { generateBriefing } from './briefing.js';
9
9
  export { effectivePermissionMode } from './permissions.js';
10
10
  export { pickBackend } from './supervisor/index.js';
11
11
  export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
12
- export { spawnPermanent, spawnTemp, buildRoleConfig, profileValues, validateSpawnOpts, } from './spawn.js';
12
+ export { spawnPermanent, spawnTemp, agentDefinitionFromSpawn, validateSpawnOpts, } from './spawn.js';
13
13
  export { RoleRepository } from './application/role-repository.js';
14
14
  export { FleetQueryService } from './application/fleet-query-service.js';
15
15
  export { AcpRoleSessionAdapter } from './application/session-control.js';
@@ -0,0 +1 @@
1
+ export declare const INIT_COMPLETION_GUIDANCE = "\nNext:\n cp \"$(npm root -g)/@ours.network/fleet/examples/fleet.yaml\" ~/fleet.yaml\n cp -R \"$(npm root -g)/@ours.network/fleet/examples/fleet\" ~/fleet\n edit ~/fleet.yaml and ~/fleet/{agents,roles,brains}/*.yaml, then: ours-fleet up";
@@ -0,0 +1,5 @@
1
+ export const INIT_COMPLETION_GUIDANCE = `
2
+ Next:
3
+ cp "$(npm root -g)/@ours.network/fleet/examples/fleet.yaml" ~/fleet.yaml
4
+ cp -R "$(npm root -g)/@ours.network/fleet/examples/fleet" ~/fleet
5
+ edit ~/fleet.yaml and ~/fleet/{agents,roles,brains}/*.yaml, then: ours-fleet up`;
@@ -10,14 +10,13 @@ import type { ResolvedRole } from './config.js';
10
10
  * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
11
11
  * an explicitly requested one.
12
12
  */
13
- export declare const MODEL_ENV_BY_HARNESS: Readonly<Record<string, string>>;
14
13
  /** The model-pin variable for a harness, or undefined if it pins no model by env. */
15
14
  export declare function modelEnvVar(harness: string | undefined): string | undefined;
16
15
  export interface RoleModelEnvInput {
17
16
  harness: string;
18
17
  /** Already resolved by `resolveRoleModel` — may come from the fleet default. */
19
18
  model: string | undefined;
20
- /** True when the role (or `--model`) named a model, including `model: null`. */
19
+ /** True when the resolved Brain named a model, including `model: null`. */
21
20
  modelWasExplicit: boolean;
22
21
  defaultsEnv?: Record<string, string>;
23
22
  roleEnv?: Record<string, string>;
@@ -36,7 +35,7 @@ export interface RoleModelEnv {
36
35
  * never disagree.
37
36
  *
38
37
  * Precedence, highest first:
39
- * 1. an explicit `model:` / `--model` on the role
38
+ * 1. an explicit `model:` on the resolved Brain
40
39
  * 2. the role's own `env:` pin
41
40
  * 3. the fleet `defaults.model`
42
41
  * 4. the fleet `defaults.env` pin
package/dist/model-env.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { getAdapter } from './harness/registry.js';
1
2
  /**
2
3
  * Which environment variable a harness reads to pin the model it RUNS.
3
4
  *
@@ -9,19 +10,16 @@
9
10
  * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
10
11
  * an explicitly requested one.
11
12
  */
12
- export const MODEL_ENV_BY_HARNESS = {
13
- 'claude-code': 'ANTHROPIC_MODEL',
14
- };
15
13
  /** The model-pin variable for a harness, or undefined if it pins no model by env. */
16
14
  export function modelEnvVar(harness) {
17
- return harness === undefined ? undefined : MODEL_ENV_BY_HARNESS[harness];
15
+ return harness === undefined ? undefined : getAdapter(harness).agentSession.modelEnvironmentVariable();
18
16
  }
19
17
  /**
20
18
  * Resolve a role's environment and its runtime model TOGETHER, so the two can
21
19
  * never disagree.
22
20
  *
23
21
  * Precedence, highest first:
24
- * 1. an explicit `model:` / `--model` on the role
22
+ * 1. an explicit `model:` on the resolved Brain
25
23
  * 2. the role's own `env:` pin
26
24
  * 3. the fleet `defaults.model`
27
25
  * 4. the fleet `defaults.env` pin
package/dist/ops.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, unlinkSync, } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID, createHash } from 'node:crypto';
4
- import { agentDir, fleetDDir, watchdogsRoot } from './paths.js';
4
+ import { agentDir, watchdogsRoot } from './paths.js';
5
5
  import { findRole } from './config.js';
6
6
  import { getAdapter } from './harness/registry.js';
7
7
  import { generateBriefing } from './briefing.js';
@@ -9,6 +9,7 @@ import { resetRestartLedger } from './runner.js';
9
9
  import { archiveTempState, stopTempSupervisor, tempSupervisorLiveness, } from './temp-lifecycle.js';
10
10
  import { realExec } from './exec.js';
11
11
  import { daemonIdentityProvisioner, reconcilePermanentRoleIdentities, } from './creation.js';
12
+ import { provesGeneratedAgentSource } from './generated-agent-source.js';
12
13
  // Launch staggering now lives at the harness-launch point (the runner's start
13
14
  // gate, driven by `start_stagger_ms`), so it covers systemd host-boot too — not
14
15
  // just the `up`/`restart` command loop below. The old in-loop FLEET_START_STAGGER
@@ -264,9 +265,10 @@ export async function rmRole(cfg, name, deps) {
264
265
  return;
265
266
  }
266
267
  const role = findRole(cfg, name);
268
+ const generatedSource = provesGeneratedAgentSource(agentDir(name), cfg.files[0], role.sourceFile);
267
269
  await deps.backend.uninstall(name);
268
270
  rmSync(agentDir(name), { recursive: true, force: true });
269
- if (role.sourceFile.startsWith(fleetDDir() + '/')) {
271
+ if (generatedSource && existsSync(role.sourceFile)) {
270
272
  unlinkSync(role.sourceFile);
271
273
  deps.log(`removed ${role.sourceFile}`);
272
274
  }
@@ -630,7 +630,8 @@ export const ownerCommands = [
630
630
  ...(t.builtin ? ['Source: built-in'] : []),
631
631
  ...(t.contract ? [`Contract: ${t.contract}`] : []),
632
632
  'Members:',
633
- ...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → role_ref: ${m.role_ref}`),
633
+ ...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → `
634
+ + `${'ref' in m.agent ? `Agent ref: ${m.agent.ref}` : 'inline Agent'}`),
634
635
  ];
635
636
  await ctx.reply(lines.join('\n'));
636
637
  };
@@ -1,5 +1,6 @@
1
1
  import type { FleetConfig, ResolvedRole } from './config.js';
2
- export declare const RESOLVED_PLAN_SCHEMA_VERSION = 1;
2
+ export declare const RESOLVED_PLAN_SCHEMA_VERSION = 2;
3
+ export declare const redactSensitive: (value: unknown, key?: string) => unknown;
3
4
  /** Stable, secret-safe machine contract consumed by config JSON and spawn dry-run. */
4
5
  export declare function resolvedPlan(cfg: FleetConfig): Record<string, unknown>;
5
- export declare function resolvedRolePlan(role: ResolvedRole): Record<string, unknown>;
6
+ export declare function resolvedRolePlan(role: ResolvedRole, manifestDir?: string): Record<string, unknown>;
@@ -1,8 +1,10 @@
1
1
  import { analyzeRolePermissions } from './permissions.js';
2
- export const RESOLVED_PLAN_SCHEMA_VERSION = 1;
2
+ import { dirname, relative } from 'node:path';
3
+ import { isSensitiveConfigKey } from './sensitive-config.js';
4
+ export const RESOLVED_PLAN_SCHEMA_VERSION = 2;
3
5
  const sortedObject = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
4
- const redactSensitive = (value, key = '') => {
5
- if (/(?:secret|token|password|authorization|api[_-]?key)/i.test(key))
6
+ export const redactSensitive = (value, key = '') => {
7
+ if (isSensitiveConfigKey(key))
6
8
  return '<redacted>';
7
9
  if (Array.isArray(value))
8
10
  return value.map(item => redactSensitive(item));
@@ -14,12 +16,17 @@ const redactSensitive = (value, key = '') => {
14
16
  };
15
17
  /** Stable, secret-safe machine contract consumed by config JSON and spawn dry-run. */
16
18
  export function resolvedPlan(cfg) {
19
+ const manifestDir = dirname(cfg.files[0] ?? '.');
17
20
  return {
18
21
  schemaVersion: RESOLVED_PLAN_SCHEMA_VERSION,
22
+ configMode: cfg.configMode ?? 'split-v2',
19
23
  sourceFiles: [...cfg.files],
24
+ sourceDocuments: (cfg.sourceDocuments ?? []).map(document => ({
25
+ ...document, path: relative(manifestDir, document.path) || document.path,
26
+ })),
20
27
  startStaggerMs: cfg.startStaggerMs,
21
28
  diagnostics: cfg.diagnostics.map(diagnostic => ({ ...diagnostic })),
22
- roles: cfg.roles.map(resolvedRolePlan),
29
+ roles: cfg.roles.map(role => resolvedRolePlan(role, manifestDir)),
23
30
  loops: cfg.loops.map(loop => sortedObject({
24
31
  name: loop.name, selectors: [...loop.selectors], roles: [...loop.roleNames],
25
32
  intervalMs: loop.intervalMs, initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
@@ -36,16 +43,25 @@ export function resolvedPlan(cfg) {
36
43
  })),
37
44
  };
38
45
  }
39
- export function resolvedRolePlan(role) {
46
+ export function resolvedRolePlan(role, manifestDir = dirname(role.sourceFile)) {
40
47
  const analysis = analyzeRolePermissions(role);
41
48
  return {
42
49
  name: role.name,
43
50
  sourceFile: role.sourceFile,
51
+ references: Object.values(role.provenance ?? {}).flatMap(item => item.viaReference ? [item.viaReference] : [])
52
+ .filter((item, index, all) => all.findIndex(other => JSON.stringify(other) === JSON.stringify(item)) === index)
53
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id)
54
+ || a.sourcePointer.localeCompare(b.sourcePointer)),
55
+ provenance: sortedObject(Object.fromEntries(Object.entries(role.provenance ?? {}).map(([field, item]) => [
56
+ field, { ...item, sourceFile: item.sourceKind === 'built-in'
57
+ ? item.sourceFile : relative(manifestDir, item.sourceFile) || item.sourceFile },
58
+ ]))),
44
59
  identity: role.identity,
45
60
  harness: role.harness,
46
61
  session: role.session,
47
62
  sessionOptions: role.session_options ?? null,
48
63
  model: role.model ?? null,
64
+ effort: role.effort ?? null,
49
65
  modelChain: role.model_chain ?? null,
50
66
  cwd: role.cwd ?? null,
51
67
  coordinator: role.coordinator ?? null,
@@ -409,7 +409,8 @@ export function registerTemplateCommands(parent, cOpt) {
409
409
  console.log(`\nContract:\n${t.contract}`);
410
410
  console.log(`\nMembers:`);
411
411
  for (const m of t.members)
412
- console.log(` ${m.slot}: ${m.count}× ${m.role} (ref: ${m.role_ref})`);
412
+ console.log(` ${m.slot}: ${m.count}× ${m.role} (`
413
+ + `${'ref' in m.agent ? `agent ref: ${m.agent.ref}` : 'inline Agent'})`);
413
414
  console.log(`\nContent hash: ${t.content_hash}`);
414
415
  }
415
416
  catch (e) {
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, TEMPLATE_OVERRIDE_KEYS as TOK, } from './types.js';
3
+ import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
4
4
  import { BUILTIN_TEMPLATES } from './templates.js';
5
+ import { isSensitiveConfigKey } from '../sensitive-config.js';
5
6
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
6
7
  const CID_RE = /^[0-9a-fA-F]{64}$/;
7
8
  export class RoomsTasksConfigError extends Error {
@@ -136,19 +137,33 @@ export function validateRoomTemplatesConfig(raw, path) {
136
137
  throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].role: required string`);
137
138
  if (!Number.isInteger(m.count) || m.count < 1)
138
139
  throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].count: required positive integer`);
139
- if (!m.role_ref || typeof m.role_ref !== 'string')
140
- throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].role_ref: required string`);
141
- if (m.overrides !== undefined) {
142
- if (!isPlainObject(m.overrides))
143
- throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].overrides: must be a mapping`);
144
- rejectUnknown(m.overrides, TOK, path, `room_templates.${name}.members[${i}].overrides`);
140
+ if (!isPlainObject(m.agent))
141
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: required mapping`);
142
+ const agentKeys = Object.keys(m.agent);
143
+ const isRef = agentKeys.length === 1 && typeof m.agent.ref === 'string';
144
+ const isDefinition = agentKeys.includes('role') && agentKeys.includes('brain');
145
+ if (!isRef && !isDefinition)
146
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: must be {ref} or canonical {role, brain, ...}`);
147
+ const containsSensitive = (value) => Array.isArray(value)
148
+ ? value.some(containsSensitive)
149
+ : Boolean(value && typeof value === 'object' && Object.entries(value)
150
+ .some(([key, child]) => isSensitiveConfigKey(key) || containsSensitive(child)));
151
+ if (isDefinition && containsSensitive(m.agent))
152
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: inline sensitive configuration cannot be persisted; use references`);
153
+ if (isDefinition) {
154
+ const definition = m.agent;
155
+ const brain = isPlainObject(definition.brain) && isPlainObject(definition.brain.inline)
156
+ ? definition.brain.inline : undefined;
157
+ if (definition.env !== undefined || definition.owner_channel !== undefined
158
+ || definition.auth_proxy !== undefined || brain?.harness_options !== undefined
159
+ || brain?.session_options !== undefined)
160
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: secret-capable inline fields cannot be persisted; use references`);
145
161
  }
146
162
  return {
147
163
  slot: m.slot,
148
164
  role: m.role,
149
165
  count: m.count,
150
- role_ref: m.role_ref,
151
- overrides: m.overrides,
166
+ agent: structuredClone(m.agent),
152
167
  };
153
168
  });
154
169
  let room;
@@ -1,10 +1,10 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { parse } from 'yaml';
4
4
  import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, activateRoom, getRoomRecord, } from './room-state.js';
5
5
  import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
6
6
  import { spawnTemp } from '../spawn.js';
7
- import { findRole } from '../config.js';
7
+ import { findRole, loadConfig } from '../config.js';
8
8
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
9
9
  import { controlRequest } from '../session/control.js';
10
10
  import { SessionControlError } from '../session/types.js';
@@ -21,6 +21,26 @@ export function getBinPath() {
21
21
  return process.argv[1];
22
22
  }
23
23
  }
24
+ function canonicalJson(value) {
25
+ if (Array.isArray(value))
26
+ return `[${value.map(canonicalJson).join(',')}]`;
27
+ if (value && typeof value === 'object')
28
+ return `{${Object.entries(value)
29
+ .sort(([a], [b]) => a.localeCompare(b))
30
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
31
+ return JSON.stringify(value);
32
+ }
33
+ function launchDefinition(definition) {
34
+ const selectionEvidence = (selection) => 'ref' in selection ? { kind: 'ref', id: selection.ref } : { kind: 'inline' };
35
+ return {
36
+ projection: {
37
+ brain: selectionEvidence(definition.brain), role: selectionEvidence(definition.role),
38
+ operationalFields: Object.keys(definition)
39
+ .filter(key => key !== 'brain' && key !== 'role').sort(),
40
+ },
41
+ fingerprint: createHash('sha256').update(canonicalJson(definition)).digest('hex'),
42
+ };
43
+ }
24
44
  async function spawnRoomMember(options, binPath) {
25
45
  const stateDir = process.env[FLEET_PROXY_STATE_DIR_ENV];
26
46
  if (!stateDir)
@@ -52,28 +72,31 @@ function expandMembers(template, prefix) {
52
72
  name: `${prefix}-${slot.slot}-${i}`,
53
73
  slot: slot.slot,
54
74
  coworkRole: slot.role,
55
- roleRef: slot.role_ref,
56
- overrides: slot.overrides,
75
+ agent: structuredClone(slot.agent),
57
76
  });
58
77
  }
59
78
  }
60
79
  return result;
61
80
  }
62
81
  function settingsFor(member, cfg) {
63
- let refRole;
64
- try {
65
- refRole = findRole(cfg, member.roleRef);
82
+ if (!('ref' in member.agent)) {
83
+ const role = member.agent.role;
84
+ if (cfg.files[0] && existsSync(cfg.files[0]) && statSync(cfg.files[0]).isFile())
85
+ loadConfig(cfg.files[0], { additionalAgent: {
86
+ id: `RoomPreflight${member.name.replace(/[^A-Za-z0-9_-]/g, '')}`.slice(0, 64),
87
+ definition: member.agent,
88
+ } });
89
+ return { definition: structuredClone(member.agent),
90
+ ...('inline' in role && typeof role.inline.persona === 'string'
91
+ ? { persona: role.inline.persona } : {}) };
66
92
  }
67
- catch { /* no ref role */ }
68
- const permissions = member.overrides?.permissions;
93
+ const refRole = findRole(cfg, member.agent.ref);
94
+ const definition = cfg.agentDefinitions?.[member.agent.ref];
95
+ if (!definition)
96
+ throw new Error(`Agent '${member.agent.ref}' has no canonical definition`);
69
97
  return {
70
- model: member.overrides?.model ?? refRole?.model,
71
- harness: member.overrides?.harness ?? refRole?.harness,
72
- cwd: member.overrides?.cwd ?? refRole?.cwd,
73
- persona: member.overrides?.persona ?? refRole?.persona,
74
- approval: permissions?.approval,
75
- filesystem: permissions?.filesystem,
76
- unattended: permissions?.unattended,
98
+ definition: structuredClone(definition),
99
+ persona: refRole.persona,
77
100
  };
78
101
  }
79
102
  function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
@@ -104,13 +127,11 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
104
127
  const role = parse(readFileSync(`${dir}/role.yaml`, 'utf8'));
105
128
  const startup = role.roomMemberStartup;
106
129
  return role.identity === member.name
107
- && typeof role.mission === 'string'
108
- && sha256Text(role.mission) === taskSha
109
130
  && startup?.room_id === roomId
110
131
  && startup.room_identity_cid === roomIdentityCid
111
132
  && startup.identity_name === member.name
112
133
  && startup.role === member.coworkRole
113
- && startup.task === role.mission
134
+ && sha256Text(startup.task ?? '') === taskSha
114
135
  && (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
115
136
  && typeof startup.invite === 'string'
116
137
  && startup.invite.length > 0;
@@ -199,10 +220,13 @@ async function launchMember(input) {
199
220
  let effectiveActionId = actionId;
200
221
  const attempt = (seat.launch?.attempt ?? 0) + 1;
201
222
  const taskSha = sha256Text(startup.task);
223
+ const effectiveAgentDefinition = structuredClone(settings.definition);
224
+ const { projection: agentDefinition, fingerprint: agentFingerprint } = launchDefinition(effectiveAgentDefinition);
202
225
  const proxyCaller = process.env[FLEET_PROXY_STATE_DIR_ENV]
203
226
  ? process.env[FLEET_PROXY_CALLER_ENV] : undefined;
204
227
  updateMemberStartup(provision.roomId, member.name, { launch: {
205
228
  state: 'intent', attempt, action_id: actionId, mission_sha256: taskSha,
229
+ agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
206
230
  ...(proxyCaller ? { caller_role: proxyCaller } : {}),
207
231
  updated_at: new Date().toISOString(),
208
232
  } });
@@ -211,13 +235,7 @@ async function launchMember(input) {
211
235
  name: member.name,
212
236
  temp: true,
213
237
  identity: member.name,
214
- mission: startup.task,
215
- model: settings.model,
216
- harness: settings.harness,
217
- cwd: settings.cwd,
218
- approval: settings.approval,
219
- filesystem: settings.filesystem,
220
- unattended: settings.unattended,
238
+ agentDefinition: settings.definition,
221
239
  surface: 'agent',
222
240
  creationActionId: actionId,
223
241
  roomMemberStartup: startup,
@@ -227,7 +245,8 @@ async function launchMember(input) {
227
245
  if (launched.creationActionId !== actionId) {
228
246
  updateMemberStartup(provision.roomId, member.name, { launch: {
229
247
  state: 'intent', attempt, action_id: launched.creationActionId,
230
- mission_sha256: taskSha, updated_at: new Date().toISOString(),
248
+ mission_sha256: taskSha, agent_definition: agentDefinition,
249
+ agent_fingerprint: agentFingerprint, updated_at: new Date().toISOString(),
231
250
  ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
232
251
  } });
233
252
  }
@@ -237,6 +256,7 @@ async function launchMember(input) {
237
256
  }
238
257
  updateMemberStartup(provision.roomId, member.name, { launch: {
239
258
  state: 'launched', attempt, action_id: launched.creationActionId, mission_sha256: taskSha,
259
+ agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
240
260
  ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
241
261
  launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
242
262
  } });
@@ -244,6 +264,7 @@ async function launchMember(input) {
244
264
  catch (error) {
245
265
  updateMemberStartup(provision.roomId, member.name, { launch: {
246
266
  state: 'failed', attempt, action_id: effectiveActionId, mission_sha256: taskSha,
267
+ agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
247
268
  ...(proxyCaller ? { caller_role: proxyCaller } : {}),
248
269
  updated_at: new Date().toISOString(),
249
270
  error: error instanceof Error ? error.message : String(error),
@@ -289,6 +310,8 @@ export async function provisionMembers(input) {
289
310
  const { cfg, cowork, roomId, taskId, template } = input;
290
311
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
291
312
  const members = expandMembers(template, prefix);
313
+ // Resolve every Agent before persisting launch intent or touching Cowork membership.
314
+ const settings = new Map(members.map(member => [member.name, settingsFor(member, cfg)]));
292
315
  const existing = getRoomRecord(roomId);
293
316
  if (!existing?.room_identity_cid)
294
317
  throw new Error(`room ${roomId} has no pinned room identity CID`);
@@ -299,15 +322,27 @@ export async function provisionMembers(input) {
299
322
  && members.every(member => persistedNames.has(member.name));
300
323
  if (!resuming) {
301
324
  advanceSaga(roomId, 'create_members', 3);
302
- updateMemberSeats(roomId, members.map(member => ({
303
- role_name: member.name,
304
- slot: member.slot,
305
- cowork_role: member.coworkRole,
306
- seat_state: 'pending',
307
- launch: { state: 'pending', attempt: 0, updated_at: new Date().toISOString() },
308
- })));
325
+ updateMemberSeats(roomId, members.map(member => {
326
+ const evidence = launchDefinition(settings.get(member.name).definition);
327
+ return ({
328
+ role_name: member.name,
329
+ slot: member.slot,
330
+ cowork_role: member.coworkRole,
331
+ seat_state: 'pending',
332
+ launch: { state: 'pending', attempt: 0,
333
+ agent_definition: evidence.projection, agent_fingerprint: evidence.fingerprint,
334
+ updated_at: new Date().toISOString() },
335
+ });
336
+ }));
337
+ }
338
+ else {
339
+ for (const member of members) {
340
+ const seat = existing.member_seats.find(candidate => candidate.role_name === member.name);
341
+ const evidence = launchDefinition(settings.get(member.name).definition);
342
+ if (!seat.launch?.agent_fingerprint || seat.launch.agent_fingerprint !== evidence.fingerprint)
343
+ throw new Error(`Agent definition drift for ${member.name}; durable launch intent does not match current configuration`);
344
+ }
309
345
  }
310
- const settings = new Map(members.map(member => [member.name, settingsFor(member, cfg)]));
311
346
  const tasks = new Map(members.map(member => [member.name, roomTask(input, member, settings.get(member.name), members, roomIdentityCid, ownerSeatCid)]));
312
347
  const policy = {
313
348
  timeoutMs: input.startupWait?.timeoutMs ?? 60_000,
@@ -12,9 +12,9 @@ const TEAM = {
12
12
  'Completion requires tester sign-off.',
13
13
  ].join('\n'),
14
14
  members: [
15
- { slot: 'architect', role: 'Architect', count: 1, role_ref: 'Architect' },
16
- { slot: 'developer', role: 'Developer', count: 1, role_ref: 'Developer' },
17
- { slot: 'tester', role: 'Tester', count: 1, role_ref: 'Tester' },
15
+ { slot: 'architect', role: 'Architect', count: 1, agent: { ref: 'Architect' } },
16
+ { slot: 'developer', role: 'Developer', count: 1, agent: { ref: 'Developer' } },
17
+ { slot: 'tester', role: 'Tester', count: 1, agent: { ref: 'Tester' } },
18
18
  ],
19
19
  };
20
20
  const PAIR = {
@@ -30,8 +30,8 @@ const PAIR = {
30
30
  'Completion requires joint sign-off.',
31
31
  ].join('\n'),
32
32
  members: [
33
- { slot: 'secretary', role: 'Secretary', count: 1, role_ref: 'Secretary' },
34
- { slot: 'critic', role: 'Critic', count: 1, role_ref: 'Critic' },
33
+ { slot: 'secretary', role: 'Secretary', count: 1, agent: { ref: 'Secretary' } },
34
+ { slot: 'critic', role: 'Critic', count: 1, agent: { ref: 'Critic' } },
35
35
  ],
36
36
  };
37
37
  const SINGLE = {
@@ -45,7 +45,7 @@ const SINGLE = {
45
45
  'Owner reviews and approves completion.',
46
46
  ].join('\n'),
47
47
  members: [
48
- { slot: 'agent', role: 'Agent', count: 1, role_ref: 'Agent' },
48
+ { slot: 'agent', role: 'Agent', count: 1, agent: { ref: 'Agent' } },
49
49
  ],
50
50
  };
51
51
  export const BUILTIN_TEMPLATES = [TEAM, PAIR, SINGLE];