@inetafrica/open-claudia 3.5.17 → 3.5.18

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/.env.example CHANGED
@@ -70,6 +70,16 @@ USAGE_ALERT_RATE_MULTIPLIER=1.75
70
70
  USAGE_ALERT_BASELINE_TURNS=20
71
71
  USAGE_ALERT_MIN_BASELINE_TURNS=6
72
72
  USAGE_ALERT_COOLDOWN_MS=1800000
73
+ # Plan limits (Claude Pro/Max, the ChatGPT plan behind Codex). Warn in the chat
74
+ # when a window's use passes these percentages, once per window (off = never).
75
+ LIMIT_WARN_PERCENTS=80,95
76
+ # A message a usage limit stopped is picked up when the limit resets, unless
77
+ # the person writes again first: on (default) | off.
78
+ LIMIT_PICKUP=on
79
+ # Seconds after the reset before it runs (default 120); a reset further out
80
+ # than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited for.
81
+ LIMIT_PICKUP_DELAY_S=120
82
+ LIMIT_PICKUP_MAX_WAIT_H=192
73
83
  MEMORY_RECALL_MAX_CHARS=9000
74
84
  # Default recall engine when a chat hasn't picked one with /engine: classic | discoverer
75
85
  RECALL_ENGINE=classic
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.18
4
+
5
+ Usage limits (Sumeet, 7 Oct 15:00 EAT: "track usage and notify the user that they are going to hit their limit, if the limit is hit can the bot auto schedule to continue when the limit replenishes"; 15:14 EAT: "if the user sends it a message it cancels and if the session limit message comes up again it will just rearm").
6
+
7
+ - **A message a usage limit stopped is picked up when the limit resets.** When Claude's 5-hour or weekly limit (or Codex's plan limit) stops a chat turn, the bot says which limit and when it resets ("⏳ Claude's weekly limit is used up, so I stopped here. I'll pick your message back up on Fri 9 Oct at 08:02, when it resets. If you write to me before then, I'll answer that instead and drop this.") and runs the message again 2 minutes after the reset (`LIMIT_PICKUP_DELAY_S`), in the same conversation, as the person who sent it, with "check what you already did; never repeat a send, push, deploy or write". One pick-up per person per conversation, kept in `limit-pickups.json` so it survives a restart. The person writing again in that conversation drops it (also when the guard holds that message), as do `/stop`, `/new` and `/end`; another person in a group does not. A run that hits a limit again arms it again with the same message, at most 5 times. Resets further out than `LIMIT_PICKUP_MAX_WAIT_H` (192 h) are not waited for. `LIMIT_PICKUP=off` only says when the limit resets. An unasked group reply that hits a limit posts nothing.
8
+ - **The limit notice is never the reply.** Claude Code words a plan limit "You've hit your limit / session limit / weekly limit · resets …", which the old checks missed: the bot posted "Claude Code run failed: You've hit your session limit · resets 11:30am (Africa/Nairobi)", or the bare notice as if it were the answer (the tickets bot to a staff member, 7 Oct). The limit is now read from Claude Code's `rate_limit_event` (status `rejected`, the reset time) and its synthetic `rate_limit` message, which never streams as answer text; Codex's "You've hit your usage limit … try again at …" was already caught. The reset time comes from the event, else Codex's rollout, else the notice's own words.
9
+ - **Warnings at 80% and 95%.** Claude Code reports each window's use on every run (`rate_limit_event.unifiedWindows`, a 0-1 fraction per 5-hour and weekly window); Codex in its rollout (its windows are named by length: on a Pro account the main one is weekly). After the reply that takes a window past 80% or 95%, the chat gets "⚠️ Claude: 82% of the 5-hour limit is used. It resets at 17:20." Once per window per percentage (`LIMIT_WARN_PERCENTS`, `off` for none); never to an outside contact, on a call or for an unasked group reply. Claude Code's own `allowed_warning` status is a pace warning (25% early in the week) and is not used.
10
+ - **/usage shows the plan limits** ("Claude plan limits: 5-hour 57% (resets 17:20) · weekly 26% (resets Wed 14 Oct, 08:00)") and the waiting pick-up in this conversation.
11
+ - **Tests:** `test-usage-limits.js`, `test-limit-pickup.js` (new); `test-provider-events.js`; the fake agent CLI gains a `usage-limit` scenario and `FAKE_AGENT_RATE_LIMIT_INFO`.
12
+
13
+ Token diet for every bot (Sumeet, 8 Oct 08:19 EAT: "Let's roll it out to the fleet if we already seeing savings?"). On the owner's bot the 3.5.17 canary cut a typical reply from $8.72 to $4.03, and the cost per reply with compactions included by 48%, over 82 replies.
14
+
15
+ - **The token diet is on by default.** Every conversation compacts at 200k, and before a cold resume over 120k, as listed conversations did in 3.5.17. `TOKEN_DIET_CONVERSATIONS=off` turns it off; a list of conversation keys still limits it to those.
16
+ - **The compaction summary no longer dies on a tool call.** Claude runs it with `--max-turns 1`, and its prompt never said not to use tools: a model that checked a fact first used up the one turn and left no brief (4 of 24 compactions on 7-8 Oct, $35 for nothing). The prompt now says so, and a threshold compaction that does not complete logs why.
17
+ - **A scheduled job's own conversation gets the same cap.** A watch, cron or wakeup resumes the conversation it was made in; once its thread had moved on, nothing compacted that conversation (the Kazee Bugs monitor's grew to 894k, $55 a wake). It is now compacted like its thread, before a cold run and after a run past the threshold, without touching the thread's live session. The usage ledger marks these compactions `scope: "job"`.
18
+ - **Jobs follow their compactions.** The completed-compactions index keeps only the last 20, which the token diet fills in under a day, so jobs lost their way and resumed old, huge conversations (the PBX thread's wakeup and watch: 790k, $86 for two runs). The scheduler now also reads the lineage in the session history, and a recurring job remembers the session it last ran on.
19
+ - **Tests:** `test-token-diet.js` (the default; job conversations end to end through the scheduler), `test-provider-compaction.js` (branch commit, the no-tools summary), `test-provider-scheduler.js` (history lineage, a job keeps its session); `test-usage-accounting.js` pins the classic thresholds with the diet off.
20
+
3
21
  ## 3.5.17
4
22
 
5
23
  One combined release (Sumeet, 7 Oct 09:21 EAT): Release 1 below, the 7 Oct security fixes, and the dashboard and provider fixes found on 6 Oct. What people will notice: Codex bots start answering in groups when a message concerns them (their checks failed before), a file over 200 MB sent to a bot is refused, and a /dashboard link opens a Continue page first.
package/README.md CHANGED
@@ -453,6 +453,9 @@ All stored in `~/.open-claudia/`:
453
453
  | `USAGE_ALERT_CONTEXT_TOKENS` | No | With alerts on: alert when one completed turn's context tokens exceed this ceiling (default `120000`, `off` disables) |
454
454
  | `USAGE_ALERT_RATE_MULTIPLIER` | No | With alerts on: alert when the latest context-token rate exceeds the recent baseline by this multiple (default `1.75`, `off` disables) |
455
455
  | `USAGE_ALERT_BASELINE_TURNS` / `USAGE_ALERT_MIN_BASELINE_TURNS` / `USAGE_ALERT_COOLDOWN_MS` | No | Tune token-rate baseline size, minimum sample size, and alert cooldown |
456
+ | `LIMIT_WARN_PERCENTS` | No | Plan-limit warnings in the chat: comma-separated percentages of a 5-hour or weekly window (default `80,95`, `off` disables). Each is said once per window, after the reply that crossed it; never to an outside contact |
457
+ | `LIMIT_PICKUP` | No | `on` (default) or `off`. A message a Claude or Codex usage limit stopped is picked up when the limit resets; the person writing again in that conversation, `/stop`, `/new` or `/end` drops it, and a new limit hit arms it again |
458
+ | `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before the pick-up runs (default `120`); resets further out than this many hours (default `192`) are not waited for |
456
459
  | `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
457
460
  | `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
458
461
  | `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
package/bot.js CHANGED
@@ -381,6 +381,13 @@ async function safeModeBoot() {
381
381
  }
382
382
  } catch (e) { console.error("interrupted-turn resume failed:", e.message); }
