@ours.network/fleet 0.7.1 → 0.8.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 CHANGED
@@ -51,9 +51,11 @@ roles:
51
51
 
52
52
  Each role gets a state dir (`~/.ours-fleet/agents/<Name>/`) holding its briefing,
53
53
  logs, routines, and session markers. On boot the agent reads its briefing: bind
54
- identity, publish bio/persona, arm a mail monitor (`ours-mcp watch`), announce to
55
- its coordinator, work. On crash the supervisor relaunches it and the harness
56
- resumes the same session.
54
+ identity, publish bio/persona, announce to its coordinator, work while the
55
+ supervisor delivers its mail wakes as `[fleet-monitor]` console lines (see
56
+ [Mail monitor](#mail-monitor); `monitor.enabled: false` reverts to the agent
57
+ arming its own `ours-mcp watch`). On crash the supervisor relaunches it and the
58
+ harness resumes the same session.
57
59
 
58
60
  The state dir contract:
59
61
 
@@ -182,12 +184,22 @@ defaults:
182
184
  harness: claude-code # for roles that don't set one
183
185
  model: claude-fable-5 # default model for roles that don't set one (per-role model / --model wins)
184
186
  max_tokens: 500000 # session cap (harness-interpreted)
187
+ monitor: # supervisor-owned mail wake (fleet-wide default)
188
+ enabled: true # default true; a role block overrides key-by-key
185
189
  roles:
186
190
  Name: # [A-Za-z0-9_-]+
187
191
  harness: claude-code
188
192
  identity: "Display Name" # ours identity to bind (default: Name)
189
193
  cwd: ${work_root}/repo # where the harness process runs
190
194
  coordinator: FleetCoordinator # announce target on boot
195
+ monitor: # deterministic wake, owned by the supervisor
196
+ enabled: true # default (defaults.monitor.enabled ?? true);
197
+ # # false = legacy in-session `ours-mcp watch`
198
+ wake_sources: # which daemon events wake the console (default:
199
+ - message_received # message_received, file_received,
200
+ - file_received # local_contact_request, pending_message)
201
+ batch_ms: 2000 # coalesce a burst into one line (default 2000)
202
+ inject: notification # notification (default) | full (bodies inline; roadmap)
191
203
  model: claude-fable-5 # launch on a specific model (pass-through id; default: launcher default)
192
204
  mission: one line
193
205
  persona: | # operating contract (published as persona)
@@ -225,7 +237,23 @@ Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
225
237
  error naming both files. Identities and roles are decoupled — removing a role
226
238
  never deletes an identity. `defaults.harness_options` is shallow-merged with each
227
239
  role's `harness_options`, so a fleet can set common Codex permission/profile defaults
228
- and override individual keys per role.
240
+ and override individual keys per role. `monitor` merges the same way — a role block
241
+ overrides `defaults.monitor` key-by-key.
242
+
243
+ ### Mail monitor
244
+
245
+ With `monitor.enabled` (the default), the **supervisor** delivers a role's mail
246
+ wakes: the per-role runner long-polls the ours daemon's notification API and
247
+ injects a single `[fleet-monitor] N new messages from … — run get_messages` line
248
+ straight into the console. It is a deterministic program whose lifetime is fused to
249
+ the tmux session — it primes the notification cursor *before* the session launches
250
+ (no missed arrivals), cannot be orphaned or left deaf-but-armed, and writes its
251
+ health to `<agentDir>/.monitor-status` (`armed | degraded | failed`), surfaced in
252
+ `ours-fleet status`/`doctor`. The agent's briefing tells it **not** to arm an
253
+ in-session Monitor. Set `monitor.enabled: false` to keep the legacy behavior where
254
+ the agent arms its own `ours-mcp watch`. `inject: full` (pushing message bodies
255
+ inline) is on the roadmap and needs two new ours-mcp daemon endpoints; today all
256
+ roles deliver `notification` lines and drain via `get_messages`.
229
257
 
230
258
  ## Codex roles
231
259
 
package/dist/briefing.js CHANGED
@@ -34,7 +34,12 @@ export function generateBriefing(role, v, opts) {
34
34
  : ' with a 1–2 sentence summary of your Charter above. Skip if it already matches.');
35
35
  L.push(`5. SET your **persona** (local operating contract, never shared in invites) via`);
36
36
  L.push(` **${v.setPersonaTool}** with the **Charter** section above, verbatim. Skip if it matches.`);
37
- L.push(`6. ${v.monitorInstruction(id, role)}`);
37
+ // When the supervisor owns the monitor (monitor.enabled), the agent must NOT arm
38
+ // its own in-session watch — wakes are injected as [fleet-monitor] lines (design §5).
39
+ const wakeNote = role.monitor?.enabled
40
+ ? v.supervisedWakeNote(id, role)
41
+ : v.monitorInstruction(id, role);
42
+ L.push(`6. ${wakeNote}`);
38
43
  if (role.coordinator) {
39
44
  L.push(`7. ANNOUNCE yourself: call **${v.sendTool}** to contact "${role.coordinator}" with text:`);
40
45
  L.push(` "${role.name} online — identity '${id}' bound, ready."`);
@@ -70,7 +75,7 @@ export function generateBriefing(role, v, opts) {
70
75
  L.push('change between wakes without a restart; treat the file, not your memory of it, as current.');
71
76
  L.push('', '## On restart (you run under a supervised launcher)');
72
77
  L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true), then`);
73
- L.push(`${v.monitorInstruction(id, role)} Then continue from your WORKLOG.`);
78
+ L.push(`${wakeNote} Then continue from your WORKLOG.`);
74
79
  L.push('Do not blindly re-run whatever may have crashed you.');
75
80
  L.push('', '## House rules');
76
81
  L.push('- Never broad `rm -rf` on home/critical paths; quote globs; use explicit paths.');
package/dist/config.d.ts CHANGED
@@ -3,6 +3,21 @@ export interface OverseeEntry {
3
3
  role: string;
4
4
  interval: string;
5
5
  }
6
+ /** The 8 content-free event types the ours daemon appends to notifications.log. */
7
+ export declare const NOTIFY_EVENT_TYPES: readonly ["message_received", "file_received", "sibling_contact_added", "local_contact_request", "pending_message", "contact_restored", "inbound_error", "state_import_failed"];
8
+ export type NotifyEventType = (typeof NOTIFY_EVENT_TYPES)[number];
9
+ export type InjectMode = 'notification' | 'full';
10
+ /** Resolved per-role supervisor-monitor config (see DESIGN-external-monitor §2). */
11
+ export interface MonitorConfig {
12
+ enabled: boolean;
13
+ wake_sources: string[];
14
+ batch_ms: number;
15
+ inject: InjectMode;
16
+ }
17
+ /** Default wake sources when a role does not list its own (design §2). */
18
+ export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
19
+ /** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
20
+ export declare function validateMonitorConfig(raw: unknown): string[];
6
21
  export interface RoleConfig {
7
22
  harness?: string;
8
23
  identity?: string;
@@ -19,12 +34,14 @@ export interface RoleConfig {
19
34
  oversee?: OverseeEntry[];
20
35
  harness_options?: Record<string, unknown>;
21
36
  isolation?: IsolationConfig;
37
+ monitor?: Partial<MonitorConfig>;
22
38
  }
23
39
  export interface ResolvedRole extends RoleConfig {
24
40
  name: string;
25
41
  harness: string;
26
42
  identity: string;
27
43
  sourceFile: string;
44
+ monitor: MonitorConfig;
28
45
  }
29
46
  export interface FleetConfig {
30
47
  roles: ResolvedRole[];
@@ -36,4 +53,16 @@ export declare class ConfigError extends Error {
36
53
  }
37
54
  /** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
38
55
  export declare function loadConfig(configPath?: string): FleetConfig;
56
+ /**
57
+ * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
58
+ * result, and fill code-constant defaults (design §2). `defaults.monitor.enabled`
59
+ * is the fleet-wide default; absent everywhere ⇒ enabled. Throws ConfigError on a
60
+ * malformed block so a typo fails loudly rather than silently disarming a monitor.
61
+ * Exported so temp-spawn (which builds a ResolvedRole by hand) resolves identically.
62
+ */
63
+ export declare function resolveMonitorConfig(defMonitor: unknown, roleMonitor?: Partial<MonitorConfig>, labels?: {
64
+ base?: string;
65
+ file?: string;
66
+ name?: string;
67
+ }): MonitorConfig;
39
68
  export declare function findRole(cfg: FleetConfig, name: string): ResolvedRole;
package/dist/config.js CHANGED
@@ -3,13 +3,52 @@ import { join } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { defaultConfigPath, fleetDDir } from './paths.js';
5
5
  import { validateIsolationConfig } from './isolation/policy.js';
6
+ /** The 8 content-free event types the ours daemon appends to notifications.log. */
7
+ export const NOTIFY_EVENT_TYPES = [
8
+ 'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
9
+ 'pending_message', 'contact_restored', 'inbound_error', 'state_import_failed',
10
+ ];
11
+ /** Default wake sources when a role does not list its own (design §2). */
12
+ export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
13
+ const MONITOR_KEYS = ['enabled', 'wake_sources', 'batch_ms', 'inject'];
14
+ const INJECT_MODES = ['notification', 'full'];
15
+ const MONITOR_DEFAULT_BATCH_MS = 2000;
16
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
17
+ /** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
18
+ export function validateMonitorConfig(raw) {
19
+ const problems = [];
20
+ if (!isPlainObject(raw))
21
+ return ['monitor: must be a mapping'];
22
+ const m = raw;
23
+ const bad = Object.keys(m).filter(k => !MONITOR_KEYS.includes(k));
24
+ if (bad.length)
25
+ problems.push(`monitor: unknown key(s) ${bad.join(', ')}; allowed: ${MONITOR_KEYS.join(', ')}`);
26
+ if (m.enabled !== undefined && typeof m.enabled !== 'boolean')
27
+ problems.push('monitor.enabled: must be true or false');
28
+ if (m.batch_ms !== undefined
29
+ && (typeof m.batch_ms !== 'number' || !Number.isFinite(m.batch_ms) || m.batch_ms < 0))
30
+ problems.push('monitor.batch_ms: must be a non-negative number');
31
+ if (m.inject !== undefined && !INJECT_MODES.includes(m.inject))
32
+ problems.push(`monitor.inject: invalid value '${m.inject}'; allowed: ${INJECT_MODES.join(', ')}`);
33
+ if (m.wake_sources !== undefined) {
34
+ if (!Array.isArray(m.wake_sources))
35
+ problems.push('monitor.wake_sources: must be a list');
36
+ else {
37
+ const unknown = m.wake_sources.filter(w => !NOTIFY_EVENT_TYPES.includes(w));
38
+ if (unknown.length)
39
+ problems.push(`monitor.wake_sources: unknown source(s) ${unknown.join(', ')}; ` +
40
+ `allowed: ${NOTIFY_EVENT_TYPES.join(', ')}`);
41
+ }
42
+ }
43
+ return problems;
44
+ }
6
45
  export class ConfigError extends Error {
7
46
  }
8
47
  const NAME_RE = /^[A-Za-z0-9_-]+$/;
9
48
  const ROLE_KEYS = [
10
49
  'harness', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
11
50
  'briefing_file', 'model', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
12
- 'isolation',
51
+ 'isolation', 'monitor',
13
52
  ];
14
53
  function deepSub(v, vars) {
15
54
  if (typeof v === 'string')
@@ -78,6 +117,7 @@ export function loadConfig(configPath) {
78
117
  if (problems.length)
79
118
  throw new ConfigError(`${file}: role '${name}' ${problems.join('; ')}`);
80
119
  }
120
+ const monitor = resolveMonitorConfig(defaults.monitor, r.monitor, { base, file, name });
81
121
  roles.push({
82
122
  ...r,
83
123
  name,
@@ -88,11 +128,39 @@ export function loadConfig(configPath) {
88
128
  max_tokens: r.max_tokens ?? defaults.max_tokens,
89
129
  harness_options: harnessOptions,
90
130
  isolation,
131
+ monitor,
91
132
  });
92
133
  }
93
134
  }
94
135
  return { roles, vars, defaults, files };
95
136
  }
137
+ /**
138
+ * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
139
+ * result, and fill code-constant defaults (design §2). `defaults.monitor.enabled`
140
+ * is the fleet-wide default; absent everywhere ⇒ enabled. Throws ConfigError on a
141
+ * malformed block so a typo fails loudly rather than silently disarming a monitor.
142
+ * Exported so temp-spawn (which builds a ResolvedRole by hand) resolves identically.
143
+ */
144
+ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
145
+ const where = labels.file && labels.name ? `${labels.file}: role '${labels.name}' ` : '';
146
+ if (defMonitor !== undefined && !isPlainObject(defMonitor))
147
+ throw new ConfigError(`${labels.base ?? 'config'}: defaults.monitor must be a map`);
148
+ if (roleMonitor !== undefined && !isPlainObject(roleMonitor))
149
+ throw new ConfigError(`${where}monitor: must be a mapping`);
150
+ const merged = {
151
+ ...(defMonitor ?? {}),
152
+ ...(roleMonitor ?? {}),
153
+ };
154
+ const problems = validateMonitorConfig(merged);
155
+ if (problems.length)
156
+ throw new ConfigError(`${where}${problems.join('; ')}`);
157
+ return {
158
+ enabled: merged.enabled ?? true,
159
+ wake_sources: merged.wake_sources ?? [...DEFAULT_WAKE_SOURCES],
160
+ batch_ms: merged.batch_ms ?? MONITOR_DEFAULT_BATCH_MS,
161
+ inject: merged.inject ?? 'notification',
162
+ };
163
+ }
96
164
  export function findRole(cfg, name) {
97
165
  const r = cfg.roles.find(r => r.name === name);
98
166
  if (!r)
package/dist/doctor.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type Exec } from './exec.js';
2
+ import { type FetchLike } from './monitor.js';
2
3
  import type { PrereqReport } from './harness/types.js';
3
4
  /** Host-level + per-harness prerequisite report with actionable messages. */
4
5
  export declare function doctor(opts?: {
5
6
  harness?: string;
6
7
  configPath?: string;
7
- }, exec?: Exec, platform?: NodeJS.Platform): Promise<PrereqReport>;
8
+ }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike): Promise<PrereqReport>;
package/dist/doctor.js CHANGED
@@ -20,7 +20,7 @@ function cgroupDelegationDetail() {
20
20
  }
21
21
  }
22
22
  /** Host-level + per-harness prerequisite report with actionable messages. */
23
- export async function doctor(opts = {}, exec = realExec, platform = process.platform) {
23
+ export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
24
24
  const checks = [];
25
25
  const major = Number(process.versions.node.split('.')[0]);
26
26
  checks.push({
@@ -107,6 +107,38 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
107
107
  detail = `backend=${policy.backend} (not yet implemented)`;
108
108
  checks.push({ name: `isolation: ${r.name}`, ok, detail });
109
109
  }
110
+ // Monitor daemon-API reachability (design §5): only when a role is supervised.
111
+ // /state-dir is unauthenticated (liveness); /identities exercises the token so a
112
+ // shared-mode misconfig (401) surfaces here rather than as a silent deaf monitor.
113
+ if (roles.some(r => r.monitor?.enabled)) {
114
+ const port = Number(process.env.OURS_PORT) || 3050;
115
+ const token = process.env.OURS_API_TOKEN;
116
+ const headers = token ? { 'x-ours-api-token': token } : {};
117
+ let ok = false, detail;
118
+ try {
119
+ const live = await fetchImpl(`http://127.0.0.1:${port}/state-dir`, {});
120
+ if (!live.ok) {
121
+ detail = `daemon on :${port} answered /state-dir with HTTP ${live.status} — not the ours daemon?`;
122
+ }
123
+ else {
124
+ const auth = await fetchImpl(`http://127.0.0.1:${port}/identities`, { headers });
125
+ if (auth.status === 401)
126
+ detail = `reachable on :${port} but the API token was rejected (401) — set OURS_API_TOKEN to the ` +
127
+ `daemon's token (shared mode) or run the fleet as the daemon owner`;
128
+ else if (!auth.ok)
129
+ detail = `reachable on :${port} but /identities returned HTTP ${auth.status}`;
130
+ else {
131
+ ok = true;
132
+ detail = `reachable on :${port}, authorized — supervisor wake stream available`;
133
+ }
134
+ }
135
+ }
136
+ catch (e) {
137
+ detail = `unreachable on :${port} — monitored roles run degraded until it is up ` +
138
+ `(start it: ours-mcp start) [${e?.message ?? e}]`;
139
+ }
140
+ checks.push({ name: 'monitor: daemon API', ok, detail });
141
+ }
110
142
  const harnesses = opts.harness
