@ouro.bot/cli 0.1.0-alpha.483 → 0.1.0-alpha.485

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,20 @@
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.485",
6
+ "changes": [
7
+ "Session JSON storage no longer accumulates duplicate event ids when two writers race for the same session — `parseSessionEnvelope` now dedupes on read (last-occurrence-wins) so existing corrupted sessions self-heal on the next save, `buildCanonicalSessionEnvelope` assigns the next sequence as `max(existing) + 1` instead of `events.length + 1` so pruning gaps cannot collide, and `deferPostTurnPersist` serializes per-`sessPath` through an in-process queue so concurrent BlueBubbles webhooks for the same chat (or CLI postTurn racing the inner-dialog turn for the same MCP session) cannot interleave their writes.",
8
+ "Auto-created BlueBubbles group friends are now marked with a `notes.autoCreatedGroup` flag at resolver time, and the trust gate's family-member bypass surfaces a one-time inner-pending notice the first time messages route through an unacknowledged stranger-trust group so the agent can label, rename, or dismiss the relationship before activity accumulates invisibly.",
9
+ "Inner-dialog worker now caps consecutive `instinct` follow-on turns at `MAX_CONSECUTIVE_INSTINCT_TURNS = 3` to break self-sustaining loops where a tool that writes to the inner-dialog pending dir during a turn would otherwise re-fire the worker indefinitely; externally-queued messages reset the counter so legitimate cascading follow-ups still run, and a new `senses.inner_dialog_worker_instinct_loop_capped` event surfaces when the cap fires."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.484",
14
+ "changes": [
15
+ "Agent-driven failover (the `switch to <provider>` reply path) now re-pings the candidate provider before mutating provider state. If credentials are missing or the ping fails, the active lane is left untouched, a `senses.failover_switch_refused` event is emitted, and the agent receives an operational refusal context message naming the lane it is still standing on plus the verified alternatives that remain so the next turn does not re-enter discovery mode."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.483",
6
20
  "changes": [
@@ -5,6 +5,7 @@ exports.formatReadyProviderLabel = formatReadyProviderLabel;
5
5
  exports.buildFailoverContext = buildFailoverContext;
6
6
  exports.handleFailoverReply = handleFailoverReply;
7
7
  exports.runMachineProviderFailoverInventory = runMachineProviderFailoverInventory;
8
+ exports.validateFailoverSwitchCandidate = validateFailoverSwitchCandidate;
8
9
  const identity_1 = require("./identity");
9
10
  const provider_ping_1 = require("./provider-ping");
10
11
  const provider_models_1 = require("./provider-models");
@@ -170,6 +171,7 @@ function buildFailoverContext(errorMessage, classification, currentProvider, cur
170
171
  errorSummary,
171
172
  classification,
172
173
  currentProvider,
174
+ currentModel,
173
175
  currentLane,
174
176
  agentName,
175
177
  workingProviders,
@@ -264,3 +266,36 @@ async function runMachineProviderFailoverInventory(agentName, currentProvider, o
264
266
  });
265
267
  return inventory;
266
268
  }
269
+ /**
270
+ * Re-verify a failover candidate is actually reachable right before we mutate
271
+ * provider state. The inventory ping that produced the candidate may be stale
272
+ * (creds revoked between inventory and reply); without this preflight, an
273
+ * agent-driven "switch to <provider>" can move the lane onto an unreachable
274
+ * provider and brick the next turn.
275
+ */
276
+ async function validateFailoverSwitchCandidate(agentName, candidate, options = {}) {
277
+ const ping = options.ping ?? provider_ping_1.pingProvider;
278
+ const refreshPool = options.refreshPool ?? provider_credentials_1.refreshProviderCredentialPool;
279
+ const poolResult = await refreshPool(agentName);
280
+ if (!poolResult.ok) {
281
+ return {
282
+ ok: false,
283
+ classification: "auth-failure",
284
+ message: `provider credential pool unavailable (${poolResult.reason}): ${poolResult.error}`,
285
+ };
286
+ }
287
+ const record = poolResult.pool.providers[candidate.provider];
288
+ if (!record) {
289
+ return {
290
+ ok: false,
291
+ classification: "auth-failure",
292
+ message: `no credentials configured for ${candidate.provider}`,
293
+ };
294
+ }
295
+ const config = { ...record.credentials, ...record.config };
296
+ const result = await ping(candidate.provider, config, { model: candidate.model });
297
+ if (!result.ok) {
298
+ return { ok: false, classification: result.classification, message: result.message };
299
+ }
300
+ return { ok: true };
301
+ }
@@ -254,6 +254,35 @@ function messageFingerprint(message) {
254
254
  function makeEventId(sequence) {
255
255
  return `evt-${String(sequence).padStart(6, "0")}`;
256
256
  }
257
+ /**
258
+ * Collapse duplicate event ids to a single entry, last-occurrence-wins.
259
+ *
260
+ * Concurrent writers in older versions of postTurnPersist could each load the
261
+ * envelope, compute `events.length + 1` for the next sequence, and both write
262
+ * an event with the same id. The duplicates would persist in the saved JSON
263
+ * and confuse downstream replay (the same outbound message could appear to
264
+ * have been sent twice from the agent's perspective without the agent knowing
265
+ * it sent it). We dedupe defensively on every load so corrupted sessions
266
+ * self-heal on the next save and so any future race produces a consistent
267
+ * view.
268
+ */
269
+ function dedupeEventsByIdLastWins(events) {
270
+ // Index id → last position so we can preserve original order while
271
+ // collapsing duplicates to their final occurrence.
272
+ const lastIndexById = new Map();
273
+ for (let i = 0; i < events.length; i++) {
274
+ lastIndexById.set(events[i].id, i);
275
+ }
276
+ return events.filter((event, index) => lastIndexById.get(event.id) === index);
277
+ }
278
+ /**
279
+ * The next sequence to assign for a freshly-built event. Uses max(existing
280
+ * sequences) + 1 rather than `events.length + 1` so that gaps from earlier
281
+ * pruning, archive replay, or self-heal dedup never produce a colliding id.
282
+ */
283
+ function nextEventSequence(existing) {
284
+ return existing.reduce((max, event) => Math.max(max, event.sequence), 0) + 1;
285
+ }
257
286
  function validateSessionMessages(messages) {
258
287
  const violations = [];
259
288
  let prevNonToolRole = null;
@@ -665,7 +694,7 @@ function parseSessionEnvelope(raw, options = {}) {
665
694
  if (record.version !== 2 || !Array.isArray(record.events) || !record.projection || typeof record.projection !== "object") {
666
695
  return null;
667
696
  }
668
- const events = record.events
697
+ const rawEvents = record.events
669
698
  .filter((event) => event != null && typeof event === "object")
670
699
  .map((event, index) => {
671
700
  const role = normalizeRole(event.role);
@@ -705,6 +734,12 @@ function parseSessionEnvelope(raw, options = {}) {
705
734
  },
706
735
  };
707
736
  });
737
+ // Self-heal duplicate event ids that may have been written by concurrent
738
+ // writers in older harness versions. Last-occurrence-wins by id (later
739
+ // entries in the persisted file are the more recent state for that id).
740
+ // We preserve the original document order otherwise, so projection.eventIds
741
+ // still resolves predictably.
742
+ const events = dedupeEventsByIdLastWins(rawEvents);
708
743
  const projection = record.projection;
709
744
  return {
710
745
  version: 2,
@@ -821,8 +856,10 @@ function buildCanonicalSessionEnvelope(options) {
821
856
  else {
822
857
  if (!isSystem)
823
858
  nonSystemSeen++;
824
- // Create a new event
825
- const event = buildEventFromMessage(currentMessages[i], events.length + 1, options.recordedAt, "live", null, null, currentIngressTimes[i]);
859
+ // Create a new event. Use nextEventSequence(events) instead of
860
+ // `events.length + 1` so that any gap (from pruning, archive replay,
861
+ // or self-heal dedup) cannot collide with an existing id.
862
+ const event = buildEventFromMessage(currentMessages[i], nextEventSequence(events), options.recordedAt, "live", null, null, currentIngressTimes[i]);
826
863
  events.push(event);
827
864
  currentEventIds.push(event.id);
828
865
  }
@@ -315,11 +315,43 @@ function postTurnPersist(sessPath, prepared, usage, state) {
315
315
  return envelope.events;
316
316
  }
317
317
  /**
318
- * Deferred persist: same as postTurnPersist but runs on the next event loop tick.
319
- * Returns a promise that resolves when the persist completes.
318
+ * Per-sessPath serialization queue. Without this, two concurrent
319
+ * `deferPostTurnPersist` calls (e.g. two BlueBubbles webhooks for the same
320
+ * chat firing back-to-back, or a CLI postTurn racing the inner-dialog turn
321
+ * for the same MCP session) would each load the envelope, both compute the
322
+ * same "next sequence", and write events with colliding ids. The session
323
+ * file would silently accumulate duplicates and replay would diverge from
324
+ * what was actually sent on the wire.
325
+ *
326
+ * The queue is in-process only — it does not protect against multiple Node
327
+ * processes writing to the same file. The dedup-on-load behaviour in
328
+ * parseSessionEnvelope keeps cross-process races from leaving permanent
329
+ * corruption; this serializer just keeps the common (single-process) case
330
+ * race-free in the first place.
331
+ */
332
+ const sessionPersistQueues = new Map();
333
+ function enqueueSessionPersist(sessPath, fn) {
334
+ const previous = sessionPersistQueues.get(sessPath) ?? Promise.resolve();
335
+ // Chain on the previous tail. fn runs whether previous resolved or rejected,
336
+ // so one failed turn cannot block subsequent turns on the same session.
337
+ const next = previous.then(fn, fn);
338
+ // Save a swallowed-rejection sentinel as the new tail so the next caller's
339
+ // `previous.then(fn, fn)` sees a clean resolution; the original `next` still
340
+ // propagates rejection to its own caller as expected.
341
+ /* v8 ignore start -- the swallow only matters when fn rejects, which is the failure path covered separately */
342
+ const sentinel = next.then(() => undefined, () => undefined);
343
+ /* v8 ignore stop */
344
+ sessionPersistQueues.set(sessPath, sentinel);
345
+ return next;
346
+ }
347
+ /**
348
+ * Deferred persist: same as postTurnPersist but runs on the next event loop
349
+ * tick AND serializes against any other deferred persist for the same
350
+ * sessPath, so concurrent turns cannot race and produce duplicate event ids
351
+ * in the saved session.
320
352
  */
321
353
  function deferPostTurnPersist(sessPath, prepared, usage, state) {
322
- return new Promise((resolve) => {
354
+ return enqueueSessionPersist(sessPath, () => new Promise((resolve) => {
323
355
  setImmediate(() => {
324
356
  try {
325
357
  const events = postTurnPersist(sessPath, prepared, usage, state);
@@ -336,7 +368,7 @@ function deferPostTurnPersist(sessPath, prepared, usage, state) {
336
368
  resolve([]);
337
369
  }
338
370
  });
339
- });
371
+ }));
340
372
  }
341
373
  function deleteSession(filePath) {
342
374
  try {
@@ -87,6 +87,21 @@ class FriendResolver {
87
87
  hasAnyFriends = false;
88
88
  }
89
89
  const isFirstImprint = !hasAnyFriends;
90
+ // BlueBubbles group chats route through here as `imessage-handle` with an
91
+ // externalId of the form `group:any;+;<chatHash>`. When the harness auto-
92
+ // creates the group friend at stranger trust, we mark the record so that
93
+ // the trust gate can surface the relationship for explicit acknowledgment
94
+ // later instead of letting messages accumulate silently.
95
+ const isImessageGroup = this.params.provider === "imessage-handle" &&
96
+ typeof this.params.externalId === "string" &&
97
+ this.params.externalId.startsWith("group:");
98
+ const notes = {};
99
+ if (this.params.displayName !== "Unknown") {
100
+ notes.name = { value: this.params.displayName, savedAt: now };
101
+ }
102
+ if (isImessageGroup && !isFirstImprint) {
103
+ notes.autoCreatedGroup = { value: "true", savedAt: now };
104
+ }
90
105
  const friend = {
91
106
  id: (0, crypto_1.randomUUID)(),
92
107
  name: this.params.displayName,
@@ -96,7 +111,7 @@ class FriendResolver {
96
111
  externalIds: [externalId],
97
112
  tenantMemberships,
98
113
  toolPreferences: {},
99
- notes: this.params.displayName !== "Unknown" ? { name: { value: this.params.displayName, savedAt: now } } : {},
114
+ notes,
100
115
  totalTokens: 0,
101
116
  createdAt: now,
102
117
  updatedAt: now,
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MAX_CONSECUTIVE_INSTINCT_TURNS = void 0;
36
37
  exports.createInnerDialogWorker = createInnerDialogWorker;
37
38
  exports.startInnerDialogWorker = startInnerDialogWorker;
38
39
  const path = __importStar(require("path"));
@@ -41,6 +42,20 @@ const runtime_1 = require("../nerves/runtime");
41
42
  const identity_1 = require("../heart/identity");
42
43
  const pending_1 = require("../mind/pending");
43
44
  const habit_runtime_state_1 = require("../heart/habits/habit-runtime-state");
45
+ /**
46
+ * Cap on consecutive `instinct` follow-on turns triggered by `hasPendingWork()`
47
+ * with no externally-queued work in between. Without this cap, a turn that
48
+ * writes anything back into the inner-dialog pending dir as a side effect of
49
+ * processing (e.g. a surface tool routing a response) puts the worker into
50
+ * a self-sustaining loop where the next turn's drain produces another write,
51
+ * and so on. Real workflows rarely chain more than 2–3 instinct turns; an
52
+ * external trigger (habit, poke, chat) resets the counter so legitimate
53
+ * follow-on work is unaffected.
54
+ *
55
+ * Three feels right: legitimate cascading follow-ups (e.g. processing a
56
+ * batch of delegated returns) get through; a true self-loop caps fast.
57
+ */
58
+ exports.MAX_CONSECUTIVE_INSTINCT_TURNS = 3;
44
59
  function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)()))) {
45
60
  let running = false;
46
61
  const queue = [];
@@ -54,6 +69,7 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
54
69
  let nextReason = reason;
55
70
  let nextTaskId = taskId;
56
71
  let nextHabitName = habitName;
72
+ let consecutiveInstinctTurns = reason === "instinct" ? 1 : 0;
57
73
  do {
58
74
  try {
59
75
  await runTurn({ reason: nextReason, taskId: nextTaskId, habitName: nextHabitName });
@@ -82,16 +98,36 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
82
98
  // Habit file/state may be unavailable during the turn — skip gracefully
83
99
  }
84
100
  }
85
- // Drain queue first
101
+ // Drain queue first. Externally-queued work resets the instinct cap
102
+ // because a real outside trigger arrived between turns.
86
103
  if (queue.length > 0) {
87
104
  const next = queue.shift();
88
105
  nextReason = next.reason;
89
106
  nextTaskId = next.taskId;
90
107
  nextHabitName = next.habitName;
108
+ consecutiveInstinctTurns = nextReason === "instinct" ? consecutiveInstinctTurns + 1 : 0;
91
109
  continue;
92
110
  }
93
- // Then check hasPendingWork fallback
111
+ // Then check hasPendingWork fallback. This is the loop site: any
112
+ // tool that writes to the inner-dialog pending dir during a turn
113
+ // would cause hasPendingWork() to be true here, producing a
114
+ // self-sustaining "instinct" loop with no external input. Cap it.
94
115
  if (hasPendingWork()) {
116
+ if (consecutiveInstinctTurns >= exports.MAX_CONSECUTIVE_INSTINCT_TURNS) {
117
+ (0, runtime_1.emitNervesEvent)({
118
+ level: "warn",
119
+ component: "senses",
120
+ event: "senses.inner_dialog_worker_instinct_loop_capped",
121
+ message: "inner dialog worker stopped chaining instinct turns; pending work remains for next external trigger",
122
+ meta: {
123
+ consecutiveInstinctTurns,
124
+ cap: exports.MAX_CONSECUTIVE_INSTINCT_TURNS,
125
+ lastReason: nextReason,
126
+ },
127
+ });
128
+ break;
129
+ }
130
+ consecutiveInstinctTurns += 1;
95
131
  nextReason = "instinct";
96
132
  nextTaskId = undefined;
97
133
  nextHabitName = undefined;
@@ -97,7 +97,23 @@ function resolveCurrentFailoverBinding(agentName, lane) {
97
97
  const fallback = lane === "inner" ? agentConfig.agentFacing : agentConfig.humanFacing;
98
98
  return { provider: fallback.provider, model: fallback.model };
99
99
  }
100
- function writeFailoverProviderStateSwitch(agentName, action) {
100
+ /**
101
+ * Apply an agent-driven failover switch to provider state, but only after
102
+ * re-pinging the candidate. The inventory ping that produced the candidate
103
+ * may be stale by the time the agent replies — without this preflight, a
104
+ * "switch to <provider>" reply can move the lane onto an unreachable provider.
105
+ *
106
+ * Returns:
107
+ * { ok: true } — preflight passed, state mutated
108
+ * { ok: false, refused } — preflight failed, state untouched, caller should
109
+ * surface the refusal to the agent
110
+ * Throws on disk errors only (caught by caller as before).
111
+ */
112
+ async function writeFailoverProviderStateSwitch(agentName, action) {
113
+ const validation = await (0, provider_failover_1.validateFailoverSwitchCandidate)(agentName, { provider: action.provider, model: action.model });
114
+ if (!validation.ok) {
115
+ return { ok: false, refused: true, classification: validation.classification, message: validation.message };
116
+ }
101
117
  const agentRoot = (0, identity_1.getAgentRoot)(agentName);
102
118
  const stateResult = (0, provider_state_1.readProviderState)(agentRoot);
103
119
  if (!stateResult.ok) {
@@ -125,11 +141,36 @@ function writeFailoverProviderStateSwitch(agentName, action) {
125
141
  lanes,
126
142
  readiness,
127
143
  });
144
+ return { ok: true };
128
145
  }
129
146
  function formatFailoverSwitchLabel(action) {
130
147
  const provenance = (0, provider_failover_1.formatCredentialProvenanceLabel)(action);
131
148
  return `${action.provider} (${action.model}${provenance ? `; ${provenance}` : ""})`;
132
149
  }
150
+ /**
151
+ * Build the operational refusal context message handed back to the agent when
152
+ * a failover switch is rejected by the preflight ping. Slugger-tested format:
153
+ * lead with the refusal + reason, restate the lane that's still standing, then
154
+ * list remaining ready alternatives so the next turn doesn't have to re-enter
155
+ * discovery mode.
156
+ */
157
+ function buildFailoverSwitchRefusedMessage(pendingContext, refusedAction, refusal) {
158
+ const refusedLabel = formatFailoverSwitchLabel(refusedAction);
159
+ const remaining = pendingContext.readyProviders.filter((candidate) => candidate.provider !== refusedAction.provider);
160
+ const alternativesLine = remaining.length > 0
161
+ ? `available verified alternatives right now: ${remaining.map((c) => `${c.provider} (${c.model})`).join(", ")}.`
162
+ : "no other verified alternatives are ready right now.";
163
+ const nextMove = remaining.length > 0
164
+ ? `next move: reply "switch to <provider>" picking one of the alternatives above, or tell the user you cannot continue and why.`
165
+ : `next move: ask the operator to repair credentials for ${refusedAction.provider} (or another provider), or tell the user you cannot continue and why.`;
166
+ return [
167
+ `[provider switch refused: tried to switch ${refusedAction.lane} lane to ${refusedLabel}.`,
168
+ `reason: preflight ping failed (${refusal.classification}: ${refusal.message}).`,
169
+ `current lane unchanged: ${pendingContext.currentProvider} / ${pendingContext.currentModel} on the ${pendingContext.currentLane} lane.`,
170
+ alternativesLine,
171
+ nextMove + "]",
172
+ ].join(" ");
173
+ }
133
174
  function prependTurnSections(message, sections) {
134
175
  /* v8 ignore next -- defensive: only user messages with non-empty sections reach here @preserve */
135
176
  if (message.role !== "user" || sections.length === 0)
@@ -176,10 +217,9 @@ async function handleInboundTurn(input) {
176
217
  const failoverAgentName = pendingContext.agentName;
177
218
  input.failoverState.pending = null; // always clear before acting
178
219
  if (failoverAction.action === "switch") {
179
- let switchSucceeded = false;
220
+ let switchOutcome = null;
180
221
  try {
181
- writeFailoverProviderStateSwitch(failoverAgentName, failoverAction);
182
- switchSucceeded = true;
222
+ switchOutcome = await writeFailoverProviderStateSwitch(failoverAgentName, failoverAction);
183
223
  /* v8 ignore start -- defensive: write failure during provider switch @preserve */
184
224
  }
185
225
  catch (switchError) {
@@ -192,8 +232,7 @@ async function handleInboundTurn(input) {
192
232
  });
193
233
  }
194
234
  /* v8 ignore stop */
195
- /* v8 ignore next -- false branch: write-failure fallthrough @preserve */
196
- if (switchSucceeded) {
235
+ if (switchOutcome?.ok) {
197
236
  (0, runtime_1.emitNervesEvent)({
198
237
  component: "senses",
199
238
  event: "senses.failover_switch",
@@ -217,6 +256,29 @@ async function handleInboundTurn(input) {
217
256
  }];
218
257
  input.switchedProvider = failoverAction.provider;
219
258
  }
259
+ else if (switchOutcome && !switchOutcome.ok) {
260
+ // Preflight refused the switch — the candidate provider is not actually
261
+ // reachable right now. Keep the existing lane intact and tell the agent
262
+ // what happened so it can pick something else next turn.
263
+ (0, runtime_1.emitNervesEvent)({
264
+ level: "warn",
265
+ component: "senses",
266
+ event: "senses.failover_switch_refused",
267
+ message: `refused failover switch of ${failoverAction.lane} lane to ${failoverAction.provider}: ${switchOutcome.message}`,
268
+ meta: {
269
+ agentName: failoverAgentName,
270
+ lane: failoverAction.lane,
271
+ provider: failoverAction.provider,
272
+ model: failoverAction.model,
273
+ classification: switchOutcome.classification,
274
+ error: switchOutcome.message,
275
+ },
276
+ });
277
+ input.messages = [{
278
+ role: "user",
279
+ content: buildFailoverSwitchRefusedMessage(pendingContext, failoverAction, switchOutcome),
280
+ }];
281
+ }
220
282
  // Switch failed OR succeeded — either way, fall through to normal processing.
221
283
  }
222
284
  }
@@ -93,6 +93,91 @@ function writeInnerPendingNotice(bundleRoot, noticeContent, nowIso) {
93
93
  fs.mkdirSync(innerPendingDir, { recursive: true });
94
94
  fs.writeFileSync(filePath, JSON.stringify(payload), "utf-8");
95
95
  }
96
+ const ACKNOWLEDGED_GROUPS_FILENAME = "acknowledged-auto-groups.json";
97
+ function acknowledgedGroupsPath(bundleRoot) {
98
+ return path.join(bundleRoot, "state", ACKNOWLEDGED_GROUPS_FILENAME);
99
+ }
100
+ function loadAcknowledgedGroupsState(bundleRoot) {
101
+ try {
102
+ const raw = fs.readFileSync(acknowledgedGroupsPath(bundleRoot), "utf-8");
103
+ if (!raw.trim())
104
+ return {};
105
+ const parsed = JSON.parse(raw);
106
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
107
+ return {};
108
+ return parsed;
109
+ }
110
+ catch {
111
+ return {};
112
+ }
113
+ }
114
+ function persistAcknowledgedGroupsState(bundleRoot, state) {
115
+ const target = acknowledgedGroupsPath(bundleRoot);
116
+ fs.mkdirSync(path.dirname(target), { recursive: true });
117
+ fs.writeFileSync(target, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
118
+ }
119
+ /**
120
+ * For BlueBubbles group chats that were auto-created at stranger trust (no
121
+ * explicit operator/agent action ever bound the harness to this group), the
122
+ * gate's family-member bypass would otherwise let messages flow through
123
+ * silently and the agent would accumulate a session it has no mental model
124
+ * for. Surface the relationship as an inner-pending notice exactly once so
125
+ * the agent can categorize / rename / dismiss the group on its next turn.
126
+ *
127
+ * Returns true if a notice was written so callers can emit a telemetry event.
128
+ */
129
+ function maybeSurfaceAutoCreatedGroup(input, bundleRoot, nowIso) {
130
+ // Caller guarantees isGroupChat = true (only invoked from the family-member
131
+ // bypass branch); skip a redundant guard here.
132
+ if (input.friend.trustLevel !== "stranger")
133
+ return false;
134
+ if (!input.friend.notes?.["autoCreatedGroup"])
135
+ return false;
136
+ // loadAcknowledgedGroupsState is defensive (its own try/catch returns {})
137
+ // so we don't wrap it in another try here.
138
+ const state = loadAcknowledgedGroupsState(bundleRoot);
139
+ if (state[input.friend.id])
140
+ return false;
141
+ const noticeContent = `New BlueBubbles group "${input.friend.name}" became active without explicit acknowledgment. ` +
142
+ `It was auto-created at stranger trust the first time a message routed through it. ` +
143
+ `If you recognize the group, label or rename it (and consider promoting trust); if not, you can leave it as a stranger group or rename it for clarity. ` +
144
+ `external id: ${input.externalId}; friend id: ${input.friend.id}.`;
145
+ try {
146
+ writeInnerPendingNotice(bundleRoot, noticeContent, nowIso);
147
+ persistAcknowledgedGroupsState(bundleRoot, {
148
+ ...state,
149
+ [input.friend.id]: { surfacedAt: nowIso },
150
+ });
151
+ (0, runtime_1.emitNervesEvent)({
152
+ level: "info",
153
+ component: "senses",
154
+ event: "senses.trust_gate_group_acknowledgment_surfaced",
155
+ message: "auto-created group surfaced for agent acknowledgment",
156
+ meta: {
157
+ friendId: input.friend.id,
158
+ friendName: input.friend.name,
159
+ externalId: input.externalId,
160
+ provider: input.provider,
161
+ },
162
+ });
163
+ return true;
164
+ /* v8 ignore start -- defensive: surfacing failure must not block the gate decision @preserve */
165
+ }
166
+ catch (error) {
167
+ (0, runtime_1.emitNervesEvent)({
168
+ level: "error",
169
+ component: "senses",
170
+ event: "senses.trust_gate_error",
171
+ message: "failed to surface auto-created group for acknowledgment",
172
+ meta: {
173
+ friendId: input.friend.id,
174
+ reason: error instanceof Error ? error.message : String(error),
175
+ },
176
+ });
177
+ return false;
178
+ }
179
+ /* v8 ignore stop */
180
+ }
96
181
  function enforceTrustGate(input) {
97
182
  const { senseType } = input;
98
183
  // Local (CLI) and internal (inner dialog) — always allow
@@ -104,8 +189,18 @@ function enforceTrustGate(input) {
104
189
  return { allowed: true };
105
190
  }
106
191
  // Open senses (BlueBubbles/iMessage) — enforce trust rules
107
- // Group chat with a family member present — allow regardless of trust level
192
+ // Group chat with a family member present — allow regardless of trust level.
193
+ // BUT if this is an auto-created stranger group (the harness picked it up
194
+ // silently via the family-member shortcut and the agent never explicitly
195
+ // acknowledged it), surface a one-time inner-pending notice so the agent
196
+ // gets a chance to categorize / rename / dismiss the relationship instead
197
+ // of accumulating activity invisibly.
108
198
  if (input.isGroupChat && input.groupHasFamilyMember) {
199
+ /* v8 ignore start -- defaults shared with the rest of the gate; tested via the stranger-trust path */
200
+ const bundleRoot = input.bundleRoot ?? (0, identity_1.getAgentRoot)();
201
+ const nowIso = (input.now ?? (() => new Date()))().toISOString();
202
+ /* v8 ignore stop */
203
+ maybeSurfaceAutoCreatedGroup(input, bundleRoot, nowIso);
109
204
  return { allowed: true };
110
205
  }
111
206
  const trustLevel = input.friend.trustLevel ?? "friend";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.483",
3
+ "version": "0.1.0-alpha.485",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",