383
383
 
384
+ // Messages a usage limit stopped are picked up when the limit resets
385
+ // (core/limit-pickup.js): a timer for each one still waiting.
386
+ try {
387
+ const waiting = require("./core/limit-pickup").start(adapters);
388
+ if (waiting.length) console.log(`Limit pick-ups waiting: ${waiting.length}`);
389
+ } catch (e) { console.error("limit pick-up start failed:", e.message); }
390
+
384
391
  // Kazee Cards: snoozed owner decisions are asked again when due.
385
392
  try { require("./core/cards").startDecisionPump(registry); } catch (e) { console.error("Decision pump start failed:", e.message); }
386
393
 
package/core/config.js CHANGED
@@ -126,15 +126,18 @@ const CLAUDE_PATH = configuredProviderPath("claude");
126
126
  const CODEX_PATH = config.CODEX_PATH || null;
127
127
  const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
128
128
  const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
129
- // Token diet canary (off unless conversations are listed). TOKEN_DIET_CONVERSATIONS
130
- // is a comma-separated list of conversation keys as the usage ledger's `project`
131
- // field writes them (e.g. kazee:<room>#thread:<root>), or "*" for all. A listed
132
- // conversation compacts at TOKEN_DIET_COMPACT_TOKENS instead of
129
+ // Token diet, on for every conversation by default since 3.5.18 (the 3.5.17
130
+ // canary halved the cost of a reply on the owner's bot). TOKEN_DIET_CONVERSATIONS
131
+ // is "*" (all), a comma-separated list of conversation keys as the usage
132
+ // ledger's `project` field writes them (e.g. kazee:<room>#thread:<root>), or
133
+ // "off". A listed conversation compacts at TOKEN_DIET_COMPACT_TOKENS instead of
133
134
  // AUTO_COMPACT_TOKENS, and is compacted before a turn that resumes it after
