@ours.network/fleet 0.11.1 → 0.13.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.
Files changed (43) hide show
  1. package/README.md +225 -0
  2. package/dist/briefing.js +25 -0
  3. package/dist/cli.js +408 -1
  4. package/dist/config.d.ts +31 -1
  5. package/dist/config.js +123 -2
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +143 -0
  8. package/dist/duration.js +7 -3
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -0
  11. package/dist/loops/config.d.ts +30 -0
  12. package/dist/loops/config.js +135 -0
  13. package/dist/loops/manager.d.ts +48 -0
  14. package/dist/loops/manager.js +237 -0
  15. package/dist/loops/state.d.ts +54 -0
  16. package/dist/loops/state.js +148 -0
  17. package/dist/monitor.js +26 -2
  18. package/dist/owner-channel/attachments.d.ts +74 -0
  19. package/dist/owner-channel/attachments.js +378 -0
  20. package/dist/owner-channel/channel.d.ts +167 -0
  21. package/dist/owner-channel/channel.js +874 -0
  22. package/dist/owner-channel/mcp.d.ts +24 -0
  23. package/dist/owner-channel/mcp.js +123 -0
  24. package/dist/owner-channel/notices.d.ts +21 -0
  25. package/dist/owner-channel/notices.js +66 -0
  26. package/dist/owner-channel/state.d.ts +44 -0
  27. package/dist/owner-channel/state.js +184 -0
  28. package/dist/owner-channel/tasks.d.ts +62 -0
  29. package/dist/owner-channel/tasks.js +246 -0
  30. package/dist/resolved-plan.js +12 -0
  31. package/dist/runner.d.ts +3 -0
  32. package/dist/runner.js +112 -5
  33. package/dist/session/acp.d.ts +4 -2
  34. package/dist/session/acp.js +82 -25
  35. package/dist/session/arbiter.d.ts +42 -0
  36. package/dist/session/arbiter.js +72 -0
  37. package/dist/session/control.d.ts +12 -1
  38. package/dist/session/control.js +56 -3
  39. package/dist/session/types.d.ts +28 -2
  40. package/dist/session/types.js +5 -2
  41. package/dist/spawn.js +7 -3
  42. package/dist/supervisor/systemd.js +12 -2
  43. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { createInterface } from 'node:readline';
7
7
  import { Command } from 'commander';
8
8
  import { VERSION } from './version.js';
9
9
  import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir, watchdogsRoot } from './paths.js';
10
- import { loadConfig, ROLE_NAME_RE } from './config.js';
10
+ import { findRole, loadConfig, ROLE_NAME_RE } from './config.js';
11
11
  import { formatDuration } from './duration.js';
12
12
  import { resolvedPlan } from './resolved-plan.js';
13
13
  import { Tmux, tmuxArgs } from './tmux.js';
@@ -28,6 +28,7 @@ import { allWarnings, analyzeFleetPermissions, formatNative } from './permission
28
28
  import { AI_DOCS } from './docs.js';
29
29
  import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
30
30
  import { SessionControlError } from './session/types.js';
31
+ import { readScheduledLoops } from './loops/state.js';
31
32
  import { startWebConsole } from './web/runtime.js';
32
33
  import { requestWebControl } from './web/control.js';
33
34
  import { WebServiceManager } from './web/service.js';
@@ -74,6 +75,31 @@ function acpStateDir(name) {
74
75
  return temp;
75
76
  return undefined;
76
77
  }
