@ours.network/fleet 0.10.3 → 0.11.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 (94) hide show
  1. package/README.md +66 -0
  2. package/dist/application/capabilities.d.ts +6 -0
  3. package/dist/application/capabilities.js +37 -0
  4. package/dist/application/errors.d.ts +31 -0
  5. package/dist/application/errors.js +51 -0
  6. package/dist/application/fleet-query-service.d.ts +42 -0
  7. package/dist/application/fleet-query-service.js +188 -0
  8. package/dist/application/log-service.d.ts +28 -0
  9. package/dist/application/log-service.js +146 -0
  10. package/dist/application/role-command-service.d.ts +37 -0
  11. package/dist/application/role-command-service.js +82 -0
  12. package/dist/application/role-creation-service.d.ts +142 -0
  13. package/dist/application/role-creation-service.js +374 -0
  14. package/dist/application/role-repository.d.ts +20 -0
  15. package/dist/application/role-repository.js +168 -0
  16. package/dist/application/session-control.d.ts +55 -0
  17. package/dist/application/session-control.js +115 -0
  18. package/dist/application/types.d.ts +156 -0
  19. package/dist/application/types.js +1 -0
  20. package/dist/cli.js +341 -3
  21. package/dist/config.d.ts +9 -2
  22. package/dist/config.js +21 -5
  23. package/dist/creation.d.ts +11 -0
  24. package/dist/creation.js +22 -5
  25. package/dist/docs.d.ts +1 -1
  26. package/dist/docs.js +56 -0
  27. package/dist/duration.d.ts +5 -0
  28. package/dist/duration.js +20 -0
  29. package/dist/index.d.ts +10 -1
  30. package/dist/index.js +9 -1
  31. package/dist/ops.d.ts +16 -0
  32. package/dist/ops.js +112 -3
  33. package/dist/paths.d.ts +1 -0
  34. package/dist/paths.js +1 -0
  35. package/dist/resolved-plan.js +7 -0
  36. package/dist/runner.js +10 -2
  37. package/dist/session/control.d.ts +4 -2
  38. package/dist/session/control.js +45 -13
  39. package/dist/spawn.d.ts +20 -2
  40. package/dist/spawn.js +94 -24
  41. package/dist/supervisor/launchd.js +17 -0
  42. package/dist/supervisor/none.js +17 -0
  43. package/dist/supervisor/systemd.js +4 -0
  44. package/dist/supervisor/types.d.ts +6 -0
  45. package/dist/tmux.d.ts +2 -0
  46. package/dist/tmux.js +8 -0
  47. package/dist/watchdog/alerts.d.ts +34 -0
  48. package/dist/watchdog/alerts.js +78 -0
  49. package/dist/watchdog/briefing.d.ts +65 -0
  50. package/dist/watchdog/briefing.js +181 -0
  51. package/dist/watchdog/config.d.ts +49 -0
  52. package/dist/watchdog/config.js +114 -0
  53. package/dist/watchdog/query.d.ts +78 -0
  54. package/dist/watchdog/query.js +124 -0
  55. package/dist/watchdog/report.d.ts +53 -0
  56. package/dist/watchdog/report.js +126 -0
  57. package/dist/watchdog/run.d.ts +61 -0
  58. package/dist/watchdog/run.js +318 -0
  59. package/dist/watchdog/scheduler.d.ts +105 -0
  60. package/dist/watchdog/scheduler.js +244 -0
  61. package/dist/watchdog/service.d.ts +46 -0
  62. package/dist/watchdog/service.js +179 -0
  63. package/dist/watchdog/store.d.ts +85 -0
  64. package/dist/watchdog/store.js +226 -0
  65. package/dist/web/audit.d.ts +22 -0
  66. package/dist/web/audit.js +54 -0
  67. package/dist/web/auth.d.ts +61 -0
  68. package/dist/web/auth.js +186 -0
  69. package/dist/web/control.d.ts +14 -0
  70. package/dist/web/control.js +110 -0
  71. package/dist/web/device-store.d.ts +27 -0
  72. package/dist/web/device-store.js +155 -0
  73. package/dist/web/events.d.ts +15 -0
  74. package/dist/web/events.js +34 -0
  75. package/dist/web/lock.d.ts +5 -0
  76. package/dist/web/lock.js +69 -0
  77. package/dist/web/runtime.d.ts +12 -0
  78. package/dist/web/runtime.js +214 -0
  79. package/dist/web/server.d.ts +37 -0
  80. package/dist/web/server.js +279 -0
  81. package/dist/web/service.d.ts +42 -0
  82. package/dist/web/service.js +180 -0
  83. package/dist/web/terminal/bridge.d.ts +27 -0
  84. package/dist/web/terminal/bridge.js +317 -0
  85. package/dist/web-app/assets/TerminalView-BvcIkuIF.js +9 -0
  86. package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
  87. package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
  88. package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
  89. package/dist/web-app/icons/ours-fleet.svg +4 -0
  90. package/dist/web-app/index.html +17 -0
  91. package/dist/web-app/manifest.webmanifest +15 -0
  92. package/dist/web-app/offline.html +18 -0
  93. package/dist/web-app/sw.js +51 -0
  94. package/package.json +26 -3