111
143
  ? [opts.harness]
112
144
  : [...new Set(roles.map(r => r.harness))];
@@ -109,10 +109,15 @@ export function makeClaudeCodeAdapter(exec = realExec) {
109
109
  getMessagesTool: 'get_messages',
110
110
  watchCommand: id => `ours-mcp watch "${id}"`,
111
111
  monitorInstruction: id => `Arm a **persistent Monitor** running the shell command \`ours-mcp watch "${id}"\` so inbound ours mail wakes you.`,
112
+ supervisedWakeNote: () => 'Your mail wake-ups are delivered by the fleet supervisor directly into this console as ' +
113
+ '`[fleet-monitor]` lines — do NOT arm an in-session Monitor. When such a line appears, run ' +
114
+ '**get_messages** to drain the mail.',
112
115
  launchNote: name => `You were launched with \`--remote-control ${name}\`. Confirm you are running.`,
113
- restartPrompt: (id, worklog) => `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
114
- `re-arm your monitor (ours-mcp watch "${id}"), then continue from ${worklog}. ` +
115
- 'Do not re-run whatever crashed you.',
116
+ restartPrompt: (id, worklog, role) => `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
117
+ (role?.monitor?.enabled
118
+ ? 'then continue from '
119
+ : `re-arm your monitor (ours-mcp watch "${id}"), then continue from `) +
120
+ `${worklog}. Do not re-run whatever crashed you.`,
116
121
  },
