@inetafrica/open-claudia 3.5.20 → 3.5.24

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.
@@ -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
  };
@@ -78,6 +78,21 @@ function pickStandardProcessEnv(source = process.env) {
78
78
  return env;
79
79
  }
80
80
 
81
+ // agent-browser's own settings (Chrome path and flags, the browser container's CDP port, pinned
82
+ // tabs, the download folder...). Every caller of agent-browser must share them: Open Claudia's
83
+ // Browser app and Teach me run it with this process's AGENT_BROWSER_*, and on a bot pod a session
84
+ // reached with different launch settings is relaunched and loses its page (with a browser
85
+ // container, an agent without AGENT_BROWSER_CDP would start its own Chrome instead). They configure
86
+ // a tool the agent runs, not a provider or the control plane, so the agent gets the same set.
87
+ const AGENT_BROWSER_KEY = /^AGENT_BROWSER_[A-Z0-9_]+$/;
88
+
89
+ function addAgentBrowserEnv(target, source = process.env) {
90
+ for (const key of Object.keys(source)) {
91
+ if (AGENT_BROWSER_KEY.test(key)) copyEnvValue(target, source, key);
92
+ }
93
+ return target;
94
+ }
95
+
81
96
  function passthroughKeys(source = process.env, configured = source.AGENT_ENV_PASSTHROUGH) {
82
97
  const keys = String(configured || "")
83
98
  .split(",")
@@ -100,6 +115,7 @@ function addConfiguredPassthrough(target, source = process.env, configured = sou
100
115
 
101
116
  function baseAgentEnv(source = process.env, options = {}) {
102
117
  const env = pickStandardProcessEnv(source);
118
+ addAgentBrowserEnv(env, source);
103
119
  addConfiguredPassthrough(env, source, options.passthrough);
104
120
  // Strip after passthrough so a broad/operator-supplied list cannot re-open a
105
121
  // provider credential, control-plane token, or keyring canary boundary.
@@ -141,6 +157,7 @@ module.exports = {
141
157
  PROVIDER_CONFIG_ROOT_KEYS,
142
158
  PROVIDER_CREDENTIAL_KEYS,
143
159
  STANDARD_PROCESS_ENV_KEYS,
160
+ addAgentBrowserEnv,
144
161
  addConfiguredPassthrough,
145
162
  baseAgentEnv,
146
163
  claudeAgentEnv,
@@ -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,
package/core/runner.js CHANGED
@@ -15,7 +15,7 @@ const {
15
15
  const {
16
16
  currentState, saveState, recordSession,
17
17
  resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
18
- laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
18
+ laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes, findQueued,
19
19
  getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction, commitBranchCompaction,
20
20
  resolveProviderSettings, getProjectSessions,
21
21
  } = require("./state");
@@ -26,7 +26,7 @@ const usageLimits = require("./usage-limits");
26
26
  const limitPickup = require("./limit-pickup");
27
27
  const { getProvider, resolveProviderForRun } = require("./providers");
28
28
  const { validateInvocation } = require("./providers/contract");
29
- const { selectTerminalText } = require("./providers/events");
29
+ const { providerFailureCode, selectTerminalText } = require("./providers/events");
30
30
  const {
31
31
  bindRunInputs,
32
32
  captureRunContext,
@@ -581,7 +581,7 @@ async function executeProviderInvocation(options = {}) {
581
581
  const event = rawErrors[0];
582
582
  failure = stableRunError(
583
583
  event.message || "Provider reported an error",
584
- event.refusal ? "PROVIDER_REFUSAL" : (event.authError ? "PROVIDER_UNAUTHENTICATED" : (event.usageLimit ? "PROVIDER_USAGE_LIMIT" : "PROVIDER_EVENT_ERROR")),
584
+ providerFailureCode(event, "PROVIDER_EVENT_ERROR"),
585
585
  event,
586
586
  );
587
587
  } else if (closeInfo.code !== 0) failure = stableRunError(`Provider process exited with code ${closeInfo.code}`, "PROVIDER_NONZERO_EXIT", { exitCode: closeInfo.code });
@@ -620,6 +620,9 @@ async function executeProviderInvocation(options = {}) {
620
620
  delivery,
621
621
  timings: { startedAt, finishedAt: null, durationMs: null },
622
622
  };
623
+ // Every run, whatever its purpose, keeps the limit record current: a limit
624
+ // hit marks the provider used up until its reset, a run that worked clears it.
625
+ noteRunLimit(result, runContext);
623
626
 
624
627
  const runStage = async (name, hook) => {
625
628
  if (!hook) return;
@@ -1773,6 +1776,23 @@ function limitPlanFor(result, runContext) {
1773
1776
  return keepOnResult(result, "limitPlan", plan);
1774
1777
  }
1775
1778
 
1779
+ // The limit record (core/usage-limits.js) background work reads: a run a
1780
+ // limit stopped marks the provider used up until the reset; a run that
1781
+ // worked clears it. Never throws.
1782
+ function noteRunLimit(result, runContext) {
1783
+ try {
1784
+ const provider = runContext?.provider || result.provider;
1785
+ if (result.ok) { usageLimits.clearLimitBlock(provider, { model: runContext?.providerSettings?.model || null }); return; }
1786
+ if (result.error?.code !== "PROVIDER_USAGE_LIMIT") return;
1787
+ const hit = usageLimits.limitHitDetails({
1788
+ provider, error: result.error, rateLimit: result.rateLimit, windows: planLimitWindows(result, runContext || {}),
1789
+ });
1790
+ if (hit?.resetsAt) usageLimits.noteLimitHit({ provider, limitType: hit.limitType, resetsAt: hit.resetsAt });
1791
+ } catch (error) {
1792
+ console.warn(`[usage-limits] limit record: ${redactSensitive(String(error.message || error))}`);
1793
+ }
1794
+ }
1795
+
1776
1796
  // Remember each window's use; the warnings now due (80% / 95%, once per
1777
1797
  // window). A run that cannot show them only updates the use, so the warning
1778
1798
  // is still due on the next turn that can.
@@ -2637,16 +2657,18 @@ function cancelQueuedCallOnAbort(lane, item) {
2637
2657
 
2638
2658
  // Admit waiting lanes into free slots: every lane with queued work and no run,
2639
2659
  // oldest waiting message first, as far as the caps allow. An admitted lane runs
2640
- // its next queued message (one at a time within a lane) in that message's own
2641
- // captured context. Called whenever a run, a compaction or a /stop frees a
2642
- // lane, and synchronous up to the admission, so no newcomer can take a freed
2643
- // slot ahead of a lane that was already waiting. Every state's lanes are
2644
- // considered (the global cap is shared); the argument is kept for callers.
2660
+ // what waits in it as ONE turn: the person's queued messages together (see
2661
+ // takeQueueBatch), in the first message's captured context. Called whenever a
2662
+ // run, a compaction or a /stop frees a lane, and synchronous up to the
2663
+ // admission, so no newcomer can take a freed slot ahead of a lane that was
2664
+ // already waiting. Every state's lanes are considered (the global cap is
2665
+ // shared); the argument is kept for callers.
2645
2666
  function drainQueuedMessages(_state) {
2646
2667
  for (const { state, lane } of waitingLanes()) {
2647
2668
  if (laneBusy(lane) || !lane.messageQueue.length) continue;
2648
2669
  if (slotBlocker(state)) { lane.waitingForSlot = true; continue; }
2649
- const item = lane.messageQueue.shift();
2670
+ const batch = takeQueueBatch(lane);
2671
+ const item = batch.length > 1 ? mergeQueueBatch(lane, batch) : batch[0];
2650
2672
  item.disposeCallListener?.();
2651
2673
  lane.preparingRun = true;
2652
2674
  lane.waitingForSlot = false;
@@ -2666,6 +2688,163 @@ function queueInLane(lane, item) {
2666
2688
  return item;
2667
2689
  }
2668
2690
 
2691
+ // ── The queue: run together, remove one ────────────────────────────────
2692
+ // Messages a person sends while their conversation is busy used to run one
2693
+ // turn each, in series, every one answering without the others. Now, when the
2694
+ // lane frees, the queued messages run as one turn (QUEUE_BATCH=off restores
2695
+ // one at a time). Only plain chat messages from the same person, in the same
2696
+ // session, project, provider and model join; anything else (a scheduled run,
2697
+ // a side chat's follow-up, a limit pick-up, a live call) runs on its own, in
2698
+ // order. Up to QUEUE_BATCH_MAX (10) per turn.
2699
+ const QUEUE_BATCH_MAX = 10;
2700
+ function queueBatchOn() {
2701
+ return !/^(off|false|0|no)$/i.test(String(process.env.QUEUE_BATCH ?? config.QUEUE_BATCH ?? "on").trim());
2702
+ }
2703
+
2704
+ function queuedSender(item) {
2705
+ return String(item?.store?.canonicalUserId || item?.runContext?.canonicalUserId || "");
2706
+ }
2707
+
2708
+ function batchable(item) {
2709
+ const runContext = item?.runContext;
2710
+ return !!runContext
2711
+ && !item.pendingSideChat
2712
+ && !item.followMainRunId
2713
+ && runContext.purpose === "foreground"
2714
+ && !runContext.liveCall
2715
+ && !runContext.limitPickup
2716
+ && item.store?.adapter?.liveCall !== true;
2717
+ }
2718
+
2719
+ function sameBatch(a, b) {
2720
+ const x = a.runContext;
2721
+ const y = b.runContext;
2722
+ return !!queuedSender(a)
2723
+ && queuedSender(a) === queuedSender(b)
2724
+ && x.provider === y.provider
2725
+ && (x.project?.dir || null) === (y.project?.dir || null)
2726
+ && (x.sessionId || null) === (y.sessionId || null)
2727
+ && (x.providerSettings?.model || null) === (y.providerSettings?.model || null);
2728
+ }
2729
+
2730
+ // The next turn's messages, taken off the front of the lane's queue.
2731
+ function takeQueueBatch(lane) {
2732
+ const first = lane.messageQueue.shift();
2733
+ const batch = [first];
2734
+ if (!queueBatchOn() || !batchable(first)) return batch;
2735
+ while (lane.messageQueue.length && batch.length < QUEUE_BATCH_MAX) {
2736
+ const next = lane.messageQueue[0];
2737
+ if (!batchable(next) || !sameBatch(first, next)) break;
2738
+ batch.push(lane.messageQueue.shift());
2739
+ }
2740
+ return batch;
2741
+ }
2742
+
2743
+ function queueClock(ms) {
2744
+ return new Date(ms).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
2745
+ }
2746
+
2747
+ function batchPrompt(batch) {
2748
+ const n = batch.length;
2749
+ const parts = [`[${n} messages arrived while you were busy. Read them all, then reply once: answer each, and where a later one changes or cancels an earlier one, the later one wins.]`];
2750
+ batch.forEach((item, index) => {
2751
+ const at = Number(item.queuedAt);
2752
+ const when = Number.isFinite(at) && at > 0 ? ` (${queueClock(at)})` : "";
2753
+ parts.push(`--- Message ${index + 1} of ${n}${when} ---\n${item.runContext.userPrompt}`);
2754
+ });
2755
+ return parts.join("\n\n");
2756
+ }
2757
+
2758
+ // One queue item for the whole batch: the first message's captured context
2759
+ // (its session and lineage), every message's text, images and files, and the
2760
+ // newest message as the one the reply answers. Every sender's caller gets the
2761
+ // one result; the journal keeps the batch as the first message's turn.
2762
+ function mergeQueueBatch(lane, batch) {
2763
+ const first = batch[0];
2764
+ const last = batch[batch.length - 1];
2765
+ for (const item of batch.slice(1)) {
2766
+ try { item.disposeCallListener?.(); } catch (_) {}
2767
+ }
2768
+ const runContext = deepFreeze({
2769
+ ...bindRunInputs(first.runContext, {
2770
+ userPrompt: batchPrompt(batch),
2771
+ imagePaths: batch.flatMap((item) => item.runContext.imagePaths || []),
2772
+ attachments: batch.flatMap((item) => item.runContext.attachments || []),
2773
+ lastInputWasVoice: !!last.runContext.lastInputWasVoice,
2774
+ voiceStream: !!last.runContext.voiceStream,
2775
+ }),
2776
+ replyToMsgId: last.runContext.replyToMsgId ?? first.runContext.replyToMsgId ?? null,
2777
+ });
2778
+ try { turnJournal.dropQueued(lane.key, batch.slice(1).map((item) => item.runContext.runId).filter(Boolean)); } catch (_) {}
2779
+ const words = batch.map((item) => String(item.store?.inboundText || "").trim()).filter(Boolean);
2780
+ const store = last.store ? { ...last.store, inboundText: words.join("\n\n") } : first.store;
2781
+ console.log(`[queue] ${lane.key}: ${batch.length} queued messages run as one turn (run ${runContext.runId || "?"})`);
2782
+ return {
2783
+ runContext,
2784
+ store,
2785
+ queuedAt: first.queuedAt,
2786
+ seq: first.seq,
2787
+ text: batch.map((item) => item.text).filter(Boolean).join(" / "),
2788
+ deferred: {
2789
+ resolve: (result) => { for (const item of batch) item.deferred?.resolve(result); },
2790
+ reject: (error) => { for (const item of batch) item.deferred?.reject(error); },
2791
+ },
2792
+ };
2793
+ }
2794
+
2795
+ // The ✕ on a "Queued." notice takes that one message out of its queue
2796
+ // (callback "qx:<boot>:<seq>"). The boot id keeps a button from before a
2797
+ // restart from naming a message that got the same number since.
2798
+ const QUEUE_BOOT = crypto.randomBytes(3).toString("hex");
2799
+
2800
+ function queueRemoveKeyboard(item) {
2801
+ if (!item?.runContext || item.runContext.liveCall || !Number.isSafeInteger(item.seq)) return null;
2802
+ return { inline_keyboard: [[{ text: "✕ Remove from queue", callback_data: `qx:${QUEUE_BOOT}:${item.seq}` }]] };
2803
+ }
2804
+
2805
+ function queuedWords(item) {
2806
+ const text = String(item?.text || item?.runContext?.userPrompt || "").replace(/\s+/g, " ").trim();
2807
+ return text.length > 40 ? `${text.slice(0, 39)}…` : (text || "…");
2808
+ }
2809
+
2810
+ // Only the person whose message it is, or the owner, in the chat it was sent in.
2811
+ function mayRemoveQueued(envelope, item, opts = {}) {
2812
+ const channelId = item.store?.channelId || item.runContext?.channelId || "";
2813
+ if (String(envelope?.channelId || "") !== String(channelId)) return false;
2814
+ if (opts.isOwner === true) return true;
2815
+ const sender = queuedSender(item);
2816
+ return !!sender && String(envelope?.canonicalUserId || "") === sender;
2817
+ }
2818
+
2819
+ async function handleQueueRemove(data, envelope, opts = {}) {
2820
+ const [, boot, seqText] = String(data || "").split(":");
2821
+ const seq = Number(seqText);
2822
+ const noticeId = envelope?.action?.sourceMessageId || null;
2823
+ const answer = (text) => (noticeId ? editMessage(String(noticeId), text) : send(text));
2824
+ const hit = boot === QUEUE_BOOT && Number.isSafeInteger(seq) ? findQueued((item) => item.seq === seq) : null;
2825
+ if (!hit) return answer("No longer queued: it already started (or was dropped). /stop cancels a running turn.");
2826
+ const { lane, item } = hit;
2827
+ if (!mayRemoveQueued(envelope, item, opts)) return send("That button belongs to another person.");
2828
+ const index = lane.messageQueue.indexOf(item);
2829
+ if (index >= 0) lane.messageQueue.splice(index, 1);
2830
+ try { item.disposeCallListener?.(); } catch (_) {}
2831
+ try { turnJournal.dropQueued(lane.key, [item.runContext?.runId].filter(Boolean)); } catch (_) {}
2832
+ if (!lane.messageQueue.length) {
2833
+ lane.waitingForSlot = false;
2834
+ if (!laneBusy(lane)) stopLaneTyping(lane);
2835
+ }
2836
+ item.deferred?.resolve({
2837
+ ok: false,
2838
+ status: "cancelled",
2839
+ runId: item.runContext?.runId || null,
2840
+ provider: item.runContext?.provider || null,
2841
+ project: item.runContext?.project || null,
2842
+ error: { code: "QUEUE_REMOVED", message: "Removed from the queue" },
2843
+ });
2844
+ console.log(`[queue] ${lane.key}: removed queued message ${seq} (${lane.messageQueue.length} left)`);
2845
+ return answer(`Removed from the queue: "${queuedWords(item)}"`);
2846
+ }
2847
+
2669
2848
  // A refusal on a RESUMED session is almost always the safety classifier
2670
2849
  // rejecting flagged material in the old context — which every resume re-sends
2671
2850
  // — not the user's new message. Recovery: quarantine the poisoned session so
@@ -2781,6 +2960,16 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2781
2960
  const persistCapture = executionOptions.persistCapture !== false;
2782
2961
  const internalCapture = capture && !persistCapture;
2783
2962
  if (tracksMainRun) lane.activeRunContext = runContext;
2963
+ // The turn after a /stop: the stopped request is in the session's history
2964
+ // looking cut off, so this turn opens with a note that it was cancelled.
2965
+ if (tracksMainRun && runContext.purpose === "foreground" && !runContext.liveCall) {
2966
+ const stopped = turnJournal.takeStopped(runLaneKey(runContext));
2967
+ if (stopped) {
2968
+ const noted = bindRunInputs(runContext, { userPrompt: `${turnJournal.stoppedNote(stopped)}\n\n${runContext.userPrompt}` });
2969
+ if (lane.activeRunContext === runContext) lane.activeRunContext = noted;
2970
+ runContext = noted;
2971
+ }
2972
+ }
2784
2973
  // Turn journal "lock": a foreground chat turn that a restart cuts short is
2785
2974
  // resumed after boot (core/turn-journal).
2786
2975
  if (tracksMainRun) turnJournal.begin(runContext);
@@ -3010,7 +3199,8 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
3010
3199
  const notice = admission.reason === "busy"
3011
3200
  ? (lane.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(lane.activeRunContext))
3012
3201
  : waitingNotice(admission.running);
3013
- send(notice, { replyTo: replyToMsgId }).catch(() => {});
3202
+ const keyboard = queueRemoveKeyboard(item);
3203
+ send(notice, keyboard ? { replyTo: replyToMsgId, keyboard } : { replyTo: replyToMsgId }).catch(() => {});
3014
3204
  }
3015
3205
  return deferred.promise;
3016
3206
  }
@@ -3110,6 +3300,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
3110
3300
 
3111
3301
  module.exports = {
3112
3302
  queuedNotice,
3303
+ handleQueueRemove,
3304
+ takeQueueBatch,
3305
+ mergeQueueBatch,
3113
3306
  waitingNotice,
3114
3307
  deliverForegroundResult,
3115
3308
  drainQueuedMessages,
package/core/scheduler.js CHANGED
@@ -44,6 +44,10 @@ const { providerStatus: defaultProviderStatus } = require("./providers");
44
44
  const { redactSensitive } = require("./redact");
45
45
 
46
46
  const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
47
+ // A new occurrence starts with no usage-limit wait behind it.
48
+ const LIMIT_WAIT_CLEARED = Object.freeze({
49
+ limitWaitCount: 0, limitWaitUntil: null, limitWaitType: null, limitWaitProvider: null, limitFirstHitAt: null,
50
+ });
47
51
  const RETRY_DELAY_MS = 30 * 1000;
48
52
  const BUSY_DELAY_MS = 30 * 1000;
49
53
  // A watch alarm that has waited this long behind a busy room says so in the
@@ -247,6 +251,9 @@ function createSchedulerService(dependencies = {}) {
247
251
  ? (dependencies.jobsStore ? null : CRONS_FILE)
248
252
  : dependencies.legacyCronsFile;
249
253
  const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
254
+ // What a run a usage limit stopped does (core/limit-pickup.js planFor): the
255
+ // same rule and wording as a chat message the limit stopped.
256
+ const planLimit = dependencies.planLimit || ((...args) => require("./limit-pickup").planFor(...args));
250
257
  const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
251
258
  const projectSessions = dependencies.getProjectSessions || getProjectSessions;
252
259
  const timers = new Map();
@@ -510,6 +517,9 @@ function createSchedulerService(dependencies = {}) {
510
517
  }
511
518
  const polled = job.check ? pendingPollNote(job) : null;
512
519
  if (polled) lines.push(polled);
520
+ if (Number(job.limitWaitCount) > 0 && Number.isFinite(Number(job.limitFirstHitAt))) {
521
+ lines.push(`[A usage limit stopped this run at ${new Date(Number(job.limitFirstHitAt)).toISOString()}; it waited for the reset and runs now. Check what still applies before acting.]`);
522
+ }
513
523
  if (target.fallbackFrom) {
514
524
  lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
515
525
  } else if (target.revertedFrom) {
@@ -653,6 +663,7 @@ function createSchedulerService(dependencies = {}) {
653
663
  watchLastHitAt: null,
654
664
  watchLastClearAt: null,
655
665
  advisoryOccurrenceId: null,
666
+ ...LIMIT_WAIT_CLEARED,
656
667
  };
657
668
  const advanced = jobsStore.update(job.id, advance) || { ...latest, ...advance };
658
669
  unscheduleJob(job.id);
@@ -705,6 +716,41 @@ function createSchedulerService(dependencies = {}) {
705
716
  return { ok: false, deferred: true, status: updated.status, job: updated };
706
717
  }
707
718
 
719
+ // The usage-limit plan of a failed run, or null (the runner keeps it on the
720
+ // result; a run that never reached delivery is planned here).
721
+ function limitPlanOf(result, runContext) {
722
+ if (result?.error?.code !== "PROVIDER_USAGE_LIMIT") return null;
723
+ if (result.limitPlan !== undefined) return result.limitPlan;
724
+ try { return planLimit(result, runContext, { now: now() }); }
725
+ catch (_) { return null; }
726
+ }
727
+
728
+ // A usage limit is not a failed attempt: the occurrence waits for the reset,
729
+ // as a chat message does (core/limit-pickup.js), and never moves to another
730
+ // provider (it may not be signed in, or be used up too). The notice was
731
+ // posted with the run's result.
732
+ function markLimitWait(job, plan, result) {
733
+ const latest = jobsStore.get(job.id) || job;
734
+ const patch = {
735
+ status: "deferred",
736
+ disabled: false,
737
+ attemptCount: Math.max(0, (Number(latest.attemptCount) || 1) - 1),
738
+ nextAttemptAt: plan.runAt,
739
+ lastFireAt: now(),
740
+ lastFireOk: false,
741
+ lastError: stableFailure(result),
742
+ limitWaitCount: (Number(latest.limitWaitCount) || 0) + 1,
743
+ limitWaitUntil: plan.runAt,
744
+ limitWaitType: plan.hit?.limitType || null,
745
+ limitWaitProvider: plan.hit?.provider || null,
746
+ limitFirstHitAt: Number(latest.limitFirstHitAt) || now(),
747
+ };
748
+ const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
749
+ armRetry(updated);
750
+ console.log(`[scheduler] ${job.id}: ${patch.limitWaitProvider || "provider"} ${patch.limitWaitType || "usage limit"} used up; waits until ${new Date(plan.runAt).toISOString()} (wait ${patch.limitWaitCount})`);
751
+ return { ok: false, status: "deferred", limitWait: true, runAt: plan.runAt, job: updated, result };
752
+ }
753
+
708
754
  async function settleSuccess(job, result, target) {
709
755
  const successPatch = {
710
756
  status: "succeeded",
@@ -759,6 +805,7 @@ function createSchedulerService(dependencies = {}) {
759
805
  watchLastHitAt: null,
760
806
  watchLastClearAt: null,
761
807
  advisoryOccurrenceId: null,
808
+ ...LIMIT_WAIT_CLEARED,
762
809
  });
763
810
  if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
764
811
  return reset
@@ -909,6 +956,11 @@ function createSchedulerService(dependencies = {}) {
909
956
  occurrenceId: job.occurrenceId,
910
957
  scheduledJobId: job.id,
911
958
  originRunId: job.originRunId || null,
959
+ // A run that waited for a limit's reset: a new hit says "still used up"
960
+ // and counts towards the most waits (limit-pickup MAX_ATTEMPTS).
961
+ ...(Number(job.limitWaitCount) > 0 ? {
962
+ limitPickup: { attempts: Number(job.limitWaitCount), firstHitAt: Number(job.limitFirstHitAt) || null, limitType: job.limitWaitType || null },
963
+ } : {}),
912
964
  };
913
965
  // Admission is synchronous and precedes all notification delivery. A
914
966
  // refusal is a failed attempt, never a rejection that drops the job.
@@ -978,6 +1030,11 @@ function createSchedulerService(dependencies = {}) {
978
1030
  catch (persistError) { return keepRetrying(running, persistError); }
979
1031
  }
980
1032
  if (!terminalRunSucceeded(result)) {
1033
+ const limitPlan = limitPlanOf(result, runContext);
1034
+ if (limitPlan?.waits && Number.isFinite(limitPlan.runAt)) {
1035
+ try { return markLimitWait(running, limitPlan, result); }
1036
+ catch (error) { return { ...keepRetrying(running, error), result }; }
1037
+ }
981
1038
  try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
982
1039
  catch (error) { return { ...keepRetrying(running, error), result }; }
983
1040
  }
package/core/state.js CHANGED
@@ -1045,6 +1045,19 @@ function waitingLanes() {
1045
1045
  });
1046
1046
  }
1047
1047
 
1048
+ // A queued message anywhere (every person's state, every conversation), for
1049
+ // the ✕ on its "Queued." notice. Returns { state, lane, item } or null.
1050
+ function findQueued(predicate) {
1051
+ for (const state of statesWithLanes()) {
1052
+ if (!(state.lanes instanceof Map)) continue;
1053
+ for (const lane of state.lanes.values()) {
1054
+ const item = lane.messageQueue.find(predicate);
1055
+ if (item) return { state, lane, item };
1056
+ }
1057
+ }
1058
+ return null;
1059
+ }
1060
+
1048
1061
  // /stop arms lane.cancelRequested with the id of the run it stops (true only
1049
1062
  // when no run context is known), and a run acts on the flag only when it names
1050
1063
  // that run. A bare boolean let a stop outlive its run and cancel the next one.
@@ -1898,6 +1911,7 @@ module.exports = {
1898
1911
  runningLanes,
1899
1912
  slotBlocker,
1900
1913
  waitingLanes,
1914
+ findQueued,
1901
1915
  pruneIdleLanes,
1902
1916
  maxParallelTurns,
1903
1917
  maxParallelTurnsGlobal,