@inetafrica/open-claudia 3.5.16 → 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.
Files changed (46) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +4 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +26 -0
  7. package/channels/kazee/adapter.js +130 -21
  8. package/channels/telegram/adapter.js +4 -2
  9. package/core/actions.js +8 -0
  10. package/core/config.js +22 -1
  11. package/core/cost-report.js +3 -0
  12. package/core/cron-schedule.js +59 -0
  13. package/core/dream.js +141 -34
  14. package/core/guardrail.js +5 -0
  15. package/core/handlers.js +78 -17
  16. package/core/lessons.js +11 -5
  17. package/core/limit-pickup.js +313 -0
  18. package/core/loopback.js +29 -0
  19. package/core/providers/claude-events.js +79 -0
  20. package/core/providers/codex.js +48 -0
  21. package/core/providers/events.js +4 -1
  22. package/core/relationship.js +1 -1
  23. package/core/router.js +16 -1
  24. package/core/run-context.js +3 -0
  25. package/core/runner.js +440 -14
  26. package/core/safe-filename.js +47 -0
  27. package/core/scheduler.js +170 -38
  28. package/core/state.js +91 -0
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +508 -0
  32. package/core/usage-limits.js +321 -0
  33. package/core/utility-agent.js +17 -1
  34. package/package.json +4 -4
  35. package/test-fixtures/fake-agent-cli.js +26 -2
  36. package/test-guardrail.js +19 -0
  37. package/test-kazee-context-media.js +1 -1
  38. package/test-provider-compaction.js +66 -1
  39. package/test-provider-dream.js +3 -1
  40. package/test-provider-events.js +106 -0
  41. package/test-provider-language.js +16 -1
  42. package/test-provider-pack-review.js +3 -1
  43. package/test-provider-scheduler.js +72 -1
  44. package/test-thread-lanes.js +6 -1
  45. package/test-usage-accounting.js +4 -0
  46. package/web.js +104 -51
@@ -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
+ };
package/core/loopback.js CHANGED
@@ -91,12 +91,14 @@ const OWNER_ONLY_KINDS = new Set([
91
91
  "people-list", "people-show", "people-note",
92
92
  "task-add", "task-plan", "task-update", "task-remove",
93
93
  "task-list", "task-get", "task-tree", "task-clear-completed",
94
+ "task-archive", "task-restore", "task-archived",
94
95
  ]);
