@ouro.bot/cli 0.1.0-alpha.151 → 0.1.0-alpha.153

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,25 @@
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.153",
6
+ "changes": [
7
+ "Daemon health file: DaemonHealthWriter writes atomic daemon-health.json on every state change. readHealth() standalone reader for CLI and agent processes.",
8
+ "Nerves event buffer: createBufferedSink wraps sinks with bounded ring buffer (1000 events), auto-flushes on recovery, TTL discards after 5 min of unhealthy sink.",
9
+ "Cron verification + timer fallback: after cron sync, verifyCronEntries checks launchctl/crontab for registered habits. Unverified habits get in-process setTimeout fallback. getDegradedHabits() for health reporting.",
10
+ "Periodic reconciliation: 30s initial delay then 5-minute setTimeout chain re-scans habits and re-verifies cron entries.",
11
+ "Safe mode: detectSafeMode reads crash history (recentCrashes in tombstone). 3+ crashes in 5 minutes triggers safe mode - daemon stays alive, socket works, agents/habits paused. ouro up --force writes override file.",
12
+ "Health nerves sink: createHealthNervesSink registers as global sink, listens for daemon events (agent_started, agent_exit, habit_fire, safe_mode_entered, etc.), triggers debounced health writes.",
13
+ "ouro status health fallback: when daemon socket is unreachable, reads health file to show last-known status, safe mode, and degraded components.",
14
+ "Degraded state in agent dialog: habit turns read daemon-health.json and append scheduling degradation nudge when components are degraded."
15
+ ]
16
+ },
17
+ {
18
+ "version": "0.1.0-alpha.152",
19
+ "changes": [
20
+ "fix(nerves): catch EPIPE on terminal sink for detached daemon"
21
+ ]
22
+ },
4
23
  {
5
24
  "version": "0.1.0-alpha.151",
6
25
  "changes": [
@@ -72,6 +72,7 @@ const ouro_bot_global_installer_1 = require("./ouro-bot-global-installer");
72
72
  const launchd_1 = require("./launchd");
73
73
  const socket_client_1 = require("./socket-client");
74
74
  const daemon_tombstone_1 = require("./daemon-tombstone");
75
+ const daemon_health_1 = require("./daemon-health");
75
76
  const session_activity_1 = require("../session-activity");
76
77
  const auth_flow_1 = require("./auth-flow");
77
78
  function stringField(value) {
@@ -350,7 +351,7 @@ function buildStoppedStatusPayload(socketPath) {
350
351
  workers: [],
351
352
  };
352
353
  }
353
- function daemonUnavailableStatusOutput(socketPath) {
354
+ function daemonUnavailableStatusOutput(socketPath, healthFilePath) {
354
355
  /* v8 ignore start — tombstone read tested in daemon-status-tombstone.test; branch misreported @preserve */
355
356
  const tombstone = (0, daemon_tombstone_1.readDaemonTombstone)();
356
357
  const deathLine = tombstone
@@ -371,6 +372,23 @@ function daemonUnavailableStatusOutput(socketPath) {
371
372
  lines.push("");
372
373
  /* v8 ignore stop */
373
374
  }
375
+ // Read health file for last-known state (best-effort)
376
+ const resolvedHealthPath = healthFilePath ?? (0, daemon_health_1.getDefaultHealthPath)();
377
+ const health = (0, daemon_health_1.readHealth)(resolvedHealthPath);
378
+ if (health) {
379
+ lines.push(`Last known status: ${health.status} (pid ${health.pid}, uptime ${health.uptimeSeconds}s)`);
380
+ if (health.safeMode?.active) {
381
+ lines.push(`SAFE MODE: ${health.safeMode.reason}`);
382
+ }
383
+ if (health.degraded.length > 0) {
384
+ lines.push("");
385
+ lines.push("Degraded:");
386
+ for (const d of health.degraded) {
387
+ lines.push(` ${d.component}: ${d.reason} (since ${d.since})`);
388
+ }
389
+ }
390
+ lines.push("");
391
+ }
374
392
  lines.push("daemon not running; run `ouro up`");
375
393
  return lines.join("\n");
376
394
  }
@@ -3128,7 +3146,7 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
3128
3146
  return message;
3129
3147
  }
3130
3148
  if (command.kind === "daemon.status" && isDaemonUnavailableError(error)) {
3131
- const message = daemonUnavailableStatusOutput(deps.socketPath);
3149
+ const message = daemonUnavailableStatusOutput(deps.socketPath, deps.healthFilePath);
3132
3150
  deps.writeStdout(message);
3133
3151
  return message;
3134
3152
  }
@@ -0,0 +1,136 @@
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.HEALTH_TRACKED_EVENTS = exports.DaemonHealthWriter = void 0;
37
+ exports.getDefaultHealthPath = getDefaultHealthPath;
38
+ exports.createHealthNervesSink = createHealthNervesSink;
39
+ exports.readHealth = readHealth;
40
+ const fs = __importStar(require("fs"));
41
+ const os = __importStar(require("os"));
42
+ const path = __importStar(require("path"));
43
+ const runtime_1 = require("../../nerves/runtime");
44
+ class DaemonHealthWriter {
45
+ healthPath;
46
+ constructor(healthPath) {
47
+ this.healthPath = healthPath;
48
+ }
49
+ writeHealth(state) {
50
+ try {
51
+ const dir = path.dirname(this.healthPath);
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ const tmpPath = `${this.healthPath}.tmp.${process.pid}`;
54
+ fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
55
+ fs.renameSync(tmpPath, this.healthPath);
56
+ (0, runtime_1.emitNervesEvent)({
57
+ component: "daemon",
58
+ event: "daemon.health_written",
59
+ message: "daemon health file written",
60
+ meta: { path: this.healthPath, status: state.status },
61
+ });
62
+ }
63
+ catch {
64
+ // Best-effort: if we can't write, don't crash the daemon.
65
+ }
66
+ }
67
+ }
68
+ exports.DaemonHealthWriter = DaemonHealthWriter;
69
+ function getDefaultHealthPath() {
70
+ return path.join(os.homedir(), ".ouro-cli", "daemon-health.json");
71
+ }
72
+ /** Events that trigger a debounced health file write */
73
+ exports.HEALTH_TRACKED_EVENTS = new Set([
74
+ "daemon.habit_cron_verification_failed",
75
+ "daemon.habit_fire",
76
+ "daemon.agent_exit",
77
+ "daemon.agent_started",
78
+ "daemon.agent_restart_exhausted",
79
+ "daemon.agent_permanent_failure",
80
+ "daemon.agent_cooldown_recovery",
81
+ "daemon.safe_mode_entered",
82
+ "daemon.habit_scheduler_start",
83
+ ]);
84
+ /**
85
+ * Creates a nerves LogSink that triggers debounced health writes on relevant events.
86
+ * Components don't know about the health writer — they just emit events.
87
+ */
88
+ function createHealthNervesSink(writer, getState) {
89
+ let debounceTimer = null;
90
+ return (entry) => {
91
+ if (!exports.HEALTH_TRACKED_EVENTS.has(entry.event)) {
92
+ return;
93
+ }
94
+ // Debounce: max once per second
95
+ if (debounceTimer !== null) {
96
+ clearTimeout(debounceTimer);
97
+ }
98
+ debounceTimer = setTimeout(() => {
99
+ debounceTimer = null;
100
+ const state = getState();
101
+ writer.writeHealth(state);
102
+ }, 1000);
103
+ };
104
+ }
105
+ function readHealth(healthPath) {
106
+ try {
107
+ const raw = fs.readFileSync(healthPath, "utf-8");
108
+ const parsed = JSON.parse(raw);
109
+ if (typeof parsed.status !== "string" ||
110
+ typeof parsed.mode !== "string" ||
111
+ typeof parsed.pid !== "number" ||
112
+ typeof parsed.startedAt !== "string" ||
113
+ typeof parsed.uptimeSeconds !== "number" ||
114
+ !Array.isArray(parsed.degraded) ||
115
+ typeof parsed.agents !== "object" ||
116
+ parsed.agents === null ||
117
+ typeof parsed.habits !== "object" ||
118
+ parsed.habits === null) {
119
+ return null;
120
+ }
121
+ return {
122
+ status: parsed.status,
123
+ mode: parsed.mode,
124
+ pid: parsed.pid,
125
+ startedAt: parsed.startedAt,
126
+ uptimeSeconds: parsed.uptimeSeconds,
127
+ safeMode: parsed.safeMode,
128
+ degraded: parsed.degraded,
129
+ agents: parsed.agents,
130
+ habits: parsed.habits,
131
+ };
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
@@ -53,15 +53,29 @@ function setTombstonePath(p) {
53
53
  _tombstonePath = p;
54
54
  }
55
55
  function writeDaemonTombstone(reason, error) {
56
+ const now = new Date().toISOString();
57
+ const filePath = getTombstonePath();
58
+ // Read existing recentCrashes from previous tombstone (best-effort)
59
+ let existingCrashes = [];
60
+ try {
61
+ const raw = fs.readFileSync(filePath, "utf-8");
62
+ const existing = JSON.parse(raw);
63
+ if (Array.isArray(existing.recentCrashes)) {
64
+ existingCrashes = existing.recentCrashes.filter((entry) => typeof entry === "string");
65
+ }
66
+ }
67
+ catch {
68
+ // No existing tombstone or unreadable — start fresh
69
+ }
56
70
  const tombstone = {
57
71
  reason,
58
72
  message: error?.message ?? reason,
59
73
  stack: error?.stack ?? null,
60
- timestamp: new Date().toISOString(),
74
+ timestamp: now,
61
75
  pid: process.pid,
62
76
  uptimeSeconds: Math.floor(process.uptime()),
77
+ recentCrashes: [...existingCrashes, now],
63
78
  };
64
- const filePath = getTombstonePath();
65
79
  try {
66
80
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
67
81
  fs.writeFileSync(filePath, JSON.stringify(tombstone, null, 2) + "\n", "utf-8");
@@ -98,6 +112,9 @@ function readDaemonTombstone() {
98
112
  timestamp: parsed.timestamp,
99
113
  pid: typeof parsed.pid === "number" ? parsed.pid : 0,
100
114
  uptimeSeconds: typeof parsed.uptimeSeconds === "number" ? parsed.uptimeSeconds : 0,
115
+ recentCrashes: Array.isArray(parsed.recentCrashes)
116
+ ? parsed.recentCrashes.filter((e) => typeof e === "string")
117
+ : [],
101
118
  };
102
119
  }
103
120
  catch {
@@ -45,15 +45,22 @@ class HabitScheduler {
45
45
  osCronManager;
46
46
  onHabitFire;
47
47
  deps;
48
+ execForVerify;
49
+ platform;
48
50
  watcher = null;
49
51
  debounceTimer = null;
50
52
  parseErrors = [];
53
+ timerFallbacks = new Map();
54
+ degradedHabitNames = new Map();
55
+ periodicTimer = null;
51
56
  constructor(options) {
52
57
  this.agent = options.agent;
53
58
  this.habitsDir = options.habitsDir;
54
59
  this.osCronManager = options.osCronManager;
55
60
  this.onHabitFire = options.onHabitFire;
56
61
  this.deps = options.deps;
62
+ this.execForVerify = options.execForVerify;
63
+ this.platform = options.platform ?? process.platform;
57
64
  }
58
65
  start() {
59
66
  (0, runtime_1.emitNervesEvent)({
@@ -65,6 +72,7 @@ class HabitScheduler {
65
72
  const habits = this.scanHabits();
66
73
  const jobs = this.buildJobs(habits);
67
74
  this.osCronManager.sync(jobs);
75
+ this.verifyCronAndCreateFallbacks(jobs);
68
76
  // Fire overdue habits immediately
69
77
  for (const habit of habits) {
70
78
  if (habit.status !== "active")
@@ -105,9 +113,12 @@ class HabitScheduler {
105
113
  message: "habit scheduler reconciling",
106
114
  meta: { agent: this.agent },
107
115
  });
116
+ // Clear ALL existing timers FIRST to prevent overlap window
117
+ this.clearAllTimerFallbacks();
108
118
  const habits = this.scanHabits();
109
119
  const jobs = this.buildJobs(habits);
110
120
  this.osCronManager.sync(jobs);
121
+ this.verifyCronAndCreateFallbacks(jobs);
111
122
  }
112
123
  stop() {
113
124
  (0, runtime_1.emitNervesEvent)({
@@ -116,6 +127,8 @@ class HabitScheduler {
116
127
  message: "habit scheduler stopping",
117
128
  meta: { agent: this.agent },
118
129
  });
130
+ this.stopPeriodicReconciliation();
131
+ this.clearAllTimerFallbacks();
119
132
  this.osCronManager.removeAll();
120
133
  }
121
134
  listOverdueHabits() {
@@ -187,6 +200,107 @@ class HabitScheduler {
187
200
  this.watcher = null;
188
201
  }
189
202
  }
203
+ getDegradedHabits() {
204
+ const result = [];
205
+ for (const [name, reason] of this.degradedHabitNames) {
206
+ result.push({ name, reason });
207
+ }
208
+ return result;
209
+ }
210
+ static DEFAULT_PERIODIC_INTERVAL_MS = 300_000; // 5 minutes
211
+ static INITIAL_RECONCILIATION_DELAY_MS = 30_000; // 30 seconds
212
+ startPeriodicReconciliation(intervalMs) {
213
+ const interval = intervalMs ?? HabitScheduler.DEFAULT_PERIODIC_INTERVAL_MS;
214
+ // First reconciliation after a short delay (30s)
215
+ this.periodicTimer = setTimeout(() => {
216
+ this.reconcile();
217
+ this.scheduleNextReconciliation(interval);
218
+ }, HabitScheduler.INITIAL_RECONCILIATION_DELAY_MS);
219
+ }
220
+ stopPeriodicReconciliation() {
221
+ if (this.periodicTimer !== null) {
222
+ clearTimeout(this.periodicTimer);
223
+ this.periodicTimer = null;
224
+ }
225
+ }
226
+ scheduleNextReconciliation(intervalMs) {
227
+ this.periodicTimer = setTimeout(() => {
228
+ this.reconcile();
229
+ this.scheduleNextReconciliation(intervalMs);
230
+ }, intervalMs);
231
+ }
232
+ verifyCronAndCreateFallbacks(jobs) {
233
+ if (!this.execForVerify)
234
+ return;
235
+ const verifiedLabels = this.verifyCronEntries();
236
+ for (const job of jobs) {
237
+ const label = `bot.ouro.${job.agent}.${job.taskId}`;
238
+ const isVerified = this.platform === "darwin"
239
+ ? verifiedLabels.has(label)
240
+ : verifiedLabels.has(job.taskId);
241
+ if (!isVerified) {
242
+ (0, runtime_1.emitNervesEvent)({
243
+ component: "daemon",
244
+ event: "daemon.habit_cron_verification_failed",
245
+ message: `cron verification failed for habit: ${job.taskId}`,
246
+ meta: { habitName: job.taskId, agent: job.agent, label },
247
+ });
248
+ // Parse cadence from the original habit file for timer interval
249
+ const habitFile = this.getHabitFile(job.taskId);
250
+ const ms = habitFile?.cadence ? (0, cadence_1.parseCadenceToMs)(habitFile.cadence) : null;
251
+ if (ms !== null) {
252
+ this.createTimerFallback(job.taskId, ms);
253
+ }
254
+ this.degradedHabitNames.set(job.taskId, "cron registration failed — using timer fallback");
255
+ }
256
+ }
257
+ }
258
+ verifyCronEntries() {
259
+ const verified = new Set();
260
+ try {
261
+ if (this.platform === "darwin") {
262
+ const output = this.execForVerify("launchctl list");
263
+ const lines = output.split("\n");
264
+ for (const line of lines) {
265
+ const match = line.match(/bot\.ouro\.\S+\.\S+/);
266
+ if (match) {
267
+ verified.add(match[0]);
268
+ }
269
+ }
270
+ }
271
+ else {
272
+ const output = this.execForVerify("crontab -l");
273
+ const lines = output.split("\n");
274
+ for (const line of lines) {
275
+ const match = line.match(/ouro poke \S+ --habit (\S+)/);
276
+ if (match) {
277
+ verified.add(match[1]);
278
+ }
279
+ }
280
+ }
281
+ }
282
+ catch {
283
+ // Verification command failed — return empty set (all habits unverified)
284
+ }
285
+ return verified;
286
+ }
287
+ createTimerFallback(habitName, cadenceMs) {
288
+ const schedule = () => {
289
+ const timer = setTimeout(() => {
290
+ this.onHabitFire(habitName);
291
+ schedule();
292
+ }, cadenceMs);
293
+ this.timerFallbacks.set(habitName, timer);
294
+ };
295
+ schedule();
296
+ }
297
+ clearAllTimerFallbacks() {
298
+ for (const timer of this.timerFallbacks.values()) {
299
+ clearTimeout(timer);
300
+ }
301
+ this.timerFallbacks.clear();
302
+ this.degradedHabitNames.clear();
303
+ }
190
304
  scanHabits() {
191
305
  let files;
192
306
  try {
@@ -0,0 +1,161 @@
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.SAFE_MODE_OVERRIDE_FILENAME = void 0;
37
+ exports.detectSafeMode = detectSafeMode;
38
+ exports.pruneOldCrashes = pruneOldCrashes;
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ const runtime_1 = require("../../nerves/runtime");
42
+ exports.SAFE_MODE_OVERRIDE_FILENAME = "safe-mode-override.json";
43
+ /** 3+ crashes within this window triggers safe mode */
44
+ const CRASH_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
45
+ const CRASH_THRESHOLD = 3;
46
+ /**
47
+ * Reads crash history from the tombstone file and determines if safe mode should be active.
48
+ * Returns active if 3+ crashes occurred within 5 minutes.
49
+ *
50
+ * Safe mode is bypassed when:
51
+ * - devMode is true
52
+ * - A safe-mode override file exists (written by `ouro up --force`)
53
+ */
54
+ function detectSafeMode(tombstonePath, options) {
55
+ const inactive = { active: false, reason: "", enteredAt: "" };
56
+ // Dev mode: never enter safe mode
57
+ if (options?.devMode) {
58
+ return inactive;
59
+ }
60
+ // Check for override file (--force)
61
+ const overridePath = path.join(path.dirname(tombstonePath), exports.SAFE_MODE_OVERRIDE_FILENAME);
62
+ try {
63
+ if (fs.existsSync(overridePath)) {
64
+ return inactive;
65
+ }
66
+ }
67
+ catch {
68
+ // Best-effort check
69
+ }
70
+ // Read tombstone
71
+ let parsed;
72
+ try {
73
+ const raw = fs.readFileSync(tombstonePath, "utf-8");
74
+ parsed = JSON.parse(raw);
75
+ }
76
+ catch {
77
+ return inactive;
78
+ }
79
+ // Extract recentCrashes
80
+ if (!Array.isArray(parsed.recentCrashes)) {
81
+ return inactive;
82
+ }
83
+ const nowMs = options?.now ? options.now() : Date.now();
84
+ const windowStart = nowMs - CRASH_WINDOW_MS;
85
+ // Filter to valid string timestamps within the crash window
86
+ const recentInWindow = parsed.recentCrashes.filter((entry) => {
87
+ if (typeof entry !== "string")
88
+ return false;
89
+ const ts = new Date(entry).getTime();
90
+ if (isNaN(ts))
91
+ return false;
92
+ return ts >= windowStart;
93
+ });
94
+ if (recentInWindow.length < CRASH_THRESHOLD) {
95
+ return inactive;
96
+ }
97
+ const result = {
98
+ active: true,
99
+ reason: `crash loop detected: ${recentInWindow.length} crashes in last 5 minutes`,
100
+ enteredAt: new Date(nowMs).toISOString(),
101
+ };
102
+ (0, runtime_1.emitNervesEvent)({
103
+ level: "error",
104
+ component: "daemon",
105
+ event: "daemon.safe_mode_entered",
106
+ message: result.reason,
107
+ meta: {
108
+ crashCount: recentInWindow.length,
109
+ windowMs: CRASH_WINDOW_MS,
110
+ tombstonePath,
111
+ },
112
+ });
113
+ return result;
114
+ }
115
+ /**
116
+ * Prunes crash entries older than 5 minutes from the tombstone's recentCrashes.
117
+ * Also removes the safe-mode override file if present.
118
+ * Called after successful startup (uptime > stability threshold).
119
+ */
120
+ function pruneOldCrashes(tombstonePath, options) {
121
+ // Remove override file
122
+ const overridePath = path.join(path.dirname(tombstonePath), exports.SAFE_MODE_OVERRIDE_FILENAME);
123
+ try {
124
+ if (fs.existsSync(overridePath)) {
125
+ fs.unlinkSync(overridePath);
126
+ }
127
+ }
128
+ catch {
129
+ // Best-effort
130
+ }
131
+ // Read existing tombstone
132
+ let parsed;
133
+ try {
134
+ const raw = fs.readFileSync(tombstonePath, "utf-8");
135
+ parsed = JSON.parse(raw);
136
+ }
137
+ catch {
138
+ return;
139
+ }
140
+ if (!Array.isArray(parsed.recentCrashes)) {
141
+ return;
142
+ }
143
+ const nowMs = options?.now ? options.now() : Date.now();
144
+ const windowStart = nowMs - CRASH_WINDOW_MS;
145
+ // Keep only entries within the window
146
+ const pruned = parsed.recentCrashes.filter((entry) => {
147
+ if (typeof entry !== "string")
148
+ return false;
149
+ const ts = new Date(entry).getTime();
150
+ if (isNaN(ts))
151
+ return false;
152
+ return ts >= windowStart;
153
+ });
154
+ parsed.recentCrashes = pruned;
155
+ try {
156
+ fs.writeFileSync(tombstonePath, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
157
+ }
158
+ catch {
159
+ // Best-effort
160
+ }
161
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBufferedSink = createBufferedSink;
4
+ const runtime_1 = require("./runtime");
5
+ const DEFAULT_MAX_SIZE = 1000;
6
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
7
+ function createBufferedSink(inner, options = {}) {
8
+ const maxSize = options.maxSize ?? DEFAULT_MAX_SIZE;
9
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
10
+ const nowMs = options.nowMs ?? (() => Date.now());
11
+ const buffer = [];
12
+ let dropped = 0;
13
+ let sinkHealthy = true;
14
+ let unhealthySince = null;
15
+ function tryInner(event) {
16
+ try {
17
+ inner(event);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function markUnhealthy() {
25
+ sinkHealthy = false;
26
+ unhealthySince = unhealthySince ?? nowMs();
27
+ }
28
+ function markHealthy() {
29
+ sinkHealthy = true;
30
+ unhealthySince = null;
31
+ }
32
+ function discardBuffer() {
33
+ const count = buffer.length;
34
+ dropped += count;
35
+ buffer.length = 0;
36
+ (0, runtime_1.emitNervesEvent)({
37
+ component: "nerves",
38
+ event: "nerves.buffer_ttl_discard",
39
+ message: `discarded ${count} buffered events after TTL`,
40
+ meta: { discarded: count, ttlMs },
41
+ });
42
+ }
43
+ function checkTtl() {
44
+ if (unhealthySince !== null && nowMs() - unhealthySince > ttlMs) {
45
+ discardBuffer();
46
+ // Reset unhealthySince to now so TTL starts fresh for newly buffered events
47
+ unhealthySince = nowMs();
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ function addToBuffer(event) {
53
+ if (buffer.length >= maxSize) {
54
+ buffer.shift();
55
+ dropped++;
56
+ }
57
+ buffer.push(event);
58
+ }
59
+ function flushBuffer() {
60
+ while (buffer.length > 0) {
61
+ const event = buffer[0];
62
+ if (tryInner(event)) {
63
+ buffer.shift();
64
+ }
65
+ else {
66
+ markUnhealthy();
67
+ return;
68
+ }
69
+ }
70
+ }
71
+ function sink(event) {
72
+ if (!sinkHealthy) {
73
+ checkTtl();
74
+ // Try sending the new event to see if inner has recovered
75
+ if (tryInner(event)) {
76
+ markHealthy();
77
+ flushBuffer();
78
+ }
79
+ else {
80
+ addToBuffer(event);
81
+ }
82
+ return;
83
+ }
84
+ if (tryInner(event)) {
85
+ return;
86
+ }
87
+ // Inner failed
88
+ markUnhealthy();
89
+ addToBuffer(event);
90
+ }
91
+ function flush() {
92
+ if (buffer.length === 0)
93
+ return;
94
+ // Test if inner is recovered by trying the first buffered event
95
+ const first = buffer[0];
96
+ if (tryInner(first)) {
97
+ buffer.shift();
98
+ markHealthy();
99
+ flushBuffer();
100
+ }
101
+ // If still broken, leave buffer intact
102
+ }
103
+ function state() {
104
+ return {
105
+ buffered: buffer.length,
106
+ dropped,
107
+ sinkHealthy,
108
+ };
109
+ }
110
+ return { sink, flush, state };
111
+ }
@@ -83,7 +83,10 @@ function registerSpinnerHooks(pause, resume) {
83
83
  _pauseSpinner = pause;
84
84
  _resumeSpinner = resume;
85
85
  }
86
- function createTerminalSink(write = (chunk) => process.stderr.write(chunk), colorize = true) {
86
+ function createTerminalSink(write = (chunk) => { try {
87
+ process.stderr.write(chunk);
88
+ }
89
+ catch { /* EPIPE: daemon detached, no terminal */ } }, colorize = true) {
87
90
  return (entry) => {
88
91
  _pauseSpinner?.();
89
92
  const line = formatTerminalEntry(entry);
@@ -71,6 +71,7 @@ const contextual_heartbeat_1 = require("./contextual-heartbeat");
71
71
  const journal_index_1 = require("../mind/journal-index");
72
72
  const habit_parser_1 = require("../heart/daemon/habit-parser");
73
73
  const cadence_1 = require("../heart/daemon/cadence");
74
+ const daemon_health_1 = require("../heart/daemon/daemon-health");
74
75
  const DEFAULT_INNER_DIALOG_INSTINCTS = [
75
76
  {
76
77
  id: "heartbeat_checkin",
@@ -647,6 +648,17 @@ async function runInnerDialogTurn(options) {
647
648
  // Habit file not found
648
649
  userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
649
650
  }
651
+ // Degraded state nudge (best-effort: never crash on health file read failure)
652
+ try {
653
+ const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
654
+ if (health && health.degraded.length > 0) {
655
+ const reasons = health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ");
656
+ userContent = `${userContent}\n\n[note: my scheduling is degraded: ${reasons}]`;
657
+ }
658
+ }
659
+ catch {
660
+ // Best-effort: missing file or parse error -> no nudge, no crash
661
+ }
650
662
  }
651
663
  else {
652
664
  userContent = buildInstinctUserMessage(instincts, reason, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.151",
3
+ "version": "0.1.0-alpha.153",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",