134
135
  // TOKEN_DIET_COLD_MINUTES idle (the prompt cache has expired, so the whole
135
136
  // context would be re-written) when its context exceeds TOKEN_DIET_COLD_MIN_TOKENS.
136
- const TOKEN_DIET_CONVERSATIONS = String(config.TOKEN_DIET_CONVERSATIONS || process.env.TOKEN_DIET_CONVERSATIONS || "")
137
- .split(",").map((s) => s.trim()).filter(Boolean);
137
+ const TOKEN_DIET_SETTING = String(config.TOKEN_DIET_CONVERSATIONS || process.env.TOKEN_DIET_CONVERSATIONS || "*").trim();
138
+ const TOKEN_DIET_CONVERSATIONS = /^(off|none)$/i.test(TOKEN_DIET_SETTING)
139
+ ? []
140
+ : TOKEN_DIET_SETTING.split(",").map((s) => s.trim()).filter(Boolean);
138
141
  const TOKEN_DIET_COMPACT_TOKENS = parseInt(config.TOKEN_DIET_COMPACT_TOKENS || process.env.TOKEN_DIET_COMPACT_TOKENS || "200000", 10);
139
142
  const TOKEN_DIET_COLD_MINUTES = parseInt(config.TOKEN_DIET_COLD_MINUTES || process.env.TOKEN_DIET_COLD_MINUTES || "60", 10);
140
143
  const TOKEN_DIET_COLD_MIN_TOKENS = parseInt(config.TOKEN_DIET_COLD_MIN_TOKENS || process.env.TOKEN_DIET_COLD_MIN_TOKENS || "120000", 10);
package/core/handlers.js CHANGED
@@ -87,6 +87,18 @@ function cancelledQueueResult(item, reason) {
87
87
  };
88
88
  }
89
89
 
90
+ // A pick-up waiting for a usage limit to reset (core/limit-pickup.js) in
91
+ // the current conversation, dropped by /stop, /new and /end.
92
+ function dropLimitPickup(reason, state = currentState(), laneKey = currentLaneKey(state)) {
93
+ try { return require("./limit-pickup").cancel(state.userId, laneKey, reason); } catch (_) { return null; }
94
+ }
95
+
96
+ function pickupDroppedNote(count) {
97
+ return count === 1
98
+ ? "I won't pick up your earlier message when the usage limit resets."
99
+ : `I won't pick up your ${count} earlier messages when the usage limits reset.`;
100
+ }
101
+
90
102
  // Drops every message queued in one lane: each one's caller gets a cancelled
91
103
  // result (no hanging promise), and an idle lane loses its typing heartbeat (the
92
104
  // chip would stay on, and the lane could never be pruned). Returns the items.
@@ -1539,6 +1551,7 @@ register({
1539
1551
  if (!requireSession()) return;
1540
1552
  const result = startNewConversation();
1541
1553
  if (!result.ok) return send(`Could not start a new conversation: ${result.error.message}`);
1554
+ dropLimitPickup("/new");
1542
1555
  return send(`Session: ${result.project}\nNew ${result.provider} conversation\n\nSend text, voice, or images.`);
1543
1556
  },
1544
1557
  });
@@ -1792,10 +1805,16 @@ register({
1792
1805
  const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
1793
1806
  const stopped = lanes.filter((lane) => stopLane(state, lane)).length;
1794
1807
  const sideStopped = await sideCancellation;
1795
- if (!stopped && !dropped.length) return send(sideStopped ? "Cancelled side response." : "Nothing running.");
1808
+ let pickups = [];
1809
+ try { pickups = require("./limit-pickup").cancelAllFor(state.userId, "/stop all"); } catch (_) {}
1810
+ if (!stopped && !dropped.length) {
1811
+ if (sideStopped) return send("Cancelled side response.");
1812
+ return send(pickups.length ? pickupDroppedNote(pickups.length) : "Nothing running.");
1813
+ }
1796
1814
  const parts = [];
1797
1815
  if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
1798
1816
  if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
1817
+ if (pickups.length) parts.push(pickupDroppedNote(pickups.length));
1799
1818
  return send(parts.join(" "), dropOffer(dropped));
1800
1819
  }
1801
1820
  // /stop cancels the CURRENT conversation's turn only (other threads keep
@@ -1805,16 +1824,19 @@ register({
1805
1824
  const lane = laneFor(state);
1806
1825
  const sideCancellation = sideChatCoordinator.cancel(state.userId, lane.key);
1807
1826
  const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
1827
+ // A pick-up waiting for a usage limit to reset is dropped too.
1828
+ const pickup = dropLimitPickup("/stop", state, lane.key);
1829
+ const pickupNote = pickup ? ` ${pickupDroppedNote(1)}` : "";
1808
1830
  if (stopLane(state, lane)) {
1809
1831
  await sideCancellation;
1810
- await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.", dropOffer(dropped));
1832
+ await send(`${dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled."}${pickupNote}`, dropOffer(dropped));
1811
1833
  } else if (dropped.length) {
1812
1834
  // Nothing to cancel: the dropped messages were waiting for a slot (or
1813
1835
  // queued behind a turn that is only finishing its tail).
1814
1836
  await sideCancellation;
1815
- await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"), dropOffer(dropped));
1816
- } else if (await sideCancellation) await send("Cancelled side response.");
1817
- else await send("Nothing running.");
1837
+ await send(`${droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting")}${pickupNote}`, dropOffer(dropped));
1838
+ } else if (await sideCancellation) await send(`Cancelled side response.${pickupNote}`);
1839
+ else await send(pickup ? pickupDroppedNote(1) : "Nothing running.");
1818
1840
  },
1819
1841
  });