117
122
  exitPolicy: { cleanExitIsFresh: true, fastFailSecs: 20 },
118
123
  };
@@ -197,8 +197,15 @@ export function makeCodexAdapter(exec = realExec) {
197
197
  `fallback. After each arrival, call **get_messages**, handle the mail, and re-enter ` +
198
198
  `**foreground_monitor** while the approved monitoring session remains armed.`;
199
199
  },
200
+ supervisedWakeNote: () => 'Your mail wake-ups are delivered by the fleet supervisor directly into this console as ' +
201
+ '`[fleet-monitor]` lines — do NOT arm arm_monitor or foreground_monitor. When such a line ' +
202
+ 'appears, call **get_messages**, handle the mail, and reply with send_message.',
200
203
  launchNote: name => `You were launched as the fleet role \`${name}\` under a Codex session. Confirm you are running.`,
201
204
  restartPrompt: (id, worklog, configuredRole) => {
205
+ if (configuredRole?.monitor?.enabled)
206
+ return `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true); ` +
207
+ 'your mail wakes are delivered by the fleet supervisor as `[fleet-monitor]` console lines, so do ' +
208
+ `NOT arm arm_monitor/foreground_monitor. Continue from ${worklog}. Do not re-run whatever crashed you.`;
202
209
  const consented = configuredRole?.harness_options?.monitor === true;
203
210
  return `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
204
211
  (consented
@@ -37,6 +37,8 @@ export interface BriefingVocab {
37
37
  getMessagesTool: string;
38
38
  watchCommand(identity: string): string;
39
39
  monitorInstruction(identity: string, role?: ResolvedRole): string;
40
+ /** Wake-source wording for a role whose monitor is supervisor-owned (monitor.enabled). */
41
+ supervisedWakeNote(identity: string, role?: ResolvedRole): string;
40
42
  launchNote(name: string): string;
41
43
  restartPrompt(identity: string, worklogPath: string, role?: ResolvedRole): string;
42
44
  }
@@ -0,0 +1,103 @@
1
+ import type { MonitorConfig, NotifyEventType } from './config.js';
2
+ /** A content-free arrival event as the daemon serves it over the notifications API. */
3
+ export interface NotifyEvent {
4
+ event?: NotifyEventType | string;
5
+ from?: string;
6
+ msg_id?: number | string;
7
+ file_id?: number | string;
8
+ date?: string;
9
+ queued?: number | string;
10
+ }
11
+ export interface FetchResponse {
12
+ status: number;
13
+ ok: boolean;
14
+ json(): Promise<{
15
+ cursor?: number;
16
+ events?: NotifyEvent[];
17
+ }>;
18
+ }
19
+ export type FetchLike = (url: string, init?: {
20
+ headers?: Record<string, string>;
21
+ signal?: AbortSignal;
22
+ }) => Promise<FetchResponse>;
23
+ export interface MonitorTmux {
24
+ has(name: string): Promise<boolean>;
25
+ capture(name: string, lines?: number): Promise<string>;
26
+ sendText(name: string, text: string): Promise<void>;
27
+ sendKey(name: string, key: string): Promise<void>;
28
+ }
29
+ export interface MonitorDeps {
30
+ fetch: FetchLike;
31
+ tmux: MonitorTmux;
32
+ isAlive(pid: number): boolean;
33
+ sleep(ms: number): Promise<void>;
34
+ now(): number;
35
+ log(line: string): void;
36
+ env: NodeJS.ProcessEnv;
37
+ timers: {
38
+ set(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
39
+ clear(t: ReturnType<typeof setTimeout>): void;
40
+ };
41
+ }
42
+ /** Resolve the daemon endpoint + auth header from the environment (design §6c). */
43
+ export declare function resolveEndpoint(env: NodeJS.ProcessEnv): {
44
+ url(name: string): string;
45
+ headers: Record<string, string>;
46
+ };
47
+ /** Keep only the events whose type the role asked to wake on. */
48
+ export declare function filterEvents(events: NotifyEvent[], wakeSources: string[]): NotifyEvent[];
49
+ /**
50
+ * Summarize a (coalesced) batch of events into one content-free console line —
51
+ * count + senders + ids, ending in the call to action. Falls back to compact
52
+ * counts when a burst would blow past the length cap (design §3, edge: burst).
53
+ */
54
+ export declare function formatNotificationLine(events: NotifyEvent[]): string;
55
+ /**
56
+ * Heuristic: does the pane show a modal selection dialog we must not `Enter`
57
+ * into? Markers are the deployed Claude Code trust/permission dialogs — a `❯`
58
+ * pointer beside numbered options, or a "Do you want …" prompt (design §3.2,
59
+ * open question (a): refine empirically). A running turn is NOT modal.
60
+ */
61
+ export declare function looksModal(pane: string): boolean;
62
+ export interface MonitorOpts {
63
+ name: string;
64
+ agentDir: string;
65
+ cfg: MonitorConfig;
66
+ deps: MonitorDeps;
67
+ }
68
+ /** The lifecycle surface the runner drives: prime pre-launch, run, stop on pid death. */
69
+ export interface MonitorHandle {
70
+ prime(): Promise<void>;
71
+ run(pid: number): Promise<void>;
72
+ stop(): void;
73
+ }
74
+ export declare class Monitor {
75
+ private readonly name;
76
+ private readonly cfg;
77
+ private readonly deps;
78
+ private readonly ep;
79
+ private readonly statusPath;
80
+ private readonly cursorPath;
81
+ private cursor;
82
+ private fatal;
83
+ private stopped;
84
+ private bootDeadline;
85
+ private currentAbort;
86
+ constructor(o: MonitorOpts);
87
+ /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
88
+ prime(): Promise<void>;
89
+ /** Long-poll → filter → coalesce → inject, until the pane pid dies or stop(). */
90
+ run(pid: number): Promise<void>;
91
+ stop(): void;
92
+ /** Gather stragglers arriving within batch_ms so a burst lands as one line. */
93
+ private coalesce;
94
+ private deliver;
95
+ /** Block until the console can accept input; classify offline/stopped/ready. */
96
+ private awaitInjectable;
97
+ private doFetch;
98
+ private advance;
99
+ private persistCursor;
100
+ private readPersistedCursor;
101
+ private setStatus;
102
+ }
103
+ export declare function createMonitor(o: MonitorOpts): Monitor;
@@ -0,0 +1,298 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // Code constants (not config — YAGNI, design §2).
4
+ const DEFAULT_PORT = 3050;
5
+ const LONGPOLL_TIMEOUT_MS = 35_000; // > the daemon's 25s hold
6
+ const COALESCE_HOLD_MS = 500; // straggler poll must not block
7
+ const BOOT_GRACE_MS = 15_000; // hold injection until the TUI is up
8
+ const POST_VERIFY_MS = 1_000;
9
+ const MAX_ENTER_RETRIES = 2;
10
+ const MODAL_RETRY_MS = 5_000;
11
+ const BACKOFF_STEP_MS = 1_000;
12
+ const BACKOFF_MAX_MS = 5_000;
13
+ const PREFIX = '[fleet-monitor]';
14
+ const MAX_LINE = 260;
15
+ class AuthError extends Error {
16
+ }
17
+ /** Resolve the daemon endpoint + auth header from the environment (design §6c). */
18
+ export function resolveEndpoint(env) {
19
+ const port = Number(env.OURS_PORT) || DEFAULT_PORT;
20
+ const token = env.OURS_API_TOKEN;
21
+ return {
22
+ url: (name) => `http://127.0.0.1:${port}/identities/${encodeURIComponent(name)}/notifications`,
23
+ headers: token ? { 'x-ours-api-token': token } : {},
24
+ };
25
+ }
26
+ /** Keep only the events whose type the role asked to wake on. */
27
+ export function filterEvents(events, wakeSources) {
28
+ const set = new Set(wakeSources);
29
+ return events.filter(e => e.event !== undefined && set.has(e.event));
30
+ }
31
+ const uniq = (xs) => [...new Set(xs)];
32
+ const plural = (n, one, many = one + 's') => (n === 1 ? one : many);
33
+ /**
34
+ * Summarize a (coalesced) batch of events into one content-free console line —
35
+ * count + senders + ids, ending in the call to action. Falls back to compact
36
+ * counts when a burst would blow past the length cap (design §3, edge: burst).
37
+ */
38
+ export function formatNotificationLine(events) {
39
+ const of = (t) => events.filter(e => e.event === t);
40
+ const msgs = of('message_received');
41
+ const files = of('file_received');
42
+ const intros = of('local_contact_request');
43
+ const pending = of('pending_message');
44
+ const known = new Set(['message_received', 'file_received', 'local_contact_request', 'pending_message']);
45
+ const others = events.filter(e => !known.has(e.event ?? ''));
46
+ const senders = (list) => uniq(list.map(e => e.from ?? '?')).join(', ');
47
+ const ids = (list) => {
48
+ const xs = list.map(e => e.msg_id).filter(v => v !== undefined);
49
+ return xs.length ? ` (${xs.map(x => `#${x}`).join(', ')})` : '';
50
+ };
51
+ const clauses = [];
52
+ if (msgs.length)
53
+ clauses.push(`${msgs.length} new ${plural(msgs.length, 'message')} from ${senders(msgs)}${ids(msgs)}`);
54
+ if (files.length)
55
+ clauses.push(`${files.length} ${plural(files.length, 'file')} from ${senders(files)}`);
56
+ if (intros.length)
57
+ clauses.push(`${intros.length} pending ${plural(intros.length, 'introduction')} from ${senders(intros)}`);
58
+ if (pending.length)
59
+ clauses.push(`${pending.length} queued ${plural(pending.length, 'message')} from ${senders(pending)}`);
60
+ if (others.length)
61
+ clauses.push(`${others.length} other ${plural(others.length, 'event')} (${uniq(others.map(e => e.event ?? '?')).join(', ')})`);
62
+ const line = `${PREFIX} ${clauses.join(', ')} — run get_messages`;
63
+ if (line.length <= MAX_LINE)
64
+ return line;
65
+ const compact = [
66
+ msgs.length && `${msgs.length} messages`,
67
+ files.length && `${files.length} files`,
68
+ intros.length && `${intros.length} introductions`,
69
+ pending.length && `${pending.length} queued`,
70
+ others.length && `${others.length} other`,
71
+ ].filter(Boolean).join(', ');
72
+ return `${PREFIX} ${compact} — run get_messages`;
73
+ }
74
+ /**
75
+ * Heuristic: does the pane show a modal selection dialog we must not `Enter`
76
+ * into? Markers are the deployed Claude Code trust/permission dialogs — a `❯`
77
+ * pointer beside numbered options, or a "Do you want …" prompt (design §3.2,
78
+ * open question (a): refine empirically). A running turn is NOT modal.
79
+ */
80
+ export function looksModal(pane) {
81
+ if (/Do you want\b/i.test(pane))
82
+ return true;
83
+ const hasPointer = /❯/.test(pane);
84
+ const hasNumbered = /(^|\n)\s*[❯>]?\s*\d+[.)]\s+\S/.test(pane);
85
+ return hasPointer && hasNumbered;
86
+ }
87
+ /** Is the injected line still sitting unsubmitted in the composer (bottom of pane)? */
88
+ function stillInComposer(pane, line) {
89
+ const frag = line.slice(0, 48);
90
+ const tail = pane.split('\n').slice(-4).join('\n');
91
+ return tail.includes(frag);
92
+ }
93
+ export class Monitor {
94
+ name;
95
+ cfg;
96
+ deps;
97
+ ep;
98
+ statusPath;
99
+ cursorPath;
100
+ cursor = null;
101
+ fatal = false;
102
+ stopped = false;
103
+ bootDeadline = 0;
104
+ currentAbort = null;
105
+ constructor(o) {
106
+ this.name = o.name;
107
+ this.cfg = o.cfg;
108
+ this.deps = o.deps;
109
+ this.ep = resolveEndpoint(o.deps.env);
110
+ this.statusPath = join(o.agentDir, '.monitor-status');
111
+ this.cursorPath = join(o.agentDir, '.notify-cursor');
112
+ }
113
+ /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
114
+ async prime() {
115
+ try {
116
+ const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
117
+ this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
118
+ this.persistCursor();
119
+ this.setStatus('armed');
120
+ }
121
+ catch (e) {
122
+ if (e instanceof AuthError) {
123
+ this.fatal = true;
124
+ this.setStatus(`failed: ${e.message}`);
125
+ }
126
+ else {
127
+ this.cursor = this.readPersistedCursor();
128
+ this.setStatus(`degraded: prime failed (${msg(e)})`);
129
+ }
130
+ }
131
+ }
132
+ /** Long-poll → filter → coalesce → inject, until the pane pid dies or stop(). */
133
+ async run(pid) {
134
+ if (this.fatal)
135
+ return;
136
+ this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
137
+ let backoff = 0;
138
+ while (!this.stopped) {
139
+ if (!this.deps.isAlive(pid)) {
140
+ this.setStatus('degraded: session offline');
141
+ return;
142
+ }
143
+ let body;
144
+ try {
145
+ body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_TIMEOUT_MS);
146
+ backoff = 0;
147
+ }
148
+ catch (e) {
149
+ if (this.stopped)
150
+ return;
151
+ if (e instanceof AuthError) {
152
+ this.fatal = true;
153
+ this.setStatus(`failed: ${e.message}`);
154
+ return;
155
+ }
156
+ backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
157
+ this.setStatus(`degraded: stream hiccup (${msg(e)})`);
158
+ await this.deps.sleep(backoff);
159
+ continue;
160
+ }
161
+ this.advance(body.cursor);
162
+ const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
163
+ if (batch.length === 0)
164
+ continue;
165
+ await this.coalesce(batch);
166
+ await this.deliver(pid, batch);
167
+ }
168
+ }
169
+ stop() {
170
+ this.stopped = true;
171
+ this.currentAbort?.abort();
172
+ }
173
+ // ── internals ──────────────────────────────────────────────────────────────
174
+ /** Gather stragglers arriving within batch_ms so a burst lands as one line. */
175
+ async coalesce(batch) {
176
+ if (this.cfg.batch_ms <= 0 || this.stopped)
177
+ return;
178
+ await this.deps.sleep(this.cfg.batch_ms);
179
+ if (this.stopped)
180
+ return;
181
+ try {
182
+ const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS);
183
+ this.advance(more.cursor);
184
+ batch.push(...filterEvents(more.events ?? [], this.cfg.wake_sources));
185
+ }
186
+ catch { /* no stragglers / abort — deliver what we have */ }
187
+ }
188
+ async deliver(pid, batch) {
189
+ const state = await this.awaitInjectable(pid);
190
+ if (state !== 'ready') {
191
+ if (state === 'offline')
192
+ this.setStatus('degraded: offline during delivery');
193
+ return; // events remain covered by unread.json / SessionStart backlog
194
+ }
195
+ const line = formatNotificationLine(batch);
196
+ await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
197
+ let delivered = false;
198
+ // Verify submission for THIS line even if stop() arrives mid-flight: the text
199
+ // is already in the composer and we want it submitted (at-least-once). A truly
200
+ // dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
201
+ for (let i = 0; i < MAX_ENTER_RETRIES; i++) {
202
+ await this.deps.sleep(POST_VERIFY_MS);
203
+ const pane = await safeCapture(this.deps.tmux, this.name);
204
+ if (!stillInComposer(pane, line)) {
205
+ delivered = true;
206
+ break;
207
+ }
208
+ await this.deps.tmux.sendKey(this.name, 'Enter');
209
+ }
210
+ this.setStatus(delivered ? 'armed' : 'degraded: injection unverified');
211
+ }
212
+ /** Block until the console can accept input; classify offline/stopped/ready. */
213
+ async awaitInjectable(pid) {
214
+ for (;;) {
215
+ if (this.stopped)
216
+ return 'stopped';
217
+ if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
218
+ return 'offline';
219
+ const now = this.deps.now();
220
+ if (now < this.bootDeadline) {
221
+ await this.deps.sleep(this.bootDeadline - now);
222
+ continue;
223
+ }
224
+ const pane = await safeCapture(this.deps.tmux, this.name);
225
+ if (looksModal(pane)) {
226
+ await this.deps.sleep(MODAL_RETRY_MS);
227
+ continue;
228
+ }
229
+ return 'ready';
230
+ }
231
+ }
232
+ async doFetch(since, holdMs) {
233
+ const ctrl = new AbortController();
234
+ this.currentAbort = ctrl;
235
+ const timer = this.deps.timers.set(() => ctrl.abort(), holdMs);
236
+ let resp;
237
+ try {
238
+ resp = await this.deps.fetch(`${this.ep.url(this.name)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
239
+ }
240
+ finally {
241
+ this.deps.timers.clear(timer);
242
+ this.currentAbort = null;
243
+ }
244
+ if (resp.status === 401)
245
+ throw new AuthError('daemon rejected the API token (401) — set OURS_API_TOKEN or run as the daemon owner');
246
+ if (!resp.ok)
247
+ throw new Error(`daemon returned HTTP ${resp.status}`);
248
+ return resp.json();
249
+ }
250
+ advance(cursor) {
251
+ if (typeof cursor === 'number' && cursor !== this.cursor) {
252
+ this.cursor = cursor;
253
+ this.persistCursor();
254
+ }
255
+ }
256
+ persistCursor() {
257
+ try {
258
+ if (this.cursor !== null)
259
+ writeFileSync(this.cursorPath, `${this.cursor}\n`);
260
+ }
261
+ catch (e) {
262
+ this.deps.log(`[${this.name}] monitor: failed to persist cursor: ${msg(e)}`);
263
+ }
264
+ }
265
+ readPersistedCursor() {
266
+ try {
267
+ if (!existsSync(this.cursorPath))
268
+ return null;
269
+ const n = parseInt(readFileSync(this.cursorPath, 'utf8').trim(), 10);
270
+ return Number.isFinite(n) ? n : null;
271
+ }
272
+ catch {
273
+ return null;
274
+ }
275
+ }
276
+ setStatus(s) {
277
+ try {
278
+ writeFileSync(this.statusPath, `${s}\n`);
279
+ }
280
+ catch (e) {
281
+ this.deps.log(`[${this.name}] monitor: failed to write status: ${msg(e)}`);
282
+ }
283
+ if (!s.startsWith('armed'))
284
+ this.deps.log(`[${this.name}] monitor ${s}`);
285
+ }
286
+ }
287
+ export function createMonitor(o) {
288
+ return new Monitor(o);
289
+ }
290
+ async function safeCapture(tmux, name) {
291
+ try {
292
+ return await tmux.capture(name);
293
+ }
294
+ catch {
295
+ return '';
296
+ }
297
+ }
298
+ const msg = (e) => e?.message ?? String(e);
package/dist/runner.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ResolvedRole } from './config.js';
2
2
  import type { Launch } from './harness/types.js';
3
3
  import { Tmux } from './tmux.js';
4
+ import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
4
5
  import { type Exec } from './exec.js';
5
6
  export interface RunnerDeps {
6
7
  tmux: Tmux;
@@ -10,6 +11,10 @@ export interface RunnerDeps {
10
11
  sleep(ms: number): Promise<void>;
11
12
  now(): number;
12
13
  log(line: string): void;
14
+ /** HTTP transport for the monitor's daemon long-poll (injectable for tests). */
15
+ fetch: FetchLike;
16
+ /** Construct the supervisor mail monitor (injectable so tests stub it out). */
17
+ createMonitor(opts: MonitorOpts): MonitorHandle;
13
18
  }
14
19
  /**
15
20
  * Compose the tmux pane shell command: env prefix + argv + exit-status capture.
package/dist/runner.js CHANGED
@@ -6,6 +6,7 @@ import { agentDir, home } from './paths.js';
6
6
  import { loadConfig, findRole } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
8
  import { Tmux } from './tmux.js';
9
+ import { createMonitor } from './monitor.js';
9
10
  import { realExec, shq } from './exec.js';
10
11
  import { resolveIsolation } from './isolation/policy.js';
11
12
  import { selectIsolationBackend } from './isolation/registry.js';
@@ -24,6 +25,8 @@ const defaultDeps = () => ({
24
25
  sleep: ms => new Promise(r => setTimeout(r, ms)),
25
26
  now: () => Date.now(),
26
27
  log: line => process.stderr.write(line + '\n'),
28
+ fetch: (url, init) => globalThis.fetch(url, init),
29
+ createMonitor: opts => createMonitor(opts),
27
30
  });
28
31
  /**
29
32
  * Compose the tmux pane shell command: env prefix + argv + exit-status capture.
@@ -38,6 +41,19 @@ export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = lau
38
41
  const cmd = paneArgv.map(shq).join(' ');
39
42
  return `${envPfx} ${cmd}; echo $? > ${shq(exitStatusPath)}`;
40
43
  }
44
+ /** Adapt the runner's injected deps into the monitor's dependency surface. */
45
+ function monitorDeps(deps) {
46
+ return {
47
+ fetch: deps.fetch,
48
+ tmux: deps.tmux,
49
+ isAlive: deps.isAlive,
50
+ sleep: deps.sleep,
51
+ now: deps.now,
52
+ log: deps.log,
53
+ env: process.env,
54
+ timers: { set: (fn, ms) => setTimeout(fn, ms), clear: t => clearTimeout(t) },
55
+ };
56
+ }
41
57
  /** Read a temp role's config snapshot written by spawnTemp. */
42
58
  export function loadTempRole(name) {
43
59
  const p = join(agentDir(name, true), 'role.yaml');
@@ -116,6 +132,17 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
116
132
  if (rprefix.length)
117
133
  paneArgv = [...rprefix, ...paneArgv];
118
134
  }
135
+ // Supervisor mail monitor (design §1): prime the notification cursor at the
136
+ // stream tip BEFORE the session launches so no arrival is missed during boot
137
+ // (backlog before the tip is the SessionStart hook's job). Disabled roles keep
138
+ // the legacy in-session watch. Temp snapshots predating `monitor:` are treated
139
+ // as disabled (monitor may be undefined on an old role.yaml).
140
+ const monitor = role.monitor?.enabled ? deps.createMonitor({
141
+ name, agentDir: dir, cfg: role.monitor,
142
+ deps: monitorDeps(deps),
143
+ }) : null;
144
+ if (monitor)
145
+ await monitor.prime();
119
146
  rmSync(exitFile, { force: true });
120
147
  await deps.tmux.kill(name);
121
148
  await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, paneArgv));
@@ -128,9 +155,16 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
128
155
  if (pid === null)
129
156
  throw new Error(`[${name}] could not resolve tmux pane pid`);
130
157
  deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} mode=${mode}`);
158
+ // The monitor loop lives exactly as long as the session: it starts once the
159
+ // pane pid is known and is stopped when that pid dies (task dies with runner).
160
+ const monitorLoop = monitor?.run(pid);
131
161
  const start = deps.now();
132
162
  while (deps.isAlive(pid))
133
163
  await deps.sleep(2000);
164
+ if (monitor) {
165
+ monitor.stop();
166
+ await monitorLoop;
167
+ }
134
168
  const elapsed = (deps.now() - start) / 1000;
135
169
  const code = existsSync(exitFile) ? readFileSync(exitFile, 'utf8').trim() : 'crash';
136
170
  const rotate = (why) => {
package/dist/spawn.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'no
3
3
  import { join } from 'node:path';
4
4
  import { stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
- import { loadConfig } from './config.js';
6
+ import { loadConfig, resolveMonitorConfig } from './config.js';
7
7
  import { applyRole, up } from './ops.js';
8
8
  function roleFromOpts(o, defaultHarness) {
9
9
  const r = {};
@@ -90,6 +90,8 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
90
90
  identity: o.identity ?? o.name,
91
91
  model: o.model?.trim() || cfg.defaults.model,
92
92
  harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
93
+ // Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
94
+ monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
93
95
  sourceFile: '(temp)',
94
96
  };
95
97
  const dir = applyRole(role, { temp: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",