@ouro.bot/cli 0.1.0-alpha.526 → 0.1.0-alpha.528

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,23 @@
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.528",
6
+ "changes": [
7
+ "Daemon overview health now degrades whenever an enabled sense is configured but not externally proven healthy, and `ouro up` refuses a healthy handoff when runtime senses are degraded instead of treating unreachable iMessage as green.",
8
+ "Managed sense health probes now carry restart ownership, so failed BlueBubbles/iMessage probes trigger daemon-managed recovery while preserving proof method, proof age, failure layer, recovery action, and pending recovery age in status surfaces.",
9
+ "BlueBubbles webhooks now durably capture inbound and mutation sidecars before acknowledging the HTTP request, then process the slower agent turn asynchronously so live iMessage delivery is not blocked by model/runtime latency.",
10
+ "MCP agent status now returns concrete runtime facts instead of terse placeholder text, giving external tools a proof-bearing status surface for Slugger and other agents."
11
+ ]
12
+ },
13
+ {
14
+ "version": "0.1.0-alpha.527",
15
+ "changes": [
16
+ "Suppresses `onResult`/`onFailure` in the shared tool-activity callbacks factory for any tool that started hidden, so a hidden tool's END never re-emits its raw args into chat surfaces — fixing rejected `settle` calls leaking `answer=`/`intent=` into BlueBubbles and Teams threads.",
17
+ "Tracks hidden-at-start tools by per-name counter to stay sound across concurrent same-name hidden starts, with no behavior change for visible tools.",
18
+ "Adds heart-level regression tests for hidden-tool END suppression (success and failure paths, concurrent same-name) and senses-level regression tests against `createBlueBubblesCallbacks` and `createTeamsCallbacks` asserting that a rejected settle following a visible read_file produces no chat output containing the settle answer text or `intent=`/`answer=` substrings."
19
+ ]
20
+ },
4
21
  {
5
22
  "version": "0.1.0-alpha.526",
6
23
  "changes": [
@@ -157,13 +157,22 @@ async function handleAgentStatus(params) {
157
157
  const innerStatus = readInnerDialogStatus(params.agent);
158
158
  const sessions = enumerateSessions(params.agent);
159
159
  emit("daemon.agent_service_end", "completed agent.status", { agent: params.agent });
160
+ const innerStatusValue = innerStatus?.status ?? "unknown";
161
+ const lastThoughtAt = innerStatus?.lastCompletedAt ?? null;
162
+ const message = [
163
+ `agent=${params.agent}`,
164
+ `innerStatus=${innerStatusValue}`,
165
+ `lastThoughtAt=${lastThoughtAt ?? "never"}`,
166
+ `sessionCount=${sessions.length}`,
167
+ `diaryEntries=${facts.length}`,
168
+ ].join("\t");
160
169
  return {
161
170
  ok: true,
162
- message: `Agent ${params.agent} status`,
171
+ message,
163
172
  data: {
164
173
  agent: params.agent,
165
- innerStatus: innerStatus?.status ?? "unknown",
166
- lastThoughtAt: innerStatus?.lastCompletedAt ?? null,
174
+ innerStatus: innerStatusValue,
175
+ lastThoughtAt,
167
176
  sessionCount: sessions.length,
168
177
  hasDiaryEntries: facts.length > 0,
169
178
  factCount: facts.length,
@@ -621,7 +621,7 @@ function finalDaemonFailureMessage(deps, reason) {
621
621
  lines.push("Run `ouro up` again or `ouro doctor` for a deeper diagnosis.");
622
622
  return lines.join("\n");
623
623
  }
624
- async function verifyDaemonReadyForHandoff(deps) {
624
+ async function verifyDaemonReadyForHandoff(deps, options = {}) {
625
625
  const socketAlive = await deps.checkSocketAlive(deps.socketPath);
626
626
  if (!socketAlive) {
627
627
  return {
@@ -654,6 +654,23 @@ async function verifyDaemonReadyForHandoff(deps) {
654
654
  message: finalDaemonFailureMessage(deps, `the daemon reported state ${payload.overview.daemon}`),
655
655
  };
656
656
  }
657
+ if (payload.overview.health !== "ok") {
658
+ const degradedSenses = payload.senses
659
+ .filter((sense) => sense.enabled && !["running", "interactive", "disabled", "not_attached"].includes(sense.status))
660
+ .map((sense) => `${sense.agent}/${sense.sense}: ${sense.status} - ${sense.detail}`);
661
+ const detail = degradedSenses.length > 0 ? `; ${degradedSenses.join("; ")}` : "";
662
+ if (options.allowDegradedHealth) {
663
+ return {
664
+ ok: true,
665
+ summary: `runtime health ${payload.overview.health}`,
666
+ };
667
+ }
668
+ return {
669
+ ok: false,
670
+ summary: `daemon health ${payload.overview.health}`,
671
+ message: finalDaemonFailureMessage(deps, `runtime health is ${payload.overview.health}${detail}`),
672
+ };
673
+ }
657
674
  const workerCount = payload.workers.length;
658
675
  return {
659
676
  ok: true,
@@ -5863,7 +5880,10 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
5863
5880
  daemonResult.stability = mergeStartupStability(daemonResult.stability, providerDegraded);
5864
5881
  progress.completePhase("provider checks", providerRepairCountSummary(providerDegraded.length));
5865
5882
  progress.startPhase("final daemon check");
5866
- const finalDaemonCheck = await verifyDaemonReadyForHandoff(deps);
5883
+ const finalDaemonCheck = await verifyDaemonReadyForHandoff(deps, {
5884
+ allowDegradedHealth: Boolean(command.noRepair
5885
+ && ((daemonResult.stability?.degraded.length ?? 0) > 0 || providerDegraded.length > 0)),
5886
+ });
5867
5887
  if (!finalDaemonCheck.ok) {
5868
5888
  ;
5869
5889
  progress.failPhase?.("final daemon check", finalDaemonCheck.summary);
@@ -66,6 +66,50 @@ function numberField(value) {
66
66
  function booleanField(value) {
67
67
  return typeof value === "boolean" ? value : null;
68
68
  }
69
+ function formatDurationMs(ms) {
70
+ if (ms < 1_000)
71
+ return `${Math.round(ms)}ms`;
72
+ if (ms < 60_000)
73
+ return `${Math.round(ms / 1_000)}s`;
74
+ if (ms < 3_600_000)
75
+ return `${Math.round(ms / 60_000)}m`;
76
+ if (ms < 86_400_000)
77
+ return `${Math.round(ms / 3_600_000)}h`;
78
+ return `${Math.round(ms / 86_400_000)}d`;
79
+ }
80
+ function formatSenseDetail(row) {
81
+ const details = [row.detail];
82
+ const proofParts = [];
83
+ if (row.proofMethod)
84
+ proofParts.push(`proof=${row.proofMethod}`);
85
+ if (row.lastProofAt)
86
+ proofParts.push(`lastProof=${row.lastProofAt}`);
87
+ if (row.proofAgeMs !== undefined)
88
+ proofParts.push(`proofAge=${formatDurationMs(row.proofAgeMs)}`);
89
+ if (proofParts.length > 0)
90
+ details.push(proofParts.join(" "));
91
+ const recoveryParts = [];
92
+ if (row.pendingRecoveryCount !== undefined)
93
+ recoveryParts.push(`pendingRecovery=${row.pendingRecoveryCount}`);
94
+ if (row.failedRecoveryCount !== undefined)
95
+ recoveryParts.push(`failedRecovery=${row.failedRecoveryCount}`);
96
+ if (row.oldestPendingRecoveryAt)
97
+ recoveryParts.push(`oldestPending=${row.oldestPendingRecoveryAt}`);
98
+ if (row.oldestPendingRecoveryAgeMs !== undefined)
99
+ recoveryParts.push(`oldestPendingAge=${formatDurationMs(row.oldestPendingRecoveryAgeMs)}`);
100
+ if (recoveryParts.length > 0)
101
+ details.push(recoveryParts.join(" "));
102
+ const failureParts = [];
103
+ if (row.failureLayer)
104
+ failureParts.push(`failureLayer=${row.failureLayer}`);
105
+ if (row.lastFailure)
106
+ failureParts.push(`lastFailure=${row.lastFailure}`);
107
+ if (row.recoveryAction)
108
+ failureParts.push(`recovery=${row.recoveryAction}`);
109
+ if (failureParts.length > 0)
110
+ details.push(failureParts.join(" "));
111
+ return details.filter(Boolean).join("; ");
112
+ }
69
113
  // ── Parsers ──
70
114
  function parseStatusPayload(data) {
71
115
  if (!data || typeof data !== "object" || Array.isArray(data))
@@ -116,7 +160,7 @@ function parseStatusPayload(data) {
116
160
  const enabled = booleanField(row.enabled);
117
161
  if (!agent || !sense || !status || detail === null || enabled === null)
118
162
  return null;
119
- return {
163
+ const parsed = {
120
164
  agent,
121
165
  sense,
122
166
  label: stringField(row.label) ?? undefined,
@@ -124,6 +168,37 @@ function parseStatusPayload(data) {
124
168
  status,
125
169
  detail,
126
170
  };
171
+ const proofMethod = stringField(row.proofMethod);
172
+ const lastProofAt = stringField(row.lastProofAt);
173
+ const proofAgeMs = numberField(row.proofAgeMs);
174
+ const lastFailure = stringField(row.lastFailure);
175
+ const failureLayer = stringField(row.failureLayer);
176
+ const recoveryAction = stringField(row.recoveryAction);
177
+ const pendingRecoveryCount = numberField(row.pendingRecoveryCount);
178
+ const failedRecoveryCount = numberField(row.failedRecoveryCount);
179
+ const oldestPendingRecoveryAt = stringField(row.oldestPendingRecoveryAt);
180
+ const oldestPendingRecoveryAgeMs = numberField(row.oldestPendingRecoveryAgeMs);
181
+ if (proofMethod !== null)
182
+ parsed.proofMethod = proofMethod;
183
+ if (lastProofAt !== null)
184
+ parsed.lastProofAt = lastProofAt;
185
+ if (proofAgeMs !== null)
186
+ parsed.proofAgeMs = proofAgeMs;
187
+ if (lastFailure !== null)
188
+ parsed.lastFailure = lastFailure;
189
+ if (failureLayer !== null)
190
+ parsed.failureLayer = failureLayer;
191
+ if (recoveryAction !== null)
192
+ parsed.recoveryAction = recoveryAction;
193
+ if (pendingRecoveryCount !== null)
194
+ parsed.pendingRecoveryCount = pendingRecoveryCount;
195
+ if (failedRecoveryCount !== null)
196
+ parsed.failedRecoveryCount = failedRecoveryCount;
197
+ if (oldestPendingRecoveryAt !== null)
198
+ parsed.oldestPendingRecoveryAt = oldestPendingRecoveryAt;
199
+ if (oldestPendingRecoveryAgeMs !== null)
200
+ parsed.oldestPendingRecoveryAgeMs = oldestPendingRecoveryAgeMs;
201
+ return parsed;
127
202
  });
128
203
  const parsedWorkers = workers.map((entry) => {
129
204
  if (!entry || typeof entry !== "object" || Array.isArray(entry))
@@ -364,7 +439,7 @@ function formatDaemonStatusOutput(response, fallback) {
364
439
  const name = humanizeSenseName(row.sense, row.label).padEnd(nameWidth);
365
440
  const dot = row.enabled ? statusDot(row.status) : dim("○");
366
441
  const statusText = (row.enabled ? row.status : "disabled").padEnd(statusWidth);
367
- lines.push(` ${name} ${dot} ${statusText} ${dim(row.detail)}`);
442
+ lines.push(` ${name} ${dot} ${statusText} ${dim(formatSenseDetail(row))}`);
368
443
  }
369
444
  }
370
445
  lines.push("");
@@ -148,6 +148,13 @@ const healthMonitor = new health_monitor_1.HealthMonitor({
148
148
  }
149
149
  catch { /* recovery is best-effort */ }
150
150
  },
151
+ /* v8 ignore next 3 -- wiring: delegates to senseManager.restartSense which has focused tests @preserve */
152
+ onCriticalSense: (managedName) => {
153
+ try {
154
+ void senseManager.restartSense(managedName);
155
+ }
156
+ catch { /* recovery is best-effort */ }
157
+ },
151
158
  });
152
159
  const habitSchedulers = [];
153
160
  let entryRuntimeStopping = false;
@@ -345,17 +345,46 @@ function buildWorkerRows(snapshots) {
345
345
  fixHint: snapshot.fixHint ?? null,
346
346
  }));
347
347
  }
348
+ function unhealthySenseRows(senses) {
349
+ return senses.filter((row) => {
350
+ if (!row.enabled)
351
+ return false;
352
+ if (row.status === "disabled" || row.status === "not_attached")
353
+ return false;
354
+ if (row.status === "interactive" || row.status === "running")
355
+ return false;
356
+ return true;
357
+ });
358
+ }
359
+ function overviewHealth(workers, senses) {
360
+ if (!workers.every((worker) => worker.status === "running"))
361
+ return "warn";
362
+ if (unhealthySenseRows(senses).length > 0)
363
+ return "warn";
364
+ return "ok";
365
+ }
348
366
  function formatStatusSummary(payload) {
349
367
  if (payload.overview.workerCount === 0 && payload.overview.senseCount === 0) {
350
368
  return "no managed agents";
351
369
  }
352
- const rows = [
353
- ...payload.workers.map((row) => `${row.agent}/${row.worker}:${row.status}`),
354
- ...payload.senses
355
- .filter((row) => row.enabled)
356
- .map((row) => `${row.agent}/${row.sense}:${row.status}`),
370
+ const degraded = [
371
+ ...payload.workers
372
+ .filter((row) => row.status !== "running")
373
+ .map((row) => `worker:${row.agent}/${row.worker}:${row.status}`),
374
+ ...unhealthySenseRows(payload.senses)
375
+ .map((row) => `sense:${row.agent}/${row.sense}:${row.status}`),
357
376
  ];
358
- const detail = rows.length > 0 ? `\titems=${rows.join(",")}` : "";
377
+ const detail = degraded.length > 0 ? `\tdegraded=${degraded.join(",")}` : "";
378
+ if (!detail) {
379
+ const rows = [
380
+ ...payload.workers.map((row) => `${row.agent}/${row.worker}:${row.status}`),
381
+ ...payload.senses
382
+ .filter((row) => row.enabled)
383
+ .map((row) => `${row.agent}/${row.sense}:${row.status}`),
384
+ ];
385
+ const items = rows.length > 0 ? `\titems=${rows.join(",")}` : "";
386
+ return `daemon=${payload.overview.daemon}\tworkers=${payload.overview.workerCount}\tsenses=${payload.overview.senseCount}\thealth=${payload.overview.health}${items}`;
387
+ }
359
388
  return `daemon=${payload.overview.daemon}\tworkers=${payload.overview.workerCount}\tsenses=${payload.overview.senseCount}\thealth=${payload.overview.health}${detail}`;
360
389
  }
361
390
  function parseIncomingCommand(raw) {
@@ -480,7 +509,7 @@ class OuroDaemon {
480
509
  return {
481
510
  overview: {
482
511
  daemon: "running",
483
- health: workers.every((worker) => worker.status === "running") ? "ok" : "warn",
512
+ health: overviewHealth(workers, senses),
484
513
  socketPath: this.socketPath,
485
514
  mailboxUrl,
486
515
  outlookUrl: mailboxUrl,
@@ -8,6 +8,7 @@ class HealthMonitor {
8
8
  alertSink;
9
9
  diskUsagePercent;
10
10
  onCriticalAgent;
11
+ onCriticalSense;
11
12
  senseProbes;
12
13
  senseProbeProvider;
13
14
  intervalHandle = null;
@@ -17,9 +18,31 @@ class HealthMonitor {
17
18
  this.alertSink = options.alertSink ?? (() => undefined);
18
19
  this.diskUsagePercent = options.diskUsagePercent ?? (() => 0);
19
20
  this.onCriticalAgent = options.onCriticalAgent ?? (() => undefined);
21
+ this.onCriticalSense = options.onCriticalSense ?? (() => undefined);
20
22
  this.senseProbes = options.senseProbes ?? [];
21
23
  this.senseProbeProvider = options.senseProbeProvider ?? (() => []);
22
24
  }
25
+ triggerSenseRecovery(probe, detail) {
26
+ if (!probe.managedName)
27
+ return;
28
+ try {
29
+ (0, runtime_1.emitNervesEvent)({
30
+ level: "warn",
31
+ component: "daemon",
32
+ event: "daemon.health_check_sense_recovery_attempted",
33
+ message: "triggering recovery restart for failed sense probe",
34
+ meta: {
35
+ probeName: probe.name,
36
+ managedName: probe.managedName,
37
+ detail,
38
+ },
39
+ });
40
+ this.onCriticalSense(probe.managedName, probe.name);
41
+ }
42
+ catch {
43
+ // Recovery is best-effort -- callback errors must not crash runChecks
44
+ }
45
+ }
23
46
  startPeriodicChecks(intervalMs) {
24
47
  if (this.intervalHandle !== null)
25
48
  return;
@@ -137,6 +160,7 @@ class HealthMonitor {
137
160
  });
138
161
  }
139
162
  else {
163
+ this.triggerSenseRecovery(probe, outcome.detail ?? "unknown");
140
164
  results.push({
141
165
  name: `sense-probe:${probe.name}`,
142
166
  status: "critical",
@@ -145,6 +169,7 @@ class HealthMonitor {
145
169
  }
146
170
  }
147
171
  catch (error) {
172
+ this.triggerSenseRecovery(probe, error instanceof Error ? error.message : String(error));
148
173
  results.push({
149
174
  name: `sense-probe:${probe.name}`,
150
175
  status: "critical",
@@ -266,12 +266,19 @@ function readBlueBubblesRuntimeJson(runtimePath) {
266
266
  ? parsed.detail
267
267
  : "startup health probe pending",
268
268
  lastCheckedAt: typeof parsed.lastCheckedAt === "string" ? parsed.lastCheckedAt : undefined,
269
+ proofMethod: typeof parsed.proofMethod === "string" && parsed.proofMethod.trim()
270
+ ? parsed.proofMethod
271
+ : undefined,
269
272
  pendingRecoveryCount: typeof parsed.pendingRecoveryCount === "number" && Number.isFinite(parsed.pendingRecoveryCount)
270
273
  ? parsed.pendingRecoveryCount
271
274
  : 0,
272
275
  failedRecoveryCount: typeof parsed.failedRecoveryCount === "number" && Number.isFinite(parsed.failedRecoveryCount)
273
276
  ? parsed.failedRecoveryCount
274
277
  : 0,
278
+ oldestPendingRecoveryAt: typeof parsed.oldestPendingRecoveryAt === "string" ? parsed.oldestPendingRecoveryAt : undefined,
279
+ oldestPendingRecoveryAgeMs: typeof parsed.oldestPendingRecoveryAgeMs === "number" && Number.isFinite(parsed.oldestPendingRecoveryAgeMs)
280
+ ? parsed.oldestPendingRecoveryAgeMs
281
+ : undefined,
275
282
  };
276
283
  /* v8 ignore stop */
277
284
  /* v8 ignore start -- defensive: catch for missing/corrupt BB runtime state file @preserve */
@@ -288,34 +295,66 @@ function readBlueBubblesRuntimeFacts(agent, bundlesRoot, snapshot) {
288
295
  return { runtime: snapshot?.runtime };
289
296
  }
290
297
  const state = readBlueBubblesRuntimeJson(runtimePath);
298
+ const checkedAtMs = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : Number.NaN;
299
+ const proofFacts = {
300
+ proofMethod: state.proofMethod ?? "bluebubbles.checkHealth",
301
+ lastProofAt: state.lastCheckedAt,
302
+ proofAgeMs: Number.isFinite(checkedAtMs) ? Math.max(0, Date.now() - checkedAtMs) : undefined,
303
+ pendingRecoveryCount: state.pendingRecoveryCount,
304
+ failedRecoveryCount: state.failedRecoveryCount,
305
+ oldestPendingRecoveryAt: state.oldestPendingRecoveryAt,
306
+ oldestPendingRecoveryAgeMs: state.oldestPendingRecoveryAgeMs,
307
+ };
291
308
  if (!blueBubblesRuntimeStateIsFresh(state.lastCheckedAt)) {
292
- return { runtime: snapshot?.runtime };
309
+ return {
310
+ runtime: snapshot?.runtime,
311
+ lastFailure: state.lastCheckedAt ? "BlueBubbles proof is stale" : undefined,
312
+ failureLayer: state.lastCheckedAt ? "proof_freshness" : undefined,
313
+ };
293
314
  }
294
315
  if (snapshot?.runtime !== "running") {
295
316
  return {
296
317
  runtime: "error",
297
318
  detail: "BlueBubbles listener is not running",
319
+ ...proofFacts,
320
+ lastFailure: "listener process is not running",
321
+ failureLayer: "listener",
322
+ recoveryAction: "daemon health monitor will restart the BlueBubbles listener when its probe fails",
298
323
  };
299
324
  }
300
325
  if (state.upstreamStatus === "error") {
301
326
  return {
302
327
  runtime: "error",
303
328
  detail: state.detail,
329
+ ...proofFacts,
330
+ lastFailure: state.detail,
331
+ failureLayer: "upstream",
332
+ recoveryAction: "verify BlueBubbles server/app auth and local machine attachment, then retry the listener",
304
333
  };
305
334
  }
306
335
  if (state.pendingRecoveryCount > 0) {
307
336
  return {
308
337
  runtime: "error",
309
338
  detail: state.detail,
339
+ ...proofFacts,
340
+ lastFailure: state.detail,
341
+ failureLayer: "recovery_queue",
342
+ recoveryAction: "queued recovery will retry; inspect BlueBubbles inbound/recovery sidecar logs if age keeps growing",
310
343
  };
311
344
  }
312
345
  if (state.upstreamStatus === "ok") {
313
346
  return {
314
347
  runtime: "running",
348
+ ...proofFacts,
315
349
  ...(state.failedRecoveryCount > 0 ? { detail: state.detail } : {}),
350
+ ...(state.failedRecoveryCount > 0 ? {
351
+ lastFailure: state.detail,
352
+ failureLayer: "recovery_quarantine",
353
+ recoveryAction: "inspect quarantined BlueBubbles recovery failures; live transport remains reachable",
354
+ } : {}),
316
355
  };
317
356
  }
318
- return { runtime: snapshot?.runtime };
357
+ return { runtime: snapshot?.runtime, ...proofFacts };
319
358
  }
320
359
  class DaemonSenseManager {
321
360
  processManager;
@@ -467,10 +506,20 @@ class DaemonSenseManager {
467
506
  const machinePayload = machineRuntimeConfig.config;
468
507
  const bluebubblesChannel = machinePayload.bluebubblesChannel;
469
508
  const port = numberField(bluebubblesChannel, "port", DEFAULT_BLUEBUBBLES_PORT);
470
- probes.push((0, http_health_probe_1.createHttpHealthProbe)(`bluebubbles:${agent}`, port));
509
+ probes.push({
510
+ ...(0, http_health_probe_1.createHttpHealthProbe)(`bluebubbles:${agent}`, port),
511
+ managedName: `${agent}:bluebubbles`,
512
+ });
471
513
  }
472
514
  return probes;
473
515
  }
516
+ async restartSense(managedName) {
517
+ if (this.processManager.restartAgent) {
518
+ await this.processManager.restartAgent(managedName);
519
+ return;
520
+ }
521
+ await this.processManager.startAgent?.(managedName);
522
+ }
474
523
  listSenseRows() {
475
524
  const runtime = new Map();
476
525
  for (const snapshot of this.processManager.listAgentSnapshots()) {
@@ -513,6 +562,18 @@ class DaemonSenseManager {
513
562
  ?? context.facts[entry.sense].detail
514
563
  : context.facts[entry.sense].detail
515
564
  : "not enabled in agent.json",
565
+ ...(entry.sense === "bluebubbles" ? {
566
+ proofMethod: blueBubblesRuntimeFacts.proofMethod,
567
+ lastProofAt: blueBubblesRuntimeFacts.lastProofAt,
568
+ proofAgeMs: blueBubblesRuntimeFacts.proofAgeMs,
569
+ lastFailure: blueBubblesRuntimeFacts.lastFailure,
570
+ failureLayer: blueBubblesRuntimeFacts.failureLayer,
571
+ recoveryAction: blueBubblesRuntimeFacts.recoveryAction,
572
+ pendingRecoveryCount: blueBubblesRuntimeFacts.pendingRecoveryCount,
573
+ failedRecoveryCount: blueBubblesRuntimeFacts.failedRecoveryCount,
574
+ oldestPendingRecoveryAt: blueBubblesRuntimeFacts.oldestPendingRecoveryAt,
575
+ oldestPendingRecoveryAgeMs: blueBubblesRuntimeFacts.oldestPendingRecoveryAgeMs,
576
+ } : {}),
516
577
  }));
517
578
  });
518
579
  (0, runtime_1.emitNervesEvent)({
@@ -12,24 +12,47 @@ function createToolActivityCallbacks(options) {
12
12
  });
13
13
  // Track the last description so we can reference it in END messages
14
14
  let lastDescription = null;
15
+ // Track in-flight hidden tools so onToolEnd can SYMMETRICALLY suppress
16
+ // emission for the same tools that onToolStart already suppresses.
17
+ // Without this, a rejected hidden tool (e.g. settle blocked by the
18
+ // mustResolveBeforeHandoff gate or the inner-dialog attention-queue gate)
19
+ // would emit "✗ <previous visible tool's description> — <hidden tool's args summary>"
20
+ // because lastDescription persists across calls and the hidden tool's summary
21
+ // (built via summarizeArgs) leaks args like settle's `answer`/`intent` into
22
+ // the visible chat. Counter map (not bool) so concurrent hidden starts don't
23
+ // underflow if ends arrive in any order.
24
+ const hiddenInFlight = new Map();
15
25
  return {
16
26
  onToolStart(name, args) {
17
27
  const description = (0, tool_description_1.humanReadableToolDescription)(name, args);
18
- if (description === null)
19
- return; // hidden tool (settle, rest, descend)
28
+ if (description === null) {
29
+ // hidden tool (settle, rest, descend, observe, speak) — track so the
30
+ // matching onToolEnd is also suppressed symmetrically.
31
+ hiddenInFlight.set(name, (hiddenInFlight.get(name) ?? 0) + 1);
32
+ return;
33
+ }
20
34
  lastDescription = description;
21
35
  options.onDescription(description);
22
36
  },
23
37
  onToolEnd(name, summary, success) {
38
+ const hiddenCount = hiddenInFlight.get(name) ?? 0;
39
+ if (hiddenCount > 0) {
40
+ // Hidden tool's start was suppressed; suppress its end too.
41
+ if (hiddenCount === 1)
42
+ hiddenInFlight.delete(name);
43
+ else
44
+ hiddenInFlight.set(name, hiddenCount - 1);
45
+ return;
46
+ }
24
47
  const desc = lastDescription ?? name;
25
48
  // Strip trailing "..." from description for the result line
26
49
  const cleanDesc = desc.endsWith("...") ? desc.slice(0, -3) : desc;
27
50
  if (!success) {
28
- options.onFailure(`\u2717 ${cleanDesc} — ${summary}`);
51
+ options.onFailure(`✗ ${cleanDesc} — ${summary}`);
29
52
  return;
30
53
  }
31
54
  if (options.isDebug()) {
32
- options.onResult(`\u2713 ${cleanDesc}`);
55
+ options.onResult(`✓ ${cleanDesc}`);
33
56
  }
34
57
  },
35
58
  };
@@ -1239,6 +1239,9 @@ function countPendingRecoveryCandidates(agentName) {
1239
1239
  .length;
1240
1240
  }
1241
1241
  function countPendingCapturedInboundMessages(agentName) {
1242
+ return listPendingCapturedInboundMessages(agentName).length;
1243
+ }
1244
+ function listPendingCapturedInboundMessages(agentName) {
1242
1245
  const seenMessageGuids = new Set();
1243
1246
  return (0, inbound_log_1.listRecordedBlueBubblesInbound)(agentName)
1244
1247
  .filter((entry) => {
@@ -1247,8 +1250,7 @@ function countPendingCapturedInboundMessages(agentName) {
1247
1250
  seenMessageGuids.add(entry.messageGuid);
1248
1251
  return true;
1249
1252
  })
1250
- .filter((entry) => !(0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, entry.sessionKey, entry.messageGuid))
1251
- .length;
1253
+ .filter((entry) => !(0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, entry.sessionKey, entry.messageGuid));
1252
1254
  }
1253
1255
  function parseTimestampMs(value) {
1254
1256
  if (!value)
@@ -1273,6 +1275,23 @@ function formatBlueBubblesRuntimeDetail(queued, failed) {
1273
1275
  return `${failed} message(s) unrecoverable this cycle; upstream ok`;
1274
1276
  return "upstream reachable";
1275
1277
  }
1278
+ function blueBubblesPendingRecoverySnapshot(agentName, nowMs = Date.now()) {
1279
+ const pendingRecordedAt = [
1280
+ ...listPendingCapturedInboundMessages(agentName).map((entry) => entry.recordedAt),
1281
+ ...(0, mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)
1282
+ .filter((entry) => !(0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, entry.sessionKey, entry.messageGuid))
1283
+ .map((entry) => entry.recordedAt),
1284
+ ]
1285
+ .map((value) => ({ value, ms: Date.parse(value) }))
1286
+ .filter((entry) => Number.isFinite(entry.ms))
1287
+ .sort((left, right) => left.ms - right.ms);
1288
+ const oldest = pendingRecordedAt[0];
1289
+ return {
1290
+ pendingRecoveryCount: countPendingCapturedInboundMessages(agentName) + countPendingRecoveryCandidates(agentName),
1291
+ oldestPendingRecoveryAt: oldest?.value,
1292
+ oldestPendingRecoveryAgeMs: oldest ? Math.max(0, nowMs - oldest.ms) : undefined,
1293
+ };
1294
+ }
1276
1295
  async function syncBlueBubblesRuntime(deps = {}) {
1277
1296
  const resolvedDeps = { ...defaultDeps, ...deps };
1278
1297
  const agentName = resolvedDeps.getAgentName();
@@ -1281,13 +1300,13 @@ async function syncBlueBubblesRuntime(deps = {}) {
1281
1300
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
1282
1301
  try {
1283
1302
  await client.checkHealth();
1284
- const capturedPending = countPendingCapturedInboundMessages(agentName);
1285
- const recoveryPending = countPendingRecoveryCandidates(agentName);
1303
+ const pendingBeforeCatchup = blueBubblesPendingRecoverySnapshot(agentName);
1286
1304
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
1287
1305
  upstreamStatus: "ok",
1288
1306
  detail: "upstream reachable; recovery pass running",
1289
1307
  lastCheckedAt: checkedAt,
1290
- pendingRecoveryCount: capturedPending + recoveryPending,
1308
+ proofMethod: "bluebubbles.checkHealth",
1309
+ ...pendingBeforeCatchup,
1291
1310
  lastRecoveredAt: previousState.lastRecoveredAt,
1292
1311
  lastRecoveredMessageGuid: previousState.lastRecoveredMessageGuid,
1293
1312
  });
@@ -1295,7 +1314,8 @@ async function syncBlueBubblesRuntime(deps = {}) {
1295
1314
  processTurns: false,
1296
1315
  });
1297
1316
  const failed = catchUp.failed;
1298
- const queued = capturedPending + recoveryPending + (catchUp.queued ?? 0);
1317
+ const pendingAfterCatchup = blueBubblesPendingRecoverySnapshot(agentName);
1318
+ const queued = pendingAfterCatchup.pendingRecoveryCount;
1299
1319
  // upstreamStatus reflects whether BlueBubbles itself and the local bridge
1300
1320
  // can answer webhook traffic. The daemon status layer treats
1301
1321
  // pendingRecoveryCount as unhealthy for user-facing iMessage reachability,
@@ -1304,6 +1324,8 @@ async function syncBlueBubblesRuntime(deps = {}) {
1304
1324
  upstreamStatus: "ok",
1305
1325
  detail: formatBlueBubblesRuntimeDetail(queued, failed),
1306
1326
  lastCheckedAt: checkedAt,
1327
+ proofMethod: "bluebubbles.checkHealth",
1328
+ ...pendingAfterCatchup,
1307
1329
  pendingRecoveryCount: queued,
1308
1330
  failedRecoveryCount: failed,
1309
1331
  lastRecoveredAt: previousState.lastRecoveredAt,
@@ -1315,7 +1337,8 @@ async function syncBlueBubblesRuntime(deps = {}) {
1315
1337
  upstreamStatus: "error",
1316
1338
  detail: error instanceof Error ? error.message : String(error),
1317
1339
  lastCheckedAt: checkedAt,
1318
- pendingRecoveryCount: countPendingCapturedInboundMessages(agentName) + countPendingRecoveryCandidates(agentName),
1340
+ proofMethod: "bluebubbles.checkHealth",
1341
+ ...blueBubblesPendingRecoverySnapshot(agentName),
1319
1342
  failedRecoveryCount: 0,
1320
1343
  });
1321
1344
  }
@@ -1324,13 +1347,14 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
1324
1347
  const resolvedDeps = { ...defaultDeps, ...deps };
1325
1348
  const agentName = resolvedDeps.getAgentName();
1326
1349
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
1327
- const initialPending = countPendingCapturedInboundMessages(agentName) + countPendingRecoveryCandidates(agentName);
1350
+ const initialPending = blueBubblesPendingRecoverySnapshot(agentName).pendingRecoveryCount;
1328
1351
  if (initialPending === 0) {
1329
1352
  return { recovered: 0, skipped: 0, failed: 0, pendingRecoveryCount: 0 };
1330
1353
  }
1331
1354
  const captured = await recoverCapturedBlueBubblesInboundMessages(resolvedDeps);
1332
1355
  const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
1333
- const pendingRecoveryCount = countPendingCapturedInboundMessages(agentName) + countPendingRecoveryCandidates(agentName);
1356
+ const pendingSnapshot = blueBubblesPendingRecoverySnapshot(agentName);
1357
+ const pendingRecoveryCount = pendingSnapshot.pendingRecoveryCount;
1334
1358
  const failed = captured.failed + recovery.failed;
1335
1359
  const recovered = captured.recovered + recovery.recovered;
1336
1360
  const skipped = captured.skipped + recovery.skipped;
@@ -1341,7 +1365,8 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
1341
1365
  upstreamStatus: "ok",
1342
1366
  detail: formatBlueBubblesRuntimeDetail(pendingRecoveryCount, failed),
1343
1367
  lastCheckedAt: checkedAt,
1344
- pendingRecoveryCount,
1368
+ proofMethod: "bluebubbles.checkHealth",
1369
+ ...pendingSnapshot,
1345
1370
  failedRecoveryCount: failed,
1346
1371
  lastRecoveredAt: recovered > 0 ? checkedAt : previousState.lastRecoveredAt,
1347
1372
  lastRecoveredMessageGuid: previousState.lastRecoveredMessageGuid,
@@ -1352,7 +1377,8 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
1352
1377
  upstreamStatus: "error",
1353
1378
  detail: error instanceof Error ? error.message : String(error),
1354
1379
  lastCheckedAt: checkedAt,
1355
- pendingRecoveryCount,
1380
+ proofMethod: "bluebubbles.checkHealth",
1381
+ ...pendingSnapshot,
1356
1382
  failedRecoveryCount: failed,
1357
1383
  lastRecoveredAt: recovered > 0 ? checkedAt : previousState.lastRecoveredAt,
1358
1384
  lastRecoveredMessageGuid: previousState.lastRecoveredMessageGuid,
@@ -1691,24 +1717,85 @@ function createBlueBubblesWebhookHandler(deps = {}) {
1691
1717
  writeJson(res, 400, { error: "Invalid JSON body" });
1692
1718
  return;
1693
1719
  }
1720
+ let normalized;
1694
1721
  try {
1695
- const result = await handleBlueBubblesEvent(payload, deps);
1696
- writeJson(res, 200, result);
1722
+ normalized = (0, model_1.normalizeBlueBubblesEvent)(payload);
1697
1723
  }
1698
1724
  catch (error) {
1725
+ if (error instanceof model_1.BlueBubblesIgnoredEventError) {
1726
+ (0, runtime_1.emitNervesEvent)({
1727
+ component: "senses",
1728
+ event: "senses.bluebubbles_event_skipped",
1729
+ message: "skipped ignorable bluebubbles event",
1730
+ meta: {
1731
+ eventType: error.eventType,
1732
+ },
1733
+ });
1734
+ writeJson(res, 200, {
1735
+ handled: true,
1736
+ notifiedAgent: false,
1737
+ reason: "ignored",
1738
+ });
1739
+ return;
1740
+ }
1699
1741
  (0, runtime_1.emitNervesEvent)({
1700
1742
  level: "error",
1701
1743
  component: "senses",
1702
1744
  event: "senses.bluebubbles_webhook_error",
1703
1745
  message: "bluebubbles webhook handling failed",
1704
1746
  meta: {
1747
+ /* v8 ignore next -- normalizeBlueBubblesEvent throws Error subclasses; String fallback is defensive @preserve */
1705
1748
  reason: error instanceof Error ? error.message : String(error),
1706
1749
  },
1707
1750
  });
1708
1751
  writeJson(res, 500, {
1752
+ /* v8 ignore next -- normalizeBlueBubblesEvent throws Error subclasses; String fallback is defensive @preserve */
1709
1753
  error: error instanceof Error ? error.message : String(error),
1710
1754
  });
1755
+ return;
1756
+ }
1757
+ const resolvedDeps = { ...defaultDeps, ...deps };
1758
+ if (normalized.kind === "message" && !normalized.fromMe) {
1759
+ (0, inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), normalized, "webhook");
1711
1760
  }
1761
+ else if (normalized.kind === "mutation") {
1762
+ try {
1763
+ resolvedDeps.recordMutation(resolvedDeps.getAgentName(), normalized);
1764
+ }
1765
+ catch (error) {
1766
+ (0, runtime_1.emitNervesEvent)({
1767
+ level: "error",
1768
+ component: "senses",
1769
+ event: "senses.bluebubbles_mutation_log_error",
1770
+ message: "failed recording bluebubbles mutation sidecar",
1771
+ meta: {
1772
+ messageGuid: normalized.messageGuid,
1773
+ mutationType: normalized.mutationType,
1774
+ reason: error instanceof Error ? error.message : String(error),
1775
+ },
1776
+ });
1777
+ }
1778
+ }
1779
+ writeJson(res, 200, {
1780
+ handled: true,
1781
+ notifiedAgent: false,
1782
+ kind: normalized.kind,
1783
+ queued: true,
1784
+ reason: "queued",
1785
+ });
1786
+ setTimeout(() => {
1787
+ void handleBlueBubblesEvent(payload, deps).catch((error) => {
1788
+ (0, runtime_1.emitNervesEvent)({
1789
+ level: "error",
1790
+ component: "senses",
1791
+ event: "senses.bluebubbles_webhook_async_error",
1792
+ message: "bluebubbles webhook async handling failed after durable capture",
1793
+ meta: {
1794
+ reason: error instanceof Error ? error.message : String(error),
1795
+ },
1796
+ });
1797
+ });
1798
+ }, 0);
1712
1799
  };
1713
1800
  }
1714
1801
  function findImessageHandle(friend) {
@@ -64,10 +64,19 @@ function readBlueBubblesRuntimeState(agentName, agentRoot) {
64
64
  ? parsed.detail
65
65
  : DEFAULT_RUNTIME_STATE.detail,
66
66
  lastCheckedAt: typeof parsed.lastCheckedAt === "string" ? parsed.lastCheckedAt : undefined,
67
+ proofMethod: typeof parsed.proofMethod === "string" && parsed.proofMethod.trim()
68
+ ? parsed.proofMethod
69
+ : undefined,
67
70
  pendingRecoveryCount: typeof parsed.pendingRecoveryCount === "number" && Number.isFinite(parsed.pendingRecoveryCount)
68
71
  ? parsed.pendingRecoveryCount
69
72
  : 0,
70
73
  ...(typeof failedRecoveryCount === "number" ? { failedRecoveryCount } : {}),
74
+ oldestPendingRecoveryAt: typeof parsed.oldestPendingRecoveryAt === "string"
75
+ ? parsed.oldestPendingRecoveryAt
76
+ : undefined,
77
+ oldestPendingRecoveryAgeMs: typeof parsed.oldestPendingRecoveryAgeMs === "number" && Number.isFinite(parsed.oldestPendingRecoveryAgeMs)
78
+ ? parsed.oldestPendingRecoveryAgeMs
79
+ : undefined,
71
80
  lastRecoveredAt: typeof parsed.lastRecoveredAt === "string" ? parsed.lastRecoveredAt : undefined,
72
81
  lastRecoveredMessageGuid: typeof parsed.lastRecoveredMessageGuid === "string"
73
82
  ? parsed.lastRecoveredMessageGuid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.526",
3
+ "version": "0.1.0-alpha.528",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",