78
+ const CONTACT_CID_RE = /^[A-Fa-f0-9]{64}$/;
79
+ const OWNER_REQUEST_ID_RE = /^[a-f0-9]{64}$/;
80
+ const MAX_INVITE_BYTES = 48 * 1024;
81
+ const MAX_OWNER_UPDATE_BYTES = 1_024;
82
+ function ownerChannelStateDir(roleName, configuration) {
83
+ const role = findRole(loadConfig(configuration), roleName);
84
+ if (role.session !== 'acp')
85
+ throw new Error(`role '${roleName}' uses session '${role.session}', but owner-channel management requires ACP`);
86
+ if (!role.owner_channel)
87
+ throw new Error(`role '${roleName}' has no owner_channel configured`);
88
+ const stateDir = acpStateDir(roleName);
89
+ if (!stateDir)
90
+ throw new Error(`role '${roleName}' is stopped or its authenticated ACP control socket is unavailable`);
91
+ return stateDir;
92
+ }
93
+ async function manageOwnerChannel(role, configuration, ownerChannel) {
94
+ const response = await controlRequest(ownerChannelStateDir(role, configuration), { command: 'owner_channel_manage', ownerChannel });
95
+ if (!response.ok)
96
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'owner-channel request failed');
97
+ return response.result;
98
+ }
99
+ function assertContactCid(cid) {
100
+ if (!CONTACT_CID_RE.test(cid))
101
+ throw new Error('contact CID must be exactly 64 hexadecimal characters');
102
+ }
77
103
  function renderSessionEvent(event) {
78
104
  switch (event.kind) {
79
105
  case 'agent_text':
@@ -155,6 +181,10 @@ cOpt(program.command('config').description('validate + print the merged plan (no
155
181
  console.log(` monitor: ${r.monitor.mode}`
156
182
  + (r.monitor.mode === 'fleet' ? ` (interrupt=${r.monitor.interrupt})` : ''));
157
183
  console.log(` identity: ${r.identity}`);
184
+ if (r.owner_channel)
185
+ console.log(` owner ch: ${r.owner_channel.identity} `
186
+ + `(${r.owner_channel.owners.length} authorized sender(s), `
187
+ + `interrupt=${r.owner_channel.interrupt})`);
158
188
  console.log(` permissions: approval=${r.permissions.approval} `
159
189
  + `filesystem=${r.permissions.filesystem} unattended=${r.permissions.unattended}`);
160
190
  if (perms?.supported)
@@ -201,6 +231,15 @@ cOpt(program.command('config').description('validate + print the merged plan (no
201
231
  console.log(` isolation: ${JSON.stringify(w.isolation)}`);
202
232
  }
203
233
  }
234
+ if (cfg.loops.length) {
235
+ console.log('loops:');
236
+ for (const loop of cfg.loops) {
237
+ console.log(`● ${loop.name}${loop.enabled ? '' : ' (disabled)'}`);
238
+ console.log(` every ${formatDuration(loop.intervalMs)} initial=${formatDuration(loop.initialDelayMs)} jitter=${formatDuration(loop.jitterMs)}`);
239
+ console.log(` roles: ${loop.roleNames.join(', ')}`);
240
+ console.log(` prompt: ${loop.promptBytes} bytes sha256=${loop.promptHash.slice(0, 12)}`);
241
+ }
242
+ }
204
243
  }
205
244
  catch (e) {
206
245
  die(e);
@@ -433,6 +472,374 @@ program.command('status <name>').description('unit/agent state')
433
472
  console.log('session: acp control unavailable');
434
473
  }
435
474
  }
475
+ const loopState = readScheduledLoops(agentDir(name));
476
+ if (loopState) {
477
+ const values = Object.values(loopState.loops);
478
+ const enabled = values.filter(loop => loop.enabled && !loop.operatorDisabled).length;
479
+ const next = values.filter(loop => loop.enabled && !loop.operatorDisabled)
480
+ .map(loop => Date.parse(loop.nextDueAt)).filter(Number.isFinite).sort((a, b) => a - b)[0];
481
+ console.log(`loops: ${enabled} enabled${next ? `, next ${formatDuration(Math.max(0, next - Date.now()))}` : ''}, ${loopState.health}`);
482
+ }
483
+ });
484
+ const loopsCommand = program.command('loops')
485
+ .description('validate, inspect, and control strict idle-only scheduled agent loops');
486
+ function loopFailure(error, json, code = 1) {
487
+ const err = error instanceof SessionControlError ? error : undefined;
488
+ const message = error instanceof Error ? error.message : String(error);
489
+ if (json)
490
+ console.log(JSON.stringify({
491
+ schemaVersion: 1, ok: false,
492
+ error: { kind: err?.kind ?? 'invalid', message, retrySafe: err?.kind !== 'timeout' },
493
+ }));
494
+ else
495
+ console.error(message);
496
+ process.exitCode = code;
497
+ }
498
+ function permanentLoopControlDir(role) {
499
+ const dir = agentDir(role);
500
+ return existsSync(controlSocketPath(dir)) ? dir : undefined;
501
+ }
502
+ cOpt(loopsCommand.command('validate').description('validate loop config and expanded ACP targets'))
503
+ .option('--json', 'emit stable JSON')
504
+ .action(opts => {
505
+ try {
506
+ const cfg = loadConfig(opts.configuration);
507
+ const result = { schemaVersion: 1, ok: true, loops: cfg.loops.length,
508
+ pairs: cfg.roles.reduce((sum, role) => sum + (role.loops?.length ?? 0), 0) };
509
+ if (opts.json)
510
+ console.log(JSON.stringify(result));
511
+ else
512
+ console.log(`valid: ${result.loops} loop(s), ${result.pairs} resolved role pair(s)`);
513
+ }
514
+ catch (error) {
515
+ loopFailure(error, opts.json === true);
516
+ }
517
+ });
518
+ cOpt(loopsCommand.command('list').description('list redacted resolved loop definitions'))
519
+ .option('--role <role>', 'filter to one permanent role')
520
+ .option('--json', 'emit stable JSON')
521
+ .action(opts => {
522
+ try {
523
+ const cfg = loadConfig(opts.configuration);
524
+ if (opts.role)
525
+ findRole(cfg, opts.role);
526
+ const values = cfg.loops.filter(loop => !opts.role || loop.roleNames.includes(opts.role)).map(loop => ({
527
+ name: loop.name, selectors: loop.selectors, roles: loop.roleNames,
528
+ enabled: loop.enabled, intervalMs: loop.intervalMs, initialDelayMs: loop.initialDelayMs,
529
+ jitterMs: loop.jitterMs, prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
530
+ sourceFile: loop.sourceFile,
531
+ }));
532
+ if (opts.json)
533
+ console.log(JSON.stringify({ schemaVersion: 1, loops: values }));
534
+ else
535
+ for (const loop of values)
536
+ console.log(`${loop.name} ${loop.enabled ? 'enabled' : 'disabled'} every=${formatDuration(loop.intervalMs)} roles=${loop.roles.join(',')} prompt=${loop.prompt.bytes}B/${loop.prompt.sha256.slice(0, 12)}`);
537
+ }
538
+ catch (error) {
539
+ loopFailure(error, opts.json === true);
540
+ }
541
+ });
542
+ function selectLoopConfig(configuration, roleName, loopName) {
543
+ const cfg = loadConfig(configuration);
544
+ const role = findRole(cfg, roleName);
545
+ const definitions = role.loops ?? [];
546
+ if (loopName && !definitions.some(loop => loop.name === loopName))
547
+ throw new Error(`role '${roleName}' has no scheduled loop '${loopName}'`);
548
+ return { role, definitions };
549
+ }
550
+ function renderLoopRows(role, state, loop) {
551
+ if (!state)
552
+ return [];
553
+ return Object.entries(state.loops).filter(([name]) => !loop || name === loop).map(([name, item]) => `${role}/${name} ${item.enabled && !item.operatorDisabled ? 'enabled' : 'disabled'} `
554
+ + `${item.activeRunId ? 'running' : 'idle'} next=${item.nextDueAt} last=${item.lastOutcome ?? 'never'} `
555
+ + `counts=${item.counts.started}/${item.counts.completed}/${item.counts.failed} `
556
+ + `skip=${item.counts.skipped}(busy=${item.counts.skippedBusy},missed=${item.counts.skippedMissed})`);
557
+ }
558
+ cOpt(loopsCommand.command('status [role] [loop]').description('show live or stored loop state'))
559
+ .option('--json', 'emit stable JSON')
560
+ .action(async (roleName, loopName, opts) => {
561
+ try {
562
+ const cfg = loadConfig(opts.configuration);
563
+ const roles = roleName ? [findRole(cfg, roleName)] : cfg.roles.filter(role => role.loops?.length);
564
+ if (roleName && loopName)
565
+ selectLoopConfig(opts.configuration, roleName, loopName);
566
+ const results = [];
567
+ for (const role of roles) {
568
+ let state = readScheduledLoops(agentDir(role.name));
569
+ let evidence = 'stored';
570
+ const live = permanentLoopControlDir(role.name);
571
+ if (live)
572
+ try {
573
+ const response = await controlRequest(live, { command: 'loop_status' }, 2_000);
574
+ if (response.ok) {
575
+ state = response.result;
576
+ evidence = 'live';
577
+ }
578
+ }
579
+ catch { /* stored evidence remains honest; timeout is not offline */ }
580
+ results.push({ role: role.name, evidence, state });
581
+ }
582
+ if (opts.json)
583
+ console.log(JSON.stringify({ schemaVersion: 1, roles: results }));
584
+ else {
585
+ const rows = results.flatMap(result => renderLoopRows(result.role, result.state, loopName)
586
+ .map(row => `${row} evidence=${result.evidence}`));
587
+ console.log(rows.join('\n') || '(no scheduled loop state)');
588
+ }
589
+ }
590
+ catch (error) {
591
+ loopFailure(error, opts.json === true);
592
+ }
593
+ });
594
+ cOpt(loopsCommand.command('reload <role>').description('reload trusted scheduled-loop config in a live ACP role'))
595
+ .option('--json', 'emit stable JSON')
596
+ .action(async (roleName, opts) => {
597
+ try {
598
+ const { role } = selectLoopConfig(opts.configuration, roleName);
599
+ if (role.session !== 'acp')
600
+ throw new Error(`role '${roleName}' is not ACP-compatible`);
601
+ const stateDir = permanentLoopControlDir(roleName);
602
+ if (!stateDir)
603
+ throw new SessionControlError('control-unavailable', `role '${roleName}' has no live authenticated ACP control socket`);
604
+ const response = await controlRequest(stateDir, { command: 'reload_config' });
605
+ if (!response.ok)
606
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'config reload failed');
607
+ const result = response.result;
608
+ if (opts.json)
609
+ console.log(JSON.stringify({ schemaVersion: 1, ok: true, role: roleName, ...result }));
610
+ else
611
+ console.log(`${roleName}: ${result.changed ? 'reloaded' : 'unchanged'} (${result.loops} loops)`);
612
+ }
613
+ catch (error) {
614
+ loopFailure(error, opts.json === true, error instanceof SessionControlError
615
+ && ['timeout', 'control-unavailable'].includes(error.kind) ? 2 : 1);
616
+ }
617
+ });
618
+ for (const command of ['run-now', 'disable', 'enable']) {
619
+ cOpt(loopsCommand.command(`${command} <role> <loop>`)
620
+ .description(`${command} one trusted configured loop through the live private control socket`))
621
+ .option('--json', 'emit stable JSON')
622
+ .action(async (roleName, loopName, opts) => {
623
+ try {
624
+ const { role, definitions } = selectLoopConfig(opts.configuration, roleName, loopName);
625
+ if (role.session !== 'acp')
626
+ throw new Error(`role '${roleName}' is not ACP-compatible`);
627
+ const definition = definitions.find(loop => loop.name === loopName);
628
+ if (command === 'enable' && !definition.enabled)
629
+ throw new Error(`loop '${loopName}' is disabled in YAML; edit the config before enabling it`);
630
+ const stateDir = permanentLoopControlDir(roleName);
631
+ if (!stateDir)
632
+ throw new SessionControlError('control-unavailable', `role '${roleName}' has no live authenticated ACP control socket`);
633
+ const response = await controlRequest(stateDir, {
634
+ command: command === 'run-now' ? 'loop_run_now'
635
+ : command === 'disable' ? 'loop_disable' : 'loop_enable',
636
+ loop: loopName,
637
+ });
638
+ if (!response.ok)
639
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'loop control failed');
640
+ const result = response.result;
641
+ if (opts.json)
642
+ console.log(JSON.stringify({ schemaVersion: 1, ok: true, role: roleName, loop: loopName, ...result }));
643
+ else
644
+ console.log(`${roleName}/${loopName}: ${result.state}${result.runId ? ` ${result.runId}` : ''}`);
645
+ if (command === 'run-now' && result.state !== 'started')
646
+ process.exitCode = 3;
647
+ }
648
+ catch (error) {
649
+ loopFailure(error, opts.json === true, error instanceof SessionControlError
650
+ && ['timeout', 'control-unavailable'].includes(error.kind) ? 2 : 1);
651
+ }
652
+ });
653
+ }
654
+ const ownerChannelCommand = program.command('owner-channel')
655
+ .description('manage owner routing and task-correlated reports through a running role supervisor')
656
+ .addHelpText('after', '\nPairing is two-step: establish a contact first, then explicitly authorize its exact CID.');
657
+ const ownerContactCommand = ownerChannelCommand.command('contact')
658
+ .description('establish contacts without granting owner authority');
659
+ const ownerAuthorizationCommand = ownerChannelCommand.command('owner')
660
+ .description('manage the effective owner CID set (separate from contacts)');
661
+ const ownerTaskCommand = ownerChannelCommand.command('task')
662
+ .description('register and report bounded follow-up work correlated to an authenticated owner request');
663
+ cOpt(ownerTaskCommand.command('open <Role> <active-request-id>')
664
+ .description('register a durable follow-up task during the exact active owner request'))
665
+ .action(async (role, requestId, opts) => {
666
+ try {
667
+ if (!OWNER_REQUEST_ID_RE.test(requestId))
668
+ throw new Error('owner task request ID must be exactly 64 lowercase hexadecimal characters');
669
+ const result = await manageOwnerChannel(role, opts.configuration, {
670
+ action: 'task_open', requestId,
671
+ });
672
+ if (result.action !== 'task_open')
673
+ throw new Error('unexpected owner-channel response');
674
+ console.log(`Owner task ${result.taskId} opened; expires ${result.expiresAt}.`);
675
+ }
676
+ catch (e) {
677
+ die(e);
678
+ }
679
+ });
680
+ cOpt(ownerTaskCommand.command('report <Role> <task-id>')
681
+ .description('send a proactive follow-up to the task\'s stored authenticated origin')
682
+ .requiredOption('--phase <phase>', 'progress, done, or blocked')
683
+ .requiredOption('--message-stdin', 'read the one-line report body from stdin'))
684
+ .action(async (role, taskId, opts) => {
685
+ try {
686
+ if (!OWNER_REQUEST_ID_RE.test(taskId))
687
+ throw new Error('owner task ID must be exactly 64 lowercase hexadecimal characters');
688
+ const phase = String(opts.phase);
689
+ if (!['progress', 'done', 'blocked'].includes(phase))
690
+ throw new Error('owner task report phase must be progress, done, or blocked');
691
+ const message = readFileSync(0, 'utf8');
692
+ if (Buffer.byteLength(message) > MAX_OWNER_UPDATE_BYTES)
693
+ throw new Error(`owner task report input exceeds ${MAX_OWNER_UPDATE_BYTES} bytes`);
694
+ const result = await manageOwnerChannel(role, opts.configuration, {
695
+ action: 'task_report', taskId, phase: phase, message,
696
+ });
697
+ if (result.action !== 'task_report')
698
+ throw new Error('unexpected owner-channel response');
699
+ console.log(`Owner task report ${result.sequence} delivered; task ${result.state}.`);
700
+ }
701
+ catch (e) {
702
+ die(e);
703
+ }
704
+ });
705
+ cOpt(ownerChannelCommand.command('update <Role> <request-id>')
706
+ .description('send one bounded agent-authored update for an active owner request')
707
+ .requiredOption('--phase <phase>', 'working, approval, or blocked')
708
+ .requiredOption('--message-stdin', 'read the one-line update body from stdin'))
709
+ .action(async (role, requestId, opts) => {
710
+ try {
711
+ if (!OWNER_REQUEST_ID_RE.test(requestId))
712
+ throw new Error('owner update request ID must be exactly 64 lowercase hexadecimal characters');
713
+ const phase = String(opts.phase);
714
+ if (!['working', 'approval', 'blocked'].includes(phase))
715
+ throw new Error('owner update phase must be working, approval, or blocked');
716
+ const message = readFileSync(0, 'utf8');
717
+ if (Buffer.byteLength(message) > MAX_OWNER_UPDATE_BYTES)
718
+ throw new Error(`owner update input exceeds ${MAX_OWNER_UPDATE_BYTES} bytes`);
719
+ const result = await manageOwnerChannel(role, opts.configuration, {
720
+ action: 'request_update', requestId,
721
+ phase: phase, message,
722
+ });
723
+ if (result.action !== 'request_update')
724
+ throw new Error('unexpected owner-channel response');
725
+ console.log(`Owner update ${result.sequence} delivered.`);
726
+ }
727
+ catch (e) {
728
+ die(e);
729
+ }
730
+ });
731
+ cOpt(ownerContactCommand.command('list <Role>')
732
+ .description('list established/pending contacts using safe identity metadata only'))
733
+ .action(async (role, opts) => {
734
+ try {
735
+ const result = await manageOwnerChannel(role, opts.configuration, { action: 'contact_list' });
736
+ if (result.action !== 'contact_list')
737
+ throw new Error('unexpected owner-channel response');
738
+ if (!result.contacts.length) {
739
+ console.log('(no contacts)');
740
+ return;
741
+ }
742
+ console.log('CID\tNAME\tSTATUS\tKIND\tHUMAN');
743
+ for (const contact of result.contacts)
744
+ console.log([
745
+ contact.cid, contact.name, contact.status, contact.kind ?? '', contact.human?.name ?? '',
746
+ ].join('\t'));
747
+ }
748
+ catch (e) {
749
+ die(e);
750
+ }
751
+ });
752
+ cOpt(ownerContactCommand.command('invite <Role>')
753
+ .description('generate an invite on the already-bound owner channel')
754
+ .option('--name <label>', 'optional contact label'))
755
+ .action(async (role, opts) => {
756
+ try {
757
+ const result = await manageOwnerChannel(role, opts.configuration, {
758
+ action: 'contact_invite', ...(opts.name ? { name: String(opts.name) } : {}),
759
+ });
760
+ if (result.action !== 'contact_invite')
761
+ throw new Error('unexpected owner-channel response');
762
+ // Invite material is intentionally the only stdout content.
763
+ process.stdout.write(result.invite + '\n');
764
+ }
765
+ catch (e) {
766
+ die(e);
767
+ }
768
+ });
769
+ cOpt(ownerContactCommand.command('add <Role>')
770
+ .description('accept an invite without granting owner authority')
771
+ .option('--invite-file <path>', 'read invite material from a file')
772
+ .option('--invite-stdin', 'read invite material from stdin')
773
+ .option('--name <label>', 'optional contact label'))
774
+ .action(async (role, opts) => {
775
+ try {
776
+ if (Boolean(opts.inviteFile) === Boolean(opts.inviteStdin))
777
+ throw new Error('choose exactly one of --invite-file <path> or --invite-stdin');
778
+ const invite = readFileSync(opts.inviteStdin ? 0 : String(opts.inviteFile), 'utf8').trim();
779
+ if (!invite)
780
+ throw new Error('invite input is empty');
781
+ if (Buffer.byteLength(invite) > MAX_INVITE_BYTES)
782
+ throw new Error(`invite input exceeds ${MAX_INVITE_BYTES} bytes`);
783
+ const result = await manageOwnerChannel(role, opts.configuration, {
784
+ action: 'contact_add', invite, ...(opts.name ? { name: String(opts.name) } : {}),
785
+ });
786
+ if (result.action !== 'contact_add')
787
+ throw new Error('unexpected owner-channel response');
788
+ console.log('Invite accepted; contact establishment is pending peer verification.');
789
+ console.log('No owner authority was granted. After the contact is established, authorize its exact CID separately.');
790
+ }
791
+ catch (e) {
792
+ die(e);
793
+ }
794
+ });
795
+ cOpt(ownerAuthorizationCommand.command('list <Role>')
796
+ .description('show baseline/dynamic source and effective authorization state'))
797
+ .action(async (role, opts) => {
798
+ try {
799
+ const result = await manageOwnerChannel(role, opts.configuration, { action: 'owner_list' });
800
+ if (result.action !== 'owner_list')
801
+ throw new Error('unexpected owner-channel response');
802
+ if (!result.integrity.ok)
803
+ console.error('warning: owner authorization overlay is corrupt; effective authorization is fail-closed');
804
+ console.log('CID\tSOURCE\tEFFECTIVE');
805
+ for (const owner of result.owners)
806
+ console.log(`${owner.cid}\t${owner.source}\t${owner.effective ? 'yes' : 'no'}`);
807
+ }
808
+ catch (e) {
809
+ die(e);
810
+ }
811
+ });
812
+ cOpt(ownerAuthorizationCommand.command('authorize <Role> <contact-cid>')
813
+ .description('authorize an exact CID which is already an established contact'))
814
+ .action(async (role, cid, opts) => {
815
+ try {
816
+ assertContactCid(cid);
817
+ const result = await manageOwnerChannel(role, opts.configuration, {
818
+ action: 'owner_authorize', cid,
819
+ });
820
+ if (result.action !== 'owner_authorize')
821
+ throw new Error('unexpected owner-channel response');
822
+ console.log(`Authorized owner ${result.owner.cid} (${result.owner.source}).`);
823
+ }
824
+ catch (e) {
825
+ die(e);
826
+ }
827
+ });
828
+ cOpt(ownerAuthorizationCommand.command('revoke <Role> <contact-cid>')
829
+ .description('revoke an exact effective owner CID; the last owner is protected'))
830
+ .action(async (role, cid, opts) => {
831
+ try {
832
+ assertContactCid(cid);
833
+ const result = await manageOwnerChannel(role, opts.configuration, {
834
+ action: 'owner_revoke', cid,
835
+ });
836
+ if (result.action !== 'owner_revoke')
837
+ throw new Error('unexpected owner-channel response');
838
+ console.log(`Revoked owner ${result.owner.cid} (${result.owner.source}).`);
839
+ }
840
+ catch (e) {
841
+ die(e);
842
+ }
436
843
  });
