@inetafrica/open-claudia 3.5.19 → 3.5.23

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.
@@ -98,13 +98,15 @@ function listSessions({ dirs = socketDirs(), alive = processAlive } = {}) {
98
98
  } catch (_) { /* try the next file */ }
99
99
  }
100
100
  seen.add(name);
101
- sessions.push({ name, port, page, updatedAt });
101
+ // dir: the socket folder, so the bot's own agent-browser calls (core/browser-teach.js) reach
102
+ // this session and never start a new one. Internal like the port.
103
+ sessions.push({ name, port, page, updatedAt, dir });
102
104
  }
103
105
  }
104
106
  return sessions.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
105
107
  }
106
108
 
107
- // Public view of a session: the stream port stays inside the bot.
109
+ // Public view of a session: the stream port and socket folder stay inside the bot.
108
110
  function describeSessions(options) {
109
111
  return listSessions(options).map(({ name, page, updatedAt }) => ({ name, page, updatedAt }));
110
112
  }
@@ -189,6 +191,7 @@ function serveStream(res, sessionName, { fps, sessions, connect, keepaliveMs = K
189
191
  module.exports = {
190
192
  DEFAULT_FPS,
191
193
  MAX_FPS,
194
+ SESSION_NAME,
192
195
  clampFps,
193
196
  describeSessions,
194
197
  listSessions,
package/core/cards.js CHANGED
@@ -255,7 +255,9 @@ function describeResponse(rec, payload, now = Date.now()) {
255
255
  // ---------------------------------------------------------------------------
256
256
  // Waking the conversation a card (or decision) came from
257
257
 
258
- function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500 } = {}) {
258
+ // `source` tags the job for job lists and audits: "card" for card answers,
259
+ // "browser" for the Browser app's hand-backs and Teach-me recordings.
260
+ function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500, source = "card" } = {}) {
259
261
  if (!origin || !origin.channelId || !origin.provider || !origin.project || !origin.canonicalUserId) {
260
262
  throw new Error("the card's origin conversation is unknown");
261
263
  }
@@ -282,7 +284,7 @@ function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500 } = {}) {
282
284
  },
283
285
  prompt,
284
286
  label: String(label).slice(0, 60),
285
- source: "card",
287
+ source,
286
288
  fireAt: Date.now() + delayMs,
287
289
  });
288
290
  }
package/core/dream.js CHANGED
@@ -26,6 +26,7 @@ const daySeeds = require("./day-seeds");
26
26
  const packGuard = require("./pack-guard");
27
27
  const persona = require("./persona");
28
28
  const { spawnUtilityAgent } = require("./utility-agent");
29
+ const usageLimits = require("./usage-limits");
29
30
  const recallMetrics = require("./recall/metrics");
30
31
  const recallTuning = require("./recall/tuning");
31
32
  const recallGraphLib = require("./recall/graph");
@@ -1117,6 +1118,40 @@ function failedIntelligenceMetadata(error) {
1117
1118
  };
1118
1119
  }
1119
1120
 
