@ours.network/fleet 0.9.0 → 0.9.1

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/doctor.js CHANGED
@@ -6,6 +6,7 @@ import { getAdapter } from './harness/registry.js';
6
6
  import { agentDir, home, deriveXdgRuntimeDir } from './paths.js';
7
7
  import { resolveIsolation } from './isolation/policy.js';
8
8
  import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
9
+ import { authResolutionHint, resolveEndpoint, } from './monitor.js';
9
10
  /** Which cgroup-v2 controllers are delegated to this user manager (advisory). */
10
11
  function cgroupDelegationDetail() {
11
12
  try {
@@ -19,6 +20,21 @@ function cgroupDelegationDetail() {
19
20
  return 'unknown (not cgroup-v2 or no delegation info)';
20
21
  }
21
22
  }
23
+ /** Resolve and deduplicate the effective daemon profiles used by monitored roles. */
24
+ function resolveMonitorProfiles(roles) {
25
+ const profiles = [];
26
+ for (const role of roles.filter(r => r.monitor?.enabled)) {
27
+ const endpoint = resolveEndpoint({ ...process.env, ...(role.env ?? {}) });
28
+ const token = endpoint.headers['x-ours-api-token'];
29
+ const existing = profiles.find(p => p.endpoint.origin === endpoint.origin
30
+ && p.endpoint.headers['x-ours-api-token'] === token);
31
+ if (existing)
32
+ existing.roles.push(role.name);
33
+ else
34
+ profiles.push({ endpoint, roles: [role.name] });
35
+ }
36
+ return profiles;
37
+ }
22
38
  /** Host-level + per-harness prerequisite report with actionable messages. */
23
39
  export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
24
40
  const checks = [];
@@ -110,34 +126,36 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
110
126
  // Monitor daemon-API reachability (design §5): only when a role is supervised.
111
127
  // /state-dir is unauthenticated (liveness); /identities exercises the token so a
112
128
  // 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 } : {};
129
+ const monitorProfiles = resolveMonitorProfiles(roles);
130
+ for (const profile of monitorProfiles) {
131
+ const { endpoint } = profile;
132
+ const checkName = monitorProfiles.length === 1
133
+ ? 'monitor: daemon API'
134
+ : `monitor: daemon API (${profile.roles.join(', ')})`;
117
135
  let ok = false, detail;
118
136
  try {
119
- const live = await fetchImpl(`http://127.0.0.1:${port}/state-dir`, {});
137
+ const live = await fetchImpl(`${endpoint.origin}/state-dir`, {});
120
138
  if (!live.ok) {
121
- detail = `daemon on :${port} answered /state-dir with HTTP ${live.status} — not the ours daemon?`;
139
+ detail = `daemon on :${endpoint.port} answered /state-dir with HTTP ${live.status} — not the ours daemon?`;
122
140
  }
123
141
  else {
124
- const auth = await fetchImpl(`http://127.0.0.1:${port}/identities`, { headers });
142
+ const auth = await fetchImpl(`${endpoint.origin}/identities`, { headers: endpoint.headers });
125
143
  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`;
144
+ detail = `reachable on :${endpoint.port} but the API token was rejected (401) — ` +
145
+ authResolutionHint(endpoint);
128
146
  else if (!auth.ok)
129
- detail = `reachable on :${port} but /identities returned HTTP ${auth.status}`;
147
+ detail = `reachable on :${endpoint.port} but /identities returned HTTP ${auth.status}`;
130
148
  else {
131
149
  ok = true;
132
- detail = `reachable on :${port}, authorized — supervisor wake stream available`;
150
+ detail = `reachable on :${endpoint.port}, authorized — supervisor wake stream available`;
133
151
  }
134
152
  }
135
153
  }
136
154
  catch (e) {
137
- detail = `unreachable on :${port} — monitored roles run degraded until it is up ` +
155
+ detail = `unreachable on :${endpoint.port} — monitored roles run degraded until it is up ` +
138
156
  `(start it: ours-mcp start) [${e?.message ?? e}]`;
139
157
  }
140
- checks.push({ name: 'monitor: daemon API', ok, detail });
158
+ checks.push({ name: checkName, ok, detail });
141
159
  }
142
160
  const harnesses = opts.harness
143
161
  ? [opts.harness]
@@ -38,6 +38,15 @@ export function pretrust(dir) {
38
38
  e.projectOnboardingSeenCount = Math.max(e.projectOnboardingSeenCount ?? 0, 1);
39
39
  writeFileSync(p, JSON.stringify(d, null, 2));
40
40
  }
41
+ /**
42
+ * Shared Monitor-arming mandate (issue #16). Single-sourced so the briefing and
43
+ * the restart prompt can never diverge on how the agent must arm its monitor —
44
+ * it must be the Monitor TOOL, not a background Bash task (which never wakes the
45
+ * agent on output → an armed-looking but deaf monitor).
46
+ */
47
+ const armMonitor = (id) => 'arm a **persistent Monitor** (the Monitor TOOL — NOT a background Bash command; a ' +
48
+ `background Bash task never wakes you on output) running \`ours-mcp watch "${id}"\` ` +
49
+ 'so inbound ours mail wakes you';
41
50
  export function makeClaudeCodeAdapter(exec = realExec) {
42
51
  return {
43
52
  id: 'claude-code',
@@ -108,7 +117,10 @@ export function makeClaudeCodeAdapter(exec = realExec) {
108
117
  sendTool: 'send_message',
109
118
  getMessagesTool: 'get_messages',
110
119
  watchCommand: id => `ours-mcp watch "${id}"`,
111
- monitorInstruction: id => `Arm a **persistent Monitor** running the shell command \`ours-mcp watch "${id}"\` so inbound ours mail wakes you.`,
120
+ monitorInstruction: id => {
121
+ const m = armMonitor(id);
122
+ return `${m.charAt(0).toUpperCase()}${m.slice(1)}.`;
123
+ },
112
124
  supervisedWakeNote: () => 'Your mail wake-ups are delivered by the fleet supervisor directly into this console as ' +
113
125
  '`[fleet-monitor]` lines — do NOT arm an in-session Monitor. When such a line appears, run ' +
114
126
  '**get_messages** to drain the mail.',
@@ -116,8 +128,12 @@ export function makeClaudeCodeAdapter(exec = realExec) {
116
128
  restartPrompt: (id, worklog, role) => `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
117
129
  (role?.monitor?.enabled
118
130
  ? 'then continue from '
119
- : `re-arm your monitor (ours-mcp watch "${id}"), then continue from `) +
120
- `${worklog}. Do not re-run whatever crashed you.`,
131
+ : `then ${armMonitor(id)}, then continue from `) +
132
+ `${worklog}. Do not re-run whatever crashed you.` +
133
+ (role?.monitor?.enabled
134
+ ? ' Your mail wakes arrive as `[fleet-monitor]` console lines from the supervisor — ' +
135
+ 'do NOT arm an in-session Monitor.'
136
+ : ''),
121
137
  },
122
138
  exitPolicy: { cleanExitIsFresh: true, fastFailSecs: 20 },
123
139
  };
package/dist/monitor.d.ts CHANGED
@@ -39,11 +39,37 @@ export interface MonitorDeps {
39
39
  clear(t: ReturnType<typeof setTimeout>): void;
40
40
  };
41
41
  }
42
- /** Resolve the daemon endpoint + auth header from the environment (design §6c). */
43
- export declare function resolveEndpoint(env: NodeJS.ProcessEnv): {
42
+ /** Best-effort daemon config (issue #17): the fields the MCP client reads. */
43
+ interface DaemonConfig {
44
+ apiToken?: string;
45
+ port?: number;
46
+ stateDir?: string;
47
+ }
48
+ /**
49
+ * Read the daemon config the way the MCP client does — best-effort. Any missing,
50
+ * malformed, or unreadable config yields `{}` so token resolution falls through
51
+ * (issue #17). Only the well-typed fields we consume are surfaced.
52
+ */
53
+ export declare function readDaemonConfig(env: NodeJS.ProcessEnv): DaemonConfig;
54
+ /**
55
+ * Resolve the daemon API token exactly like the MCP client (issue #17), a 3-step
56
+ * chain: `OURS_API_TOKEN` (trimmed) → config `apiToken` (trimmed) → the 0600 owner
57
+ * token at `<stateDir>/daemon-token`. Never generates a token; a failed read of
58
+ * any source (missing/unreadable) silently falls through to the next.
59
+ */
60
+ export declare function resolveApiToken(env: NodeJS.ProcessEnv, file?: DaemonConfig): string | undefined;
61
+ export interface DaemonEndpoint {
62
+ origin: string;
63
+ port: number;
64
+ configPath: string;
65
+ stateDir: string;
44
66
  url(name: string): string;
45
67
  headers: Record<string, string>;
46
- };
68
+ }
69
+ /** Resolve the daemon endpoint + auth header from env → config → defaults. */
70
+ export declare function resolveEndpoint(env: NodeJS.ProcessEnv): DaemonEndpoint;
71
+ /** Actionable, secret-free description of every token source for this profile. */
72
+ export declare function authResolutionHint(ep: DaemonEndpoint): string;
47
73
  /** Keep only the events whose type the role asked to wake on. */
48
74
  export declare function filterEvents(events: NotifyEvent[], wakeSources: string[]): NotifyEvent[];
49
75
  /**
@@ -101,3 +127,4 @@ export declare class Monitor {
101
127
  private setStatus;
102
128
  }
103
129
  export declare function createMonitor(o: MonitorOpts): Monitor;
130
+ export {};
package/dist/monitor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  // Code constants (not config — YAGNI, design §2).
4
5
  const DEFAULT_PORT = 3050;
@@ -14,15 +15,81 @@ const PREFIX = '[fleet-monitor]';
14
15
  const MAX_LINE = 260;
15
16
  class AuthError extends Error {
16
17
  }
17
- /** Resolve the daemon endpoint + auth header from the environment (design §6c). */
18
+ /** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
19
+ const daemonConfigPath = (env) => env.OURS_CONFIG ?? join(homedir(), '.ours', 'config.json');
20
+ /** Match ours-mcp's env integer semantics: parseInt, invalid → absent. */
21
+ function envInt(env, name) {
22
+ const raw = env[name];
23
+ if (raw === undefined)
24
+ return undefined;
25
+ const n = parseInt(raw, 10);
26
+ return Number.isNaN(n) ? undefined : n;
27
+ }
28
+ /**
29
+ * Read the daemon config the way the MCP client does — best-effort. Any missing,
30
+ * malformed, or unreadable config yields `{}` so token resolution falls through
31
+ * (issue #17). Only the well-typed fields we consume are surfaced.
32
+ */
33
+ export function readDaemonConfig(env) {
34
+ try {
35
+ const p = JSON.parse(readFileSync(daemonConfigPath(env), 'utf8'));
36
+ const o = {};
37
+ if (typeof p.apiToken === 'string' && p.apiToken.trim())
38
+ o.apiToken = p.apiToken.trim();
39
+ if (typeof p.port === 'number' && Number.isFinite(p.port))
40
+ o.port = p.port;
41
+ if (typeof p.stateDir === 'string')
42
+ o.stateDir = p.stateDir;
43
+ return o;
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ }
49
+ /**
50
+ * Resolve the daemon API token exactly like the MCP client (issue #17), a 3-step
51
+ * chain: `OURS_API_TOKEN` (trimmed) → config `apiToken` (trimmed) → the 0600 owner
52
+ * token at `<stateDir>/daemon-token`. Never generates a token; a failed read of
53
+ * any source (missing/unreadable) silently falls through to the next.
54
+ */
55
+ export function resolveApiToken(env, file = readDaemonConfig(env)) {
56
+ const e = env.OURS_API_TOKEN?.trim();
57
+ if (e)
58
+ return e;
59
+ if (file.apiToken)
60
+ return file.apiToken;
61
+ const sd = env.OURS_STATE_DIR ?? file.stateDir ?? join(homedir(), '.ours');
62
+ try {
63
+ const t = readFileSync(join(sd, 'daemon-token'), 'utf8').trim();
64
+ if (t)
65
+ return t;
66
+ }
67
+ catch { /* missing/unreadable (e.g. cross-user 0600) → fall through */ }
68
+ return undefined;
69
+ }
70
+ /** Resolve the daemon endpoint + auth header from env → config → defaults. */
18
71
  export function resolveEndpoint(env) {
19
- const port = Number(env.OURS_PORT) || DEFAULT_PORT;
20
- const token = env.OURS_API_TOKEN;
72
+ const file = readDaemonConfig(env);
73
+ const port = envInt(env, 'OURS_PORT') ?? file.port ?? DEFAULT_PORT;
74
+ const configPath = daemonConfigPath(env);
75
+ const stateDir = env.OURS_STATE_DIR ?? file.stateDir ?? join(homedir(), '.ours');
76
+ const token = resolveApiToken(env, file);
77
+ const origin = `http://127.0.0.1:${port}`;
21
78
  return {
22
- url: (name) => `http://127.0.0.1:${port}/identities/${encodeURIComponent(name)}/notifications`,
79
+ origin,
80
+ port,
81
+ configPath,
82
+ stateDir,
83
+ url: (name) => `${origin}/identities/${encodeURIComponent(name)}/notifications`,
23
84
  headers: token ? { 'x-ours-api-token': token } : {},
24
85
  };
25
86
  }
87
+ /** Actionable, secret-free description of every token source for this profile. */
88
+ export function authResolutionHint(ep) {
89
+ const tokenPath = join(ep.stateDir, 'daemon-token');
90
+ return `set OURS_API_TOKEN, set apiToken in ${JSON.stringify(ep.configPath)}, or ensure ` +
91
+ `${JSON.stringify(tokenPath)} is readable by the fleet supervisor`;
92
+ }
26
93
  /** Keep only the events whose type the role asked to wake on. */
27
94
  export function filterEvents(events, wakeSources) {
28
95
  const set = new Set(wakeSources);
@@ -242,7 +309,7 @@ export class Monitor {
242
309
  this.currentAbort = null;
243
310
  }
244
311
  if (resp.status === 401)
245
- throw new AuthError('daemon rejected the API token (401) — set OURS_API_TOKEN or run as the daemon owner');
312
+ throw new AuthError(`daemon rejected the API token (401) — ${authResolutionHint(this.ep)}`);
246
313
  if (!resp.ok)
247
314
  throw new Error(`daemon returned HTTP ${resp.status}`);
248
315
  return resp.json();
package/dist/runner.js CHANGED
@@ -41,8 +41,8 @@ export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = lau
41
41
  const cmd = paneArgv.map(shq).join(' ');
42
42
  return `${envPfx} ${cmd}; echo $? > ${shq(exitStatusPath)}`;
43
43
  }
44
- /** Adapt the runner's injected deps into the monitor's dependency surface. */
45
- function monitorDeps(deps) {
44
+ /** Adapt runner deps and the role's daemon-profile overrides for the monitor. */
45
+ function monitorDeps(deps, roleEnv) {
46
46
  return {
47
47
  fetch: deps.fetch,
48
48
  tmux: deps.tmux,
@@ -50,7 +50,9 @@ function monitorDeps(deps) {
50
50
  sleep: deps.sleep,
51
51
  now: deps.now,
52
52
  log: deps.log,
53
- env: process.env,
53
+ // The agent pane receives role.env too. The supervisor monitor must select
54
+ // the same ours daemon profile, with per-role values winning over service env.
55
+ env: { ...process.env, ...(roleEnv ?? {}) },
54
56
  timers: { set: (fn, ms) => setTimeout(fn, ms), clear: t => clearTimeout(t) },
55
57
  };
56
58
  }
@@ -232,7 +234,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
232
234
  // as disabled (monitor may be undefined on an old role.yaml).
233
235
  const monitor = role.monitor?.enabled ? deps.createMonitor({
234
236
  name, agentDir: dir, cfg: role.monitor,
235
- deps: monitorDeps(deps),
237
+ deps: monitorDeps(deps, role.env),
236
238
  }) : null;
237
239
  if (monitor)
238
240
  await monitor.prime();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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",