@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.
- package/README.md +66 -0
- package/dist/application/capabilities.d.ts +6 -0
- package/dist/application/capabilities.js +37 -0
- package/dist/application/errors.d.ts +31 -0
- package/dist/application/errors.js +51 -0
- package/dist/application/fleet-query-service.d.ts +42 -0
- package/dist/application/fleet-query-service.js +188 -0
- package/dist/application/log-service.d.ts +28 -0
- package/dist/application/log-service.js +146 -0
- package/dist/application/role-command-service.d.ts +37 -0
- package/dist/application/role-command-service.js +82 -0
- package/dist/application/role-creation-service.d.ts +142 -0
- package/dist/application/role-creation-service.js +374 -0
- package/dist/application/role-repository.d.ts +20 -0
- package/dist/application/role-repository.js +168 -0
- package/dist/application/session-control.d.ts +55 -0
- package/dist/application/session-control.js +115 -0
- package/dist/application/types.d.ts +156 -0
- package/dist/application/types.js +1 -0
- package/dist/cli.js +341 -3
- package/dist/config.d.ts +9 -2
- package/dist/config.js +21 -5
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +56 -0
- package/dist/duration.d.ts +5 -0
- package/dist/duration.js +20 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/ops.d.ts +16 -0
- package/dist/ops.js +112 -3
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/resolved-plan.js +7 -0
- package/dist/runner.js +10 -2
- package/dist/session/control.d.ts +4 -2
- package/dist/session/control.js +45 -13
- package/dist/spawn.d.ts +20 -2
- package/dist/spawn.js +94 -24
- package/dist/supervisor/launchd.js +17 -0
- package/dist/supervisor/none.js +17 -0
- package/dist/supervisor/systemd.js +4 -0
- package/dist/supervisor/types.d.ts +6 -0
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +8 -0
- package/dist/watchdog/alerts.d.ts +34 -0
- package/dist/watchdog/alerts.js +78 -0
- package/dist/watchdog/briefing.d.ts +65 -0
- package/dist/watchdog/briefing.js +181 -0
- package/dist/watchdog/config.d.ts +49 -0
- package/dist/watchdog/config.js +114 -0
- package/dist/watchdog/query.d.ts +78 -0
- package/dist/watchdog/query.js +124 -0
- package/dist/watchdog/report.d.ts +53 -0
- package/dist/watchdog/report.js +126 -0
- package/dist/watchdog/run.d.ts +61 -0
- package/dist/watchdog/run.js +318 -0
- package/dist/watchdog/scheduler.d.ts +105 -0
- package/dist/watchdog/scheduler.js +244 -0
- package/dist/watchdog/service.d.ts +46 -0
- package/dist/watchdog/service.js +179 -0
- package/dist/watchdog/store.d.ts +85 -0
- package/dist/watchdog/store.js +226 -0
- package/dist/web/audit.d.ts +22 -0
- package/dist/web/audit.js +54 -0
- package/dist/web/auth.d.ts +61 -0
- package/dist/web/auth.js +186 -0
- package/dist/web/control.d.ts +14 -0
- package/dist/web/control.js +110 -0
- package/dist/web/device-store.d.ts +27 -0
- package/dist/web/device-store.js +155 -0
- package/dist/web/events.d.ts +15 -0
- package/dist/web/events.js +34 -0
- package/dist/web/lock.d.ts +5 -0
- package/dist/web/lock.js +69 -0
- package/dist/web/runtime.d.ts +12 -0
- package/dist/web/runtime.js +214 -0
- package/dist/web/server.d.ts +37 -0
- package/dist/web/server.js +279 -0
- package/dist/web/service.d.ts +42 -0
- package/dist/web/service.js +180 -0
- package/dist/web/terminal/bridge.d.ts +27 -0
- package/dist/web/terminal/bridge.js +317 -0
- package/dist/web-app/assets/TerminalView-BvcIkuIF.js +9 -0
- package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
- package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
- package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
- package/dist/web-app/icons/ours-fleet.svg +4 -0
- package/dist/web-app/index.html +17 -0
- package/dist/web-app/manifest.webmanifest +15 -0
- package/dist/web-app/offline.html +18 -0
- package/dist/web-app/sw.js +51 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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)
|
package/dist/creation.d.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
166
|
+
return {
|
|
167
|
+
state: 'verified', evidence: 'verified',
|
|
168
|
+
detail: 'the ours daemon reports it exists',
|
|
169
|
+
};
|
|
164
170
|
if (present === 'unknown')
|
|
165
|
-
return {
|
|
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 {
|
|
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 {
|
|
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
|
}
|