@ours.network/fleet 0.10.4 → 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/dist/application/fleet-query-service.d.ts +11 -0
- package/dist/application/fleet-query-service.js +9 -1
- package/dist/cli.js +200 -3
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +22 -0
- package/dist/duration.d.ts +5 -0
- package/dist/duration.js +20 -0
- 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/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/runtime.js +46 -2
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +18 -0
- package/dist/web-app/assets/{TerminalView-DcImdrI1.js → TerminalView-BvcIkuIF.js} +1 -1
- 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/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-app/assets/index-BokQN1Ao.js +0 -9
- package/dist/web-app/assets/index-lAXzaOZM.css +0 -1
|
@@ -10,6 +10,17 @@ export interface FleetQueryOptions {
|
|
|
10
10
|
tmux?: Tmux;
|
|
11
11
|
control?: typeof controlRequest;
|
|
12
12
|
capabilityContext?: CapabilityContext;
|
|
13
|
+
/**
|
|
14
|
+
* Watchdog anomalies feeding into Needs Attention (Task 19): roleId -> worst
|
|
15
|
+
* current finding across all watchdogs. Optional and absent by default so
|
|
16
|
+
* every pre-existing caller (and its tests) is unaffected; runtime.ts wires
|
|
17
|
+
* the real provider, built from watchdog reports on disk.
|
|
18
|
+
*/
|
|
19
|
+
watchdogFindings?: () => Map<string, {
|
|
20
|
+
watchdog: string;
|
|
21
|
+
status: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
}>;
|
|
13
24
|
}
|
|
14
25
|
export declare class FleetQueryService {
|
|
15
26
|
private readonly options;
|
|
@@ -45,7 +45,8 @@ function readIsolation(dir) {
|
|
|
45
45
|
}
|
|
46
46
|
function sessionOverall(supervisor, session, restart, monitor, isolation, problems) {
|
|
47
47
|
if (restart.circuit === 'open' || monitor.health === 'failed' || monitor.health === 'degraded'
|
|
48
|
-
|| isolation.degraded
|
|
48
|
+
|| isolation.degraded
|
|
49
|
+
|| problems.some(problem => problem.severity === 'error' || problem.source === 'watchdog')
|
|
49
50
|
|| session.readiness === 'failed')
|
|
50
51
|
return 'attention';
|
|
51
52
|
// A reachable ACP/tmux session is the user's live interaction surface. Its
|
|
@@ -111,6 +112,13 @@ export class FleetQueryService {
|
|
|
111
112
|
code: 'session_supervisor_disagreement', severity: 'warning',
|
|
112
113
|
detail: 'session is live while its permanent supervisor service is inactive',
|
|
113
114
|
});
|
|
115
|
+
const watchdogFinding = this.options.watchdogFindings?.().get(role.id);
|
|
116
|
+
if (watchdogFinding)
|
|
117
|
+
problems.push({
|
|
118
|
+
code: 'watchdog_finding', severity: 'warning',
|
|
119
|
+
detail: `${watchdogFinding.watchdog}: ${watchdogFinding.status} — ${watchdogFinding.reason}`,
|
|
120
|
+
source: 'watchdog',
|
|
121
|
+
});
|
|
114
122
|
const supervisor = {
|
|
115
123
|
backend: this.options.supervisor.id, liveness: live.state,
|
|
116
124
|
nativeState: live.detail.split(/\s/)[0], detail: clean(live.detail),
|
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';
|
|
@@ -40,6 +46,7 @@ const deps = () => ({
|
|
|
40
46
|
backend: pickBackend(),
|
|
41
47
|
binPath,
|
|
42
48
|
log: l => console.log(l),
|
|
49
|
+
watchdogService: new WatchdogServiceManager(),
|
|
43
50
|
});
|
|
44
51
|
const die = (e) => { console.error(String(e instanceof Error ? e.message : e)); process.exit(1); };
|
|
45
52
|
/** Exec a child with our stdio (logs/attach). */
|
|
@@ -179,6 +186,19 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
179
186
|
for (const w of perms ? allWarnings(perms) : [])
|
|
180
187
|
console.log(` warning: ${w}`);
|
|
181
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
|
+
}
|
|
182
202
|
}
|
|
183
203
|
catch (e) {
|
|
184
204
|
die(e);
|
|
@@ -205,7 +225,21 @@ cOpt(program.command('down [names...]').description('stop roles'))
|
|
|
205
225
|
cOpt(program.command('restart [names...]').description('re-sync config + bounce, RESUMING context'))
|
|
206
226
|
.action(async (names, opts) => {
|
|
207
227
|
try {
|
|
208
|
-
|
|
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);
|
|
209
243
|
}
|
|
210
244
|
catch (e) {
|
|
211
245
|
die(e);
|
|
@@ -398,6 +432,144 @@ program.command('status <name>').description('unit/agent state')
|
|
|
398
432
|
}
|
|
399
433
|
}
|
|
400
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
|
+
});
|
|
401
573
|
cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fleet.d file if spawned)'))
|
|
402
574
|
.action(async (name, opts) => {
|
|
403
575
|
try {
|
|
@@ -670,4 +842,29 @@ program.command('_run-temp <name>', { hidden: true }).description('internal: tem
|
|
|
670
842
|
die(e);
|
|
671
843
|
}
|
|
672
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
|
+
});
|
|
673
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;
|
|
@@ -114,6 +115,7 @@ export interface FleetConfig {
|
|
|
114
115
|
startStaggerMs: number;
|
|
115
116
|
/** Warning-first non-plain YAML migration diagnostics, in source order. */
|
|
116
117
|
diagnostics: ConfigDiagnostic[];
|
|
118
|
+
watchdogs: ResolvedWatchdog[];
|
|
117
119
|
}
|
|
118
120
|
export declare class ConfigError extends Error {
|
|
119
121
|
}
|
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',
|
|
@@ -234,7 +235,8 @@ export function loadConfig(configPath, options = {}) {
|
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
|
-
|
|
238
|
+
const watchdogs = resolveWatchdogs(baseDoc, base, roles, vars, defaults);
|
|
239
|
+
return { roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs };
|
|
238
240
|
}
|
|
239
241
|
export function resolveModelChain(model, chain, file = 'config', name = 'role') {
|
|
240
242
|
if (chain === undefined)
|
package/dist/docs.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Keep this concise enough to place directly in an agent context. Unlike
|
|
5
5
|
* Commander's per-command help, this describes how the pieces compose.
|
|
6
6
|
*/
|
|
7
|
-
export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
|
|
7
|
+
export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # default: every role in the merged config\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
|
|
8
8
|
/**
|
|
9
9
|
* What every shipped spawn-skill variant must say, and must not say (7.1).
|
|
10
10
|
*
|
package/dist/docs.js
CHANGED
|
@@ -39,6 +39,8 @@ ours-fleet logs -f Name
|
|
|
39
39
|
ours-fleet send Name "prompt"
|
|
40
40
|
ours-fleet send Name --key Enter # tmux only
|
|
41
41
|
ours-fleet rm Name
|
|
42
|
+
ours-fleet watchdog-report <name> [run-id] [--list] [--json]
|
|
43
|
+
ours-fleet watchdog-run <name>
|
|
42
44
|
\`\`\`
|
|
43
45
|
|
|
44
46
|
\`peek\`, \`attach\`, and text \`send\` work with tmux and ACP. ACP attachment
|
|
@@ -159,8 +161,28 @@ roles:
|
|
|
159
161
|
KEY: value
|
|
160
162
|
oversee:
|
|
161
163
|
- { role: Worker, interval: 5m }
|
|
164
|
+
watchdogs:
|
|
165
|
+
nightwatch: # [A-Za-z0-9_-], must not collide with a role name
|
|
166
|
+
coordinator: FleetCoordinator # required — where alerts go
|
|
167
|
+
# everything below is optional
|
|
168
|
+
enabled: true # default true; false = configured but never scheduled
|
|
169
|
+
interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m
|
|
170
|
+
watch: [Alice, CodexReviewer] # default: every role in the merged config
|
|
171
|
+
harness: claude-code # default: defaults.harness
|
|
172
|
+
model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)
|
|
173
|
+
session: acp # default: defaults.session
|
|
174
|
+
identity: Watchdog-nightwatch # default: Watchdog-<name>
|
|
175
|
+
timeout: 5m # default 5m; a run past this is killed and recorded as error
|
|
176
|
+
keep_reports: 50 # default 50 reports retained per watchdog
|
|
177
|
+
alert_cooldown: 60m # default 60m before the same finding alerts again
|
|
178
|
+
prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract
|
|
162
179
|
\`\`\`
|
|
163
180
|
|
|
181
|
+
A watchdog observes and reports; it never restarts, stops, spawns, or removes a
|
|
182
|
+
role, answers a pending permission, edits a workspace, or approves anything on
|
|
183
|
+
the owner's behalf. \`watchdogs:\` may appear only in the base config
|
|
184
|
+
(\`~/fleet.yaml\` or \`-c FILE\`), not in \`~/fleet.d/*.yaml\` drop-ins.
|
|
185
|
+
|
|
164
186
|
Role values override defaults. \`\${name}\` substitutes entries from \`vars\`.
|
|
165
187
|
Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`.
|
|
166
188
|
Use README.md for the complete isolation policy and resource-cap schema.
|
package/dist/duration.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Parse `30s | 10m | 2h` duration strings to milliseconds (spec §2). */
|
|
2
|
+
const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000 };
|
|
3
|
+
const RE = /^(\d+)([smh])$/;
|
|
4
|
+
export function parseDuration(text, opts = {}) {
|
|
5
|
+
const label = opts.name ?? 'duration';
|
|
6
|
+
const m = RE.exec(text);
|
|
7
|
+
if (!m)
|
|
8
|
+
throw new Error(`${label}: invalid duration '${text}' (expected e.g. 30s, 10m, 2h)`);
|
|
9
|
+
const ms = Number(m[1]) * UNIT_MS[m[2]];
|
|
10
|
+
if (opts.minMs !== undefined && ms < opts.minMs)
|
|
11
|
+
throw new Error(`${label}: '${text}' is below the minimum ${formatDuration(opts.minMs)}`);
|
|
12
|
+
return ms;
|
|
13
|
+
}
|
|
14
|
+
export function formatDuration(ms) {
|
|
15
|
+
if (ms % 3_600_000 === 0 && ms >= 3_600_000)
|
|
16
|
+
return `${ms / 3_600_000}h`;
|
|
17
|
+
if (ms % 60_000 === 0 && ms >= 60_000)
|
|
18
|
+
return `${ms / 60_000}m`;
|
|
19
|
+
return `${Math.round(ms / 1_000)}s`;
|
|
20
|
+
}
|
package/dist/ops.d.ts
CHANGED
|
@@ -16,6 +16,22 @@ export interface OpsDeps {
|
|
|
16
16
|
* `ours-fleet up` has no transaction to tell.
|
|
17
17
|
*/
|
|
18
18
|
onInstalled?(outcome: InstallOutcome): void;
|
|
19
|
+
/**
|
|
20
|
+
* Optional: supervises the single watchdog-scheduler process (Task 10).
|
|
21
|
+
* Absent for callers that predate watchdogs — `up`/`down` must never throw
|
|
22
|
+
* just because this hook is missing.
|
|
23
|
+
*/
|
|
24
|
+
watchdogService?: {
|
|
25
|
+
/** `changed` is true when the unit/plist content itself differs from what's on disk (or was absent). */
|
|
26
|
+
install(binPath: string, configPath?: string): Promise<{
|
|
27
|
+
changed: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
start(): Promise<void>;
|
|
30
|
+
stop(): Promise<void>;
|
|
31
|
+
/** Bounces an already-running scheduler so a config change actually reaches it — `start()` is a no-op on an active unit. */
|
|
32
|
+
restart(): Promise<void>;
|
|
33
|
+
supervised(): boolean;
|
|
34
|
+
};
|
|
19
35
|
}
|
|
20
36
|
/** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
|
|
21
37
|
export declare function applyRole(role: ResolvedRole, opts?: {
|
package/dist/ops.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, unlinkSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { agentDir, fleetDDir } from './paths.js';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
import { agentDir, fleetDDir, watchdogsRoot } from './paths.js';
|
|
5
5
|
import { findRole } from './config.js';
|
|
6
6
|
import { getAdapter } from './harness/registry.js';
|
|
7
7
|
import { generateBriefing } from './briefing.js';
|
|
@@ -75,8 +75,103 @@ export async function up(cfg, names, deps, configPath, identityGuarantee) {
|
|
|
75
75
|
outcomes.push(outcome);
|
|
76
76
|
deps.log(`↑ up: ${role.name} (harness: ${role.harness}, identity: ${role.identity}${role.cwd ? `, cwd: ${role.cwd}` : ''})`);
|
|
77
77
|
}
|
|
78
|
+
await reconcileWatchdogScheduler(cfg, deps, configPath);
|
|
78
79
|
return outcomes;
|
|
79
80
|
}
|
|
81
|
+
const WATCHDOG_FINGERPRINT_FILE = '.config-fingerprint';
|
|
82
|
+
/** Deterministic JSON: object keys sorted recursively, so key-insertion order never affects the hash. */
|
|
83
|
+
function stableStringify(value) {
|
|
84
|
+
return JSON.stringify(value, (_key, v) => {
|
|
85
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
86
|
+
return Object.keys(v).sort()
|
|
87
|
+
.reduce((acc, k) => { acc[k] = v[k]; return acc; }, {});
|
|
88
|
+
}
|
|
89
|
+
return v;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** sha256 over the resolved enabled-watchdog set, stable regardless of config source order (finding #4). */
|
|
93
|
+
function watchdogFingerprint(enabled) {
|
|
94
|
+
const sorted = [...enabled].sort((a, b) => a.name.localeCompare(b.name));
|
|
95
|
+
return createHash('sha256').update(stableStringify(sorted)).digest('hex');
|
|
96
|
+
}
|
|
97
|
+
function fingerprintPath() {
|
|
98
|
+
return join(watchdogsRoot(), WATCHDOG_FINGERPRINT_FILE);
|
|
99
|
+
}
|
|
100
|
+
function readStoredFingerprint() {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(fingerprintPath(), 'utf8').trim();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function writeFingerprint(fingerprint) {
|
|
109
|
+
mkdirSync(watchdogsRoot(), { recursive: true, mode: 0o700 });
|
|
110
|
+
writeFileSync(fingerprintPath(), fingerprint + '\n', { mode: 0o600 });
|
|
111
|
+
chmodSync(fingerprintPath(), 0o600); // mkdirSync/writeFileSync's mode is masked by umask; force it
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Install/start (or stop) the single supervised watchdog-scheduler process
|
|
115
|
+
* (Task 10) to match the config's enabled watchdogs. Runs on every `up`,
|
|
116
|
+
* including a named `up <Role>` — cheap and idempotent, and the alternative
|
|
117
|
+
* (only reconciling on a whole-fleet `up`) would leave a newly-enabled
|
|
118
|
+
* watchdog unscheduled until the next bare `up`. Never throws: a scheduler
|
|
119
|
+
* hiccup must not fail the role installs that already succeeded.
|
|
120
|
+
*
|
|
121
|
+
* Restarts ONLY when something the scheduler actually needs to pick up
|
|
122
|
+
* changed (finding #4): an unconditional `restart()` on every `up` —
|
|
123
|
+
* including a named `up <SomeUnrelatedRole>` — interrupted whatever the
|
|
124
|
+
* scheduler was mid-run (a 15s stop timeout can kill a long check without a
|
|
125
|
+
* report). Two independent signals decide: `svc.install()`'s own `changed`
|
|
126
|
+
* (the unit/plist content — binPath/configPath — differs), and a config
|
|
127
|
+
* fingerprint over the resolved enabled-watchdog set (sha256, stored at
|
|
128
|
+
* `<watchdogsRoot>/.config-fingerprint`) — needed because interval/watch/etc.
|
|
129
|
+
* never appear in the unit content itself (the unit just re-execs `-c
|
|
130
|
+
* <configPath>`), so `install()` alone can never see them change. Either
|
|
131
|
+
* signal true -> restart(); neither -> the idempotent start() (a no-op if
|
|
132
|
+
* already active, otherwise brings up a stopped unit).
|
|
133
|
+
*/
|
|
134
|
+
async function reconcileWatchdogScheduler(cfg, deps, configPath) {
|
|
135
|
+
const svc = deps.watchdogService;
|
|
136
|
+
if (!svc)
|
|
137
|
+
return;
|
|
138
|
+
const enabled = cfg.watchdogs.filter(w => w.enabled);
|
|
139
|
+
try {
|
|
140
|
+
// Check supervised() before any stop (final review #3): on an
|
|
141
|
+
// unsupervised platform/config, the scheduler was never installed, so
|
|
142
|
+
// stopping it is not just a no-op — on Linux, stop() throws for a unit
|
|
143
|
+
// that was never loaded. A watchdog-less fleet must not see that
|
|
144
|
+
// failure surface as a spurious "! watchdogs scheduler: ..." warning.
|
|
145
|
+
if (!enabled.length) {
|
|
146
|
+
if (svc.supervised())
|
|
147
|
+
await svc.stop();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!svc.supervised()) {
|
|
151
|
+
deps.log("! watchdogs configured but OURS_FLEET_SUPERVISOR=none — run 'ours-fleet _run-watchdogs' in the foreground");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const { changed: unitChanged } = await svc.install(deps.binPath, configPath);
|
|
155
|
+
const fingerprint = watchdogFingerprint(enabled);
|
|
156
|
+
const fingerprintChanged = readStoredFingerprint() !== fingerprint;
|
|
157
|
+
if (unitChanged || fingerprintChanged) {
|
|
158
|
+
// restart(), not start(): start() is a no-op on an already-active unit,
|
|
159
|
+
// so a config change (new/changed watchdogs) would never reach a
|
|
160
|
+
// scheduler that's already running (final review #4).
|
|
161
|
+
await svc.restart();
|
|
162
|
+
deps.log(`↑ watchdogs scheduler (${enabled.map(w => w.name).join(', ')})`);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Idempotent: a no-op on an already-active unit, but still brings up a
|
|
166
|
+
// stopped one (e.g. the scheduler crashed and systemd gave up retrying).
|
|
167
|
+
await svc.start();
|
|
168
|
+
}
|
|
169
|
+
writeFingerprint(fingerprint);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
deps.log(` ! watchdogs scheduler: ${e instanceof Error ? e.message : String(e)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
80
175
|
export async function down(cfg, names, deps) {
|
|
81
176
|
for (const role of selectRoles(cfg, names)) {
|
|
82
177
|
// Never swallow the backend's reason. "maybe not running" hid real stop
|
|
@@ -89,6 +184,20 @@ export async function down(cfg, names, deps) {
|
|
|
89
184
|
deps.log(` ! could not stop ${role.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
90
185
|
}
|
|
91
186
|
}
|
|
187
|
+
// Only a whole-fleet `down` (no names) stops the scheduler — stopping one
|
|
188
|
+
// named role is not a decision to stop watching the others (tolerate
|
|
189
|
+
// absence/errors: a never-installed scheduler must not fail `down`).
|
|
190
|
+
// supervised() gates the call itself (final review #3): on an
|
|
191
|
+
// unsupervised platform/config there is nothing installed to stop, and
|
|
192
|
+
// Linux's stop() throws for a unit that was never loaded.
|
|
193
|
+
if (names.length === 0 && deps.watchdogService?.supervised()) {
|
|
194
|
+
try {
|
|
195
|
+
await deps.watchdogService.stop();
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
deps.log(` ! could not stop watchdogs scheduler: ${e instanceof Error ? e.message : String(e)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
92
201
|
}
|
|
93
202
|
/** Re-sync from config + bounce. mode 'keep' resumes context; 'fresh' wipes it. */
|
|
94
203
|
export async function restartRoles(cfg, names, deps, mode, configPath) {
|
package/dist/paths.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const stateRoot: () => string;
|
|
|
14
14
|
export declare const agentsRoot: () => string;
|
|
15
15
|
export declare const tmpRoot: () => string;
|
|
16
16
|
export declare const logsRoot: () => string;
|
|
17
|
+
export declare const watchdogsRoot: () => string;
|
|
17
18
|
export declare const agentDir: (name: string, temp?: boolean) => string;
|
|
18
19
|
export declare const defaultConfigPath: () => string;
|
|
19
20
|
export declare const fleetDDir: () => string;
|
package/dist/paths.js
CHANGED
|
@@ -24,6 +24,7 @@ export const stateRoot = () => join(home(), '.ours-fleet');
|
|
|
24
24
|
export const agentsRoot = () => join(stateRoot(), 'agents');
|
|
25
25
|
export const tmpRoot = () => join(stateRoot(), 'tmp');
|
|
26
26
|
export const logsRoot = () => join(stateRoot(), 'logs');
|
|
27
|
+
export const watchdogsRoot = () => join(stateRoot(), 'watchdogs');
|
|
27
28
|
export const agentDir = (name, temp = false) => join(temp ? tmpRoot() : agentsRoot(), name);
|
|
28
29
|
export const defaultConfigPath = () => join(home(), 'fleet.yaml');
|
|
29
30
|
export const fleetDDir = () => join(home(), 'fleet.d');
|