95
96
  const JSON_KINDS = new Set([
96
97
  "whoami",
97
98
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
98
99
  "watch-add", "watch-list", "watch-remove", "watch-toggle",
99
100
  "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
101
+ "task-archive", "task-restore", "task-archived",
100
102
  "people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
101
103
  "people-remove", "people-set-primary",
102
104
  "intros-list", "intros-approve", "intros-reject",
@@ -668,9 +670,12 @@ async function handleJson(req, res, url, kind) {
668
670
  if (kind === "task-add") {
669
671
  if (!payload.content) return reply(res, 400, { error: "missing content" });
670
672
  try {
673
+ // A task belongs to the conversation it was made in (the run's thread,
674
+ // null on the main timeline); a subtask follows its plan.
671
675
  const t = tasksStore.add(adapterId, channelId, payload.content, {
672
676
  parentId: payload.parentId || null,
673
677
  description: payload.description || null,
678
+ threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
674
679
  });
675
680
  return reply(res, 200, { ok: true, task: t });
676
681
  } catch (e) { return reply(res, 400, { error: e.message }); }
@@ -682,6 +687,7 @@ async function handleJson(req, res, url, kind) {
682
687
  if (subs.length === 0) return reply(res, 400, { error: "subtasks must be a non-empty array" });
683
688
  const result = tasksStore.plan(adapterId, channelId, payload.title, subs, {
684
689
  description: payload.description || null,
690
+ threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
685
691
  });
686
692
  return reply(res, 200, { ok: true, plan: result });
687
693
  }
@@ -723,6 +729,29 @@ async function handleJson(req, res, url, kind) {
723
729
  return reply(res, got ? 200 : 404, { ok: !!got, ...(got || {}) });
724
730
  }
725
731
 
732
+ if (kind === "task-archive") {
733
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
734
+ try {
735
+ const result = tasksStore.archive(adapterId, channelId, payload.id, payload.reason);
736
+ return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
737
+ } catch (e) { return reply(res, 400, { error: e.message }); }
738
+ }
739
+
740
+ if (kind === "task-restore") {
741
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
742
+ try {
743
+ const result = tasksStore.restore(adapterId, channelId, payload.id);
744
+ return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
745
+ } catch (e) { return reply(res, 400, { error: e.message }); }
746
+ }
747
+
748
+ if (kind === "task-archived") {
749
+ const limit = Number(payload.limit);
750
+ const opts = { search: payload.search || "" };
751
+ if (Number.isFinite(limit) && limit > 0) opts.limit = limit;
752
+ return reply(res, 200, { ok: true, tasks: tasksStore.archived(adapterId, channelId, opts) });
753
+ }
754
+
726
755
  if (kind === "task-clear-completed") {
727
756
  const remaining = tasksStore.clearCompleted(adapterId, channelId);
728
757
  return reply(res, 200, { ok: true, tasks: remaining });
@@ -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;
@@ -58,11 +110,27 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
58
110
  const decoderFailure = normalizeDecoderError(rawEvent);
59
111
  if (decoderFailure) return [decoderFailure];
60
112
 
113
+ // A helper agent's (Agent/Task tool) own messages carry parent_tool_use_id:
114
+ // its working notes, tool calls and refusals are not this turn's reply.
115
+ // Only its spend still counts.
116
+ if (rawEvent.parent_tool_use_id && ["assistant", "stream_event", "user"].includes(rawEvent.type)) {
117
+ if (rawEvent.type !== "assistant") return [];
118
+ const usage = normalizedUsage(rawEvent.message?.usage, "provider_call", state.seenUsage);
119
+ return usage ? [usage] : [];
120
+ }
121
+
61
122
  if (rawEvent.type === "system" && rawEvent.subtype === "init") {
62
123
  const event = sessionEvent(rawEvent.session_id, state);
63
124
  return event ? [event] : [];
64
125
  }
65
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
+
66
134
  if (rawEvent.type === "stream_event") {
67
135
  if (rawEvent.event?.type === "message_delta") {
68
136
  captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
@@ -76,6 +144,11 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
76
144
  }
77
145
 
78
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
+ }
79
152
  const events = [];
80
153
  captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
81
154
  for (const block of rawEvent.message?.content || []) {
@@ -124,6 +197,12 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
124
197
  if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
125
198
  return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
126
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
+ }
127
206
  const events = [];
128
207
  const session = sessionEvent(rawEvent.session_id, state);
129
208
  if (session) events.push(session);
@@ -75,6 +75,52 @@ function imagePathsOf(context) {
75
75
  return paths;
76
76
  }
77
77
 
78
+ // Codex hands --output-schema to OpenAI in strict mode, which refuses the call
79
+ // (400 invalid_json_schema) when the root is not an object, or an object has no
80
+ // properties, allows extra keys or leaves a property out of `required`
81
+ // (6 Oct 2026: every passive-reply check, pack review, dedup and dream on Codex
82
+ // bots failed that way). An object whose properties are all required is closed
83
+ // with additionalProperties: false, which costs a reply nothing. A schema that
84
+ // needs optional or free-form fields gives null: no schema is sent, and the
85
+ // utility agent still checks the reply against the caller's full schema.
86
+ function strictOutputSchema(schema) {
87
+ function close(node, depth) {
88
+ if (!node || typeof node !== "object" || Array.isArray(node)) return depth > 0 ? node : null;
89
+ if (depth > 20 || node.$ref !== undefined) return null;
90
+ const copy = { ...node };
91
+ if ([].concat(node.type || []).includes("object")) {
92
+ const properties = node.properties && typeof node.properties === "object" ? node.properties : {};
93
+ const keys = Object.keys(properties);
94
+ if (!keys.length) return null;
95
+ if (node.additionalProperties && typeof node.additionalProperties === "object") return null;
96
+ const required = new Set(Array.isArray(node.required) ? node.required : []);
97
+ if (keys.some((key) => !required.has(key))) return null;
98
+ copy.properties = {};
99
+ for (const key of keys) {
100
+ const child = close(properties[key], depth + 1);
101
+ if (child === null) return null;
102
+ copy.properties[key] = child;
103
+ }
104
+ copy.additionalProperties = false;
105
+ }
106
+ if (node.items !== undefined) {
107
+ const items = Array.isArray(node.items) ? null : close(node.items, depth + 1);
108
+ if (items === null) return null;
109
+ copy.items = items;
110
+ }
111
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
112
+ if (node[key] === undefined) continue;
113
+ if (!Array.isArray(node[key])) return null;
114
+ const variants = node[key].map((variant) => close(variant, depth + 1));
115
+ if (variants.includes(null)) return null;
116
+ copy[key] = variants;
117
+ }
118
+ return copy;
119
+ }
120
+ if (!schema || typeof schema !== "object" || schema.type !== "object") return null;
121
+ return close(schema, 0);
122
+ }
123
+
78
124
  function createCodexProvider(options = {}) {
79
125
  const resolveExecutable = options.resolveExecutable || (() => config.discoverProviderExecutable("codex"));
80
126
  const defaultModel = options.defaultModel ?? config.config.CODEX_MODEL ?? null;
@@ -266,6 +312,7 @@ function createCodexProvider(options = {}) {
266
312
  return buildInvocation({ ...context, permissionMode: "plan" }, false);
267
313
  },
268
314
  buildUtilityInvocation: (context) => buildInvocation({ ...context, fresh: true }, true),
315
+ outputSchemaFor: (schema) => strictOutputSchema(schema),
269
316
  createEventDecoder: (decoderOptions) => createJsonlDecoder(decoderOptions),
270
317
  normalizeEvent: normalizeCodexEvent,
271
318
  authStatus: () => getAuthStatus(executable()),
@@ -300,4 +347,5 @@ module.exports = {
300
347
  capabilities,
301
348
  codexProvider,
302
349
  createCodexProvider,
350
+ strictOutputSchema,
303
351
  };
@@ -11,6 +11,7 @@ const NORMALIZED_EVENT_TYPES = Object.freeze([
11
11
  "result",
12
12
  "error",
13
13
  "warning",
14
+ "rate_limit",
14
15
  ]);
15
16
 
16
17
  // 16MB: tool results carrying base64 images legitimately exceed 1MB — the old
@@ -130,7 +131,9 @@ function providerErrorFlags(message) {
130
131
  const text = String(message || "").toLowerCase();
131
132
  return {
132
133
  authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
133
- usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit/.test(text),
134
+ // "You've hit your limit / session limit / weekly limit / Opus limit · resets …"
135
+ // is how Claude Code words a plan limit; the older patterns never matched it.
136
+ usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit|hit your (?:[a-z0-9'’-]+ ){0,3}limit/.test(text),
134
137
  refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
135
138
  };
136
139
  }
@@ -323,4 +323,4 @@ function ensureContact(envelope) {
323
323
  }
324
324
  }
325
325
 
326
- module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact };
326
+ module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact, resolveHandleSendTarget };
package/core/router.js CHANGED
@@ -167,6 +167,16 @@ async function onMessage(envelope) {
167
167
  // and the meet-greet lane all have a person to attach to.
168
168
  try { require("./relationship").ensureContact(envelope); } catch (e) {}
169
169
 
170
+ // The person writing again drops their pick-up of a message a usage
171
+ // limit stopped (core/limit-pickup.js): the conversation moved on. Also
172
+ // when the guard then holds this message. Another bot's message does not.
173
+ if (!envelope.botHandoff) {
174
+ try {
175
+ const state = currentState();
176
+ require("./limit-pickup").cancel(state.userId, require("./state").currentLaneKey(state), "a new message");
177
+ } catch (e) {}
178
+ }
179
+
170
180
  if (envelope.type === "voice") return handleVoice(envelope);
171
181
  if (envelope.type === "audio") return handleAudio(envelope);
172
182
  if (envelope.type === "photo") return handlePhoto(envelope);
@@ -431,8 +441,13 @@ async function handleDocument(envelope) {
431
441
  attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
432
442
  });
433
443
  stopPreTurnTyping = startPreTurnTyping(state);
444
+ // Saved before the guard runs: a held request resumes after the owner's
445
+ // approval from the guard's text alone (core/actions.js), so that text
446
+ // must say where the file is. The adapters keep the file inside FILES_DIR
447
+ // under a cleaned name (core/safe-filename.js), and that name is the one
448
+ // shown, never the sender's raw path.
434
449
  const savePath = await envelope.adapter.downloadMedia(media);
435
- const fileName = media.fileName || path.basename(savePath);
450
+ const fileName = path.basename(savePath);
436
451
  const mime = media.mimeType || "";
437
452
  const caption = envelope.caption || "";
438
453
  const isImage = mime.startsWith("image/");
@@ -213,6 +213,9 @@ function captureRunContext({
213
213
  occurrenceId: opts.occurrenceId || null,
214
214
  scheduledJobId: opts.scheduledJobId || null,
215
215
  originRunId: opts.originRunId || null,
216
+ // Set on a turn that core/limit-pickup.js re-runs after a usage limit
217
+ // reset: { originalPrompt, firstHitAt, attempts }.
218
+ limitPickup: opts.limitPickup ? cloneSerializable(opts.limitPickup, "limit pickup") : null,
216
219
  };
217
220
 
218
221
  return deepFreeze(context);