437
844
  /**
438
845
  * A watchdog is addressable if it's still configured, or its store dir survives
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ConfigDiagnostic, type YamlMode } from './config-yaml.js';
2
2
  import type { IsolationConfig, WrapContext } from './isolation/types.js';
3
3
  import type { ResolvedWatchdog } from './watchdog/config.js';
4
+ import type { ResolvedLoop, ResolvedRoleLoop } from './loops/config.js';
4
5
  export interface OverseeEntry {
5
6
  role: string;
6
7
  interval: string;
@@ -58,6 +59,30 @@ export interface MonitorConfig {
58
59
  */
59
60
  turn_fail_threshold?: number;
60
61
  }
62
+ /** A trusted, fleet-owned ours mailbox which is never bound inside the agent. */
63
+ export interface OwnerChannelConfig {
64
+ /** Existing ours identity exclusively bound by the fleet supervisor. */
65
+ identity: string;
66
+ /** Authenticated ours contact IDs allowed to issue owner instructions. */
67
+ owners: string[];
68
+ /** Cancel active work before each owner request instead of queueing it. */
69
+ interrupt: boolean;
70
+ /** Deterministic in-progress notice interval; 0 disables progress notices. */
71
+ progress_interval_ms: number;
72
+ attachments: OwnerAttachmentConfig;
73
+ }
74
+ export interface OwnerAttachmentConfig {
75
+ enabled: boolean;
76
+ max_files_per_request: number;
77
+ max_file_bytes: number;
78
+ max_request_bytes: number;
79
+ retention_ms: number;
80
+ allowed_mime: string[];
81
+ }
82
+ export type OwnerChannelConfigInput = Omit<Partial<OwnerChannelConfig>, 'attachments'> & {
83
+ attachments?: Partial<OwnerAttachmentConfig>;
84
+ };
85
+ export declare const DEFAULT_OWNER_ATTACHMENT_MIME: readonly ["application/pdf", "application/json", "text/plain", "image/png", "image/jpeg", "image/gif", "image/webp", "audio/ogg", "audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/webm", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation"];
61
86
  /** Default wake sources when a role does not list its own (design §2). */
62
87
  export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
63
88
  /** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
@@ -84,10 +109,11 @@ export interface RoleConfig {
84
109
  harness_options?: Record<string, unknown>;
85
110
  isolation?: IsolationConfig;
86
111
  monitor?: Partial<MonitorConfig>;
112
+ owner_channel?: OwnerChannelConfigInput;
87
113
  worklog?: WorklogPolicy;
88
114
  auth_proxy?: Partial<AuthProxyConfig>;
89
115
  }
90
- export interface ResolvedRole extends Omit<RoleConfig, 'model'> {
116
+ export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel'> {
91
117
  name: string;
92
118
  harness: string;
93
119
  session: SessionBackendId;
@@ -103,8 +129,10 @@ export interface ResolvedRole extends Omit<RoleConfig, 'model'> {
103
129
  model?: string;
104
130
  sourceFile: string;
105
131
  monitor: MonitorConfig;
132
+ owner_channel?: OwnerChannelConfig;
106
133
  worklog?: WorklogPolicy;
107
134
  auth_proxy?: AuthProxyConfig;
135
+ loops?: ResolvedRoleLoop[];
108
136
  }
109
137
  export interface FleetConfig {
110
138
  roles: ResolvedRole[];
@@ -116,6 +144,7 @@ export interface FleetConfig {
116
144
  /** Warning-first non-plain YAML migration diagnostics, in source order. */
117
145
  diagnostics: ConfigDiagnostic[];
118
146
  watchdogs: ResolvedWatchdog[];
147
+ loops: ResolvedLoop[];
119
148
  }
120
149
  export declare class ConfigError extends Error {
121
150
  }
@@ -133,6 +162,7 @@ export declare const ROLE_NAME_RE: RegExp;
133
162
  export declare function loadConfig(configPath?: string, options?: {
134
163
  yamlMode?: YamlMode;
135
164
  }): FleetConfig;
165
+ export declare function resolveOwnerChannelConfig(defaults: unknown, role: OwnerChannelConfigInput | undefined, session: SessionBackendId, file?: string, name?: string): OwnerChannelConfig | undefined;
136
166
  export declare function resolveModelChain(model: string | undefined, chain: string[] | undefined, file?: string, name?: string): string[] | undefined;
137
167
  export declare function resolveAuthProxy(defaults: unknown, role: Partial<AuthProxyConfig> | undefined, file?: string, name?: string): AuthProxyConfig | undefined;
138
168
  export declare function resolveWorklogPolicy(defaults: unknown, role: WorklogPolicy | undefined, file?: string, name?: string): WorklogPolicy | undefined;