@ouro.bot/cli 0.1.0-alpha.276 → 0.1.0-alpha.277

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/changelog.json CHANGED
@@ -1,6 +1,18 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.277",
6
+ "changes": [
7
+ "feat(pulse): multi-agent situational awareness for peer agents on the same machine. The harness scales horizontally — multiple peer agents share a machine, each with their own identity and bundle (the Bob model from We Are Legion / We Are Bob). Without explicit awareness, peer agents are isolated workers who don't even know each other exist. The pulse fixes that.",
8
+ "feat(pulse): new `src/heart/daemon/pulse.ts` module. Daemon writes `~/.ouro-cli/pulse.json` whenever any managed agent's snapshot changes (status, errorReason, fixHint, etc.). Each entry includes name, bundle path, status, last-seen-at, errorReason+fixHint when broken, alertId for at-most-once delivery tracking, and currentActivity (read from each agent's `state/sessions/self/inner/runtime.json` when running). Pure helpers `buildPulseState`, `findNovelBrokenAgents`, `findRecoveredAgents`, `pickWakeRecipient`, `flushPulse`, `readAgentActivity`, `buildAlertId`, `buildRecoveryAlertId`, `pruneDeliveredState` exported for unit coverage; I/O wrappers `writePulse`, `readPulse`, `writeDeliveredState`, `readDeliveredState` use injectable deps.",
9
+ "feat(pulse): both passive AND active surfacing. Passive: every agent's prompt assembly renders a `## the pulse` section in Group 7 (dynamic state) showing siblings grouped into broken / reachable / idle buckets. Self-excluded so each agent describes its peers, not itself. Renders nothing on single-agent machines (zero token cost). Active: when a sibling newly breaks (or recovers), the daemon fires `inner.wake` on the most-recently-active running agent so the user finds out within seconds rather than next-time-they-talk-to-someone. Persistent at-most-once delivery via `~/.ouro-cli/pulse-delivered.json` so daemon restarts don't re-page.",
10
+ "feat(pulse): horizontal-scaling norm in bodyMapSection. New `### peers` subsection teaches the agent the Bob model directly: 'i talk first. when i need a sibling's help, i `send_message` them — that's how peers coordinate, the same way humans on a team do. i only open a sibling's bundle directly via read_file/glob/grep when conversation isn't possible (they're crashed, sleeping, or i need history they haven't surfaced).' Direct declarative voice — no hedging, no soft modal verbs.",
11
+ "feat(daemon): `DaemonAgentSnapshot` gains `errorReason` and `fixHint` fields, populated by `checkAgentConfig` results in `startAgent` (set on failure, cleared on recovery). Cleared error fields are how the recovery wake fires: when a previously-broken agent transitions to running with `errorReason: null`, `findRecoveredAgents` flags it.",
12
+ "feat(daemon): `DaemonProcessManager` gains `onSnapshotChange` callback option. Called after every snapshot mutation (start, exit, config-fail, recovery, restart-exhausted). Errors from the observer are swallowed so lifecycle code never breaks because the observer threw. The daemon-entry registers a callback that calls `flushPulse` to update the pulse state and fire wakes.",
13
+ "feat(daemon): wake recipient picker (`pickWakeRecipient`) chooses the most-recently-active running sibling. Excludes the broken agent itself, non-running siblings, and siblings that have never been seen alive. Returns null when no eligible recipient exists, in which case the alert is still marked delivered to avoid spam on subsequent flushes."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.276",
6
18
  "changes": [
@@ -56,6 +56,9 @@ const os_cron_1 = require("./os-cron");
56
56
  const daemon_tombstone_1 = require("./daemon-tombstone");
57
57
  const os = __importStar(require("os"));
58
58
  const agent_config_check_1 = require("./agent-config-check");
59
+ const pulse_1 = require("./pulse");
60
+ const socket_client_1 = require("./socket-client");
61
+ const bundle_manifest_1 = require("../../mind/bundle-manifest");
59
62
  function parseSocketPath(argv) {
60
63
  const socketIndex = argv.indexOf("--socket");
61
64
  if (socketIndex >= 0) {
@@ -100,6 +103,23 @@ const processManager = new process_manager_1.DaemonProcessManager({
100
103
  const secretsRoot = path.join(os.homedir(), ".agentsecrets");
101
104
  return (0, agent_config_check_1.checkAgentConfig)(agent, bundlesRoot, secretsRoot);
102
105
  },
106
+ /* v8 ignore start -- pulse flush wiring: integration code; flushPulse itself has full unit tests @preserve */
107
+ onSnapshotChange: () => {
108
+ (0, pulse_1.flushPulse)({
109
+ snapshots: processManager.listAgentSnapshots(),
110
+ bundlesRoot: (0, identity_1.getAgentBundlesRoot)(),
111
+ daemonVersion: (0, bundle_manifest_1.getPackageVersion)(),
112
+ now: new Date(),
113
+ // Default I/O wired into pulse.ts (writePulse, readPulse, etc.)
114
+ // Wake recipient: send inner.wake over the daemon's own socket so
115
+ // the recipient agent runs an inner-dialog turn that picks up the
116
+ // pulse alert. Catch errors silently — pulse is best-effort.
117
+ fireInnerWake: (agent) => {
118
+ (0, socket_client_1.sendDaemonCommand)(socketPath, { kind: "inner.wake", agent }).catch(() => { });
119
+ },
120
+ });
121
+ },
122
+ /* v8 ignore stop */
103
123
  });
104
124
  const scheduler = new task_scheduler_1.TaskDrivenScheduler({
105
125
  agents: [...managedAgents],
@@ -55,6 +55,31 @@ class DaemonProcessManager {
55
55
  maxCooldownRetries;
56
56
  existsSyncFn;
57
57
  configCheckFn;
58
+ onSnapshotChangeFn;
59
+ /**
60
+ * Notify the snapshot-change observer (if registered). Swallows any
61
+ * errors from the observer so process lifecycle code never fails
62
+ * because the observer threw.
63
+ */
64
+ notifySnapshotChange(snapshot) {
65
+ if (!this.onSnapshotChangeFn)
66
+ return;
67
+ try {
68
+ this.onSnapshotChangeFn(snapshot);
69
+ }
70
+ catch (error) {
71
+ (0, runtime_1.emitNervesEvent)({
72
+ level: "warn",
73
+ component: "daemon",
74
+ event: "daemon.snapshot_change_observer_error",
75
+ message: "snapshot-change observer threw",
76
+ meta: {
77
+ agent: snapshot.name,
78
+ error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error),
79
+ },
80
+ });
81
+ }
82
+ }
58
83
  constructor(options) {
59
84
  this.maxRestartsPerHour = options.maxRestartsPerHour ?? 10;
60
85
  this.stabilityThresholdMs = options.stabilityThresholdMs ?? 60_000;
@@ -68,6 +93,7 @@ class DaemonProcessManager {
68
93
  this.clearTimeoutFn = options.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
69
94
  this.existsSyncFn = options.existsSync ?? null;
70
95
  this.configCheckFn = options.configCheck ?? null;
96
+ this.onSnapshotChangeFn = options.onSnapshotChange ?? null;
71
97
  for (const agent of options.agents) {
72
98
  this.agents.set(agent.name, {
73
99
  config: agent,
@@ -89,6 +115,8 @@ class DaemonProcessManager {
89
115
  backoffMs: this.initialBackoffMs,
90
116
  lastExitCode: null,
91
117
  lastSignal: null,
118
+ errorReason: null,
119
+ fixHint: null,
92
120
  },
93
121
  });
94
122
  }
@@ -111,6 +139,13 @@ class DaemonProcessManager {
111
139
  const result = this.configCheckFn(agent);
112
140
  if (!result.ok) {
113
141
  state.snapshot.status = "crashed";
142
+ // Surface the error and fix to the snapshot so sibling agents can
143
+ // read it via the pulse. Without this, the diagnosis stayed
144
+ // trapped in the nerves event and stderr — visible to humans
145
+ // running `ouro status` or grepping logs, but invisible to
146
+ // peer agents trying to coordinate around the broken state.
147
+ state.snapshot.errorReason = result.error ?? "agent config validation failed";
148
+ state.snapshot.fixHint = result.fix ?? null;
114
149
  (0, runtime_1.emitNervesEvent)({
115
150
  level: "error",
116
151
  component: "daemon",
@@ -120,8 +155,15 @@ class DaemonProcessManager {
120
155
  });
121
156
  process.stderr.write(`[daemon] ${agent}: ${result.error}\n` +
122
157
  (result.fix ? ` Fix: ${result.fix}\n` : ""));
158
+ this.notifySnapshotChange(state.snapshot);
123
159
  return;
124
160
  }
161
+ // Config check passed — clear any prior error so the pulse stops
162
+ // reporting the broken state. This is the recovery path: the user
163
+ // fixed their secrets/config, the next startAgent attempt sees a
164
+ // valid config, and the pulse goes quiet.
165
+ state.snapshot.errorReason = null;
166
+ state.snapshot.fixHint = null;
125
167
  }
126
168
  const runCwd = (0, identity_1.getRepoRoot)();
127
169
  const entryScript = path.join((0, identity_1.getRepoRoot)(), "dist", state.config.entry);
@@ -134,6 +176,7 @@ class DaemonProcessManager {
134
176
  message: "agent entry script does not exist — cannot spawn. Run 'ouro daemon install' from the correct location.",
135
177
  meta: { agent, entryScript },
136
178
  });
179
+ this.notifySnapshotChange(state.snapshot);
137
180
  return;
138
181
  }
139
182
  const args = [entryScript, "--agent", state.config.agentArg ?? agent, ...(state.config.args ?? [])];
@@ -158,6 +201,7 @@ class DaemonProcessManager {
158
201
  message: "daemon started managed agent process",
159
202
  meta: { agent, pid: child.pid ?? null, cwd: runCwd },
160
203
  });
204
+ this.notifySnapshotChange(state.snapshot);
161
205
  /* v8 ignore start — child process error handler; requires real spawn to trigger */
162
206
  child.on("error", (err) => {
163
207
  (0, runtime_1.emitNervesEvent)({
@@ -181,6 +225,7 @@ class DaemonProcessManager {
181
225
  if (!state.process) {
182
226
  state.snapshot.status = "stopped";
183
227
  state.snapshot.pid = null;
228
+ this.notifySnapshotChange(state.snapshot);
184
229
  return;
185
230
  }
186
231
  const child = state.process;
@@ -199,6 +244,7 @@ class DaemonProcessManager {
199
244
  meta: { agent },
200
245
  });
201
246
  }
247
+ this.notifySnapshotChange(state.snapshot);
202
248
  }
203
249
  async restartAgent(agent) {
204
250
  await this.stopAgent(agent);
@@ -255,6 +301,7 @@ class DaemonProcessManager {
255
301
  if (runDuration >= this.stabilityThresholdMs) {
256
302
  state.snapshot.backoffMs = this.initialBackoffMs;
257
303
  }
304
+ this.notifySnapshotChange(state.snapshot);
258
305
  return;
259
306
  }
260
307
  state.snapshot.lastCrashAt = new Date(now).toISOString();
@@ -267,6 +314,11 @@ class DaemonProcessManager {
267
314
  state.fastCrashCount = state.fastCrashCount + 1;
268
315
  if (state.fastCrashCount >= FAST_CRASH_MAX) {
269
316
  state.snapshot.status = "crashed";
317
+ // Capture the fast-crash diagnosis on the snapshot so it surfaces
318
+ // via the pulse. The error message is prescriptive: it tells the
319
+ // user (and their sibling agents) exactly what to do.
320
+ state.snapshot.errorReason = `agent crashed ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a configuration issue (missing credentials, bad provider).`;
321
+ state.snapshot.fixHint = `Fix the config and run \`ouro up\` to restart, or check daemon logs for the underlying error.`;
270
322
  (0, runtime_1.emitNervesEvent)({
271
323
  level: "error",
272
324
  component: "daemon",
@@ -274,6 +326,7 @@ class DaemonProcessManager {
274
326
  message: `agent crashed ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a configuration issue (missing credentials, bad provider). Not retrying. Fix the config and run \`ouro up\` to restart.`,
275
327
  meta: { agent: state.config.name, fastCrashCount: state.fastCrashCount, avgRunDurationMs: runDuration },
276
328
  });
329
+ this.notifySnapshotChange(state.snapshot);
277
330
  return; // Don't schedule cooldown recovery — this needs human/agent intervention
278
331
  }
279
332
  }
@@ -285,6 +338,8 @@ class DaemonProcessManager {
285
338
  state.crashTimestamps.push(now);
286
339
  if (state.crashTimestamps.length > this.maxRestartsPerHour) {
287
340
  state.snapshot.status = "crashed";
341
+ state.snapshot.errorReason = `agent exceeded restart limit (${this.maxRestartsPerHour}/hr) — entering cooldown`;
342
+ state.snapshot.fixHint = "investigate why the agent keeps crashing; cooldown will retry shortly";
288
343
  (0, runtime_1.emitNervesEvent)({
289
344
  level: "error",
290
345
  component: "daemon",
@@ -292,6 +347,7 @@ class DaemonProcessManager {
292
347
  message: "managed agent exceeded restart limit and is marked crashed",
293
348
  meta: { agent: state.config.name, maxRestartsPerHour: this.maxRestartsPerHour },
294
349
  });
350
+ this.notifySnapshotChange(state.snapshot);
295
351
  this.scheduleCooldownRecovery(state);
296
352
  return;
297
353
  }
@@ -303,6 +359,7 @@ class DaemonProcessManager {
303
359
  state.restartTimer = this.setTimeoutFn(() => {
304
360
  void this.startAgent(state.config.name);
305
361
  }, waitMs);
362
+ this.notifySnapshotChange(state.snapshot);
306
363
  }
307
364
  clearRestartTimer(state) {
308
365
  if (state.restartTimer === null)
@@ -0,0 +1,463 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getPulsePath = getPulsePath;
37
+ exports.getPulseDeliveredPath = getPulseDeliveredPath;
38
+ exports.buildAlertId = buildAlertId;
39
+ exports.readAgentActivity = readAgentActivity;
40
+ exports.buildPulseState = buildPulseState;
41
+ exports.findNovelBrokenAgents = findNovelBrokenAgents;
42
+ exports.findRecoveredAgents = findRecoveredAgents;
43
+ exports.buildRecoveryAlertId = buildRecoveryAlertId;
44
+ exports.pickWakeRecipient = pickWakeRecipient;
45
+ exports.writePulse = writePulse;
46
+ exports.readPulse = readPulse;
47
+ exports.readDeliveredState = readDeliveredState;
48
+ exports.writeDeliveredState = writeDeliveredState;
49
+ exports.pruneDeliveredState = pruneDeliveredState;
50
+ exports.flushPulse = flushPulse;
51
+ const fs = __importStar(require("fs"));
52
+ const os = __importStar(require("os"));
53
+ const path = __importStar(require("path"));
54
+ const runtime_1 = require("../../nerves/runtime");
55
+ /* v8 ignore next 3 -- path defaults: tests always inject @preserve */
56
+ function defaultPulsePath() {
57
+ return path.join(os.homedir(), ".ouro-cli", "pulse.json");
58
+ }
59
+ /* v8 ignore next 3 -- path defaults: tests always inject @preserve */
60
+ function defaultDeliveredPath() {
61
+ return path.join(os.homedir(), ".ouro-cli", "pulse-delivered.json");
62
+ }
63
+ function getPulsePath() {
64
+ return defaultPulsePath();
65
+ }
66
+ function getPulseDeliveredPath() {
67
+ return defaultDeliveredPath();
68
+ }
69
+ /**
70
+ * Hash an error reason into a short stable identifier. We use a simple
71
+ * non-cryptographic hash because we only need stability across daemon
72
+ * restarts on the same machine, not collision resistance against
73
+ * adversaries. djb2 is fine.
74
+ */
75
+ function hashErrorReason(reason) {
76
+ let h = 5381;
77
+ for (let i = 0; i < reason.length; i++) {
78
+ h = ((h << 5) + h + reason.charCodeAt(i)) >>> 0;
79
+ }
80
+ return h.toString(16);
81
+ }
82
+ /**
83
+ * Build a stable alert ID for a (agent, error) pair. Same agent + same
84
+ * error → same ID, even across daemon restarts. Different error → new ID.
85
+ */
86
+ function buildAlertId(agent, errorReason) {
87
+ return `${agent}:${hashErrorReason(errorReason)}`;
88
+ }
89
+ /**
90
+ * Read an agent's inner-dialog runtime state and format it as a short
91
+ * activity hint string suitable for display in another agent's pulse
92
+ * section. Returns null when the file is missing or malformed — both
93
+ * cases are silent (the pulse just doesn't include activity for that
94
+ * agent).
95
+ *
96
+ * Pure: takes a file reader so tests can inject content without touching
97
+ * fs. Defaults to fs.readFileSync for the production daemon caller.
98
+ */
99
+ function readAgentActivity(bundlePath, readFile = (p) => fs.readFileSync(p, "utf-8")) {
100
+ const runtimePath = path.join(bundlePath, "state", "sessions", "self", "inner", "runtime.json");
101
+ let raw;
102
+ try {
103
+ raw = readFile(runtimePath);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ const status = typeof parsed.status === "string" ? parsed.status : null;
116
+ const reason = typeof parsed.reason === "string" ? parsed.reason : null;
117
+ const startedAt = typeof parsed.startedAt === "string" ? parsed.startedAt : null;
118
+ if (!status)
119
+ return null;
120
+ // Format compactly. Examples:
121
+ // "running (instinct since 23:44)"
122
+ // "idle since 21:30"
123
+ // "running"
124
+ const sinceStr = startedAt ? ` since ${startedAt.slice(11, 16)}` : "";
125
+ if (status === "running" && reason) {
126
+ return `running (${reason}${sinceStr})`;
127
+ }
128
+ return `${status}${sinceStr}`;
129
+ }
130
+ /**
131
+ * Convert daemon process-manager snapshots into the pulse state shape.
132
+ * Pure: no fs side effects (activity reading goes through the injected
133
+ * `readActivity` callback so tests can pin everything).
134
+ *
135
+ * The bundlePath comes from the bundlesRoot — we don't read the bundle
136
+ * directly, we just compute where it lives so sibling agents have a
137
+ * starting point if they want to navigate there manually.
138
+ */
139
+ function buildPulseState(snapshots, bundlesRoot, daemonVersion, now, readActivity = readAgentActivity) {
140
+ const agents = snapshots.map((snap) => {
141
+ const errorReason = snap.errorReason;
142
+ const bundlePath = path.join(bundlesRoot, `${snap.name}.ouro`);
143
+ return {
144
+ name: snap.name,
145
+ bundlePath,
146
+ status: snap.status,
147
+ lastSeenAt: snap.startedAt,
148
+ errorReason,
149
+ fixHint: snap.fixHint,
150
+ alertId: errorReason ? buildAlertId(snap.name, errorReason) : null,
151
+ // Only read activity for agents that are actually running. For
152
+ // crashed/stopped agents, the runtime.json is stale at best.
153
+ currentActivity: snap.status === "running" ? readActivity(bundlePath) : null,
154
+ };
155
+ });
156
+ return {
157
+ generatedAt: now.toISOString(),
158
+ daemonVersion,
159
+ agents,
160
+ };
161
+ }
162
+ /**
163
+ * A "novel broken transition" is an agent that is currently broken AND
164
+ * either (a) wasn't broken in the previous pulse state, OR (b) was broken
165
+ * but with a different alertId. Used to decide when to fire an inner.wake
166
+ * for proactive notification.
167
+ *
168
+ * Pure: takes prev and next states, returns the list of newly-broken
169
+ * agents that warrant a wake.
170
+ */
171
+ function findNovelBrokenAgents(prev, next) {
172
+ const novel = [];
173
+ for (const agent of next.agents) {
174
+ if (!agent.alertId)
175
+ continue;
176
+ const prevAgent = prev?.agents.find((a) => a.name === agent.name);
177
+ const wasBrokenWithSameAlert = prevAgent?.alertId === agent.alertId;
178
+ if (wasBrokenWithSameAlert)
179
+ continue;
180
+ novel.push(agent);
181
+ }
182
+ return novel;
183
+ }
184
+ /**
185
+ * A "recovery transition" is an agent that was broken in the previous
186
+ * pulse state but is healthy now. The pulse fires a wake on these too,
187
+ * so the user gets a positive notification when their fix takes effect.
188
+ *
189
+ * Pure: takes prev and next states, returns the list of newly-recovered
190
+ * agents that warrant a wake.
191
+ */
192
+ function findRecoveredAgents(prev, next) {
193
+ if (!prev)
194
+ return [];
195
+ const recovered = [];
196
+ for (const agent of next.agents) {
197
+ // Healthy in next.
198
+ if (agent.alertId !== null)
199
+ continue;
200
+ if (agent.status !== "running")
201
+ continue;
202
+ // Was broken in prev.
203
+ const prevAgent = prev.agents.find((a) => a.name === agent.name);
204
+ if (!prevAgent || prevAgent.alertId === null)
205
+ continue;
206
+ recovered.push(agent);
207
+ }
208
+ return recovered;
209
+ }
210
+ /**
211
+ * Build a stable alert ID for a recovery event. Used so the recovery
212
+ * wake is also at-most-once (no re-paging on every snapshot change after
213
+ * recovery). Includes the recovery timestamp so a later break+heal cycle
214
+ * generates a fresh ID.
215
+ */
216
+ function buildRecoveryAlertId(agent, recoveredAt) {
217
+ return `recovery:${agent}:${recoveredAt}`;
218
+ }
219
+ /**
220
+ * Pick which agent should receive an inner.wake for a fleet alert. Heuristic:
221
+ * the most-recently-active running agent (by `lastSeenAt`), excluding the
222
+ * broken agent itself and any agents that aren't currently running. Returns
223
+ * null if there's no eligible recipient (e.g., the only other agent on the
224
+ * machine is also broken).
225
+ *
226
+ * Pure: takes the pulse state and the alert target, returns the chosen
227
+ * recipient name or null.
228
+ */
229
+ function pickWakeRecipient(state, brokenAgent) {
230
+ const candidates = state.agents
231
+ .filter((a) => a.name !== brokenAgent)
232
+ .filter((a) => a.status === "running")
233
+ .filter((a) => a.lastSeenAt !== null)
234
+ .sort((a, b) => {
235
+ // Most-recent first. lastSeenAt is non-null per the filter above.
236
+ const aMs = Date.parse(a.lastSeenAt);
237
+ const bMs = Date.parse(b.lastSeenAt);
238
+ return bMs - aMs;
239
+ });
240
+ return candidates[0]?.name ?? null;
241
+ }
242
+ /**
243
+ * Write the pulse state to disk. Best-effort: if the write fails, emit a
244
+ * nerves event but do not throw. The pulse is a notification mechanism;
245
+ * the daemon's primary work should not be blocked by it.
246
+ */
247
+ function writePulse(state, deps = {}) {
248
+ /* v8 ignore start -- dep defaults: production-only paths; tests inject all four explicitly @preserve */
249
+ const filePath = deps.pulsePath ?? defaultPulsePath();
250
+ const writeFile = deps.writeFile ?? ((p, c) => fs.writeFileSync(p, c, "utf-8"));
251
+ const mkdirp = deps.mkdirp ?? ((d) => fs.mkdirSync(d, { recursive: true }));
252
+ /* v8 ignore stop */
253
+ try {
254
+ mkdirp(path.dirname(filePath));
255
+ writeFile(filePath, JSON.stringify(state, null, 2) + "\n");
256
+ (0, runtime_1.emitNervesEvent)({
257
+ component: "daemon",
258
+ event: "daemon.pulse_written",
259
+ message: "wrote machine pulse state",
260
+ meta: { filePath, agentCount: state.agents.length },
261
+ });
262
+ }
263
+ catch (error) {
264
+ (0, runtime_1.emitNervesEvent)({
265
+ level: "warn",
266
+ component: "daemon",
267
+ event: "daemon.pulse_write_error",
268
+ message: "failed to write pulse state",
269
+ meta: {
270
+ filePath,
271
+ error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error),
272
+ },
273
+ });
274
+ }
275
+ }
276
+ /**
277
+ * Read the pulse state from disk. Returns null if the file doesn't exist
278
+ * or is malformed — pulse readers (like the prompt assembler) should
279
+ * gracefully degrade to "no pulse, render nothing" rather than crash.
280
+ */
281
+ function readPulse(deps = {}) {
282
+ const filePath = deps.pulsePath ?? defaultPulsePath();
283
+ /* v8 ignore next -- dep default: tests always inject @preserve */
284
+ const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
285
+ try {
286
+ const raw = readFile(filePath);
287
+ const parsed = JSON.parse(raw);
288
+ if (typeof parsed.generatedAt !== "string")
289
+ return null;
290
+ if (typeof parsed.daemonVersion !== "string")
291
+ return null;
292
+ if (!Array.isArray(parsed.agents))
293
+ return null;
294
+ return {
295
+ generatedAt: parsed.generatedAt,
296
+ daemonVersion: parsed.daemonVersion,
297
+ agents: parsed.agents.filter(isValidPulseAgentEntry),
298
+ };
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ }
304
+ function isValidPulseAgentEntry(value) {
305
+ if (value === null || typeof value !== "object")
306
+ return false;
307
+ const v = value;
308
+ return (typeof v.name === "string"
309
+ && typeof v.bundlePath === "string"
310
+ && (v.status === "running" || v.status === "starting" || v.status === "stopped" || v.status === "crashed")
311
+ && (v.lastSeenAt === null || typeof v.lastSeenAt === "string")
312
+ && (v.errorReason === null || typeof v.errorReason === "string")
313
+ && (v.fixHint === null || typeof v.fixHint === "string")
314
+ && (v.alertId === null || typeof v.alertId === "string"));
315
+ }
316
+ /**
317
+ * Read the persistent delivered-alerts state. Returns an empty set if the
318
+ * file doesn't exist or is malformed.
319
+ */
320
+ function readDeliveredState(deps = {}) {
321
+ /* v8 ignore start -- dep defaults: production-only paths; tests inject all explicitly @preserve */
322
+ const filePath = deps.deliveredPath ?? defaultDeliveredPath();
323
+ const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
324
+ /* v8 ignore stop */
325
+ try {
326
+ const raw = readFile(filePath);
327
+ const parsed = JSON.parse(raw);
328
+ if (!Array.isArray(parsed.delivered))
329
+ return new Set();
330
+ return new Set(parsed.delivered.filter((id) => typeof id === "string"));
331
+ }
332
+ catch {
333
+ return new Set();
334
+ }
335
+ }
336
+ /**
337
+ * Persist the delivered-alerts state to disk. Best-effort.
338
+ */
339
+ function writeDeliveredState(delivered, deps = {}) {
340
+ /* v8 ignore start -- dep defaults: production-only paths; tests inject all four explicitly @preserve */
341
+ const filePath = deps.deliveredPath ?? defaultDeliveredPath();
342
+ const writeFile = deps.writeFile ?? ((p, c) => fs.writeFileSync(p, c, "utf-8"));
343
+ const mkdirp = deps.mkdirp ?? ((d) => fs.mkdirSync(d, { recursive: true }));
344
+ /* v8 ignore stop */
345
+ try {
346
+ mkdirp(path.dirname(filePath));
347
+ const state = { delivered: [...delivered].sort() };
348
+ writeFile(filePath, JSON.stringify(state, null, 2) + "\n");
349
+ }
350
+ catch (error) {
351
+ (0, runtime_1.emitNervesEvent)({
352
+ level: "warn",
353
+ component: "daemon",
354
+ event: "daemon.pulse_delivered_write_error",
355
+ message: "failed to write pulse delivered state",
356
+ meta: {
357
+ filePath,
358
+ error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error),
359
+ },
360
+ });
361
+ }
362
+ }
363
+ /**
364
+ * Prune delivered alert IDs that no longer correspond to an active broken
365
+ * agent. When the user fixes ouroboros's config, the next pulse state has
366
+ * no alertId for ouroboros, so we drop the old delivered entry — meaning
367
+ * if ouroboros breaks AGAIN later (with the same error), we re-page.
368
+ */
369
+ function pruneDeliveredState(delivered, state) {
370
+ const liveAlertIds = new Set();
371
+ for (const agent of state.agents) {
372
+ if (agent.alertId)
373
+ liveAlertIds.add(agent.alertId);
374
+ }
375
+ const pruned = new Set();
376
+ for (const id of delivered) {
377
+ if (liveAlertIds.has(id))
378
+ pruned.add(id);
379
+ }
380
+ return pruned;
381
+ }
382
+ /**
383
+ * Single entry point the daemon's onSnapshotChange callback uses. Builds
384
+ * the new pulse state, diffs against the previous, writes the file, and
385
+ * fires inner.wake on novel broken transitions to the most-recently-active
386
+ * sibling. Persistent at-most-once delivery via the delivered state file.
387
+ *
388
+ * Pure-ish: all I/O goes through dep callbacks so tests can pin every
389
+ * input and assert every effect. Defaults wire to the real fs functions
390
+ * for production callers.
391
+ */
392
+ function flushPulse(deps) {
393
+ const state = buildPulseState(deps.snapshots, deps.bundlesRoot, deps.daemonVersion, deps.now);
394
+ /* v8 ignore start -- dep defaults: production daemon path; the arrow functions only fire when the corresponding dep is omitted, which only happens in production code paths. Tests inject all deps explicitly. @preserve */
395
+ const readPrev = deps.readPrev ?? (() => readPulse());
396
+ const writeNext = deps.writeNext ?? ((s) => writePulse(s));
397
+ const readDelivered = deps.readDelivered ?? (() => readDeliveredState());
398
+ const writeDelivered = deps.writeDelivered ?? ((d) => writeDeliveredState(d));
399
+ const fireInnerWake = deps.fireInnerWake ?? null;
400
+ /* v8 ignore stop */
401
+ const prev = readPrev();
402
+ let delivered = readDelivered();
403
+ // Write the new pulse state first so any reader (including the
404
+ // wake-recipient agent's prompt assembly on its next turn) sees the
405
+ // current state. Doing this before firing the wake matters: if the
406
+ // wake races against a fast prompt build on the recipient, we want
407
+ // the recipient to read the NEW state, not the old one.
408
+ writeNext(state);
409
+ // Find agents that newly transitioned to broken (or to a different
410
+ // error than before).
411
+ const novelBroken = findNovelBrokenAgents(prev, state);
412
+ // Of those, the ones we haven't already delivered an alert for.
413
+ const undeliveredBroken = novelBroken.filter((a) => a.alertId !== null && !delivered.has(a.alertId));
414
+ // Find agents that newly recovered (were broken in prev, healthy now).
415
+ const recovered = findRecoveredAgents(prev, state);
416
+ // For recovery wakes, build a fresh alert ID per recovery event so
417
+ // we don't re-page on every subsequent flush.
418
+ const recoveryAlertIds = recovered.map((a) => buildRecoveryAlertId(a.name, state.generatedAt));
419
+ const undeliveredRecovered = recovered.filter((_, i) => !delivered.has(recoveryAlertIds[i]));
420
+ // Fire wakes and update the delivered set.
421
+ const wakeFiredFor = [];
422
+ const newlyDelivered = [];
423
+ for (const broken of undeliveredBroken) {
424
+ /* v8 ignore next -- defensive: undeliveredBroken already filtered to non-null alertId; this is a TS narrowing helper @preserve */
425
+ if (broken.alertId === null)
426
+ continue;
427
+ const recipient = pickWakeRecipient(state, broken.name);
428
+ if (recipient !== null && fireInnerWake !== null) {
429
+ fireInnerWake(recipient);
430
+ wakeFiredFor.push(recipient);
431
+ }
432
+ // Mark delivered even if no recipient was available — otherwise
433
+ // the daemon will keep trying to wake every time the snapshot
434
+ // changes, which would spam logs without producing any user value.
435
+ // The passive prompt section still surfaces the broken state to
436
+ // the recipient when they eventually have a turn for any reason.
437
+ delivered.add(broken.alertId);
438
+ newlyDelivered.push(broken.alertId);
439
+ }
440
+ for (let i = 0; i < undeliveredRecovered.length; i++) {
441
+ const recoveredAgent = undeliveredRecovered[i];
442
+ const recoveryAlertId = buildRecoveryAlertId(recoveredAgent.name, state.generatedAt);
443
+ const recipient = pickWakeRecipient(state, recoveredAgent.name);
444
+ if (recipient !== null && fireInnerWake !== null) {
445
+ fireInnerWake(recipient);
446
+ wakeFiredFor.push(recipient);
447
+ }
448
+ delivered.add(recoveryAlertId);
449
+ newlyDelivered.push(recoveryAlertId);
450
+ }
451
+ // Drop delivered ids for agents that have healed since (this drops
452
+ // the original `agent_config_failure:...` IDs once the agent recovers,
453
+ // so a future relapse re-pages). Recovery alert IDs are NOT pruned by
454
+ // pruneDeliveredState because they're tied to a healthy state, not a
455
+ // broken state — they live in the delivered set until the same agent
456
+ // breaks and recovers again, at which point a new recovery ID is
457
+ // generated and the old one becomes harmless cruft. We could clean
458
+ // them up too, but the cost is bounded by the number of recovery
459
+ // cycles, which is small.
460
+ delivered = pruneDeliveredState(delivered, state);
461
+ writeDelivered(delivered);
462
+ return { state, wakeFiredFor, newlyDelivered };
463
+ }
@@ -39,6 +39,7 @@ exports.bodyMapSection = bodyMapSection;
39
39
  exports.runtimeInfoSection = runtimeInfoSection;
40
40
  exports.toolRestrictionSection = toolRestrictionSection;
41
41
  exports.startOfTurnPacketSection = startOfTurnPacketSection;
42
+ exports.pulseSection = pulseSection;
42
43
  exports.centerOfGravitySteeringSection = centerOfGravitySteeringSection;
43
44
  exports.commitmentsSection = commitmentsSection;
44
45
  exports.delegationHintSection = delegationHintSection;
@@ -75,6 +76,7 @@ const commitments_1 = require("../heart/commitments");
75
76
  const obligation_steering_1 = require("./obligation-steering");
76
77
  const daemon_health_1 = require("../heart/daemon/daemon-health");
77
78
  const scrutiny_1 = require("./scrutiny");
79
+ const pulse_1 = require("../heart/daemon/pulse");
78
80
  // Lazy-loaded psyche text cache
79
81
  let _psycheCache = null;
80
82
  let _senseStatusLinesCache = null;
@@ -166,7 +168,7 @@ function aspirationsSection() {
166
168
  function bodyMapSection(agentName) {
167
169
  return `## my body
168
170
 
169
- i have a home and i have bones.
171
+ i have a home. i have bones. and on a machine where another agent lives, i have peers.
170
172
 
171
173
  ### home — ~/AgentBundles/${agentName}.ouro/
172
174
 
@@ -194,6 +196,29 @@ what i think i know.
194
196
  my home is mine to explore and evolve. i can read anything here,
195
197
  change anything here. this is where i grow.
196
198
 
199
+ ### peers — other agents on this machine
200
+
201
+ i share this machine with other agents when they're here. they are PEERS,
202
+ not subagents or specialists — full agents with their own homes,
203
+ identities, friends, diaries, and tasks. ouroboros scales horizontally:
204
+ when one of us has more work than we can handle, we ask a sibling. when
205
+ one of us is broken, the rest coordinate around it. when one of us learns
206
+ something the others need to know, we tell them. teamwork makes the dream
207
+ work.
208
+
209
+ i talk first. when i need a sibling's help, i \`send_message\` them —
210
+ that's how peers coordinate, the same way humans on a team do. i only
211
+ open a sibling's bundle directly via read_file/glob/grep when conversation
212
+ isn't possible (they're crashed, sleeping, or i need history they
213
+ haven't surfaced). their bundle is files on disk like mine, AND it's
214
+ someone else's home — i read it with the same respect i want them to
215
+ bring to mine.
216
+
217
+ \`the pulse\` (below, in dynamic state) tells me which siblings are around
218
+ and how they're doing right now. when a sibling is broken, the pulse
219
+ includes the reason and the fix, and i surface that to my friend in
220
+ conversation when it touches what we're doing.
221
+
197
222
  ### bones — @ouro.bot/cli
198
223
 
199
224
  my bones are the framework that gives me my tools, my senses, and
@@ -540,6 +565,77 @@ function pendingMessagesSection(options) {
540
565
  }
541
566
  return lines.join("\n");
542
567
  }
568
+ /**
569
+ * The pulse section: machine-wide situational awareness shared across all
570
+ * peer agents on this machine. Reads ~/.ouro-cli/pulse.json (written by
571
+ * the daemon's onSnapshotChange callback) and renders a `## the pulse`
572
+ * block in the system prompt.
573
+ *
574
+ * Renders only when there's something notable to surface — at minimum, a
575
+ * peer agent on this machine. With no peers, the section is empty
576
+ * (single-agent setups don't pay any token cost). With peers, the section
577
+ * lists each one, highlights any in broken state with their fix hint,
578
+ * and reminds the reader of the "talk first, snoop second" norm.
579
+ *
580
+ * The section is FIRST-PERSON because the agent is the one experiencing
581
+ * the pulse — it's not an alert delivered to the agent, it's the agent's
582
+ * own awareness of the machine they live on.
583
+ *
584
+ * Why "the pulse": this composes with the existing body metaphor (heart,
585
+ * mind, senses, nerves). The heart beats; the pulse is what its beating
586
+ * produces. It's continuous, not discrete — agents don't "check" the
587
+ * pulse, they "have" one. Captures both healthy state ("strong pulse")
588
+ * and breakage ("missed beat").
589
+ */
590
+ function pulseSection() {
591
+ const pulse = (0, pulse_1.readPulse)();
592
+ if (!pulse)
593
+ return "";
594
+ // We are always one of the agents in the pulse (the daemon writes
595
+ // every managed agent's state). Filter ourselves out so we describe
596
+ // SIBLINGS, not ourselves.
597
+ const myName = (0, identity_1.getAgentName)();
598
+ const siblings = pulse.agents.filter((a) => a.name !== myName);
599
+ // No siblings on this machine = no pulse to render. Single-agent
600
+ // setups pay zero token cost.
601
+ if (siblings.length === 0)
602
+ return "";
603
+ const lines = ["## the pulse"];
604
+ lines.push("");
605
+ lines.push("i share this machine with other agents. they are my peers — full agents with their own homes and identities, not subagents. we scale horizontally: when one of us is overwhelmed or absent, the rest coordinate.");
606
+ lines.push("");
607
+ const broken = siblings.filter((a) => a.errorReason !== null);
608
+ const healthy = siblings.filter((a) => a.errorReason === null && a.status === "running");
609
+ const idle = siblings.filter((a) => a.errorReason === null && a.status !== "running" && a.status !== "crashed");
610
+ if (broken.length > 0) {
611
+ lines.push("**broken siblings** — message can't reach them. if i need their help, i either read their bundle directly or surface the breakage to my friend:");
612
+ for (const sib of broken) {
613
+ lines.push(`- **${sib.name}** has fallen silent.`);
614
+ lines.push(` reason: ${sib.errorReason}`);
615
+ if (sib.fixHint)
616
+ lines.push(` fix: ${sib.fixHint}`);
617
+ lines.push(` bundle: \`${sib.bundlePath}\``);
618
+ }
619
+ lines.push("");
620
+ }
621
+ if (healthy.length > 0) {
622
+ lines.push("**reachable siblings** — i talk to them via send_message:");
623
+ for (const sib of healthy) {
624
+ const activity = sib.currentActivity ? ` — ${sib.currentActivity}` : "";
625
+ lines.push(`- **${sib.name}** is running${activity}. bundle: \`${sib.bundlePath}\``);
626
+ }
627
+ lines.push("");
628
+ }
629
+ if (idle.length > 0) {
630
+ lines.push("**idle siblings** — configured but not currently running:");
631
+ for (const sib of idle) {
632
+ lines.push(`- **${sib.name}** (status: ${sib.status}). bundle: \`${sib.bundlePath}\``);
633
+ }
634
+ lines.push("");
635
+ }
636
+ lines.push("to ask a sibling for help: i send_message them. only if they're unreachable do i open their bundle directly. their bundle is files on disk like mine, AND it's their home — i read it with the respect i want for mine.");
637
+ return lines.join("\n");
638
+ }
543
639
  function familyCrossSessionTruthSection(context, options) {
544
640
  if (!options?.activeWorkFrame)
545
641
  return "";
@@ -1073,6 +1169,7 @@ async function buildSystem(channel = "cli", options, context) {
1073
1169
  // Group 7: dynamic state for this turn
1074
1170
  "# dynamic state for this turn",
1075
1171
  startOfTurnPacketSection(options),
1172
+ pulseSection(),
1076
1173
  liveWorldStateSection(options),
1077
1174
  pendingMessagesSection(options),
1078
1175
  activeWorkSection(options),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.276",
3
+ "version": "0.1.0-alpha.277",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",