@ours.network/fleet 1.1.0-nightly.20 → 1.1.0-nightly.21

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.
package/dist/docs.js CHANGED
@@ -31,6 +31,12 @@ selects one inline/ref Role and Brain and carries its operational fields.
31
31
  Agent Templates under \`~/fleet/agent_templates\` are inert reusable launch definitions;
32
32
  only explicit files under \`~/fleet/agents\` are persistent lifecycle instances.
33
33
  Room members use \`agent_template\` and receive immutable content-addressed snapshots.
34
+ An Agent Template may declare up to 64 temporary-only named \`loops\`; persistent Agent
35
+ instances reject them. Each loop requires \`interval\` (1m..30d) and bounded
36
+ nonblank \`prompt\`, with optional \`enabled\` (default true), \`initial_delay\`
37
+ (default interval, 0s..30d), and \`jitter\` (default 0s, < interval, <=1h).
38
+ Execution is fixed skip-if-busy with no ordinary missed-tick backlog/replay;
39
+ restart recovery may preserve at most one recent late occurrence.
34
40
  Legacy top-level \`roles:\` and \`fleet.d\` are rejected. Validate the complete
35
41
  trusted source set with \`config\` and \`doctor\` before starting or restarting.
36
42
 
@@ -318,8 +324,23 @@ ours-fleet template show team [-c FILE]
318
324
  ours-fleet task create --title "Solo task" --template single [-c FILE]
319
325
  ours-fleet task create --title "Reviewed change" --template pair [-c FILE]
320
326
  ours-fleet task create --title "Phased delivery" --template team [-c FILE]
327
+ ours-fleet spawn --temp Scout --role SELECTION --brain SELECTION --loops-file PRIVATE.yaml
328
+ ours-fleet task start TASK --member developer --loops-file PRIVATE.yaml
329
+ ours-fleet task start TASK --member critic --no-loops
321
330
  \`\`\`
322
331
 
332
+ \`--loops-file\` must be an owner-only, non-symlink regular file <=1 MB containing
333
+ exactly one top-level non-empty \`loops:\` mapping. \`--no-loops\` explicitly
334
+ disables loops. They are mutually exclusive and rejected for permanent spawn.
335
+ For a grouped room member the whole CLI block overrides its Agent Template;
336
+ the template overrides omission. Omission preserves historical no-loop behavior
337
+ and never inherits manifest wildcard loops. Fleet validates before side effects,
338
+ seals normalized timings plus exact private prompts, and reuses that snapshot for
339
+ idempotent start, retry, recovery, and replacement. Trusted authoring and the
340
+ private sealed runtime retain exact prompts; resolved launch, task, room,
341
+ provenance, and audit presentations show source, policy, timing, prompt bytes,
342
+ and prompt SHA-256 instead of prompt text.
343
+
323
344
  An alternate manifest \`-c /path/custom.yaml\` uses \`/path/custom/\` as its split
324
345
  root. Repeated init only fills missing files and never adopts a newer default.
325
346
  Revision-3 packaged-bootstrap and generated role defaults have an exact-semantic,
@@ -131,7 +131,7 @@ export function classifyFleetArgv(argv) {
131
131
  const marker = (value) => value === '' ? '[REDACTED:empty]' : '[REDACTED:value]';
132
132
  const sensitiveValueFlags = new Set([
133
133
  '--identity', '--invite', '--token', '--api-token', '--password', '--password-file',
134
- '--env', '--brief', '--brief-file', '--bio-file', '--persona-file', '--isolation-file',
134
+ '--env', '--brief', '--brief-file', '--bio-file', '--persona-file', '--isolation-file', '--loops-file',
135
135
  '--configuration', '-c', '--public-invite', '--public-invite-file', '--invite-file',
136
136
  '--summary-file', '--text', '--message', '--summary', '--reason', '--goal', '--cwd',
137
137
  '--identity-cid', '--owner-cid', '--contact-cid', '--codex-config', '--add-dir',
@@ -364,7 +364,7 @@ function validConfiguration(value) {
364
364
  const count = (v) => v === undefined
365
365
  || (Number.isSafeInteger(v) && Number(v) >= 0 && Number(v) <= 4_096);
366
366
  return exact(c, ['version', 'template', 'role', 'brain', 'harness', 'session', 'model',
367
- 'effort', 'mission', 'approval', 'filesystem', 'unattended', 'permissionMode', 'monitor', 'isolation'])
367
+ 'effort', 'mission', 'approval', 'filesystem', 'unattended', 'permissionMode', 'monitor', 'isolation', 'loops'])
368
368
  && c.version === 1 && origin(c.role) && origin(c.brain)
369
369
  && text(c.harness, 64) && SESSION_BACKENDS.has(c.session)
370
370
  && (c.model === null || text(c.model, 160))
@@ -389,6 +389,18 @@ function validConfiguration(value) {
389
389
  && (c.isolation.on_unavailable === undefined || ['warn', 'strict'].includes(String(c.isolation.on_unavailable)))
390
390
  && (c.isolation.network === undefined || ['broker', 'deny', 'allow', 'allowlist'].includes(String(c.isolation.network)))
391
391
  && count(c.isolation.read_mounts) && count(c.isolation.write_mounts)))
392
+ && (c.loops === undefined || (record(c.loops)
393
+ && exact(c.loops, ['source', 'policy', 'entries'])
394
+ && ['agent-template', 'cli', 'omitted'].includes(String(c.loops.source))
395
+ && c.loops.policy === 'skip-if-busy'
396
+ && Array.isArray(c.loops.entries) && c.loops.entries.length <= 64
397
+ && c.loops.entries.every(entry => record(entry)
398
+ && exact(entry, ['name', 'enabled', 'intervalMs', 'initialDelayMs', 'jitterMs', 'prompt'])
399
+ && text(entry.name, 64) && typeof entry.enabled === 'boolean'
400
+ && [entry.intervalMs, entry.initialDelayMs, entry.jitterMs].every(Number.isSafeInteger)
401
+ && record(entry.prompt) && exact(entry.prompt, ['bytes', 'sha256'])
402
+ && Number.isSafeInteger(entry.prompt.bytes) && Number(entry.prompt.bytes) >= 0
403
+ && typeof entry.prompt.sha256 === 'string' && /^[a-f0-9]{64}$/.test(entry.prompt.sha256))))
392
404
  // Escaping and code-fence growth can push a per-field-valid configuration
393
405
  // past the line budget; a v1 configuration whose complete mandatory
394
406
  // rendering cannot fit is invalid, never silently trimmed.
@@ -47,6 +47,22 @@ export interface AgentLaunchConfiguration {
47
47
  read_mounts?: number;
48
48
  write_mounts?: number;
49
49
  };
50
+ /** Present only for temporary agents; prompt bodies are never presented. */
51
+ loops?: {
52
+ source: 'agent-template' | 'cli' | 'omitted';
53
+ policy: 'skip-if-busy';
54
+ entries: Array<{
55
+ name: string;
56
+ enabled: boolean;
57
+ intervalMs: number;
58
+ initialDelayMs: number;
59
+ jitterMs: number;
60
+ prompt: {
61
+ bytes: number;
62
+ sha256: string;
63
+ };
64
+ }>;
65
+ };
50
66
  }
51
67
  /** Where a Brain/Role selection came from; labels are human, hashes secondary. */
52
68
  export type SelectionOrigin = {
@@ -58,6 +58,15 @@ export function summarizeResolvedLaunch(role, origins) {
58
58
  ...(fs?.read?.length ? { read_mounts: fs.read.length } : {}),
59
59
  ...(fs?.write?.length ? { write_mounts: fs.write.length } : {}),
60
60
  } } : {}),
61
+ ...(role.temporaryLoopSource ? { loops: {
62
+ source: role.temporaryLoopSource,
63
+ policy: 'skip-if-busy',
64
+ entries: (role.temporaryLoops ?? role.loops ?? []).map(loop => ({
65
+ name: loop.name, enabled: loop.enabled, intervalMs: loop.intervalMs,
66
+ initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
67
+ prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
68
+ })),
69
+ } } : {}),
61
70
  };
62
71
  // A configuration whose complete mandatory rendering cannot fit must never
63
72
  // be produced; real resolved values sit far below the budget, so this is a
@@ -114,6 +123,15 @@ function optionalComponents(configuration) {
114
123
  + `${configuration.isolation.read_mounts || configuration.isolation.write_mounts
115
124
  ? `, mounts +${configuration.isolation.read_mounts ?? 0}ro/+${configuration.isolation.write_mounts ?? 0}rw` : ''}`
116
125
  : undefined,
126
+ configuration.loops
127
+ ? configuration.loops.source === 'omitted'
128
+ ? 'temporary loops omitted (legacy behavior)'
129
+ : configuration.loops.entries.length === 0
130
+ ? `temporary loops disabled (source ${configuration.loops.source})`
131
+ : `temporary loops ${configuration.loops.entries.map(loop => `${loop.name}:${loop.enabled ? 'enabled' : 'disabled'}`
132
+ + ` interval=${loop.intervalMs}ms delay=${loop.initialDelayMs}ms jitter=${loop.jitterMs}ms`
133
+ + ` prompt=${loop.prompt.bytes}B/${loop.prompt.sha256.slice(0, 12)}`).join(', ')}; policy skip-if-busy; source ${configuration.loops.source}`
134
+ : undefined,
117
135
  ].filter((part) => Boolean(part));
118
136
  }
119
137
  /**
@@ -7,6 +7,9 @@ export interface LoopConfig {
7
7
  initial_delay?: string;
8
8
  jitter?: string;
9
9
  }
10
+ /** Agent Template-local loops are implicitly scoped to the temporary agent. */
11
+ export type AgentLoopConfig = Omit<LoopConfig, 'roles'>;
12
+ export type AgentLoopsConfig = Record<string, AgentLoopConfig>;
10
13
  export interface ResolvedLoop {
11
14
  name: string;
12
15
  selectors: string[];
@@ -24,7 +27,13 @@ export interface ResolvedRoleLoop extends Omit<ResolvedLoop, 'selectors' | 'role
24
27
  role: string;
25
28
  definitionHash: string;
26
29
  }
27
- export declare function resolveLoops(block: unknown, baseFile: string, roles: ResolvedRole[], vars: Record<string, string>): {
30
+ export declare function resolveLoops(block: unknown, baseFile: string, roles: ResolvedRole[], vars: Record<string, string>, options?: {
31
+ assertTrustedSource?: boolean;
32
+ }): {
28
33
  loops: ResolvedLoop[];
29
34
  byRole: Map<string, ResolvedRoleLoop[]>;
30
35
  };
36
+ /** Resolve the canonical Agent Template schema without inventing a second loop language. */
37
+ export declare function resolveAgentLoops(block: unknown, role: ResolvedRole, sourceFile?: string): ResolvedRoleLoop[];
38
+ /** Fully explicit private snapshot form; prompt text is retained for deterministic recovery. */
39
+ export declare function canonicalAgentLoops(loops: ResolvedRoleLoop[]): AgentLoopsConfig;
@@ -2,13 +2,14 @@ import { createHash } from 'node:crypto';
2
2
  import { lstatSync } from 'node:fs';
3
3
  import { ConfigError, ROLE_NAME_RE } from '../config.js';
4
4
  import { sessionBackendCapabilities } from '../session/types.js';
5
- import { parseDuration } from '../duration.js';
5
+ import { formatDuration, parseDuration } from '../duration.js';
6
6
  const LOOP_KEYS = ['roles', 'interval', 'prompt', 'enabled', 'initial_delay', 'jitter'];
7
7
  const MIN_INTERVAL_MS = 60_000;
8
8
  const MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1_000;
9
9
  const MAX_JITTER_MS = 60 * 60 * 1_000;
10
10
  const MAX_PROMPT_BYTES = 16 * 1024;
11
11
  const MAX_PROMPT_SCALARS = 12_000;
12
+ const MAX_AGENT_LOOPS = 64;
12
13
  function substitute(value, vars) {
13
14
  if (typeof value === 'string')
14
15
  return value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
@@ -48,7 +49,7 @@ function normalizePrompt(raw, where) {
48
49
  function digest(value) {
49
50
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
50
51
  }
51
- export function resolveLoops(block, baseFile, roles, vars) {
52
+ export function resolveLoops(block, baseFile, roles, vars, options = {}) {
52
53
  const byRole = new Map(roles.map(role => [role.name, []]));
53
54
  if (block === undefined || block === null)
54
55
  return { loops: [], byRole };
@@ -114,10 +115,41 @@ export function resolveLoops(block, baseFile, roles, vars) {
114
115
  for (const values of byRole.values())
115
116
  values.sort((a, b) => a.name.localeCompare(b.name));
116
117
  out.sort((a, b) => a.name.localeCompare(b.name));
117
- if (out.some(loop => loop.enabled))
118
+ if (out.some(loop => loop.enabled) && options.assertTrustedSource !== false)
118
119
  assertSafeLoopConfig(baseFile);
119
120
  return { loops: out, byRole };
120
121
  }
122
+ /** Resolve the canonical Agent Template schema without inventing a second loop language. */
123
+ export function resolveAgentLoops(block, role, sourceFile = '(temporary Agent loop)') {
124
+ if (block === undefined)
125
+ return [];
126
+ if (!block || typeof block !== 'object' || Array.isArray(block))
127
+ throw new ConfigError(`${sourceFile}: Agent loops must be a non-empty map`);
128
+ if (!Object.keys(block).length)
129
+ throw new ConfigError(`${sourceFile}: Agent loops must be non-empty; use an explicit no-loops override to disable template loops`);
130
+ if (Object.keys(block).length > MAX_AGENT_LOOPS)
131
+ throw new ConfigError(`${sourceFile}: Agent loops may contain at most ${MAX_AGENT_LOOPS} entries`);
132
+ const scoped = Object.fromEntries(Object.entries(block).map(([name, raw]) => {
133
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
134
+ return [name, raw];
135
+ const value = raw;
136
+ if (Object.hasOwn(value, 'roles'))
137
+ throw new ConfigError(`${sourceFile}: Agent loop '${name}' has unknown key roles; Agent loops are implicitly scoped`);
138
+ return [name, { ...value, roles: [role.name] }];
139
+ }));
140
+ return resolveLoops(scoped, sourceFile, [role], {}, { assertTrustedSource: false })
141
+ .byRole.get(role.name) ?? [];
142
+ }
143
+ /** Fully explicit private snapshot form; prompt text is retained for deterministic recovery. */
144
+ export function canonicalAgentLoops(loops) {
145
+ return Object.fromEntries(loops.map(loop => [loop.name, {
146
+ enabled: loop.enabled,
147
+ interval: formatDuration(loop.intervalMs),
148
+ initial_delay: formatDuration(loop.initialDelayMs),
149
+ jitter: formatDuration(loop.jitterMs),
150
+ prompt: loop.prompt,
151
+ }]));
152
+ }
121
153
  function assertSafeLoopConfig(path) {
122
154
  let stat;
123
155
  try {
@@ -1,9 +1,15 @@
1
1
  import { analyzeRolePermissions } from './permissions.js';
2
2
  import { dirname, relative } from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
  import { isSensitiveConfigKey } from './sensitive-config.js';
4
5
  export const RESOLVED_PLAN_SCHEMA_VERSION = 2;
5
6
  const sortedObject = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
6
7
  export const redactSensitive = (value, key = '') => {
8
+ if (key === 'prompt' && typeof value === 'string')
9
+ return {
10
+ bytes: Buffer.byteLength(value, 'utf8'),
11
+ sha256: createHash('sha256').update(value).digest('hex'),
12
+ };
7
13
  if (isSensitiveConfigKey(key))
8
14
  return '<redacted>';
9
15
  if (Array.isArray(value))
@@ -48,6 +54,7 @@ export function resolvedPlan(cfg) {
48
54
  }
49
55
  export function resolvedRolePlan(role, manifestDir = dirname(role.sourceFile)) {
50
56
  const analysis = analyzeRolePermissions(role);
57
+ const effectiveLoops = role.temporaryLoopSource ? (role.temporaryLoops ?? []) : (role.loops ?? []);
51
58
  return {
52
59
  name: role.name,
53
60
  sourceFile: role.sourceFile,
@@ -87,7 +94,8 @@ export function resolvedRolePlan(role, manifestDir = dirname(role.sourceFile)) {
87
94
  },
88
95
  monitor: role.monitor,
89
96
  ownerChannel: role.owner_channel ?? null,
90
- loops: (role.loops ?? []).map(loop => ({
97
+ temporaryLoopSource: role.temporaryLoopSource ?? null,
98
+ loops: effectiveLoops.map(loop => ({
91
99
  name: loop.name, enabled: loop.enabled, intervalMs: loop.intervalMs,
92
100
  initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
93
101
  definitionHash: loop.definitionHash, prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
@@ -8,14 +8,17 @@ import { acquireLaunchSnapshotLock, releaseLaunchSnapshot } from './launch-snaps
8
8
  import { parseGroupedMemberArgs, readMembersFile } from './member-overrides.js';
9
9
  const MEMBER_ARG_FLAGS = new Set([
10
10
  '--member', '--agent-template', '--brain', '--role', '--approval', '--filesystem', '--unattended',
11
- '--cwd', '--model', '--effort',
11
+ '--cwd', '--model', '--effort', '--loops-file', '--no-loops',
12
12
  ]);
13
13
  export function cliMemberOverrides(membersFile, argv = process.argv.slice(2)) {
14
14
  const grouped = [];
15
15
  for (let i = 0; i < argv.length; i++)
16
16
  if (MEMBER_ARG_FLAGS.has(argv[i])) {
17
- grouped.push(argv[i], argv[i + 1]);
18
- i += 1;
17
+ grouped.push(argv[i]);
18
+ if (argv[i] !== '--no-loops') {
19
+ grouped.push(argv[i + 1]);
20
+ i += 1;
21
+ }
19
22
  }
20
23
  if (membersFile && grouped.length)
21
24
  throw new Error('--members-file cannot be combined with grouped --member options');
@@ -712,6 +715,8 @@ export function registerTaskCommands(parent, cOpt) {
712
715
  .option('--cwd <path>', 'working directory for the current member block')
713
716
  .option('--model <id>', 'model for the current member block')
714
717
  .option('--effort <level>', 'reasoning effort for the current member block')
718
+ .option('--loops-file <path>', 'owner-only loops: YAML for the current temporary member')
719
+ .option('--no-loops', 'disable loops for the current temporary member')
715
720
  .option('--json', 'JSON output')
716
721
  .action(async (opts, command) => {
717
722
  try {
@@ -926,6 +931,8 @@ export function registerTaskCommands(parent, cOpt) {
926
931
  .option('--cwd <path>', 'working directory for current member')
927
932
  .option('--model <id>', 'model for current member')
928
933
  .option('--effort <level>', 'reasoning effort for current member')
934
+ .option('--loops-file <path>', 'owner-only loops: YAML for current member')
935
+ .option('--no-loops', 'disable loops for current member')
929
936
  .option('--json', 'JSON output')
930
937
  .action(async (id, opts, command) => {
931
938
  try {
@@ -1336,6 +1343,8 @@ export function registerTaskCommands(parent, cOpt) {
1336
1343
  .option('--cwd <path>', 'working directory for current member')
1337
1344
  .option('--model <id>', 'model for current member')
1338
1345
  .option('--effort <level>', 'reasoning effort for current member')
1346
+ .option('--loops-file <path>', 'owner-only loops: YAML for current member')
1347
+ .option('--no-loops', 'disable loops for current member')
1339
1348
  .option('--json', 'JSON output')
1340
1349
  .action(async (id, opts, command) => {
1341
1350
  try {
@@ -1446,6 +1455,8 @@ export function registerRoomCommands(parent, cOpt) {
1446
1455
  .option('--cwd <path>', 'working directory for current member')
1447
1456
  .option('--model <id>', 'model for current member')
1448
1457
  .option('--effort <level>', 'reasoning effort for current member')
1458
+ .option('--loops-file <path>', 'owner-only loops: YAML for current member')
1459
+ .option('--no-loops', 'disable loops for current member')
1449
1460
  .option('--json', 'JSON output')
1450
1461
  .action(async (opts, command) => {
1451
1462
  try {
@@ -99,6 +99,11 @@ function sweepUnreferencedSnapshotsLocked() {
99
99
  }
100
100
  }
101
101
  export function redactLaunchDefinition(value, key = '') {
102
+ if (key === 'prompt' && typeof value === 'string')
103
+ return {
104
+ bytes: Buffer.byteLength(value, 'utf8'),
105
+ sha256: createHash('sha256').update(value).digest('hex'),
106
+ };
102
107
  if (['env', 'harness_options', 'session_options', 'owner_channel', 'auth_proxy'].includes(key))
103
108
  return '<redacted>';
104
109
  if (Array.isArray(value))
@@ -1,5 +1,6 @@
1
1
  import { type AgentDefinition, type AgentTemplateDefinition, type FleetConfig } from '../config.js';
2
2
  import type { TemplateDefinition, TemplateSnapshot } from './types.js';
3
+ import type { AgentLoopsConfig } from '../loops/config.js';
3
4
  export interface MemberOverride {
4
5
  agent_template?: string;
5
6
  brain?: string;
@@ -10,6 +11,8 @@ export interface MemberOverride {
10
11
  cwd?: string;
11
12
  model?: string;
12
13
  effort?: string;
14
+ /** Whole-block override; false is the explicit no-loops policy. */
15
+ loops?: AgentLoopsConfig | false;
13
16
  overrides?: Partial<AgentDefinition>;
14
17
  }
15
18
  export type MemberOverrides = Record<string, MemberOverride>;
@@ -6,6 +6,8 @@ import { analyzeRolePermissions } from '../permissions.js';
6
6
  import { canonicalJson } from '../canonical-json.js';
7
7
  import { redactLaunchDefinition } from './launch-snapshot.js';
8
8
  import { snapshotTemplate } from './templates.js';
9
+ import { readLoopsFile } from '../spawn.js';
10
+ import { canonicalAgentLoops, resolveAgentLoops } from '../loops/config.js';
9
11
  export function hashMemberOverrides(overrides) {
10
12
  return createHash('sha256').update(canonicalJson(overrides)).digest('hex');
11
13
  }
@@ -16,20 +18,37 @@ const OPTION_FIELDS = {
16
18
  };
17
19
  /** Parse only the ordered member-option subsequence supplied by the CLI action. */
18
20
  export function parseGroupedMemberArgs(argv) {
19
- if (argv.length % 2)
20
- throw new Error(`${argv.at(-1)}: value required`);
21
21
  const result = {};
22
22
  let current;
23
- for (let index = 0; index < argv.length; index += 2) {
23
+ for (let index = 0; index < argv.length;) {
24
24
  const flag = argv[index];
25
+ if (flag === '--no-loops') {
26
+ if (!current)
27
+ throw new Error(`${flag} must follow --member <slot>`);
28
+ if (result[current].loops !== undefined)
29
+ throw new Error(`duplicate loop override for member '${current}'`);
30
+ result[current].loops = false;
31
+ index += 1;
32
+ continue;
33
+ }
25
34
  const value = argv[index + 1];
26
- if (!value)
35
+ if (!value || value.startsWith('--'))
27
36
  throw new Error(`${flag}: value required`);
28
37
  if (flag === '--member') {
29
38
  if (result[value])
30
39
  throw new Error(`duplicate member slot '${value}'`);
31
40
  current = value;
32
41
  result[current] = {};
42
+ index += 2;
43
+ continue;
44
+ }
45
+ if (flag === '--loops-file') {
46
+ if (!current)
47
+ throw new Error(`${flag} must follow --member <slot>`);
48
+ if (result[current].loops !== undefined)
49
+ throw new Error(`duplicate loop override for member '${current}'`);
50
+ result[current].loops = readLoopsFile(value);
51
+ index += 2;
33
52
  continue;
34
53
  }
35
54
  const field = OPTION_FIELDS[flag];
@@ -40,6 +59,7 @@ export function parseGroupedMemberArgs(argv) {
40
59
  if (result[current][field] !== undefined)
41
60
  throw new Error(`duplicate ${flag} for member '${current}'`);
42
61
  result[current][field] = value;
62
+ index += 2;
43
63
  }
44
64
  for (const [slot, value] of Object.entries(result))
45
65
  validateMemberOverride(value, `member '${slot}'`);
@@ -60,7 +80,7 @@ export function readMembersFile(path) {
60
80
  validateMemberOverride(value, `${path}: member '${slot}'`);
61
81
  return members;
62
82
  }
63
- const MEMBER_KEYS = new Set(['agent_template', 'brain', 'role', 'approval', 'filesystem', 'unattended', 'cwd', 'model', 'effort', 'overrides']);
83
+ const MEMBER_KEYS = new Set(['agent_template', 'brain', 'role', 'approval', 'filesystem', 'unattended', 'cwd', 'model', 'effort', 'overrides', 'loops']);
64
84
  function validateMemberOverride(value, where) {
65
85
  if (!value || typeof value !== 'object' || Array.isArray(value))
66
86
  throw new Error(`${where}: must be a mapping`);
@@ -81,6 +101,11 @@ function validateMemberOverride(value, where) {
81
101
  throw new Error(`${where}: ${key} must be a non-blank string`);
82
102
  if (item.overrides !== undefined && (!item.overrides || typeof item.overrides !== 'object' || Array.isArray(item.overrides)))
83
103
  throw new Error(`${where}: overrides must be a mapping`);
104
+ if (item.overrides && Object.hasOwn(item.overrides, 'loops'))
105
+ throw new Error(`${where}: overrides.loops is unsupported; use the top-level loops member override`);
106
+ if (item.loops !== undefined && item.loops !== false
107
+ && (!item.loops || typeof item.loops !== 'object' || Array.isArray(item.loops)))
108
+ throw new Error(`${where}: loops must be a mapping or false`);
84
109
  }
85
110
  const MAPS = new Set(['permissions', 'env', 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy']);
86
111
  function merge(base, overlay) {
@@ -95,6 +120,8 @@ function merge(base, overlay) {
95
120
  return out;
96
121
  }
97
122
  export function prepareExecutionPlan(template, cfg, overrides = {}) {
123
+ for (const [slot, value] of Object.entries(overrides))
124
+ validateMemberOverride(value, `member '${slot}'`);
98
125
  const slots = new Set(template.members.map(member => member.slot));
99
126
  for (const slot of Object.keys(overrides))
100
127
  if (!slots.has(slot))
@@ -108,6 +135,10 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
108
135
  if (!base)
109
136
  throw new Error(`Agent Template '${source}' not found`);
110
137
  let definition = merge(base, (input.overrides ?? {}));
138
+ if (input.loops === false)
139
+ delete definition.loops;
140
+ else if (input.loops !== undefined)
141
+ definition.loops = structuredClone(input.loops);
111
142
  const rolePreset = input.role ? cfg.rolePresets?.[input.role] : undefined;
112
143
  if (input.role && !rolePreset)
113
144
  throw new Error(`Role '${input.role}' not found`);
@@ -144,6 +175,8 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
144
175
  const role = cfg.resolveAgentDefinition(`RoomMember_${member.slot}`, {
145
176
  ...definition, identity: `RoomMember_${member.slot}`,
146
177
  });
178
+ if (definition.loops !== undefined)
179
+ definition.loops = canonicalAgentLoops(resolveAgentLoops(definition.loops, role, `(member '${member.slot}' resolved loops)`));
147
180
  const analysis = analyzeRolePermissions(role);
148
181
  if (analysis.conflicts?.length)
149
182
  throw new Error(`member '${member.slot}' has native permission conflicts: ${analysis.conflicts.map(item => item.warning).join('; ')}`);
@@ -154,6 +187,7 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
154
187
  launchDefinitions[id] = definition;
155
188
  const hash = createHash('sha256').update(canonicalJson(definition)).digest('hex');
156
189
  return { ...member, agent_template: source, launch_definition_id: id,
190
+ loop_source: input.loops !== undefined ? 'cli' : base.loops !== undefined ? 'agent-template' : 'omitted',
157
191
  agent_template_hash: hash,
158
192
  ...(input.role ? { role_preset: { id: input.role, hash: createHash('sha256').update(canonicalJson(rolePreset)).digest('hex') } } : {}),
159
193
  ...(input.brain ? { brain_preset: { id: input.brain, hash: createHash('sha256').update(canonicalJson(brainPreset)).digest('hex') } } : {}),
@@ -100,6 +100,7 @@ function expandMembers(template, prefix) {
100
100
  agentTemplate: slot.agent_template,
101
101
  launchDefinitionId: slot.launch_definition_id ?? slot.agent_template,
102
102
  agentTemplateHash: slot.agent_template_hash,
103
+ loopSource: slot.loop_source ?? 'omitted',
103
104
  });
104
105
  }
105
106
  }
@@ -121,6 +122,7 @@ function settingsFor(member, cfg, sealed) {
121
122
  template: member.agentTemplate,
122
123
  templateHash: member.agentTemplateHash
123
124
  ?? createHash('sha256').update(canonicalJson(definition)).digest('hex'),
125
+ loopSource: member.loopSource,
124
126
  };
125
127
  }
126
128
  function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
@@ -286,6 +288,9 @@ async function launchMemberUnlocked(input) {
286
288
  temp: true,
287
289
  identity: member.name,
288
290
  agentDefinition: settings.definition,
291
+ ...(settings.loopSource === 'cli' && settings.definition.loops === undefined
292
+ ? { noLoops: true } : {}),
293
+ loopSource: settings.loopSource,
289
294
  surface: 'agent',
290
295
  creationActionId: actionId,
291
296
  roomMemberStartup: startup,
@@ -276,6 +276,8 @@ export interface TemplateDefinition {
276
276
  members: TemplateMemberSlot[];
277
277
  }
278
278
  export interface TemplateSnapshotMember extends TemplateMemberSlot {
279
+ /** Source of the effective temporary-loop policy, independent of its sealed value. */
280
+ loop_source?: 'agent-template' | 'cli' | 'omitted';
279
281
  /** Secret-safe public projection; launch material lives in the sealed snapshot. */
280
282
  agent_projection?: Record<string, unknown>;
281
283
  agent_template_hash?: string;
package/dist/runner.d.ts CHANGED
@@ -7,6 +7,7 @@ import type { AgentSession, ExitRecord, TurnResult } from './session/types.js';
7
7
  import type { AgentSessionAdapter, AgentSessionStartOptions } from './harness/agent-session.js';
8
8
  import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
9
9
  import { type OwnerBinderLease } from './owner-channel/binder.js';
10
+ import { ScheduledLoopManager, type ScheduledLoopManagerHandle } from './loops/manager.js';
10
11
  export interface RunnerDeps {
11
12
  exec: Exec;
12
13
  cpuDelegated(): boolean;
@@ -26,6 +27,8 @@ export interface RunnerDeps {
26
27
  startAgentSession(adapter: AgentSessionAdapter, options: AgentSessionStartOptions): Promise<AgentSession>;
27
28
  /** Construct the authenticated role control route (injectable where sockets are unavailable). */
28
29
  createControlServer(stateDir: string, session: AgentSession, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setFleetAuditor' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
30
+ /** Construct scheduled-loop execution (injectable for fail-closed startup tests). */
31
+ createLoopManager(...args: ConstructorParameters<typeof ScheduledLoopManager>): ScheduledLoopManagerHandle;
29
32
  /** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
30
33
  acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
31
34
  /** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
package/dist/runner.js CHANGED
@@ -53,6 +53,7 @@ const defaultDeps = () => ({
53
53
  createOwnerChannel: opts => new OwnerChannel(opts),
54
54
  startAgentSession: (adapter, options) => adapter.start(options),
55
55
  createControlServer: (stateDir, session, log) => new RoleControlServer(stateDir, session, log),
56
+ createLoopManager: (...args) => new ScheduledLoopManager(...args),
56
57
  acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
57
58
  reportOwnerStartupFailure: async (stateDir) => {
58
59
  const response = await controlRequest(stateDir, {
@@ -774,7 +775,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
774
775
  if (generation === loopGeneration && (loopManager || !definitions.length))
775
776
  return { changed: false, loops: definitions.length };
776
777
  if (!loopManager && definitions.length) {
777
- loopManager = new ScheduledLoopManager(name, definitions, dir, arbiter, {
778
+ loopManager = deps.createLoopManager(name, definitions, dir, arbiter, {
778
779
  now: deps.now,
779
780
  setTimer: (callback, ms) => setTimeout(callback, ms),
780
781
  clearTimer: timer => clearTimeout(timer),
@@ -790,10 +791,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
790
791
  deps.log(`[${name}] scheduled loops reloaded (${definitions.length} definitions)`);
791
792
  return { changed: true, loops: definitions.length };
792
793
  };
793
- control.setConfigReloader(reloadLoopConfig);
794
+ // Temporary agents are immutable launch snapshots: never re-resolve mutable Fleet YAML.
795
+ if (!temp)
796
+ control.setConfigReloader(reloadLoopConfig);
794
797
  if (role.loops?.length) {
795
798
  try {
796
- loopManager = new ScheduledLoopManager(name, role.loops, dir, arbiter, {
799
+ loopManager = deps.createLoopManager(name, role.loops, dir, arbiter, {
797
800
  now: deps.now,
798
801
  setTimer: (callback, ms) => setTimeout(callback, ms),
799
802
  clearTimer: timer => clearTimeout(timer),
@@ -803,6 +806,17 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
803
806
  loopManager.start();
804
807
  }
805
808
  catch (error) {
809
+ if (temp) {
810
+ monitor?.stop();
811
+ if (monitorLoop)
812
+ await monitorLoop;
813
+ await control.close();
814
+ ownerBinder?.release();
815
+ await agentSession.close();
816
+ unsubscribeRecovery?.();
817
+ throw new Error(`[${name}] configured temporary loop manager failed to start: `
818
+ + `${error?.message ?? String(error)}`);
819
+ }
806
820
  deps.log(`[${name}] scheduled loop manager unavailable: ${error?.name ?? 'Error'}`);
807
821
  }
808
822
  }
package/dist/spawn.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { IsolationConfig } from './isolation/types.js';
2
+ import type { AgentLoopsConfig } from './loops/config.js';
2
3
  import { type ApprovalMode, type FilesystemMode, type ResolvedRole, type MonitorConfig, type UnattendedMode, type RoomMemberStartup, type AgentDefinition, type AgentSelection } from './config.js';
3
4
  import { type OpsDeps } from './ops.js';
4
5
  import { type CreationDeps, type CreationProvenance } from './creation.js';
@@ -40,6 +41,12 @@ export interface SpawnOpts {
40
41
  * input in this release.
41
42
  */
42
43
  isolationFile?: string;
44
+ /** Trusted YAML containing exactly a top-level `loops:` map. Temporary launches only. */
45
+ loopsFile?: string;
46
+ /** Explicitly override any selected Agent Template loops with none. */
47
+ noLoops?: boolean;
48
+ /** Internal source label retained after room-member plan resolution. */
49
+ loopSource?: 'agent-template' | 'cli' | 'omitted';
43
50
  overseeInterval?: string;
44
51
  configPath?: string;
45
52
  dryRun?: boolean;
@@ -56,6 +63,8 @@ export declare function agentDefinitionFromSpawn(o: SpawnOpts): AgentDefinition;
56
63
  * must fail before any artifact exists.
57
64
  */
58
65
  export declare function readIsolationFile(path: string): IsolationConfig;
66
+ /** Read a private canonical temporary-loop override before any creation side effect. */
67
+ export declare function readLoopsFile(path: string): AgentLoopsConfig;
59
68
  export declare function validateSpawnOpts(o: SpawnOpts): void;
60
69
  /** The ours identity a spawn will bind: explicit, else the role name. */
61
70
  export declare const effectiveIdentity: (o: SpawnOpts) => string;