@inetafrica/open-claudia 3.3.2 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/people.js CHANGED
@@ -8,10 +8,10 @@
8
8
  // on first read from the existing auth.json (the bootstrap owner) and
9
9
  // any KAZEE_OWNER_USER_ID in env.
10
10
 
11
- const fs = require("fs");
12
11
  const { PEOPLE_FILE, AUTH_FILE, config } = require("./config");
13
12
  const { channelKey, setIdentityMapping, canonicalForChannel } = require("./identity");
14
13
  const { slugify } = require("./entities");
14
+ const { atomicWriteFileSync, readJsonWithFallback, withFileLock } = require("./fsutil");
15
15
 
16
16
  function nowIso() { return new Date().toISOString(); }
17
17
 
@@ -19,18 +19,31 @@ function nextId() {
19
19
  return `person_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
20
20
  }
21
21
 
22
+ // An empty roster is a real state (fresh pod), but it is also what a corrupt
23
+ // read used to return — and the two are not interchangeable. Nobody in the
24
+ // roster means relationship.speakerFor finds no person and classifies every
25
+ // speaker "external", so a single torn write silently demoted the OWNER and
26
+ // started guarding them in their own pod. Recovering from `<file>.bak` makes
27
+ // that self-healing rather than silent.
22
28
  function load() {
23
- try {
24
- const raw = JSON.parse(fs.readFileSync(PEOPLE_FILE, "utf-8"));
25
- if (!Array.isArray(raw)) return [];
26
- return raw;
27
- } catch (e) { return []; }
29
+ const raw = readJsonWithFallback(PEOPLE_FILE, []);
30
+ return Array.isArray(raw) ? raw : [];
28
31
  }
29
32
 
30
33
  function save(list) {
31
- const tmp = `${PEOPLE_FILE}.tmp`;
32
- fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
33
- fs.renameSync(tmp, PEOPLE_FILE);
34
+ atomicWriteFileSync(PEOPLE_FILE, `${JSON.stringify(list, null, 2)}\n`, { backup: true });
35
+ }
36
+
37
+ // Read-modify-write under the store lock. Every mutation goes through this so
38
+ // the bot process and the CLI cannot each append to the same list and have one
39
+ // of the two renames win outright.
40
+ function mutate(fn) {
41
+ return withFileLock(PEOPLE_FILE, () => {
42
+ const list = load();
43
+ const result = fn(list);
44
+ save(list);
45
+ return result;
46
+ });
34
47
  }
35
48
 
36
49
  function findById(id) {
@@ -58,105 +71,115 @@ function findByHandle(adapter, channelId) {
58
71
  function list() { return load(); }
59
72
 
60
73
  function add({ name, isOwner = false, bio = null }) {
61
- const all = load();
62
- const p = {
63
- id: nextId(),
64
- name: String(name || "").trim(),
65
- isOwner: !!isOwner,
66
- bio: bio ? String(bio) : null,
67
- handles: [],
68
- notes: [],
69
- primaryChannel: null,
70
- entitySlug: null,
71
- createdAt: nowIso(),
72
- updatedAt: nowIso(),
73
- };
74
- if (!p.name) throw new Error("person name is required");
75
- all.push(p);
76
- save(all);
77
- return p;
74
+ return mutate((all) => {
75
+ const p = {
76
+ id: nextId(),
77
+ name: String(name || "").trim(),
78
+ isOwner: !!isOwner,
79
+ bio: bio ? String(bio) : null,
80
+ handles: [],
81
+ notes: [],
82
+ primaryChannel: null,
83
+ entitySlug: null,
84
+ createdAt: nowIso(),
85
+ updatedAt: nowIso(),
86
+ };
87
+ if (!p.name) throw new Error("person name is required");
88
+ all.push(p);
89
+ return p;
90
+ });
78
91
  }
79
92
 
80
93
  function update(id, patch) {
81
- const all = load();
82
- const idx = all.findIndex((p) => p.id === id);
83
- if (idx < 0) return null;
84
- all[idx] = { ...all[idx], ...patch, updatedAt: nowIso() };
85
- save(all);
86
- return all[idx];
94
+ return mutate((all) => {
95
+ const idx = all.findIndex((p) => p.id === id);
96
+ if (idx < 0) return null;
97
+ all[idx] = { ...all[idx], ...patch, updatedAt: nowIso() };
98
+ return all[idx];
99
+ });
87
100
  }
88
101
 
89
102
  function remove(id) {
90
- const all = load();
91
- const idx = all.findIndex((p) => p.id === id);
92
- if (idx < 0) return null;
93
- const [removed] = all.splice(idx, 1);
94
- save(all);
95
- return removed;
103
+ return mutate((all) => {
104
+ const idx = all.findIndex((p) => p.id === id);
105
+ if (idx < 0) return null;
106
+ const [removed] = all.splice(idx, 1);
107
+ return removed;
108
+ });
96
109
  }
97
110
 
98
111
  // Attach a channel handle to a person. Also writes the channel→canonical
99
112
  // link in identities.json so the rest of the bot resolves the chat to
100
113
  // the same canonical user id as any other handle on this person.
101
114
  function linkHandle(personId, { adapter, channelId, displayName = null, approvedBy = null }) {
102
- const all = load();
103
- const p = all.find((x) => x.id === personId);
104
- if (!p) throw new Error(`person not found: ${personId}`);
105
115
  const a = String(adapter || "").toLowerCase();
106
116
  const c = String(channelId || "");
107
- if (!a || !c) throw new Error("adapter and channelId are required");
108
-
109
- if (!Array.isArray(p.handles)) p.handles = [];
110
- const conflict = all.find((other) =>
111
- other.id !== p.id &&
112
- (other.handles || []).some((h) => h.adapter === a && String(h.channelId) === c));
113
- if (conflict) throw new Error(`handle ${a}:${c} already linked to ${conflict.name} (${conflict.id})`);
114
-
115
- const canonical = canonicalForChannel(a, c);
116
- const existing = p.handles.find((h) => h.adapter === a && String(h.channelId) === c);
117
- if (!existing) {
118
- p.handles.push({
119
- adapter: a,
120
- channelId: c,
121
- canonicalUserId: canonical,
122
- displayName,
123
- approvedBy,
124
- addedAt: nowIso(),
125
- });
126
- } else {
127
- existing.canonicalUserId = canonical;
128
- existing.displayName = displayName || existing.displayName;
129
- }
117
+ const p = mutate((all) => {
118
+ const person = all.find((x) => x.id === personId);
119
+ if (!person) throw new Error(`person not found: ${personId}`);
120
+ if (!a || !c) throw new Error("adapter and channelId are required");
121
+
122
+ if (!Array.isArray(person.handles)) person.handles = [];
123
+ const conflict = all.find((other) =>
124
+ other.id !== person.id &&
125
+ (other.handles || []).some((h) => h.adapter === a && String(h.channelId) === c));
126
+ if (conflict) throw new Error(`handle ${a}:${c} already linked to ${conflict.name} (${conflict.id})`);
127
+
128
+ const canonical = canonicalForChannel(a, c);
129
+ const existing = person.handles.find((h) => h.adapter === a && String(h.channelId) === c);
130
+ if (!existing) {
131
+ person.handles.push({
132
+ adapter: a,
133
+ channelId: c,
134
+ canonicalUserId: canonical,
135
+ displayName,
136
+ approvedBy,
137
+ addedAt: nowIso(),
138
+ });
139
+ } else {
140
+ existing.canonicalUserId = canonical;
141
+ existing.displayName = displayName || existing.displayName;
142
+ }
130
143
 
131
- if (!p.primaryChannel) p.primaryChannel = { adapter: a, channelId: c };
132
- p.updatedAt = nowIso();
133
- save(all);
144
+ if (!person.primaryChannel) person.primaryChannel = { adapter: a, channelId: c };
145
+ person.updatedAt = nowIso();
146
+ return person;
147
+ });
134
148
 
135
149
  // Mirror into identities.json so canonicalForChannel resolves cross-handle.
150
+ // Re-pointing a channel at a new canonical strands everything that channel
151
+ // already accumulated — settings, sessions, usage — under the old key, which
152
+ // is how the owner ended up with orphan state buckets. state.linkIdentity is
153
+ // setIdentityMapping plus that migration; required lazily because state.js
154
+ // pulls people.js in on the identity path.
136
155
  if (p.handles.length > 1) {
137
156
  const primary = p.handles[0];
138
157
  const primaryCanonical = primary.canonicalUserId || canonicalForChannel(primary.adapter, primary.channelId);
139
- try { setIdentityMapping(a, c, primaryCanonical); } catch (e) {}
158
+ try {
159
+ require("./state").linkIdentity(a, c, primaryCanonical);
160
+ } catch (e) {
161
+ try { setIdentityMapping(a, c, primaryCanonical); } catch (e2) {}
162
+ }
140
163
  }
141
164
 
142
165
  return p;
143
166
  }
144
167
 
145
168
  function unlinkHandle(personId, { adapter, channelId }) {
146
- const all = load();
147
- const p = all.find((x) => x.id === personId);
148
- if (!p) return null;
149
- const a = String(adapter || "").toLowerCase();
150
- const c = String(channelId || "");
151
- const before = (p.handles || []).length;
152
- p.handles = (p.handles || []).filter((h) => !(h.adapter === a && String(h.channelId) === c));
153
- if (p.primaryChannel && p.primaryChannel.adapter === a && String(p.primaryChannel.channelId) === c) {
154
- p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
155
- }
156
- if (p.handles.length === before) return null;
157
- p.updatedAt = nowIso();
158
- save(all);
159
- return p;
169
+ return mutate((all) => {
170
+ const p = all.find((x) => x.id === personId);
171
+ if (!p) return null;
172
+ const a = String(adapter || "").toLowerCase();
173
+ const c = String(channelId || "");
174
+ const before = (p.handles || []).length;
175
+ p.handles = (p.handles || []).filter((h) => !(h.adapter === a && String(h.channelId) === c));
176
+ if (p.primaryChannel && p.primaryChannel.adapter === a && String(p.primaryChannel.channelId) === c) {
177
+ p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
178
+ }
179
+ if (p.handles.length === before) return null;
180
+ p.updatedAt = nowIso();
181
+ return p;
182
+ });
160
183
  }
161
184
 
162
185
  // Remove every handle for a given adapter across ALL people records — used when
@@ -166,71 +189,71 @@ function unlinkHandle(personId, { adapter, channelId }) {
166
189
  function unlinkAdapterHandles(adapter) {
167
190
  const a = String(adapter || "").toLowerCase();
168
191
  if (!a) return [];
169
- const all = load();
170
- const affected = [];
171
- for (const p of all) {
172
- const before = (p.handles || []).length;
173
- if (!before) continue;
174
- p.handles = p.handles.filter((h) => h.adapter !== a);
175
- if (p.handles.length === before) continue;
176
- if (p.primaryChannel && p.primaryChannel.adapter === a) {
177
- p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
192
+ return mutate((all) => {
193
+ const affected = [];
194
+ for (const p of all) {
195
+ const before = (p.handles || []).length;
196
+ if (!before) continue;
197
+ p.handles = p.handles.filter((h) => h.adapter !== a);
198
+ if (p.handles.length === before) continue;
199
+ if (p.primaryChannel && p.primaryChannel.adapter === a) {
200
+ p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
201
+ }
202
+ p.updatedAt = nowIso();
203
+ affected.push(p.id);
178
204
  }
179
- p.updatedAt = nowIso();
180
- affected.push(p.id);
181
- }
182
- if (affected.length) save(all);
183
- return affected;
205
+ return affected;
206
+ });
184
207
  }
185
208
 
186
209
  function setPrimary(personId, { adapter, channelId }) {
187
- const all = load();
188
- const p = all.find((x) => x.id === personId);
189
- if (!p) return null;
190
- const a = String(adapter || "").toLowerCase();
191
- const c = String(channelId || "");
192
- const has = (p.handles || []).some((h) => h.adapter === a && String(h.channelId) === c);
193
- if (!has) throw new Error(`person ${p.id} has no handle ${a}:${c}`);
194
- p.primaryChannel = { adapter: a, channelId: c };
195
- p.updatedAt = nowIso();
196
- save(all);
197
- return p;
210
+ return mutate((all) => {
211
+ const p = all.find((x) => x.id === personId);
212
+ if (!p) return null;
213
+ const a = String(adapter || "").toLowerCase();
214
+ const c = String(channelId || "");
215
+ const has = (p.handles || []).some((h) => h.adapter === a && String(h.channelId) === c);
216
+ if (!has) throw new Error(`person ${p.id} has no handle ${a}:${c}`);
217
+ p.primaryChannel = { adapter: a, channelId: c };
218
+ p.updatedAt = nowIso();
219
+ return p;
220
+ });
198
221
  }
199
222
 
200
223
  function note(personId, text, { by = null } = {}) {
201
- const all = load();
202
- const p = all.find((x) => x.id === personId);
203
- if (!p) return null;
204
- if (!Array.isArray(p.notes)) p.notes = [];
205
- const entry = { at: nowIso(), by, text: String(text || "").trim() };
206
- if (!entry.text) throw new Error("note text is required");
207
- p.notes.push(entry);
208
- p.updatedAt = nowIso();
209
- save(all);
210
- return entry;
224
+ return mutate((all) => {
225
+ const p = all.find((x) => x.id === personId);
226
+ if (!p) return null;
227
+ if (!Array.isArray(p.notes)) p.notes = [];
228
+ const entry = { at: nowIso(), by, text: String(text || "").trim() };
229
+ if (!entry.text) throw new Error("note text is required");
230
+ p.notes.push(entry);
231
+ p.updatedAt = nowIso();
232
+ return entry;
233
+ });
211
234
  }
212
235
 
213
236
  function removeNote(personId, index) {
214
- const all = load();
215
- const p = all.find((x) => x.id === personId);
216
- if (!p || !Array.isArray(p.notes)) return null;
217
- if (index < 0 || index >= p.notes.length) return null;
218
- const [removed] = p.notes.splice(index, 1);
219
- p.updatedAt = nowIso();
220
- save(all);
221
- return removed;
237
+ return mutate((all) => {
238
+ const p = all.find((x) => x.id === personId);
239
+ if (!p || !Array.isArray(p.notes)) return null;
240
+ if (index < 0 || index >= p.notes.length) return null;
241
+ const [removed] = p.notes.splice(index, 1);
242
+ p.updatedAt = nowIso();
243
+ return removed;
244
+ });
222
245
  }
223
246
 
224
247
  // Bind a person to their persona pack (entities/<slug>.md). Explicit link so a
225
248
  // person can point at a differently-named pack; setEntitySlug(id, null) clears.
226
249
  function setEntitySlug(personId, slug) {
227
- const all = load();
228
- const p = all.find((x) => x.id === personId);
229
- if (!p) return null;
230
- p.entitySlug = slug ? slugify(slug) : null;
231
- p.updatedAt = nowIso();
232
- save(all);
233
- return p;
250
+ return mutate((all) => {
251
+ const p = all.find((x) => x.id === personId);
252
+ if (!p) return null;
253
+ p.entitySlug = slug ? slugify(slug) : null;
254
+ p.updatedAt = nowIso();
255
+ return p;
256
+ });
234
257
  }
235
258
 
236
259
  // Which persona pack belongs to this person: the explicit pointer if set,
@@ -252,8 +275,8 @@ function seedOwnerFromLegacy() {
252
275
  if (hasOwnerRecord()) return null;
253
276
  let legacyOwner = null;
254
277
  try {
255
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
256
- legacyOwner = (auth.authorized || []).find((a) => a.isOwner === true) || null;
278
+ const auth = readJsonWithFallback(AUTH_FILE, {});
279
+ legacyOwner = ((auth && auth.authorized) || []).find((a) => a.isOwner === true) || null;
257
280
  } catch (e) {}
258
281
  if (!legacyOwner) {
259
282
  // Pods configured with only KAZEE_OWNER_USER_ID (empty auth.json) DO have
@@ -6,7 +6,10 @@ const { createJsonlDecoder } = require("./events");
6
6
  const { createClaudeParserState, normalizeClaudeEvent } = require("./claude-events");
7
7
  const { createClaudeHookTransport } = require("./claude-hook");
8
8
 
9
+ // claude-fable-5-1 requires Claude Code >= 2.1.251; older CLIs reject it with an
10
+ // API 400 rather than falling back, so a pod on a stale image will error on it.
9
11
  const CLAUDE_MODELS = Object.freeze([
12
+ "claude-fable-5-1",
10
13
  "claude-fable-5",
11
14
  "claude-opus-5",
12
15
  "claude-opus-4-8",
@@ -40,6 +40,12 @@ function speakerFor(adapterType, channelId, userId) {
40
40
  let ownerByCanonical = false;
41
41
  try { ownerByCanonical = require("./identity").channelIsOwner(adapterType, speakerId); } catch (e) {}
42
42
 
43
+ // The principal (WHO) and the room (WHERE) are carried separately from here
44
+ // on: a capability grant is issued for the pair, so collapsing them — as
45
+ // channelId alone does on every transport where a room holds many people —
46
+ // would make one member's grant cover the whole room.
47
+ const room = require("./identity").conversationKeyFor(adapterType, channelId);
48
+
43
49
  if (ownerByCanonical || (person && person.isOwner)) {
44
50
  let ownerPerson = person && person.isOwner ? person : null;
45
51
  if (!ownerPerson) { try { ownerPerson = (people.owners() || [])[0] || null; } catch (e) {} }
@@ -47,7 +53,7 @@ function speakerFor(adapterType, channelId, userId) {
47
53
  return {
48
54
  person: ownerPerson || person || null, name, isOwner: true, relationship: "owner",
49
55
  slug: null, mandate: "", style: "", entityName: name,
50
- adapterType: String(adapterType), channelId: String(channelId),
56
+ adapterType: String(adapterType), channelId: String(channelId), speakerId, room,
51
57
  };
52
58
  }
53
59
 
@@ -57,7 +63,7 @@ function speakerFor(adapterType, channelId, userId) {
57
63
  return {
58
64
  person: null, name: "", isOwner: false, relationship: "external",
59
65
  slug: null, mandate: "", style: "", entityName: "",
60
- adapterType: String(adapterType), channelId: String(channelId),
66
+ adapterType: String(adapterType), channelId: String(channelId), speakerId, room,
61
67
  };
62
68
  }
63
69
  const entities = require("./entities");
@@ -76,7 +82,7 @@ function speakerFor(adapterType, channelId, userId) {
76
82
  mandate: require("./mandates").activeMandateText(String(sections.Mandate || "")).trim(),
77
83
  style: String(sections.Style || "").trim(),
78
84
  entityName: ent ? ent.name : person.name,
79
- adapterType: String(adapterType), channelId: String(channelId),
85
+ adapterType: String(adapterType), channelId: String(channelId), speakerId, room,
80
86
  };
81
87
  } catch (e) {
82
88
  // Fail closed: an error resolving the speaker is treated as external, not
@@ -85,6 +91,7 @@ function speakerFor(adapterType, channelId, userId) {
85
91
  person: null, name: "", isOwner: false, relationship: "external",
86
92
  slug: null, mandate: "", style: "", entityName: "",
87
93
  adapterType: String(adapterType || ""), channelId: String(channelId || ""),
94
+ speakerId: String(userId || channelId || ""), room: "",
88
95
  };
89
96
  }
90
97
  }
@@ -173,6 +180,40 @@ async function ownerTarget() {
173
180
  return configuredOwnerTarget();
174
181
  }
175
182
 
183
+ // EVERY reachable owner, not just the first. Approval is an owner-set decision:
184
+ // resolving only owners()[0] meant a pod with two owners silently posted every
185
+ // card to one of them, so the other could never approve anything and a card
186
+ // went unanswered whenever that one owner was away. Deduped by send target, so
187
+ // two owners sharing a channel get one card. The apr: handler is gated on
188
+ // isChatOwner (not on the record's approverChannel), so any owner may decide
189
+ // whichever copy they see, and decide() is idempotent.
190
+ async function ownerTargets() {
191
+ const out = [];
192
+ const seen = new Set();
193
+ const push = (t, person) => {
194
+ if (!t) return;
195
+ const key = `${t.adapter}:${t.channelId}`;
196
+ if (seen.has(key)) return;
197
+ seen.add(key);
198
+ out.push({ ...t, person: person || t.person || null });
199
+ };
200
+ try {
201
+ const people = require("./people");
202
+ for (const owner of people.owners() || []) {
203
+ const handles = owner.handles || [];
204
+ const primary = owner.primaryChannel
205
+ ? handles.find((h) => h.adapter === owner.primaryChannel.adapter &&
206
+ String(h.channelId) === String(owner.primaryChannel.channelId))
207
+ : null;
208
+ const handle = primary || handles[0];
209
+ if (!handle) continue;
210
+ try { push(await resolveHandleSendTarget(handle), owner); } catch (e) { /* skip this owner */ }
211
+ }
212
+ } catch (e) { /* fall through to the env fallback */ }
213
+ if (out.length === 0) push(await configuredOwnerTarget(), null);
214
+ return out;
215
+ }
216
+
176
217
  // Env/legacy-seeded kazee owner handles store the owner's USER id, but kazee
177
218
  // sends address a chat-document id — posting buttons to the raw user id 404s
178
219
  // and escalation silently dies. Resolve those handles to the 1:1 DM the same
@@ -282,4 +323,4 @@ function ensureContact(envelope) {
282
323
  }
283
324
  }
284
325
 
285
- module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, configuredOwnerTarget, ensureContact };
326
+ module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact };
package/core/router.js CHANGED
@@ -95,6 +95,7 @@ function scope(envelope, fn) {
95
95
  conversationKey: conversationKeyFor(envelope.adapter?.type, envelope.channelId),
96
96
  inboundText: envelope.text || "",
97
97
  speakerName: envelope.from?.name || envelope.from?.username || "",
98
+ isGroup: envelope.isGroup === true || envelope.surface === "group",
98
99
  }, fn);
99
100
  }
100
101
 
package/core/scheduler.js CHANGED
@@ -33,7 +33,7 @@ const {
33
33
  getProviderSettings,
34
34
  getUserState,
35
35
  } = require("./state");
36
- const { canonicalForChannel } = require("./identity");
36
+ const { canonicalForChannel, conversationKeyFor } = require("./identity");
37
37
  const { send: defaultSend } = require("./io");
38
38
  const { providerStatus: defaultProviderStatus } = require("./providers");
39
39
  const { redactSensitive } = require("./redact");
@@ -257,6 +257,11 @@ function createSchedulerService(dependencies = {}) {
257
257
  attachSession: lineage.attachSession,
258
258
  fallbackFrom: null,
259
259
  switchedFrom: null,
260
+ // The room's current selection exists but is not runnable, so this run
261
+ // reverts to the provider the job was created under. That revert used
262
+ // to be silent, which is why a Codex room reported a Claude quota
263
+ // error with nothing to explain where Claude came from.
264
+ revertedFrom: currentProvider && currentProvider !== job.provider ? currentProvider : null,
260
265
  fresh: !lineage.sessionId,
261
266
  };
262
267
  }
@@ -288,6 +293,8 @@ function createSchedulerService(dependencies = {}) {
288
293
  }
289
294
  if (target.fallbackFrom) {
290
295
  lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
296
+ } else if (target.revertedFrom) {
297
+ lines.push(`[This room currently selects ${target.revertedFrom}, but that provider is not runnable right now, so this occurrence reverted to the provider it was scheduled under (${target.provider}).]`);
291
298
  } else if (target.switchedFrom) {
292
299
  lines.push(`[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`);
293
300
  }
@@ -477,6 +484,12 @@ function createSchedulerService(dependencies = {}) {
477
484
  // shared session. Matches captured project.name (projectDescriptor(job).name)
478
485
  // so sameCapturedProject / getProjectKey resolve the right thread mid-run.
479
486
  conversationKey: projectDescriptor(job).name,
487
+ // …but the thread is not the room. A job belongs to the room it was
488
+ // created in and must answer under THAT room's provider; without this the
489
+ // room overlay can't resolve and the run silently falls back to the
490
+ // creator's personal default — which is how a Codex room kept firing
491
+ // wakeups on Claude.
492
+ roomKey: conversationKeyFor(adapter.type, job.channelId),
480
493
  // Rides only on triage-approval wakeups; the enforcer's reply vet honours
481
494
  // the owner's one-off "handle this request" grant through it.
482
495
  triageApprovalId: job.triageApprovalId || "",
@@ -536,7 +549,15 @@ function createSchedulerService(dependencies = {}) {
536
549
  const advisory = job.check
537
550
  ? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway` : `🔔 Watch "${job.label}" triggered`)
538
551
  : `Wakeup: ${job.label}`;
539
- try { await send(advisory); } catch (_) { /* advisory only */ }
552
+ // Say so when the run is NOT on the provider this room is set to: an
553
+ // unexplained error from a provider the room never selected is worse than
554
+ // the outage itself.
555
+ const providerNote = target.revertedFrom
556
+ ? `\n⚠️ ${target.revertedFrom} is unavailable — running on ${target.provider} instead.`
557
+ : target.fallbackFrom
558
+ ? `\n⚠️ ${target.fallbackFrom} is unavailable — running on fallback ${target.provider}.`
559
+ : "";
560
+ try { await send(`${advisory}${providerNote}`); } catch (_) { /* advisory only */ }
540
561
  let result;
541
562
  try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
542
563
  catch (error) {