package/dist/cli.js CHANGED
@@ -6,13 +6,19 @@ import { join as joinPath } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { Command } from 'commander';
8
8
  import { VERSION } from './version.js';
9
- import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
10
- import { loadConfig } from './config.js';
9
+ import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir, watchdogsRoot } from './paths.js';
10
+ import { loadConfig, ROLE_NAME_RE } from './config.js';
11
+ import { formatDuration } from './duration.js';
11
12
  import { resolvedPlan } from './resolved-plan.js';
12
13
  import { Tmux, tmuxArgs } from './tmux.js';
13
14
  import { pickBackend } from './supervisor/index.js';
14
15
  import { up, down, restartRoles, rmRole } from './ops.js';
15
16
  import { readRestartLedger, runSupervised, runTemp } from './runner.js';
17
+ import { executeWatchdogRun, runWatchdogAgent } from './watchdog/run.js';
18
+ import { readSchedulerState, resetSchedulerState, runScheduler } from './watchdog/scheduler.js';
19
+ import { partitionRestartNames } from './watchdog/config.js';
20
+ import { WatchdogServiceManager } from './watchdog/service.js';
21
+ import { acquireRunLock, latestReport, listRuns, readReport, releaseRunLock, reportsDir, } from './watchdog/store.js';
16
22
  import { lastProvenance, spawnDryRun, spawnPermanent, spawnTemp, } from './spawn.js';
17
23
  import { stringify } from 'yaml';
18
24
  import { resolvedRolePlan } from './resolved-plan.js';
@@ -22,6 +28,9 @@ import { allWarnings, analyzeFleetPermissions, formatNative } from './permission
22
28
  import { AI_DOCS } from './docs.js';
23
29
  import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
24
30
  import { SessionControlError } from './session/types.js';
31
+ import { startWebConsole } from './web/runtime.js';
32
+ import { requestWebControl } from './web/control.js';
33
+ import { WebServiceManager } from './web/service.js';
25
34
  import './harness/claude-code.js'; // registers the claude-code adapter
26
35
  import './harness/codex.js'; // registers the codex adapter
27
36
  // sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
@@ -37,6 +46,7 @@ const deps = () => ({
37
46
  backend: pickBackend(),
38
47
  binPath,
39
48
  log: l => console.log(l),
49
+ watchdogService: new WatchdogServiceManager(),
40
50
  });
41
51
  const die = (e) => { console.error(String(e instanceof Error ? e.message : e)); process.exit(1); };
42
52
  /** Exec a child with our stdio (logs/attach). */