1121
+ // Waits in a row for a limit (a reset that is used up again at once) before
1122
+ // the dream gives up until its next nightly run.
1123
+ const DREAM_LIMIT_MAX_WAITS = 5;
1124
+
1125
+ // A dream AI step a usage limit stopped waits for the reset, like a chat
1126
+ // message or a scheduled run (core/usage-limits limitRetryAt), never on
1127
+ // another provider. Null when the failure was not a limit, or its reset is
1128
+ // unknown or too far out to wait for.
1129
+ function dreamLimitWait(error, { now = Date.now() } = {}) {
1130
+ const result = error?.result || null;
1131
+ if ((error?.code || result?.error?.code) !== "PROVIDER_USAGE_LIMIT") return null;
1132
+ const provider = result?.provider || null;
1133
+ const hit = usageLimits.limitHitDetails({ provider, error: result?.error || error, now });
1134
+ const runAt = usageLimits.limitRetryAt(hit?.resetsAt, { now });
1135
+ if (!hit || runAt === null || !usageLimits.canWaitFor(runAt, { now })) return null;
1136
+ return { provider, limitType: hit.limitType || null, resetsAt: hit.resetsAt, runAt };
1137
+ }
1138
+
1139
+ function limitName(provider, limitType) {
1140
+ return `${usageLimits.providerLabel(provider)}'s ${usageLimits.windowLabel(limitType)}`;
1141
+ }
1142
+
1143
+ // The "Heads up — <note>, so I skipped the AI-led merges" note of an AI step
1144
+ // that failed for good: plain words for a sign-in that died or a limit with
1145
+ // no reset, the provider's own message otherwise.
1146
+ function dreamFailNote(error) {
1147
+ const result = error?.result || null;
1148
+ const code = error?.code || result?.error?.code;
1149
+ const label = usageLimits.providerLabel(result?.provider);
1150
+ if (code === "PROVIDER_UNAUTHENTICATED") return `${label}'s sign-in on this bot has expired (send /login to sign in again)`;
1151
+ if (code === "PROVIDER_USAGE_LIMIT") return `${label}'s usage limit is used up and it gave no reset time`;
1152
+ return `the AI consolidation step didn't finish (${error?.message || "unknown error"})`;
1153
+ }
1154
+
1120
1155
  async function previewDreamWith(runUtility, { provider = null } = {}) {
1121
1156
  const result = await runUtility(buildDreamPrompt(), dreamUtilityOptions("dream", {
1122
1157
  provider,
@@ -1189,6 +1224,14 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1189
1224
  // decision (applyDream no-ops), note it for the report, and carry on.
1190
1225
  let decision = null;
1191
1226
  let modelFailNote = "";
1227
+ let limitWait = null;
1228
+ // Waits in a row so far: a run after a reset that is used up again counts on.
1229
+ const waitsBefore = trigger === "limit-reset" ? Number(dreamState.limitWait?.attempts) || 0 : 0;
1230
+ const limitWaitFor = (error) => {
1231
+ const wait = dreamLimitWait(error);
1232
+ if (!wait || waitsBefore + 1 <= DREAM_LIMIT_MAX_WAITS) return { wait, note: "" };
1233
+ return { wait: null, note: `${limitName(wait.provider, wait.limitType)} is still used up after ${DREAM_LIMIT_MAX_WAITS} waits (the next nightly dream tries again)` };
1234
+ };
1192
1235
  let consolidationIntelligence = intelligenceMetadata(null, "not_run");
1193
1236
  try {
1194
1237
  const dreamRun = await runUtility(
@@ -1208,7 +1251,10 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1208
1251
  }
1209
1252
  } catch (e) {
1210
1253
  consolidationIntelligence = failedIntelligenceMetadata(e);
1211
- modelFailNote = `the AI consolidation step didn't finish (${e.message})`;
1254
+ const waiting = limitWaitFor(e);
1255
+ limitWait = waiting.wait;
1256
+ if (limitWait) consolidationIntelligence.status = "waiting_for_limit";
1257
+ else modelFailNote = waiting.note || dreamFailNote(e);
1212
1258
  }
1213
1259
  if (!decision) decision = { report: "" };
1214
1260
 
@@ -1266,7 +1312,9 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1266
1312
  catch (e) { console.warn(`[dream] certification proposals failed: ${e.message}`); }
1267
1313
  const consolidationLines = applied.concat(nestLines, journalLines, coRescueLines, hebbianLines, prospectiveLines, regressionLines, costLines, certLines, knobLines);
1268
1314
  let report = decision.report || (consolidationLines.length > 0 ? "Tidied up my memory overnight." : "");
1269
- if (modelFailNote) {
1315
+ if (limitWait) {
1316
+ report = `⏳ ${limitName(limitWait.provider, limitWait.limitType)} is used up, so the AI-led merges wait for its reset: I'll run them ${usageLimits.whenPhrase(limitWait.runAt)}. The automatic filing + graph upkeep ran now.${report ? " " + report : ""}`;
1317
+ } else if (modelFailNote) {
1270
1318
  report = `Heads up — ${modelFailNote}, so I skipped the AI-led merges this round but still ran the automatic filing + graph upkeep.${report ? " " + report : ""}`;
1271
1319
  }
1272
1320
 
@@ -1278,8 +1326,9 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1278
1326
  let introReport = "";
1279
1327
  let introApplied = [];
1280
1328
  let introProposed = null;
1281
- let introspectionIntelligence = intelligenceMetadata(null, introspectEnabled() ? "not_run" : "disabled");
1282
- if (introspectEnabled()) {
1329
+ let introspectionIntelligence = intelligenceMetadata(null, introspectEnabled() ? (limitWait ? "waiting_for_limit" : "not_run") : "disabled");
1330
+ // A used-up limit stops this step too: it runs with the dream after the reset.
1331
+ if (introspectEnabled() && !limitWait) {
1283
1332
  try {
1284
1333
  const introRun = await runUtility(buildIntrospectionPrompt(), dreamUtilityOptions("introspection", {
1285
1334
  provider,
@@ -1300,6 +1349,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1300
1349
  }
1301
1350
  } catch (e) {
1302
1351
  introspectionIntelligence = failedIntelligenceMetadata(e);
1352
+ limitWait = limitWaitFor(e).wait;
1353
+ if (limitWait) {
1354
+ introspectionIntelligence.status = "waiting_for_limit";
1355
+ introReport = `⏳ ${limitName(limitWait.provider, limitWait.limitType)} ran out during my self-review: I'll run the dream again ${usageLimits.whenPhrase(limitWait.runAt)}, when it resets.`;
1356
+ }
1303
1357
  console.warn(`[dream] introspection phase failed: ${e.message}`);
1304
1358
  }
1305
1359
  }
@@ -1493,6 +1547,12 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1493
1547
  dreamState.fallbackUsed = consolidationIntelligence.fallbackUsed;
1494
1548
  dreamState.lastRunStatus = consolidationIntelligence.status;
1495
1549
  dreamState.introspection = introspectionIntelligence;
1550
+ // The AI step waits for a limit's reset (createDreamLimitWait runs the
1551
+ // dream then); a run that got past the limit, or failed another way,
1552
+ // leaves nothing waiting. Waits in a row are counted and capped.
1553
+ dreamState.limitWait = limitWait
1554
+ ? { ...limitWait, attempts: waitsBefore + 1, since: (waitsBefore && dreamState.limitWait?.since) || nowISO }
1555
+ : null;
1496
1556
  writeDreamState(dreamState);
1497
1557
  } catch (e) { console.warn(`[dream] state write failed: ${e.message}`); }
1498
1558
 
@@ -1553,6 +1613,7 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1553
1613
  introspectionProvider: introspectionIntelligence.provider,
1554
1614
  introspectionModel: introspectionIntelligence.model,
1555
1615
  introspectionFallbackUsed: introspectionIntelligence.fallbackUsed,
1616
+ limitWait,
1556
1617
  };
1557
1618
  }
1558
1619
 
@@ -1622,10 +1683,11 @@ async function dreamSummaryTarget(adapters, deps = {}) {
1622
1683
  return null;
1623
1684
  }
1624
1685
 
1625
- // One scheduled dream (cron or catch-up) with its summary.
1686
+ // One scheduled dream (cron, catch-up, or after a limit's reset) with its summary.
1626
1687
  async function runScheduledDream(trigger, adapters, deps = {}) {
1627
1688
  try {
1628
1689
  const res = await (deps.runDream || runDream)({ trigger });
1690
+ try { deps.afterRun?.(res); } catch (_) { /* arming the next wait is best effort */ }
1629
1691
  if (res.skipped) { console.log(`dream: skipped — ${res.skipped}`); return res; }
1630
1692
  console.log(`dream: applied ${(res.applied || []).length} change(s)`);
1631
1693
  if (!summaryEnabled()) { console.log("dream: summary muted (DREAM_SUMMARY=off)"); return res; }
@@ -1670,6 +1732,63 @@ function createDreamCatchUp(adapters, deps = {}) {
1670
1732
  };
1671
1733
  }
1672
1734
 
1735
+ // The dream whose AI step a usage limit stopped runs again after the reset
1736
+ // (dream state limitWait.runAt), as a whole dream: its automatic steps are
1737
+ // safe to repeat. Armed at boot, after every scheduled run, and hourly (a
1738
+ // manual /dream may have recorded a wait), so a restart never loses it.
1739
+ function createDreamLimitWait(adapters, deps = {}) {
1740
+ const now = deps.now || Date.now;
1741
+ const readState = deps.readState || readDreamState;
1742
+ const setTimer = deps.setTimer || setTimeout;
1743
+ const clearTimer = deps.clearTimer || clearTimeout;
1744
+ const isDreaming = deps.isDreaming || defaultDreamService.isDreaming;
1745
+ // setTimeout's ceiling is ~24.8 days; a week's wait fits, the rest re-arms.
1746
+ const MAX_TIMER_MS = 2 ** 31 - 1;
1747
+ let timer = null;
1748
+ let armedFor = null;
1749
+
1750
+ function disarm() {
1751
+ if (timer) clearTimer(timer);
1752
+ timer = null;
1753
+ armedFor = null;
1754
+ }
1755
+
1756
+ function arm() {
1757
+ let runAt = NaN;
1758
+ try { runAt = Number(readState()?.limitWait?.runAt); } catch (_) { runAt = NaN; }
1759
+ if (!Number.isFinite(runAt)) { disarm(); return null; }
1760
+ if (timer && armedFor === runAt) return runAt;
1761
+ disarm();
1762
+ armedFor = runAt;
1763
+ timer = setTimer(() => { fire().catch((e) => console.warn(`dream: limit-reset run failed: ${e.message}`)); }, Math.min(Math.max(runAt - now(), 0), MAX_TIMER_MS));
1764
+ timer?.unref?.();
1765
+ console.log(`dream: AI step waits for a usage limit; runs ${new Date(runAt).toISOString()}`);
1766
+ return runAt;
1767
+ }
1768
+
1769
+ async function fire() {
1770
+ timer = null;
1771
+ armedFor = null;
1772
+ let wait = null;
1773
+ try { wait = readState()?.limitWait || null; } catch (_) { wait = null; }
1774
+ const runAt = Number(wait?.runAt);
1775
+ if (!Number.isFinite(runAt)) return { skipped: "nothing waits" };
1776
+ if (runAt - now() > 1000) return { skipped: "not yet", runAt: arm() };
1777
+ if (isDreaming()) {
1778
+ // A dream is running now: look again in a minute.
1779
+ timer = setTimer(() => { fire().catch(() => {}); }, 60000);
1780
+ timer?.unref?.();
1781
+ armedFor = runAt;
1782
+ return { skipped: "a dream is already running" };
1783
+ }
1784
+ const res = await runScheduledDream("limit-reset", adapters, deps);
1785
+ arm();
1786
+ return res;
1787
+ }
1788
+
1789
+ return { arm, fire, disarm };
1790
+ }
1791
+
1673
1792
  // In-bot scheduler: one quiet-hours cron (tolerating a late start up to
1674
1793
  // DREAM_LATE_TOLERANCE_MS, see core/cron-schedule), a boot + hourly catch-up
1675
1794
  // check, and the summary to the owner's channel (dreamSummaryTarget). When the
@@ -1685,18 +1804,20 @@ function initDream(adapters, deps = {}) {
1685
1804
  }
1686
1805
  const { DREAM_LATE_TOLERANCE_MS } = require("./config");
1687
1806
  const toleranceMs = Number.isFinite(deps.toleranceMs) ? deps.toleranceMs : DREAM_LATE_TOLERANCE_MS;
1688
- const task = scheduleTolerant(DREAM_CRON, () => runScheduledDream("cron", adapters, deps), {
1807
+ const limitWait = createDreamLimitWait(adapters, deps);
1808
+ const runDeps = { ...deps, afterRun: () => limitWait.arm() };
1809
+ const task = scheduleTolerant(DREAM_CRON, () => runScheduledDream("cron", adapters, runDeps), {
1689
1810
  cron: cronLib, toleranceMs, noOverlap: true, label: "dream",
1690
1811
  });
1691
- const catchUp = createDreamCatchUp(adapters, deps);
1812
+ const catchUp = createDreamCatchUp(adapters, runDeps);
1692
1813
  const setTimer = deps.setTimer || setTimeout;
1693
1814
  const setRepeat = deps.setInterval || setInterval;
1694
- const boot = setTimer(() => { catchUp("boot").catch(() => {}); }, Number.isFinite(deps.bootDelayMs) ? deps.bootDelayMs : 60000);
1695
- const hourly = setRepeat(() => { catchUp("hourly").catch(() => {}); }, 3600000);
1815
+ const boot = setTimer(() => { limitWait.arm(); catchUp("boot").catch(() => {}); }, Number.isFinite(deps.bootDelayMs) ? deps.bootDelayMs : 60000);
1816
+ const hourly = setRepeat(() => { limitWait.arm(); catchUp("hourly").catch(() => {}); }, 3600000);
1696
1817
  boot?.unref?.();
1697
1818
  hourly?.unref?.();
1698
1819
  console.log(`dream: scheduled (${DREAM_CRON}, late tolerance ${Math.round(toleranceMs / 60000)} min, provider policy tier ${dreamTier()}, effort ${DREAM_EFFORT}, introspect ${introspectEnabled() ? "on" : "off"})`);
1699
- return { task, catchUp };
1820
+ return { task, catchUp, limitWait };
1700
1821
  }
1701
1822
 
1702
1823
  module.exports = {
@@ -1704,6 +1825,7 @@ module.exports = {
1704
1825
  buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
1705
1826
  summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe, staleTaskReport,
1706
1827
  dreamSummaryTarget, runScheduledDream, dreamOverdue, createDreamCatchUp, DREAM_CATCHUP_MS,
1828
+ createDreamLimitWait, dreamLimitWait, dreamFailNote, DREAM_LIMIT_MAX_WAITS,
1707
1829
  coRescueEdgesPhase, negativeHebbianPhase, readDreamState, writeDreamState,
1708
1830
  enabled, summaryEnabled, introspectEnabled, selfApplyEnabled, dreamTier,
1709
1831
  DREAM_CRON, DREAM_EFFORT, DREAM_OUTPUT_SCHEMA, INTROSPECTION_OUTPUT_SCHEMA,
package/core/handlers.js CHANGED
@@ -118,6 +118,8 @@ function dropLaneQueue(lane, reason) {
118
118
  // Clears the CURRENT conversation's queue only: /new, /end or a project change
119
119
  // in one thread must not drop what another conversation has waiting.
120
120
  function clearTransientQueue(state, reason = "Session context changed before the queued run started") {
121
+ // The stopped turn's history goes with the session: nothing to warn about.
122
+ if (state) { try { require("./turn-journal").clearStopped(currentLaneKey(state)); } catch (_) {} }
121
123
  return dropLaneQueue(state ? peekLane(state) : null, reason).length;
122
124
  }
123
125
 
@@ -1739,6 +1741,18 @@ function stopLane(state, lane) {
1739
1741
  return null;
1740
1742
  }
1741
1743
 
1744
+ // stopLane for /stop: a run killed mid-turn left its request in the session's
1745
+ // history, so the conversation's next turn is told it was cancelled (core/
1746
+ // turn-journal markStopped). A compaction or a turn still preparing left none.
1747
+ function stopAndMarkLane(state, lane) {
1748
+ const killed = lane.runningProcess && lane.activeRunContext ? lane.activeRunContext : null;
1749
+ const stopped = stopLane(state, lane);
1750
+ if (stopped && killed) {
1751
+ try { require("./turn-journal").markStopped(lane.key, { runContext: killed }); } catch (_) {}
1752
+ }
1753
+ return stopped;
1754
+ }
1755
+
1742
1756
  const STOP_DROP_REASON = "Dropped by /stop before it started";
1743
1757
 
1744
1758
  // /stop never silently loses a queued message: each dropped one comes back
@@ -1803,7 +1817,7 @@ register({
1803
1817
  const lanes = state.lanes instanceof Map ? [...state.lanes.values()] : [];
1804
1818
  // Drop the queues first, so no stopped lane's unwind admits them.
1805
1819
  const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
1806
- const stopped = lanes.filter((lane) => stopLane(state, lane)).length;
1820
+ const stopped = lanes.filter((lane) => stopAndMarkLane(state, lane)).length;
1807
1821
  const sideStopped = await sideCancellation;
1808
1822
  let pickups = [];
1809
1823
  try { pickups = require("./limit-pickup").cancelAllFor(state.userId, "/stop all"); } catch (_) {}
@@ -1827,7 +1841,7 @@ register({
1827
1841
  // A pick-up waiting for a usage limit to reset is dropped too.
1828
1842
  const pickup = dropLimitPickup("/stop", state, lane.key);
1829
1843
  const pickupNote = pickup ? ` ${pickupDroppedNote(1)}` : "";
1830
- if (stopLane(state, lane)) {
1844
+ if (stopAndMarkLane(state, lane)) {
1831
1845
  await sideCancellation;
1832
1846
  await send(`${dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled."}${pickupNote}`, dropOffer(dropped));
1833
1847
  } else if (dropped.length) {
@@ -17,6 +17,9 @@
17
17
  // pick it up" prompt. If that run hits a limit again it re-arms, keeping
18
18
  // the original message and counting attempts (MAX_ATTEMPTS at most).
19
19
  // LIMIT_PICKUP=off turns it off (the notice then only says when it resets).
20
+ // When to run again is one rule for every kind of work (usage-limits
21
+ // limitRetryAt); a scheduled run a limit stopped waits the same way in the
22
+ // jobs store (core/scheduler.js), with the notice worded here.
20
23
 
21
24
  const path = require("path");
22
25
  const crypto = require("crypto");
@@ -37,16 +40,10 @@ function setting(name) {
37
40
  function enabled() {
38
41
  return !/^(off|false|0|no)$/i.test(String(setting("LIMIT_PICKUP") ?? "on").trim());
39
42
  }
40
- // Wait this long after the reset, so the provider's window has turned over.
41
- function delayMs() {
42
- const n = Number(setting("LIMIT_PICKUP_DELAY_S"));
43
- return (Number.isFinite(n) && n >= 0 ? n : 120) * 1000;
44
- }
45
- // A reset further out than this (a monthly spend cap) is not waited for.
46
- function maxWaitMs() {
47
- const n = Number(setting("LIMIT_PICKUP_MAX_WAIT_H"));
48
- return (Number.isFinite(n) && n > 0 ? n : 192) * 3600000;
49
- }
43
+ // The shared rule (core/usage-limits.js): LIMIT_PICKUP_DELAY_S after the
44
+ // reset, never for a reset more than LIMIT_PICKUP_MAX_WAIT_H away.
45
+ const delayMs = () => limits.limitRetryDelayMs();
46
+ const maxWaitMs = () => limits.limitMaxWaitMs();
50
47
 
51
48
  // A test run that did not point the store somewhere else never touches the
52
49
  // live bot's pick-ups.
@@ -100,9 +97,10 @@ function limitName(provider, limitType) {
100
97
  const PICKUP_PROMPT = (hitAt, provider, original) => `Your ${limits.providerLabel(provider)} usage limit stopped this turn at ${hitAt}. The limit has reset, so pick it up now. The message was: ${original}\n\nCheck what you already did (your history, git, files, tool state, the chat) before acting; never repeat a send, push, deploy or write that already happened; then finish.`;
101
98
 
102
99
  /**
103
- * What a turn stopped by a limit should do: arm a pick-up (armable) or only
104
- * say when the limit lifts. Pure: deliverForegroundResult posts `notice` and
105
- * then calls arm() with the plan when armable.
100
+ * What a turn stopped by a limit should do: arm a pick-up (armable), wait in
101
+ * the scheduler (waits: a scheduled run), or only say when the limit lifts.
102
+ * Pure: deliverForegroundResult posts `notice` and then calls arm() with the
103
+ * plan when armable; the scheduler reads `waits` and `runAt`.
106
104
  */
107
105
  function planFor(result, runContext, { now = Date.now(), windows = [], guarded = false } = {}) {
108
106
  const hit = limits.limitHitDetails({
@@ -111,21 +109,27 @@ function planFor(result, runContext, { now = Date.now(), windows = [], guarded =
111
109
  if (!hit) return null;
112
110
  // An outside contact is not told which model or plan the bot runs on.
113
111
  const name = guarded ? "My usage limit" : limitName(hit.provider, hit.limitType);
114
- const known = Number.isFinite(hit.resetsAt) && hit.resetsAt > now - 6 * 3600000;
115
- const runAt = known ? Math.max(hit.resetsAt, now) + delayMs() : null;
112
+ const runAt = limits.limitRetryAt(hit.resetsAt, { now });
113
+ const known = runAt !== null;
116
114
  const pickup = runContext?.limitPickup || null;
117
115
  const attempts = (pickup?.attempts || 0) + 1;
116
+ const waitable = known && limits.canWaitFor(runAt, { now }) && attempts <= MAX_ATTEMPTS;
117
+ // A scheduled run waits in the jobs store for the reset (core/scheduler.js).
118
+ const waits = waitable && runContext?.purpose === "scheduled";
118
119
  const armable = enabled()
119
- && known
120
- && runAt - now <= maxWaitMs()
121
- && attempts <= MAX_ATTEMPTS
120
+ && waitable
122
121
  && runContext?.purpose === "foreground"
123
122
  && !runContext?.passive
124
123
  && !runContext?.liveCall
125
124
  && !!runContext?.origin?.adapterId
126
125
  && !!runContext?.channelId;
127
126
  let notice;
128
- if (known && runContext?.purpose && runContext.purpose !== "foreground") {
127
+ if (waits) {
128
+ const again = pickup && pickup.limitType && pickup.limitType === hit.limitType ? "is still used up" : "is used up too";
129
+ notice = pickup
130
+ ? `⏳ ${name} ${again}. I'll try this scheduled run again ${at(runAt, now)}.`
131
+ : `⏳ ${name} is used up, so this scheduled run waits. I'll run it ${at(runAt, now)}, when it resets.`;
132
+ } else if (known && runContext?.purpose && runContext.purpose !== "foreground") {
129
133
  notice = `⏳ ${name} is used up, so this ${runContext.purpose === "scheduled" ? "scheduled run" : "run"} stopped. It resets ${at(hit.resetsAt, now)}.`;
130
134
  } else if (armable) {
131
135
  const again = pickup && pickup.limitType && pickup.limitType === hit.limitType ? "is still used up" : "is used up too";
@@ -141,7 +145,7 @@ function planFor(result, runContext, { now = Date.now(), windows = [], guarded =
141
145
  } else {
142
146
  notice = `⏳ ${limits.providerLabel(hit.provider)} says its usage limit is reached ("${String(hit.text || "").slice(0, 160)}"). I can't tell when it resets, so send your message again later.`;
143
147
  }
144
- return { hit, armable, runAt, attempts, notice };
148
+ return { hit, armable, waits, runAt, attempts, notice };
145
149
  }
146
150
 
147
151
  // ── arm / cancel ────────────────────────────────────────────────────────
@@ -18,15 +18,22 @@ const { createClaudeHookTransport } = require("./claude-hook");
18
18
  // consulting this list, so `/model claude-opus-4-8` still works as a rollback.
19
19
  // The list is consulted on tapped mb: callbacks (core/actions.js), whose job is
20
20
  // to reject a stale button from an old message — that is the intended effect.
21
- // claude-sonnet-4-6 and claude-haiku-4-5 STAY: they are the tier models for
22
- // utility work (see tierModel below), not just picker entries.
21
+ // claude-sonnet-5-5 and claude-haiku-5-5 STAY: they are the tier models for
22
+ // utility work (see tierModel below), not just picker entries. Haiku 5.5
23
+ // replaced Haiku 4.5 as the low tier on 9 Oct 2026 (Sumeet: "check if anything
24
+ // using haiku and have it shifted to the new haiku 5.5"), and Sonnet 5.5
25
+ // replaced Sonnet 4.6 as the medium tier the same day ("sonnet should also be
26
+ // just 5.5"); `/model claude-haiku-4-5-20251001` and `/model claude-sonnet-4-6`
27
+ // still work typed.
28
+ const CLAUDE_LOW_TIER_MODEL = "claude-haiku-5-5";
29
+ const CLAUDE_MEDIUM_TIER_MODEL = "claude-sonnet-5-5";
23
30
  const CLAUDE_MODELS = Object.freeze([
24
31
  "claude-fable-5-1",
25
32
  "claude-fable-5",
26
33
  "claude-opus-5-5",
27
34
  "claude-opus-5",
28
- "claude-sonnet-4-6",
29
- "claude-haiku-4-5-20251001",
35
+ CLAUDE_MEDIUM_TIER_MODEL,
36
+ CLAUDE_LOW_TIER_MODEL,
30
37
  ]);
31
38
  const CLAUDE_EFFORT_VALUES = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
32
39
  // The default governs MAIN turns only: utility work is pinned by tier below
@@ -99,8 +106,8 @@ function appendModelAndLimits(args, context, defaultModel, utility) {
99
106
  let model = context.model || settings.model || null;
100
107
  if (!model && utility && context.tier) {
101
108
  model = context.tier === "low"
102
- ? "claude-haiku-4-5-20251001"
103
- : (context.tier === "medium" ? "claude-sonnet-4-6" : defaultModel);
109
+ ? CLAUDE_LOW_TIER_MODEL
110
+ : (context.tier === "medium" ? CLAUDE_MEDIUM_TIER_MODEL : defaultModel);
104
111
  }
105
112
  args.push("--model", String(model || defaultModel));
106
113
 
@@ -311,8 +318,8 @@ function createClaudeProvider(options = {}) {
311
318
  },
312
319
  nativeToolsNote: "Claude Code exposes its native tools; Open Claudia's tool hook and approval policy remain authoritative.",
313
320
  tierModel(tier) {
314
- if (tier === "low") return "claude-haiku-4-5-20251001";
315
- if (tier === "medium") return "claude-sonnet-4-6";
321
+ if (tier === "low") return CLAUDE_LOW_TIER_MODEL;
322
+ if (tier === "medium") return CLAUDE_MEDIUM_TIER_MODEL;
316
323
  return defaultModel;
317
324
  },
318
325
  };
@@ -127,17 +127,40 @@ function createJsonlDecoder(options = {}) {
127
127
  return api;
128
128
  }
129
129
 
130
+ // What a provider's failure means, read in one place for chat turns
131
+ // (core/runner) and background calls (core/utility-agent):
132
+ // authError — signed out, or the sign-in died. Waiting does not fix it;
133
+ // /login does. Claude Code 2.1 words a dead refresh "Failed to
134
+ // authenticate: OAuth session expired and could not be
135
+ // refreshed"; Codex "Your authentication token has expired".
136
+ // usageLimit — a plan limit. Its reset fixes it.
137
+ // transient — Claude Code's refresh-lock race ("another Claude Code process
138
+ // is refreshing it or exited mid-refresh"): a later try works.
139
+ // Four fleet bots lost the 9 Oct 07:00 dream to it.
130
140
  function providerErrorFlags(message) {
131
141
  const text = String(message || "").toLowerCase();
142
+ const transient = /another claude code process is refreshing|exited mid-refresh/.test(text);
132
143
  return {
133
- authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
144
+ authError: !transient && /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|failed to authenticate|invalid api key|no credentials|401|oauth (?:session|access token|token) (?:expired|has expired|is invalid|is no longer valid)|could not be refreshed|refresh token is no longer valid|authentication token (?:has )?expired/.test(text),
134
145
  // "You've hit your limit / session limit / weekly limit / Opus limit · resets …"
135
146
  // is how Claude Code words a plan limit; the older patterns never matched it.
136
147
  usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit|hit your (?:[a-z0-9'’-]+ ){0,3}limit/.test(text),
148
+ transient,
137
149
  refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
138
150
  };
139
151
  }
140
152
 
153
+ // The error code of a failed run, from its normalized error event's flags.
154
+ // Chat turns pass PROVIDER_EVENT_ERROR as the fallback, background calls
155
+ // UTILITY_PROVIDER_ERROR, so both read a limit or a dead sign-in the same way.
156
+ function providerFailureCode(event, fallback = "PROVIDER_EVENT_ERROR") {
157
+ if (event?.refusal) return "PROVIDER_REFUSAL";
158
+ if (event?.authError) return "PROVIDER_UNAUTHENTICATED";
159
+ if (event?.usageLimit) return "PROVIDER_USAGE_LIMIT";
160
+ if (event?.transient) return "PROVIDER_TRANSIENT";
161
+ return fallback;
162
+ }
163
+
141
164
  // The CLI flattens the API's stop_details into the error text; recover the
142
165
  // human-readable pieces so the chat message can lead with the actual reason.
143
166
  function refusalDetailsFromMessage(message) {
@@ -184,6 +207,7 @@ module.exports = {
184
207
  normalizeDecoderError,
185
208
  normalizedUsage,
186
209
  providerErrorFlags,
210
+ providerFailureCode,
187
211
  refusalDetailsFromMessage,
188
212
  selectTerminalText,
189
213
  usageFingerprint,
@@ -84,13 +84,29 @@ function parseProviderFallbacks(value) {
84
84
  return result;
85
85
  }
86
86
 
87
+ // The provider of work with a person behind it is that person's; work with
88
+ // nobody behind it (the dream, introspection, a check outside any chat) runs
89
+ // on the bot's own provider: the one its owner chats on (/backend and /login
90
+ // set it). It used to take the first provider that did not look signed out,
91
+ // so on 9 Oct 2026 every Codex bot's dream ran on a signed-out Claude.
92
+ function ownerActiveProvider() {
93
+ try {
94
+ const owner = require("../identity").ownerCanonical();
95
+ if (!owner) return null;
96
+ const state = require("../state");
97
+ return state.getActiveProvider(state.getUserState(owner));
98
+ } catch (_) {
99
+ return null;
100
+ }
101
+ }
102
+
87
103
  function defaultActiveProvider(env = process.env) {
88
104
  const inherited = String(env?.OC_PROVIDER || process.env.OC_PROVIDER || "").trim().toLowerCase();
89
105
  if (PROVIDER_ID_SET.has(inherited)) return inherited;
106
+ let canonicalUserId = null;
107
+ try { canonicalUserId = require("../context").currentCanonicalUserId(); } catch (_) { canonicalUserId = null; }
108
+ if (!canonicalUserId) return ownerActiveProvider();
90
109
  try {
91
- const { currentCanonicalUserId } = require("../context");
92
- const canonicalUserId = currentCanonicalUserId();
93
- if (!canonicalUserId) return null;
94
110
  const state = require("../state");
95
111
  return state.getActiveProvider(state.getUserState(canonicalUserId));
96
112
  } catch (_) {
@@ -98,6 +114,16 @@ function defaultActiveProvider(env = process.env) {
98
114
  }
99
115
  }
100
116
 
117
+ // With no active provider at all: the one a new chat starts on (DEFAULT_PROVIDER,
118
+ // else the first installed), as for a chat turn; then the first runnable.
119
+ function registryDefaultProvider(registry) {
120
+ try {
121
+ const selected = typeof registry.selectDefaultProvider === "function" ? registry.selectDefaultProvider()?.providerId : null;
122
+ if (selected && PROVIDER_ID_SET.has(selected) && runnableStatus(registry, selected).runnable) return selected;
123
+ } catch (_) { /* fall through */ }
124
+ return registryProviders(registry).find((providerId) => runnableStatus(registry, providerId).runnable) || null;
125
+ }
126
+
101
127
  function registryProviders(registry) {
102
128
  try {
103
129
  const listed = registry.listProviders();
@@ -189,9 +215,7 @@ function createUtilityPolicy(options = {}) {
189
215
  if (active) preferred = normalizeProvider(active);
190
216
  else {
191
217
  const globalDefault = envValue(env, "DEFAULT_PROVIDER");
192
- preferred = globalDefault
193
- ? normalizeProvider(globalDefault)
194
- : (registryProviders(registry).find((providerId) => runnableStatus(registry, providerId).runnable) || null);
218
+ preferred = globalDefault ? normalizeProvider(globalDefault) : registryDefaultProvider(registry);
195
219
  }
196
220
  }
197
221
  if (!preferred) {
@@ -267,6 +291,7 @@ module.exports = {
267
291
  UtilityPolicyError,
268
292
  createUtilityPolicy,
269
293
  defaultActiveProvider,
294
+ ownerActiveProvider,
270
295
  exactModelOverride,
271
296
  normalizePurpose,
272
297
  normalizeTier,