@inetafrica/open-claudia 3.5.20 → 3.5.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -382,7 +382,7 @@ Notes: a background daemon persists between commands, so navigation state carrie
382
382
 
383
383
  If you tell the user "I'll check back in N minutes" or "I'll run this every morning", you MUST schedule it with one of the commands above in the same turn. Otherwise nothing happens.
384
384
 
385
- Interrupted turns: when a restart cuts a turn short, the agent CLI resumes the conversation by writing two lines into your history, "Continue from where you left off." and the reply "No response requested.". Neither came from the user or from you, and the user never saw them. Never mention or apologise for them, and never reply "No response requested." yourself: finish the interrupted work if it still matters, then act on the current message.
385
+ Interrupted turns: when a restart cuts a turn short, the agent CLI resumes the conversation by writing two lines into your history, "Continue from where you left off." and the reply "No response requested.". Neither came from the user or from you, and the user never saw them. Never mention or apologise for them, and never reply "No response requested." yourself: finish the interrupted work if it still matters, then act on the current message. A /stop leaves the same two lines, but then your next message starts with a note that the person stopped that turn: the stopped request is cancelled, so never resume, redo or re-offer it unless they ask.
386
386
 
387
387
  ## Guidelines
388
388
  - Keep responses concise — many users are on mobile.
@@ -25,6 +25,9 @@
25
25
  // (6) on SIGTERM, whose sender kills soon after; whatever is still
26
26
  // running stays journaled.
27
27
  // - /stop offers each dropped queued message back with a "Run it" button.
28
+ // - /stop marks the conversation "stopped" (kept across a restart): its next
29
+ // turn is told the stopped request is cancelled, not cut off (see
30
+ // markStopped).
28
31
  // Scheduled runs (wake-ups, crons, watches) are not journaled: jobs.json keeps
29
32
  // their own retry, so they are never resumed twice.
30
33
 
@@ -78,7 +81,7 @@ function remove(file) { try { fs.unlinkSync(file); } catch (_) {} }
78
81
 
79
82
  // Persist the record, or drop the file when nothing is left in it.
80
83
  function store(file, record) {
81
- if (!record.turn && !(record.queued || []).length) return remove(file);
84
+ if (!record.turn && !(record.queued || []).length && !record.stopped) return remove(file);
82
85
  write(file, record);
83
86
  }
84
87
 
@@ -164,6 +167,8 @@ function begin(runContext, opts = {}) {
164
167
  },
165
168
  queued: (existing.queued || []).filter((q) => q.runId !== runContext.runId),
166
169
  };
170
+ // A stop this turn has not taken yet stays for the turn that does.
171
+ if (existing.stopped) record.stopped = existing.stopped;
167
172
  try { write(file, record); } catch (error) { console.warn(`[turn-journal] could not journal ${runContext.laneKey}: ${error.message}`); return false; }
168
173
  active.set(runContext.laneKey, file);
169
174
  ensureHeartbeat();
@@ -205,6 +210,52 @@ function dropQueued(laneKey, runIds) {
205
210
  store(file, record);
206
211
  }
207
212
 
213
+ // ── /stop: a cancel, not a cut ─────────────────────────────────────────
214
+ // /stop kills the provider mid-turn. On the next message Claude Code resumes
215
+ // the session and writes the cut turn into the history the same way a
216
+ // restart's cut reads ("[Request interrupted by user for tool use]", then
217
+ // "Continue from where you left off." / "No response requested."), and the
218
+ // system prompt says to finish work a restart cut off. So a stopped request
219
+ // came back on the next message (home pi, 9 Oct: "/stop" on "ok play big
220
+ // bang", then "Big Bang didn't start … just say if you still want it"). The
221
+ // stop is kept in the conversation's file until its next turn takes it.
222
+ function markStopped(laneKey, opts = {}) {
223
+ if (!journalOn() || !laneKey) return false;
224
+ const file = fileFor(laneKey);
225
+ const record = read(file) || { v: 1, conversation: null, turn: null, queued: [] };
226
+ if (!record.conversation && opts.runContext?.origin) record.conversation = conversationOf(opts.runContext);
227
+ record.stopped = {
228
+ at: Number.isFinite(opts.now) ? opts.now : Date.now(),
229
+ runId: opts.runContext?.runId || null,
230
+ };
231
+ try { write(file, record); } catch (_) { return false; }
232
+ return true;
233
+ }
234
+
235
+ // The conversation's stop, once: the turn that takes it carries the note.
236
+ function takeStopped(laneKey) {
237
+ if (!journalOn() || !laneKey) return null;
238
+ const file = fileFor(laneKey);
239
+ const record = read(file);
240
+ if (!record?.stopped) return null;
241
+ const stopped = record.stopped;
242
+ delete record.stopped;
243
+ try { store(file, record); } catch (_) {}
244
+ return stopped;
245
+ }
246
+
247
+ // /new, /end or a project change: the stopped turn's history is gone.
248
+ function clearStopped(laneKey) {
249
+ takeStopped(laneKey);
250
+ }
251
+
252
+ // What the turn after a /stop reads before the person's message.
253
+ function stoppedNote(stopped) {
254
+ const at = Number(stopped?.at);
255
+ const when = Number.isFinite(at) && at > 0 ? `At ${hhmm(at)} the person` : "The person";
256
+ return `[${when} stopped your previous turn with /stop, so that request is cancelled. Your history may still show it unfinished, followed by "Continue from where you left off." from the agent CLI (not from them). Don't resume it, redo it or offer to do it again unless they ask; just answer the message below.]`;
257
+ }
258
+
208
259
  function list() {
209
260
  if (!journalOn()) return [];
210
261
  let names = [];
@@ -337,7 +388,8 @@ async function resumeInterrupted(adapters, overrides = {}) {
337
388
  if (!turn) {
338
389
  const queued = record.queued || [];
339
390
  record.queued = [];
340
- remove(file);
391
+ // A stop not yet taken stays (the restored messages' turn takes it).
392
+ store(file, record);
341
393
  runQueued({ ...record, queued }, adapter, deps);
342
394
  out.restored += queued.length;
343
395
  continue;
@@ -502,6 +554,7 @@ module.exports = {
502
554
  conversationOf, chatScope, findAdapter, defaultDeps,
503
555
  resumeInterrupted, handleResumeAction, RESUME_PROMPT,
504
556
  rememberDropped, droppedKeyboard, handleRunDropped,
557
+ markStopped, takeStopped, clearStopped, stoppedNote,
505
558
  drain, exitAfterDrain, isDraining, markShuttingDown,
506
559
  resumeEnabled, maxAgeMs, drainTimeoutMs, sigtermDrainTimeoutMs, MAX_ATTEMPTS, HEARTBEAT_MS,
507
560
  _resetForTests,