1820
1842
 
@@ -1867,16 +1889,42 @@ register({
1867
1889
  const state = currentState();
1868
1890
  const result = endCurrentSession({ state });
1869
1891
  if (!result.ok) return send(`Could not end conversation: ${result.error.message}`);
1892
+ dropLimitPickup("/end", state);
1870
1893
  send("Ended. Send anything to start a fresh conversation.");
1871
1894
  },
1872
1895
  });
1873
1896
 
1897
+ // Plan limits as last reported (core/usage-limits.js), and this person's
1898
+ // pick-up in this conversation if a usage limit stopped a message.
1899
+ function planLimitLines(state = currentState()) {
1900
+ const lines = [];
1901
+ try {
1902
+ const limits = require("./usage-limits");
1903
+ const byProvider = new Map();
1904
+ for (const w of limits.knownWindows()) {
1905
+ if (!byProvider.has(w.provider)) byProvider.set(w.provider, []);
1906
+ byProvider.get(w.provider).push(w);
1907
+ }
1908
+ const order = { five_hour: 0, seven_day: 1 };
1909
+ for (const [provider, windows] of byProvider) {
1910
+ const parts = windows
1911
+ .sort((a, b) => (order[a.id] ?? 9) - (order[b.id] ?? 9))
1912
+ .map((w) => `${limits.windowLabel(w.id).replace(/ limit$/, "")} ${Math.round(w.usedPercent)}%${w.resetsAt ? ` (resets ${limits.formatResetTime(w.resetsAt)})` : ""}`);
1913
+ lines.push(`${limits.providerLabel(provider)} plan limits: ${parts.join(" · ")}`);
1914
+ }
1915
+ const pickup = require("./limit-pickup").get(state.userId, currentLaneKey(state));
1916
+ if (pickup) lines.push(`Picking up your stopped message ${limits.whenPhrase(pickup.runAt)} (/stop drops it).`);
1917
+ } catch (_) {}
1918
+ return lines;
1919
+ }
1920
+
1874
1921
  register({
1875
1922
  name: "usage", description: "Show token usage & cost for this session",
1876
1923
  handler: async (env) => {
1877
1924
  if (!authorized(env)) return;
1878
1925
  const u = currentState().sessionUsage;
1879
- if (u.turns === 0) return send("No usage yet in this session.");
1926
+ const limitLines = planLimitLines();
1927
+ if (u.turns === 0) return send(["No usage yet in this session.", ...limitLines].join("\n"));
1880
1928
  const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
1881
1929
  const lines = [
1882
1930
  `*Session usage* (${u.turns} turn${u.turns === 1 ? "" : "s"})`,
@@ -1892,7 +1940,8 @@ register({
1892
1940
  if (u.subagentThreads > 0) {
1893
1941
  lines.push(`Provider sub-agents: ${u.subagentThreads} thread${u.subagentThreads === 1 ? "" : "s"} · ${fmt(u.subagentInputTokens || 0)} in / ${fmt(u.subagentOutputTokens || 0)} out (spawned by the model; not in the totals above)`);
1894
1942
  }
1895
- if (Number.isFinite(u.quotaUsedPercent)) {
1943
+ if (limitLines.length) lines.push(...limitLines);
1944
+ else if (Number.isFinite(u.quotaUsedPercent)) {
1896
1945
  lines.push(`Plan quota used: ${u.quotaUsedPercent}%${u.quotaResetsAt ? ` (resets ${String(u.quotaResetsAt).slice(0, 16).replace("T", " ")}Z)` : ""}`);
1897
1946
  }
1898
1947
  if (u.lastInputTokens > 200000) {
@@ -0,0 +1,313 @@
1
+ // Limit pick-up: a chat turn stopped by a plan usage limit runs again when
2
+ // the limit resets (Sumeet, 7 Oct 2026: "have it pick up when the session
3
+ // replenishes … if the user sends it a message it cancels and if the session
4
+ // limit message comes up again it will just rearm").
5
+ //
6
+ // - arm(): a foreground chat turn failed on PROVIDER_USAGE_LIMIT with a
7
+ // known reset time. <config>/limit-pickups.json keeps ONE pick-up per
8
+ // person per conversation (a newer one replaces it; in a group each
9
+ // sender has their own lane and session) with what is needed to run the
10
+ // message again as the person who sent it (the turn journal's shape).
11
+ // - cancel(): that person writing again in that conversation drops it
12
+ // (router.onMessage, and runner.runAgent for turns started another way),
13
+ // as do /stop, /new and /end: the conversation moved on.
14
+ // - start(adapters) at boot sets a timer per pick-up; a reset that passed
15
+ // while the bot was down fires shortly after boot. When it fires the
16
+ // record is removed and the message runs with a "the limit has reset,
17
+ // pick it up" prompt. If that run hits a limit again it re-arms, keeping
18
+ // the original message and counting attempts (MAX_ATTEMPTS at most).
19
+ // LIMIT_PICKUP=off turns it off (the notice then only says when it resets).
20
+
21
+ const path = require("path");
22
+ const crypto = require("crypto");
23
+ const CONFIG_DIR = require("../config-dir");
24
+ const { atomicWriteFileSync, readJsonWithFallback, withFileLock } = require("./fsutil");
25
+ const limits = require("./usage-limits");
26
+
27
+ const FILE = () => process.env.OC_LIMIT_PICKUPS_FILE || path.join(CONFIG_DIR, "limit-pickups.json");
28
+ const MAX_ATTEMPTS = 5;
29
+ // setTimeout's ceiling is ~24.8 days; a plan window is a week at most.
30
+ const MAX_TIMER_MS = 2 ** 31 - 1;
31
+ const BOOT_GRACE_MS = 60000;
32
+
33
+ function setting(name) {
34
+ if (process.env[name] !== undefined) return process.env[name];
35
+ try { return require("./config").config[name]; } catch (_) { return undefined; }
36
+ }
37
+ function enabled() {
38
+ return !/^(off|false|0|no)$/i.test(String(setting("LIMIT_PICKUP") ?? "on").trim());
39
+ }
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
+ }
50
+
51
+ // A test run that did not point the store somewhere else never touches the
52
+ // live bot's pick-ups.
53
+ function storeOn() {
54
+ return !(process.env.OPEN_CLAUDIA_TEST === "1" && !process.env.OPEN_CLAUDIA_CONFIG_DIR && !process.env.OC_LIMIT_PICKUPS_FILE);
55
+ }
56
+
57
+ function readAll() {
58
+ const data = readJsonWithFallback(FILE(), null);
59
+ return data && typeof data === "object" && data.pickups && typeof data.pickups === "object" ? data : { v: 1, pickups: {} };
60
+ }
61
+ function update(fn) {
62
+ return withFileLock(FILE(), () => {
63
+ const data = readAll();
64
+ const out = fn(data.pickups);
65
+ atomicWriteFileSync(FILE(), JSON.stringify(data, null, 2) + "\n");
66
+ return out;
67
+ });
68
+ }
69
+
70
+ // One pick-up per person per conversation.
71
+ function keyFor(canonicalUserId, laneKey) {
72
+ return `${canonicalUserId || "-"}::${laneKey}`;
73
+ }
74
+
75
+ // Keys with a pick-up on disk, so the check on every inbound message reads
76
+ // no file. Only this process writes the store.
77
+ let indexCache = null;
78
+ function index() {
79
+ if (!indexCache) indexCache = new Set(storeOn() ? Object.keys(readAll().pickups) : []);
80
+ return indexCache;
81
+ }
82
+
83
+ function list() {
84
+ return storeOn() ? Object.values(readAll().pickups) : [];
85
+ }
86
+ function get(canonicalUserId, laneKey) {
87
+ if (!storeOn() || !index().has(keyFor(canonicalUserId, laneKey))) return null;
88
+ return readAll().pickups[keyFor(canonicalUserId, laneKey)] || null;
89
+ }
90
+
91
+ // ── wording ─────────────────────────────────────────────────────────────
92
+
93
+ function at(ms, now = Date.now()) {
94
+ return limits.whenPhrase(ms, { now });
95
+ }
96
+ function limitName(provider, limitType) {
97
+ return `${limits.providerLabel(provider)}'s ${limits.windowLabel(limitType)}`;
98
+ }
99
+
100
+ 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
+
102
+ /**
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.
106
+ */
107
+ function planFor(result, runContext, { now = Date.now(), windows = [], guarded = false } = {}) {
108
+ const hit = limits.limitHitDetails({
109
+ provider: runContext?.provider, error: result?.error, rateLimit: result?.rateLimit, windows, now,
110
+ });
111
+ if (!hit) return null;
112
+ // An outside contact is not told which model or plan the bot runs on.
113
+ 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;
116
+ const pickup = runContext?.limitPickup || null;
117
+ const attempts = (pickup?.attempts || 0) + 1;
118
+ const armable = enabled()
119
+ && known
120
+ && runAt - now <= maxWaitMs()
121
+ && attempts <= MAX_ATTEMPTS
122
+ && runContext?.purpose === "foreground"
123
+ && !runContext?.passive
124
+ && !runContext?.liveCall
125
+ && !!runContext?.origin?.adapterId
126
+ && !!runContext?.channelId;
127
+ let notice;
128
+ if (known && runContext?.purpose && runContext.purpose !== "foreground") {
129
+ notice = `⏳ ${name} is used up, so this ${runContext.purpose === "scheduled" ? "scheduled run" : "run"} stopped. It resets ${at(hit.resetsAt, now)}.`;
130
+ } else if (armable) {
131
+ const again = pickup && pickup.limitType && pickup.limitType === hit.limitType ? "is still used up" : "is used up too";
132
+ notice = pickup
133
+ ? `⏳ ${name} ${again}. I'll try your message again ${at(runAt, now)}. If you write to me before then, I'll answer that instead and drop this.`
134
+ : `⏳ ${name} is used up, so I stopped here. I'll pick your message back up ${at(runAt, now)}, when it resets. If you write to me before then, I'll answer that instead and drop this.`;
135
+ } else if (known) {
136
+ notice = attempts > MAX_ATTEMPTS
137
+ ? `⏳ ${name} is used up again, after ${MAX_ATTEMPTS} tries. It resets ${at(hit.resetsAt, now)}: send your message again then.`
138
+ : `⏳ ${name} is used up. It resets ${at(hit.resetsAt, now)}: send your message again then.`;
139
+ } else if (guarded) {
140
+ notice = "⏳ My usage limit is reached and I can't tell when it resets, so send your message again later.";
141
+ } else {
142
+ 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
+ }
144
+ return { hit, armable, runAt, attempts, notice };
145
+ }
146
+
147
+ // ── arm / cancel ────────────────────────────────────────────────────────
148
+
149
+ const timers = new Map(); // keyFor(person, conversation) -> timeout
150
+ let runtime = null; // { adapters, deps } once start() ran
151
+
152
+ function conversationOf(runContext) {
153
+ return require("./turn-journal").conversationOf(runContext);
154
+ }
155
+
156
+ function arm(runContext, plan, { now = Date.now() } = {}) {
157
+ if (!storeOn() || !plan?.armable) return null;
158
+ const pickup = runContext.limitPickup || null;
159
+ const record = {
160
+ v: 1,
161
+ id: crypto.randomBytes(6).toString("hex"),
162
+ conversation: conversationOf(runContext),
163
+ turn: {
164
+ // A pick-up that hits a limit again keeps the person's original message.
165
+ prompt: pickup?.originalPrompt || String(runContext.userPrompt || ""),
166
+ imagePaths: runContext.imagePaths || [],
167
+ attachments: runContext.attachments || [],
168
+ replyToMsgId: runContext.replyToMsgId || null,
169
+ project: runContext.project ? { name: runContext.project.name || null, dir: runContext.project.dir || null } : null,
170
+ },
171
+ provider: plan.hit.provider,
172
+ limitType: plan.hit.limitType || null,
173
+ resetsAt: plan.hit.resetsAt,
174
+ runAt: plan.runAt,
175
+ armedAt: now,
176
+ firstHitAt: pickup?.firstHitAt || now,
177
+ attempts: plan.attempts,
178
+ };
179
+ const key = keyFor(record.conversation.canonicalUserId, record.conversation.laneKey);
180
+ update((pickups) => { pickups[key] = record; });
181
+ index().add(key);
182
+ schedule(key, record, now);
183
+ console.log(`[limit-pickup] armed ${key}: ${record.provider} ${record.limitType || "limit"} resets ${new Date(record.resetsAt).toISOString()}, runs ${new Date(record.runAt).toISOString()} (attempt ${record.attempts})`);
184
+ return record;
185
+ }
186
+
187
+ function drop(keys, reason) {
188
+ const wanted = keys.filter((key) => index().has(key) || timers.has(key));
189
+ if (!wanted.length) return [];
190
+ for (const key of wanted) {
191
+ const timer = timers.get(key);
192
+ if (timer) { clearTimeout(timer); timers.delete(key); }
193
+ index().delete(key);
194
+ }
195
+ const removed = update((pickups) => wanted.filter((key) => pickups[key]).map((key) => {
196
+ const record = pickups[key];
197
+ delete pickups[key];
198
+ return record;
199
+ }));
200
+ for (const record of removed) console.log(`[limit-pickup] cancelled ${keyFor(record.conversation.canonicalUserId, record.conversation.laneKey)} (${reason})`);
201
+ return removed;
202
+ }
203
+
204
+ /** Drop this person's pick-up in this conversation (they wrote, /stop, /new, /end). */
205
+ function cancel(canonicalUserId, laneKey, reason = "a new message") {
206
+ if (!laneKey || !storeOn()) return null;
207
+ return drop([keyFor(canonicalUserId, laneKey)], reason)[0] || null;
208
+ }
209
+
210
+ /** Drop every pick-up of this person (/stop all). */
211
+ function cancelAllFor(canonicalUserId, reason = "/stop all") {
212
+ if (!storeOn()) return [];
213
+ const prefix = `${canonicalUserId || "-"}::`;
214
+ return drop([...index()].filter((key) => key.startsWith(prefix)), reason);
215
+ }
216
+
217
+ // ── timers and the pick-up run ──────────────────────────────────────────
218
+
219
+ function schedule(key, record, now = Date.now()) {
220
+ if (!runtime) return; // start() at boot sets the timers
221
+ const old = timers.get(key);
222
+ if (old) clearTimeout(old);
223
+ const wait = Math.min(Math.max(record.runAt - now, 0), MAX_TIMER_MS);
224
+ const timer = setTimeout(() => {
225
+ timers.delete(key);
226
+ // The ceiling cut the wait short: set the rest.
227
+ if (record.runAt - Date.now() > 1000) return schedule(key, record);
228
+ fire(key, record.id).catch((error) => console.warn(`[limit-pickup] ${key}: ${error?.message || error}`));
229
+ }, wait);
230
+ timer.unref?.();
231
+ timers.set(key, timer);
232
+ }
233
+
234
+ function defaultDeps() {
235
+ const journal = require("./turn-journal");
236
+ return {
237
+ ...journal.defaultDeps(),
238
+ chatScope: journal.chatScope,
239
+ findAdapter: journal.findAdapter,
240
+ isGuarded: () => { try { return require("./relationship").isCurrentSpeakerGuarded(); } catch (_) { return false; } },
241
+ };
242
+ }
243
+
244
+ async function fire(key, id) {
245
+ if (!runtime) return null;
246
+ const { adapters, deps } = runtime;
247
+ // Take it out first: it runs once; a new limit hit arms a new one.
248
+ const record = update((pickups) => {
249
+ const current = pickups[key];
250
+ if (!current || (id && current.id !== id)) return null;
251
+ delete pickups[key];
252
+ return current;
253
+ });
254
+ if (!record) return null;
255
+ index().delete(key);
256
+ if (!enabled()) return null;
257
+ if (deps.isSafeMode()) {
258
+ // Safe mode runs no turns: keep it for the next boot.
259
+ update((pickups) => { pickups[key] = pickups[key] || record; });
260
+ index().add(key);
261
+ return null;
262
+ }
263
+ const adapter = deps.findAdapter(adapters || [], record.conversation);
264
+ if (!adapter) {
265
+ console.warn(`[limit-pickup] ${key}: its channel is not loaded; dropped`);
266
+ return null;
267
+ }
268
+ const scope = deps.chatScope(record.conversation, adapter, deps);
269
+ const hitAt = limits.formatResetTime(record.firstHitAt, { now: deps.now() });
270
+ return deps.runInChat(scope, async () => {
271
+ // An outside contact is not told which model or plan the bot runs on.
272
+ const which = deps.isGuarded() ? "my usage limit" : limitName(record.provider, record.limitType);
273
+ await Promise.resolve(deps.send(`Picking up your message from ${hitAt} now that ${which} has reset.`, { replyTo: record.turn.replyToMsgId || undefined })).catch(() => {});
274
+ return deps.runAgent(PICKUP_PROMPT(hitAt, record.provider, record.turn.prompt), record.turn.project?.dir || null, record.turn.replyToMsgId, {
275
+ purpose: "foreground",
276
+ imagePaths: record.turn.imagePaths || [],
277
+ attachments: record.turn.attachments || [],
278
+ limitPickup: { originalPrompt: record.turn.prompt, firstHitAt: record.firstHitAt, attempts: record.attempts, limitType: record.limitType },
279
+ });
280
+ });
281
+ }
282
+
283
+ /** Boot (after the adapters connect): a timer per waiting pick-up. */
284
+ function start(adapters, overrides = {}) {
285
+ const deps = { ...defaultDeps(), ...overrides };
286
+ runtime = { adapters, deps };
287
+ const now = deps.now();
288
+ const out = [];
289
+ indexCache = null;
290
+ for (const record of list()) {
291
+ if (!record.conversation?.laneKey) continue;
292
+ const key = keyFor(record.conversation.canonicalUserId, record.conversation.laneKey);
293
+ // A reset that passed while the bot was down: run shortly after boot,
294
+ // once the resumed and restored turns have started.
295
+ schedule(key, record.runAt < now + BOOT_GRACE_MS ? { ...record, runAt: now + BOOT_GRACE_MS } : record, now);
296
+ out.push(key);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ function _resetForTests() {
302
+ for (const timer of timers.values()) clearTimeout(timer);
303
+ timers.clear();
304
+ runtime = null;
305
+ indexCache = null;
306
+ }
307
+
308
+ module.exports = {
309
+ PICKUP_PROMPT, MAX_ATTEMPTS,
310
+ enabled, delayMs, maxWaitMs,
311
+ keyFor, planFor, arm, cancel, cancelAllFor, list, get, start, fire,
312
+ _resetForTests,
313
+ };
@@ -16,9 +16,56 @@ function createClaudeParserState() {
16
16
  seenUsage: new Set(),
17
17
  totalCostUsd: 0,
18
18
  refusal: null,
19
+ // Set once the plan limit rejected this run: { resetsAt, limitType, text }.
20
+ limitHit: null,
19
21
  };
20
22
  }
21
23
 
24
+ const epochMs = (seconds) => {
25
+ const n = Number(seconds);
26
+ return Number.isFinite(n) && n > 0 ? Math.round(n * 1000) : null;
27
+ };
28
+
29
+ // Claude Code's rate_limit_event (emitted when the plan status changes, so at
30
+ // least once per fresh run). rate_limit_info.unifiedWindows carries the use of
31
+ // each window as a 0-1 fraction; status "allowed_warning" is Claude Code's own
32
+ // pace warning (it fires at 25% early in the week), so callers should read the
33
+ // windows, not the status, for "close to the limit".
34
+ function claudeRateLimitEvent(info) {
35
+ if (!info || typeof info !== "object") return null;
36
+ const windows = [];
37
+ const unified = info.unifiedWindows && typeof info.unifiedWindows === "object" ? info.unifiedWindows : {};
38
+ for (const [id, window] of Object.entries(unified)) {
39
+ const utilization = Number(window?.utilization);
40
+ if (!Number.isFinite(utilization)) continue;
41
+ windows.push({ id, usedPercent: Math.round(utilization * 1000) / 10, resetsAt: epochMs(window.resetsAt) });
42
+ }
43
+ return {
44
+ type: "rate_limit",
45
+ provider: "claude",
46
+ status: typeof info.status === "string" ? info.status : null,
47
+ limitType: typeof info.rateLimitType === "string" ? info.rateLimitType : null,
48
+ resetsAt: epochMs(info.resetsAt),
49
+ windows,
50
+ };
51
+ }
52
+
53
+ function noteLimitHit(state, patch) {
54
+ state.limitHit = { resetsAt: null, limitType: null, text: null, ...(state.limitHit || {}) };
55
+ for (const [key, value] of Object.entries(patch)) {
56
+ if (value !== null && value !== undefined && value !== "") state.limitHit[key] = value;
57
+ }
58
+ }
59
+
60
+ // The limit notice arrives as a synthetic assistant message (model
61
+ // "<synthetic>", error "rate_limit"). It is not answer text.
62
+ function isLimitNotice(rawEvent) {
63
+ if (rawEvent.error === "rate_limit") return true;
64
+ if (rawEvent.message?.model !== "<synthetic>") return false;
65
+ const text = (rawEvent.message?.content || []).map((block) => (block?.type === "text" ? block.text : "")).join(" ");
66
+ return providerErrorFlags(text).usageLimit;
67
+ }
68
+
22
69
  function sessionEvent(sessionId, state) {
23
70
  if (!sessionId || state.sessionId === sessionId) return null;
24
71
  state.sessionId = sessionId;
@@ -43,6 +90,11 @@ function captureRefusal(state, stopReason, stopDetails) {
43
90
  function claudeFailureEvent(message, state) {
44
91
  const flags = providerErrorFlags(message);
45
92
  const event = { type: "error", message, ...flags };
93
+ if (state.limitHit) {
94
+ event.usageLimit = true;
95
+ event.limitResetsAt = state.limitHit.resetsAt;
96
+ event.limitType = state.limitHit.limitType;
97
+ }
46
98
  if (flags.refusal || state.refusal) {
47
99
  const parsed = refusalDetailsFromMessage(message);
48
100
  event.refusal = true;
@@ -72,6 +124,13 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
72
124
  return event ? [event] : [];
73
125
  }
74
126
 
127
+ if (rawEvent.type === "rate_limit_event") {
128
+ const event = claudeRateLimitEvent(rawEvent.rate_limit_info);
129
+ if (!event) return [];
130
+ if (event.status === "rejected") noteLimitHit(state, { resetsAt: event.resetsAt, limitType: event.limitType });
131
+ return [event];
132
+ }
133
+
75
134
  if (rawEvent.type === "stream_event") {
76
135
  if (rawEvent.event?.type === "message_delta") {
77
136
  captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
@@ -85,6 +144,11 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
85
144
  }
86
145
 
87
146
  if (rawEvent.type === "assistant") {
147
+ if (isLimitNotice(rawEvent)) {
148
+ const text = (rawEvent.message?.content || []).map((block) => (block?.type === "text" ? block.text : "")).join(" ").trim();
149
+ noteLimitHit(state, { text });
150
+ return [];
151
+ }
88
152
  const events = [];
89
153
  captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
90
154
  for (const block of rawEvent.message?.content || []) {
@@ -133,6 +197,12 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
133
197
  if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
134
198
  return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
135
199
  }
200
+ if (state.limitHit && !state.sawText) {
201
+ // A plan limit is a failed turn even when the CLI closes it as a
202
+ // "success": the limit notice must never go out as the reply. (With
203
+ // answer text, Claude Code fell back to another model and finished.)
204
+ return [claudeFailureEvent(state.limitHit.text || claudeErrorMessage(rawEvent), state)];
205
+ }
136
206
  const events = [];
137
207
  const session = sessionEvent(rawEvent.session_id, state);
138
208
  if (session) events.push(session);