@@ -47,6 +57,7 @@ const passthrough = (cmd, args) => new Promise(resolve => {
47
57
  const program = new Command()
48
58
  .name('ours-fleet')
49
59
  .description('Fleet of persistent, identity-bound AI agents — selectable harness and tmux/ACP sessions.')
60
+ .enablePositionalOptions()
50
61
  .version(VERSION);
51
62
  const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
52
63
  const collect = (value, previous) => [...previous, value];
@@ -175,6 +186,19 @@ cOpt(program.command('config').description('validate + print the merged plan (no
175
186
  for (const w of perms ? allWarnings(perms) : [])
176
187
  console.log(` warning: ${w}`);
177
188
  }
189
+ if (cfg.watchdogs.length) {
190
+ console.log('watchdogs:');
191
+ for (const w of cfg.watchdogs) {
192
+ console.log(`● ${w.name}${w.enabled ? '' : ' (disabled)'}`
193
+ + `${readSchedulerState(w.name).heldDown ? ' (held down)' : ''}`);
194
+ console.log(` every ${formatDuration(w.intervalMs)} -> ${w.coordinator}`);
195
+ console.log(` harness: ${w.harness} (${w.session})${w.model ? `, model: ${w.model}` : ''}`);
196
+ console.log(` identity: ${w.identity}`);
197
+ console.log(` watch: ${w.watch.join(', ')}`);
198
+ if (w.promptFile)
199
+ console.log(` focus: ${w.promptFile}`);
200
+ }
201
+ }
178
202
  }
179
203
  catch (e) {
180
204
  die(e);
@@ -201,7 +225,21 @@ cOpt(program.command('down [names...]').description('stop roles'))
201
225
  cOpt(program.command('restart [names...]').description('re-sync config + bounce, RESUMING context'))
202
226
  .action(async (names, opts) => {
203
227
  try {
204
- await restartRoles(loadConfig(opts.configuration), names, deps(), 'keep', opts.configuration);
228
+ const cfg = loadConfig(opts.configuration);
229
+ // Watchdog names can't collide with role names (config validation
230
+ // guarantees dispatch is unambiguous), so a name matching a configured
231
+ // watchdog is always a release, never a role restart. Release each
232
+ // named watchdog directly instead of handing it to restartRoles, which
233
+ // only knows about roles and would reject it as unknown (3.2 release path).
234
+ const { watchdogNames, roleNames } = partitionRestartNames(cfg, names);
235
+ for (const wn of watchdogNames) {
236
+ resetSchedulerState(wn);
237
+ console.log(`released watchdog '${wn}' — scheduler resumes on its next poll`);
238
+ }
239
+ // Bare `restart` (no names) restarts every role — that meaning must
240
+ // survive even though filtering an empty array also yields [].
241
+ if (names.length === 0 || roleNames.length > 0)
242
+ await restartRoles(cfg, roleNames, deps(), 'keep', opts.configuration);
205
243
  }
206
244
  catch (e) {
207
245
  die(e);
@@ -394,6 +432,144 @@ program.command('status <name>').description('unit/agent state')
394
432
  }
395
433
  }
396
434
  });
435
+ /**
436
+ * A watchdog is addressable if it's still configured, or its store dir survives
437
+ * config removal. The name-shape check runs BEFORE any filesystem lookup
438
+ * (finding #1): a hostile `name` (path separators, '..', etc.) must never
439
+ * reach `join(watchdogsRoot(), name)` — treated as simply unknown, same as
440
+ * store.ts's own `watchdogDir` choke-point guard (defense in depth).
441
+ */
442
+ function watchdogKnown(name, configPath) {
443
+ try {
444
+ if (loadConfig(configPath).watchdogs.some(w => w.name === name))
445
+ return true;
446
+ }
447
+ catch { /* config missing/broken: fall through to the store check */ }
448
+ return ROLE_NAME_RE.test(name) && existsSync(joinPath(watchdogsRoot(), name));
449
+ }
450
+ function renderHeldDownLine(state) {
451
+ if (!state.heldDown)
452
+ return undefined;
453
+ return `HELD DOWN since ${state.heldSince ?? 'unknown'} after ${state.consecutiveFailures} `
454
+ + `consecutive failures: ${state.lastError ?? 'unknown error'}`;
455
+ }
456
+ /** Full human rendering of one report: header, held-down warning, counts, then non-healthy roles + evidence. */
457
+ function renderReport(report, heldState) {
458
+ const lines = [`● ${report.watchdog} — run ${report.run_id} (${report.status})`];
459
+ const held = renderHeldDownLine(heldState);
460
+ if (held)
461
+ lines.push(held);
462
+ const s = report.summary;
463
+ lines.push(` checked=${s.checked} healthy=${s.healthy} idle=${s.idle} anomalies=${s.anomalies}`);
464
+ if (report.error)
465
+ lines.push(` error: ${report.error}`);
466
+ for (const role of report.roles) {
467
+ if (role.status === 'healthy')
468
+ continue;
469
+ lines.push(` ${role.role} ${role.status} ${role.reason ?? ''}`.trimEnd());
470
+ for (const ev of role.evidence ?? [])
471
+ lines.push(` - [${ev.source}] ${ev.detail} (${ev.observed_at})`);
472
+ if (role.alerted) {
473
+ const alert = report.alerts.find(a => a.role === role.role);
474
+ lines.push(` alerted -> ${alert?.coordinator ?? '?'}`);
475
+ }
476
+ }
477
+ const tail = report.tail;
478
+ if (report.status === 'error' && tail) {
479
+ lines.push(' --- output tail ---');
480
+ for (const l of tail.split('\n'))
481
+ lines.push(` ${l}`);
482
+ }
483
+ return lines.join('\n');
484
+ }
485
+ function renderRunDuration(startedAt, finishedAt) {
486
+ const started = Date.parse(startedAt);
487
+ const finished = Date.parse(finishedAt);
488
+ return Number.isFinite(started) && Number.isFinite(finished)
489
+ ? formatDuration(Math.max(0, finished - started)) : '-';
490
+ }
491
+ /** `--list` table: run-id, started, duration, status, checked/healthy/idle/anomalies, [error]. */
492
+ function renderRunList(entries) {
493
+ const header = ['run-id'.padEnd(18), 'started'.padEnd(22), 'duration'.padEnd(8),
494
+ 'status'.padEnd(10), 'checked/healthy/idle/anomalies', 'error'].join(' ');
495
+ const rows = entries.map(e => {
496
+ const s = e.summary;
497
+ const cols = [
498
+ e.runId.padEnd(18), (e.startedAt || '-').padEnd(22),
499
+ renderRunDuration(e.startedAt, e.finishedAt).padEnd(8), e.status.padEnd(10),
500
+ `${s.checked}/${s.healthy}/${s.idle}/${s.anomalies}`.padEnd(31),
501
+ ];
502
+ if (e.error)
503
+ cols.push(e.error);
504
+ return cols.join(' ').trimEnd();
505
+ });
506
+ return [header, ...rows].join('\n');
507
+ }
508
+ cOpt(program.command('watchdog-run <name>')
509
+ .description('run one watchdog check now, foreground, same storage as the scheduler'))
510
+ .action(async (name, opts) => {
511
+ try {
512
+ const cfg = loadConfig(opts.configuration);
513
+ const wd = cfg.watchdogs.find(w => w.name === name);
514
+ if (!wd)
515
+ throw new Error(`unknown watchdog '${name}'`);
516
+ if (!acquireRunLock(name))
517
+ throw new Error(`watchdog '${name}' is already running (run lock held)`);
518
+ try {
519
+ const { report, storedPath } = await executeWatchdogRun(wd, {
520
+ binPath, log: l => console.log(l), cfg,
521
+ });
522
+ console.log(`stored: ${storedPath}`);
523
+ console.log(renderReport(report, readSchedulerState(name)));
524
+ }
525
+ finally {
526
+ releaseRunLock(name);
527
+ }
528
+ }
529
+ catch (e) {
530
+ die(e);
531
+ }
532
+ });
533
+ cOpt(program.command('watchdog-report <name> [runId]')
534
+ .description('show a watchdog run report: latest by default, a specific run by id, --list, or --json'))
535
+ .option('--list', 'list runs instead of showing one')
536
+ .option('--json', 'print the stored report file bytes unmodified')
537
+ .action((name, runId, opts) => {
538
+ try {
539
+ if (!watchdogKnown(name, opts.configuration))
540
+ throw new Error(`unknown watchdog '${name}'`);
541
+ if (opts.list) {
542
+ // --list has no single stored file to echo byte-for-byte, so --json here can't
543
+ // mean "raw stored bytes" the way it does for a single report. Contract: emit
544
+ // machine-readable run metadata instead (JSON.stringify of listRuns()'s
545
+ // RunListEntry[], wrapped in { runs }) — the single-report --json path below is
546
+ // unaffected and still prints the exact stored bytes.
547
+ if (opts.json) {
548
+ console.log(JSON.stringify({ runs: listRuns(name) }, null, 2));
549
+ return;
550
+ }
551
+ const held = renderHeldDownLine(readSchedulerState(name));
552
+ if (held)
553
+ console.log(held);
554
+ console.log(renderRunList(listRuns(name)));
555
+ return;
556
+ }
557
+ const report = runId !== undefined ? readReport(name, runId) : latestReport(name);
558
+ if (!report) {
559
+ throw new Error(runId !== undefined
560
+ ? `watchdog '${name}': no such run '${runId}'`
561
+ : `watchdog '${name}' has no reports`);
562
+ }
563
+ if (opts.json) {
564
+ console.log(readFileSync(joinPath(reportsDir(name), `${runId ?? report.run_id}.json`), 'utf8'));
565
+ return;
566
+ }
567
+ console.log(renderReport(report, readSchedulerState(name)));
568
+ }
569
+ catch (e) {
570
+ die(e);
571
+ }
572
+ });
397
573
  cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fleet.d file if spawned)'))
398
574
  .action(async (name, opts) => {
399
575
  try {
@@ -508,6 +684,143 @@ program.command('init').description('one-time host setup (units, dirs, linger)')
508
684
  console.log(m);
509
685
  console.log('\nNext: copy examples/fleet.yaml to ~/fleet.yaml, edit, then: ours-fleet up');
510
686
  });
687
+ const webCommand = cOpt(program.command('web').description('start or open the secure localhost fleet web console'))
688
+ .enablePositionalOptions()
689
+ .option('--port <port>', 'loopback service port (default: 49271)', value => {
690
+ const port = Number(value);
691
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
692
+ throw new Error('invalid port');
693
+ return port;
694
+ })
695
+ .option('--no-open', 'do not open a browser automatically')
696
+ .action(async (opts) => {
697
+ try {
698
+ const manager = new WebServiceManager();
699
+ for (const line of await manager.install(binPath, opts.port ?? 49_271, opts.configuration))
700
+ process.stdout.write(line + '\n');
701
+ await manager.start();
702
+ if (opts.open !== false) {
703
+ await requestWebControlWhenReady('open');
704
+ process.stdout.write('Trusted-browser pairing opened locally.\n');
705
+ }
706
+ else
707
+ process.stdout.write('Web service started; run `ours-fleet web open` to pair a browser.\n');
708
+ }
709
+ catch (e) {
710
+ die(e);
711
+ }
712
+ });
713
+ const webPort = (command) => cOpt(command)
714
+ .option('--port <port>', 'loopback service port (default: 49271)', value => {
715
+ const port = Number(value);
716
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
717
+ throw new Error('invalid port');
718
+ return port;
719
+ });
720
+ const webServe = cOpt(webCommand.command('serve').description('run the web console in the foreground'))
721
+ .option('--port <port>', 'loopback port (default: 49271; 0 chooses a free port)', value => {
722
+ const port = Number(value);
723
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
724
+ throw new Error('invalid port');
725
+ return port;
726
+ });
727
+ webServe
728
+ .option('--no-open', 'do not open a browser automatically')
729
+ .action(async (opts) => {
730
+ try {
731
+ const consoleServer = await startWebConsole({
732
+ configPath: opts.configuration, port: opts.port,
733
+ open: opts.open !== false, binPath,
734
+ log: line => process.stderr.write(line + '\n'),
735
+ });
736
+ process.stdout.write(`ours-fleet web listening on ${consoleServer.address}\n`);
737
+ process.stdout.write(opts.open !== false
738
+ ? 'Trusted-browser pairing opened locally.\n'
739
+ : 'Run `ours-fleet web open` on this computer to pair a browser.\n');
740
+ const shutdown = async () => { await consoleServer.close(); process.exit(0); };
741
+ process.once('SIGINT', () => { void shutdown(); });
742
+ process.once('SIGTERM', () => { void shutdown(); });
743
+ }
744
+ catch (e) {
745
+ die(e);
746
+ }
747
+ });
748
+ webPort(webCommand.command('install').description('install or update the owner web service'))
749
+ .action(async (opts) => {
750
+ try {
751
+ for (const line of await new WebServiceManager().install(binPath, opts.port ?? 49_271, opts.configuration))
752
+ process.stdout.write(line + '\n');
753
+ }
754
+ catch (e) {
755
+ die(e);
756
+ }
757
+ });
758
+ for (const [name, description] of [
759
+ ['start', 'start the installed owner web service'],
760
+ ['stop', 'stop the owner web service'],
761
+ ['restart', 'restart the owner web service'],
762
+ ])
763
+ webCommand.command(name).description(description).action(async () => {
764
+ try {
765
+ await new WebServiceManager()[name]();
766
+ process.stdout.write(`Web service ${{ start: 'started', stop: 'stopped', restart: 'restarted' }[name]}.\n`);
767
+ }
768
+ catch (e) {
769
+ die(e);
770
+ }
771
+ });
772
+ webCommand.command('status').description('show native web service status')
773
+ .action(async () => {
774
+ try {
775
+ process.stdout.write(await new WebServiceManager().status() + '\n');
776
+ }
777
+ catch (e) {
778
+ die(e);
779
+ }
780
+ });
781
+ webCommand.command('uninstall').description('stop and uninstall the owner web service')
782
+ .action(async () => {
783
+ try {
784
+ process.stdout.write(await new WebServiceManager().uninstall() + '\n');
785
+ }
786
+ catch (e) {
787
+ die(e);
788
+ }
789
+ });
790
+ webCommand.command('open').description('securely open or re-pair a browser with the running console')
791
+ .action(async () => {
792
+ try {
793
+ await requestWebControl('open');
794
+ process.stdout.write('Trusted-browser pairing opened locally.\n');
795
+ }
796
+ catch (e) {
797
+ die(e);
798
+ }
799
+ });
800
+ webCommand.command('revoke-all').description('revoke all trusted browsers and active web sessions')
801
+ .action(async () => {
802
+ try {
803
+ await requestWebControl('revoke-all');
804
+ process.stdout.write('Revoked all trusted browsers and active web sessions.\n');
805
+ }
806
+ catch (e) {
807
+ die(e);
808
+ }
809
+ });
810
+ async function requestWebControlWhenReady(command) {
811
+ let last;
812
+ for (let attempt = 0; attempt < 40; attempt++) {
813
+ try {
814
+ await requestWebControl(command, undefined, 500);
815
+ return;
816
+ }
817
+ catch (error) {
818
+ last = error;
819
+ await new Promise(resolve => setTimeout(resolve, 125));
820
+ }
821
+ }
822
+ throw last;
823
+ }
511
824
  program.command('_run <name>', { hidden: true }).description('internal: supervisor entrypoint')
512
825
  .option('-c, --configuration <file>')
513
826
  .action(async (name, opts) => {
@@ -529,4 +842,29 @@ program.command('_run-temp <name>', { hidden: true }).description('internal: tem
529
842
  die(e);
530
843
  }
531
844
  });
845
+ program.command('_run-watchdog <name>', { hidden: true })
846
+ .description('internal: one watchdog agent run (no cleanup — parent harvests)')
847
+ .action(async (name) => {
848
+ try {
849
+ await runWatchdogAgent(name);
850
+ }
851
+ catch (e) {
852
+ die(e);
853
+ }
854
+ });
855
+ cOpt(program.command('_run-watchdogs', { hidden: true }))
856
+ .description('internal: the watchdog scheduler process')
857
+ .action(async (opts) => {
858
+ try {
859
+ let stop = false;
860
+ process.on('SIGTERM', () => { stop = true; });
861
+ await runScheduler(opts.configuration, {
862
+ now: () => new Date(), sleep: ms => new Promise(r => setTimeout(r, ms)),
863
+ log: l => console.log(l), binPath, shouldStop: () => stop,
864
+ });
865
+ }
866
+ catch (e) {
867
+ die(e);
868
+ }
869
+ });
532
870
  program.parseAsync(process.argv);
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ConfigDiagnostic, type YamlMode } from './config-yaml.js';
2
2
  import type { IsolationConfig, WrapContext } from './isolation/types.js';
3
+ import type { ResolvedWatchdog } from './watchdog/config.js';
3
4
  export interface OverseeEntry {
4
5
  role: string;
5
6
  interval: string;
@@ -73,7 +74,8 @@ export interface RoleConfig {
73
74
  persona?: string;
74
75
  bio?: string;
75
76
  briefing_file?: string;
76
- model?: string;
77
+ /** Explicit null means use the selected harness's own default, bypassing fleet defaults. */
78
+ model?: string | null;
77
79
  model_chain?: string[];
78
80
  max_tokens?: number;
79
81
  autocompact_pct?: number;
@@ -85,7 +87,7 @@ export interface RoleConfig {
85
87
  worklog?: WorklogPolicy;
86
88
  auth_proxy?: Partial<AuthProxyConfig>;
87
89
  }
88
- export interface ResolvedRole extends RoleConfig {
90
+ export interface ResolvedRole extends Omit<RoleConfig, 'model'> {
89
91
  name: string;
90
92
  harness: string;
91
93
  session: SessionBackendId;
@@ -98,6 +100,7 @@ export interface ResolvedRole extends RoleConfig {
98
100
  */
99
101
  permissionsDeclared: boolean;
100
102
  identity: string;
103
+ model?: string;
101
104
  sourceFile: string;
102
105
  monitor: MonitorConfig;
103
106
  worklog?: WorklogPolicy;
@@ -112,9 +115,12 @@ export interface FleetConfig {
112
115
  startStaggerMs: number;
113
116
  /** Warning-first non-plain YAML migration diagnostics, in source order. */
114
117
  diagnostics: ConfigDiagnostic[];
118
+ watchdogs: ResolvedWatchdog[];
115
119
  }
116
120
  export declare class ConfigError extends Error {
117
121
  }
122
+ /** Resolve a model without leaking a default that belongs to another harness. */
123
+ export declare function resolveRoleModel(model: string | null | undefined, harness: string | undefined, defaults: Record<string, unknown>): string | undefined;
118
124
  /**
119
125
  * The runtime facts the isolation resolver needs for a role. Single-sourced so
120
126
  * config validation, doctor, and the runner all judge the SAME mount set — a
@@ -122,6 +128,7 @@ export declare class ConfigError extends Error {
122
128
  * a check at all.
123
129
  */
124
130
  export declare function isolationContextFor(role: ResolvedRole): WrapContext;
131
+ export declare const ROLE_NAME_RE: RegExp;
125
132
  /** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
126
133
  export declare function loadConfig(configPath?: string, options?: {
127
134
  yamlMode?: YamlMode;
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import { agentDir, defaultConfigPath, fleetDDir, home } from './paths.js';
4
4
  import { parseFleetDocument, } from './config-yaml.js';
5
5
  import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
6
6
  import { getAdapter } from './harness/registry.js';
7
+ import { resolveWatchdogs } from './watchdog/config.js';
7
8
  /** The 8 content-free event types the ours daemon appends to notifications.log. */
8
9
  export const NOTIFY_EVENT_TYPES = [
9
10
  'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
@@ -60,6 +61,16 @@ export function validateMonitorConfig(raw) {
60
61
  }
61
62
  export class ConfigError extends Error {
62
63
  }
64
+ /** Resolve a model without leaking a default that belongs to another harness. */
65
+ export function resolveRoleModel(model, harness, defaults) {
66
+ if (model === null)
67
+ return undefined;
68
+ if (typeof model === 'string' && model.trim())
69
+ return model.trim();
70
+ const defaultHarness = defaults.harness ?? 'claude-code';
71
+ const effectiveHarness = harness ?? defaultHarness;
72
+ return effectiveHarness === defaultHarness ? defaults.model : undefined;
73
+ }
63
74
  /**
64
75
  * The runtime facts the isolation resolver needs for a role. Single-sourced so
65
76
  * config validation, doctor, and the runner all judge the SAME mount set — a
@@ -91,7 +102,7 @@ export function isolationContextFor(role) {
91
102
  harnessSharedPaths: split?.shared,
92
103
  };
93
104
  }
94
- const NAME_RE = /^[A-Za-z0-9_-]+$/;
105
+ export const ROLE_NAME_RE = /^[A-Za-z0-9_-]+$/;
95
106
  const ROLE_KEYS = [
96
107
  'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
97
108
  'briefing_file', 'model', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
@@ -143,7 +154,7 @@ export function loadConfig(configPath, options = {}) {
143
154
  const roles = [];
144
155
  for (const { file, doc } of docs) {
145
156
  for (const [name, raw] of Object.entries((doc.roles ?? {}))) {
146
- if (!NAME_RE.test(name))
157
+ if (!ROLE_NAME_RE.test(name))
147
158
  throw new ConfigError(`${file}: invalid role name '${name}' (allowed: [A-Za-z0-9_-])`);
148
159
  const prev = seen.get(name);
149
160
  if (prev)
@@ -177,9 +188,13 @@ export function loadConfig(configPath, options = {}) {
177
188
  const monitor = resolveMonitorConfig(defaults.monitor, r.monitor, { base, file, name });
178
189
  const worklog = resolveWorklogPolicy(defaults.worklog, r.worklog, file, name);
179
190
  const authProxy = resolveAuthProxy(defaults.auth_proxy, r.auth_proxy, file, name);
180
- const model = r.model ?? defaults.model;
181
- const modelChain = resolveModelChain(model, r.model_chain ?? defaults.model_chain, file, name);
182
191
  const harness = r.harness ?? defaults.harness ?? 'claude-code';
192
+ const defaultHarness = defaults.harness ?? 'claude-code';
193
+ const inheritsModelDefaults = harness === defaultHarness && r.model !== null;
194
+ const model = resolveRoleModel(r.model, r.harness, defaults);
195
+ const modelChain = resolveModelChain(model, r.model_chain ?? (inheritsModelDefaults
196
+ ? defaults.model_chain
197
+ : undefined), file, name);
183
198
  if (authProxy && harness !== 'claude-code')
184
199
  throw new ConfigError(`${file}: role '${name}' auth_proxy is supported only by claude-code`);
185
200
  const env = {
@@ -220,7 +235,8 @@ export function loadConfig(configPath, options = {}) {
220
235
  }
221
236
  }
222
237
  }
223
- return { roles, vars, defaults, files, startStaggerMs, diagnostics };
238
+ const watchdogs = resolveWatchdogs(baseDoc, base, roles, vars, defaults);
239
+ return { roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs };
224
240
  }
225
241
  export function resolveModelChain(model, chain, file = 'config', name = 'role') {
226
242
  if (chain === undefined)
@@ -24,7 +24,10 @@ export interface CreationDeps {
24
24
  identityRegistry?: IdentityRegistry;
25
25
  /** Verify/create the ours identity. Injectable so tests need no daemon. */
26
26
  identityProvisioner?: IdentityProvisioner;
27
+ /** Descriptive progress around the existing transaction; never a second workflow. */
28
+ onStage?(stage: CreationCoreStage, evidence?: Record<string, string | boolean>): void;
27
29
  }
30
+ export type CreationCoreStage = 'reserving' | 'checking_identity' | 'writing_role' | 'registering_supervisor' | 'starting_temp';
28
31
  /**
29
32
  * The contract the ours daemon must satisfy for identity names to be reserved
30
33
  * atomically across ALL of its clients, not just across fleet processes.
@@ -101,12 +104,15 @@ export interface IdentityProvisioner {
101
104
  }
102
105
  export type IdentityGuarantee = {
103
106
  state: 'verified';
107
+ evidence: 'verified';
104
108
  detail: string;
105
109
  } | {
106
110
  state: 'created';
111
+ evidence: 'missing';
107
112
  detail: string;
108
113
  } | {
109
114
  state: 'unverified';
115
+ evidence: 'missing' | 'unknown';
110
116
  detail: string;
111
117
  };
112
118
  /**
@@ -148,6 +154,9 @@ export interface CreationProvenance {
148
154
  createdAt: string;
149
155
  lifetime: 'permanent' | 'temporary';
150
156
  role: string;
157
+ /** Additive correlation for non-CLI creation surfaces; never contains request data. */
158
+ surface?: 'cli' | 'web';
159
+ creationActionId?: string;
151
160
  /** Effective settings, each tagged with where its value came from. */
152
161
  settings: Record<string, ProvenanceEntry>;
153
162
  }
@@ -170,6 +179,8 @@ export declare function buildProvenance(o: {
170
179
  fleetVersion: string;
171
180
  now?: Date;
172
181
  settings: Record<string, ProvenanceEntry>;
182
+ surface?: 'cli' | 'web';
183
+ creationActionId?: string;
173
184
  }): CreationProvenance;
174
185
  /** Write the provenance record atomically, before the role is started. */
175
186
  export declare function writeProvenance(stateDir: string, p: CreationProvenance): void;
package/dist/creation.js CHANGED
@@ -150,7 +150,10 @@ function readdirSyncSafe(dir) {
150
150
  */
151
151
  export async function ensureIdentity(name, profile, provisioner, log = () => { }) {
152
152
  if (!provisioner)
153
- return { state: 'unverified', detail: 'no identity provisioner is configured' };
153
+ return {
154
+ state: 'unverified', evidence: 'unknown',
155
+ detail: 'no identity provisioner is configured',
156
+ };
154
157
  let present;
155
158
  try {
156
159
  present = await provisioner.exists(name);
@@ -160,18 +163,30 @@ export async function ensureIdentity(name, profile, provisioner, log = () => { }
160
163
  log(`identity '${name}': could not be verified (${e.message})`);
161
164
  }
162
165
  if (present === true)
163
- return { state: 'verified', detail: 'the ours daemon reports it exists' };
166
+ return {
167
+ state: 'verified', evidence: 'verified',
168
+ detail: 'the ours daemon reports it exists',
169
+ };
164
170
  if (present === 'unknown')
165
- return { state: 'unverified', detail: 'the ours daemon could not be asked' };
171
+ return {
172
+ state: 'unverified', evidence: 'unknown',
173
+ detail: 'the ours daemon could not be asked',
174
+ };
166
175
  if (!provisioner.create) {
167
176
  // Loud, and named. The briefing will tell the agent to mint it — which is
168
177
  // what actually happens today — but nobody is told it was "predefined".
169
178
  log(`identity '${name}' does not exist and this host cannot create one automatically — `
170
179
  + `the role will be told to mint it on first boot. Create it in advance to avoid that.`);
171
- return { state: 'unverified', detail: 'it does not exist and cannot be created here' };
180
+ return {
181
+ state: 'unverified', evidence: 'missing',
182
+ detail: 'it does not exist and cannot be created here',
183
+ };
172
184
  }
173
185
  await provisioner.create(name, profile);
174
- return { state: 'created', detail: 'created during spawn, with its bio and persona published' };
186
+ return {
187
+ state: 'created', evidence: 'missing',
188
+ detail: 'created during spawn, with its bio and persona published',
189
+ };
175
190
  }
176
191
  /**
177
192
  * Ask the running ours daemon whether an identity exists, over the same
@@ -230,6 +245,8 @@ export function buildProvenance(o) {
230
245
  createdAt: (o.now ?? new Date()).toISOString(),
231
246
  lifetime: o.lifetime,
232
247
  role: o.role,
248
+ surface: o.surface ?? 'cli',
249
+ creationActionId: o.creationActionId,
233
250
  settings: o.settings,
234
251
  };
235
252
  }