@inetafrica/open-claudia 3.5.17 → 3.5.19

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.
@@ -16,9 +16,56 @@ function createClaudeParserState() {
16
16
  seenUsage: new Set(),
17
17
  totalCostUsd: 0,
18
18
  refusal: null,
19
+ // Set once the plan limit rejected this run: { resetsAt, limitType, text }.
20
+ limitHit: null,
19
21
  };
20
22
  }
21
23
 
24
+ const epochMs = (seconds) => {
25
+ const n = Number(seconds);
26
+ return Number.isFinite(n) && n > 0 ? Math.round(n * 1000) : null;
27
+ };
28
+
29
+ // Claude Code's rate_limit_event (emitted when the plan status changes, so at
30
+ // least once per fresh run). rate_limit_info.unifiedWindows carries the use of
31
+ // each window as a 0-1 fraction; status "allowed_warning" is Claude Code's own
32
+ // pace warning (it fires at 25% early in the week), so callers should read the
33
+ // windows, not the status, for "close to the limit".
34
+ function claudeRateLimitEvent(info) {
35
+ if (!info || typeof info !== "object") return null;
36
+ const windows = [];
37
+ const unified = info.unifiedWindows && typeof info.unifiedWindows === "object" ? info.unifiedWindows : {};
38
+ for (const [id, window] of Object.entries(unified)) {
39
+ const utilization = Number(window?.utilization);
40
+ if (!Number.isFinite(utilization)) continue;
41
+ windows.push({ id, usedPercent: Math.round(utilization * 1000) / 10, resetsAt: epochMs(window.resetsAt) });
42
+ }
43
+ return {
44
+ type: "rate_limit",
45
+ provider: "claude",
46
+ status: typeof info.status === "string" ? info.status : null,
47
+ limitType: typeof info.rateLimitType === "string" ? info.rateLimitType : null,
48
+ resetsAt: epochMs(info.resetsAt),
49
+ windows,
50
+ };
51
+ }
52
+
53
+ function noteLimitHit(state, patch) {
54
+ state.limitHit = { resetsAt: null, limitType: null, text: null, ...(state.limitHit || {}) };
55
+ for (const [key, value] of Object.entries(patch)) {
56
+ if (value !== null && value !== undefined && value !== "") state.limitHit[key] = value;
57
+ }
58
+ }
59
+
60
+ // The limit notice arrives as a synthetic assistant message (model
61
+ // "<synthetic>", error "rate_limit"). It is not answer text.
62
+ function isLimitNotice(rawEvent) {
63
+ if (rawEvent.error === "rate_limit") return true;
64
+ if (rawEvent.message?.model !== "<synthetic>") return false;
65
+ const text = (rawEvent.message?.content || []).map((block) => (block?.type === "text" ? block.text : "")).join(" ");
66
+ return providerErrorFlags(text).usageLimit;
67
+ }
68
+
22
69
  function sessionEvent(sessionId, state) {
23
70
  if (!sessionId || state.sessionId === sessionId) return null;
24
71
  state.sessionId = sessionId;
@@ -43,6 +90,11 @@ function captureRefusal(state, stopReason, stopDetails) {
43
90
  function claudeFailureEvent(message, state) {
44
91
  const flags = providerErrorFlags(message);
45
92
  const event = { type: "error", message, ...flags };
93
+ if (state.limitHit) {
94
+ event.usageLimit = true;
95
+ event.limitResetsAt = state.limitHit.resetsAt;
96
+ event.limitType = state.limitHit.limitType;
97
+ }
46
98
  if (flags.refusal || state.refusal) {
47
99
  const parsed = refusalDetailsFromMessage(message);
48
100
  event.refusal = true;
@@ -72,6 +124,13 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
72
124
  return event ? [event] : [];
73
125
  }
74
126
 
127
+ if (rawEvent.type === "rate_limit_event") {
128
+ const event = claudeRateLimitEvent(rawEvent.rate_limit_info);
129
+ if (!event) return [];
130
+ if (event.status === "rejected") noteLimitHit(state, { resetsAt: event.resetsAt, limitType: event.limitType });
131
+ return [event];
132
+ }
133
+
75
134
  if (rawEvent.type === "stream_event") {
76
135
  if (rawEvent.event?.type === "message_delta") {
77
136
  captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
@@ -85,6 +144,11 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
85
144
  }
86
145
 
87
146
  if (rawEvent.type === "assistant") {
147
+ if (isLimitNotice(rawEvent)) {
148
+ const text = (rawEvent.message?.content || []).map((block) => (block?.type === "text" ? block.text : "")).join(" ").trim();
149
+ noteLimitHit(state, { text });
150
+ return [];
151
+ }
88
152
  const events = [];
89
153
  captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
90
154
  for (const block of rawEvent.message?.content || []) {
@@ -133,6 +197,12 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
133
197
  if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
134
198
  return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
135
199
  }
200
+ if (state.limitHit && !state.sawText) {
201
+ // A plan limit is a failed turn even when the CLI closes it as a
202
+ // "success": the limit notice must never go out as the reply. (With
203
+ // answer text, Claude Code fell back to another model and finished.)
204
+ return [claudeFailureEvent(state.limitHit.text || claudeErrorMessage(rawEvent), state)];
205
+ }
136
206
  const events = [];
137
207
  const session = sessionEvent(rawEvent.session_id, state);
138
208
  if (session) events.push(session);
@@ -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
  }
@@ -7,9 +7,15 @@
7
7
  const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
8
8
  const MESSAGE_CLIP_CHARS = 1500;
9
9
  const CONTEXT_CLIP_CHARS = 3500;
10
+ // The root must be an object: Claude Code turns the schema into a tool whose
11
+ // input_schema the API requires to be an object, so the bare array this used
12
+ // to be got a 400 on every Claude call and the filter failed open.
10
13
  const FILTER_OUTPUT_SCHEMA = Object.freeze({
11
- type: "array",
12
- items: { type: "string" },
14
+ type: "object",
15
+ required: ["relevant"],
16
+ properties: {
17
+ relevant: { type: "array", items: { type: "string" } },
18
+ },
13
19
  });
14
20
 
15
21
  function enabled() {
@@ -18,15 +24,28 @@ function enabled() {
18
24
 
19
25
  const FILTER_SYSTEM_PROMPT = [
20
26
  "You are a relevance judge for an assistant's memory recall.",
21
- "Reply with ONLY a JSON array of candidate ids — no prose, no markdown fences.",
27
+ 'Reply with ONLY a JSON object {"relevant": [candidate ids]} — no prose, no markdown fences.',
22
28
  ].join("\n");
23
29
 
30
+ // The reply is {"relevant": [...]}; a bare array is still read.
31
+ function idList(value) {
32
+ if (Array.isArray(value)) return value;
33
+ return value && typeof value === "object" && Array.isArray(value.relevant) ? value.relevant : null;
34
+ }
35
+
24
36
  function parseIds(result) {
25
- if (Array.isArray(result?.json)) return result.json;
26
- const match = String(result?.text || "").match(/\[[\s\S]*?\]/);
27
- if (!match) return null;
28
- const parsed = JSON.parse(match[0]);
29
- return Array.isArray(parsed) ? parsed : null;
37
+ const fromJson = idList(result?.json);
38
+ if (fromJson) return fromJson;
39
+ const text = String(result?.text || "");
40
+ for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*?\]/]) {
41
+ const match = text.match(pattern);
42
+ if (!match) continue;
43
+ try {
44
+ const ids = idList(JSON.parse(match[0]));
45
+ if (ids) return ids;
46
+ } catch (_) { /* try the other shape */ }
47
+ }
48
+ return null;
30
49
  }
31
50
 
32
51
  function createRecallFilter(dependencies = {}) {
@@ -62,7 +81,7 @@ function createRecallFilter(dependencies = {}) {
62
81
  "Candidates:",
63
82
  ...lines,
64
83
  "",
65
- `Reply with ONLY a JSON array of the relevant ids, e.g. ["${candidates[0].id}"]. Use [] if none are relevant.`,
84
+ `Reply with ONLY a JSON object of the relevant ids, e.g. {"relevant": ["${candidates[0].id}"]}. Use {"relevant": []} if none are relevant.`,
66
85
  ].join("\n");
67
86
  try {
68
87
  const result = await runUtility(prompt, {
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);
@@ -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);
package/core/runner.js CHANGED
@@ -16,12 +16,14 @@ const {
16
16
  currentState, saveState, recordSession,
17
17
  resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
18
18
  laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
19
- getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
19
+ getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction, commitBranchCompaction,
20
20
  resolveProviderSettings, getProjectSessions,
21
21
  } = require("./state");
22
22
  const { chatContext, currentChannelId, currentAdapter, currentThreadId } = require("./context");
23
23
  const { buildTurnPrompt } = require("./system-prompt");
24
24
  const turnJournal = require("./turn-journal");
25
+ const usageLimits = require("./usage-limits");
26
+ const limitPickup = require("./limit-pickup");
25
27
  const { getProvider, resolveProviderForRun } = require("./providers");
26
28
  const { validateInvocation } = require("./providers/contract");
27
29
  const { selectTerminalText } = require("./providers/events");
@@ -318,6 +320,8 @@ async function executeProviderInvocation(options = {}) {
318
320
  const cleanupOutcome = { ok: true, skipped: false };
319
321
  const rawErrors = [];
320
322
  const usageEvents = [];
323
+ // The provider's last plan-limit reading this run (Claude's rate_limit_event).
324
+ let rateLimit = null;
321
325
  const tools = [];
322
326
  const textDeltas = [];
323
327
  const textFinals = [];
@@ -403,6 +407,7 @@ async function executeProviderInvocation(options = {}) {
403
407
  tools.push(event);
404
408
  }
405
409
  else if (event.type === "usage") usageEvents.push(event);
410
+ else if (event.type === "rate_limit") rateLimit = event;
406
411
  else if (event.type === "result") {
407
412
  terminalSeen = true;
408
413
  if (event.sessionId) sessionId = event.sessionId;
@@ -598,6 +603,7 @@ async function executeProviderInvocation(options = {}) {
598
603
  usage: finalUsage?.usage || null,
599
604
  usageScope: finalUsage?.scope || null,
600
605
  usageEvents,
606
+ rateLimit,
601
607
  costUsd: Number.isFinite(invocation.parserState.totalCostUsd) ? invocation.parserState.totalCostUsd : 0,
602
608
  tools,
603
609
  exitCode: closeInfo.code,
@@ -1084,7 +1090,12 @@ function coldResumeDecision(state, runContext, opts = {}) {
1084
1090
  if (runContext.fresh || runContext.continueSession || runContext.liveCall) return no("fresh-or-continue");
1085
1091
  const provider = runContext.provider;
1086
1092
  const sessionId = runContext.sessionId || null;
1087
- if (!sessionId || getProviderSession(state, provider, key) !== sessionId) return no("no-session");
1093
+ if (!sessionId) return no("no-session");
1094
+ // A scheduled job on its own conversation (a branch: its thread has moved
1095
+ // on) gets the same cap, on that branch. Any other run only on the session
1096
+ // its conversation points at.
1097
+ const branch = getProviderSession(state, provider, key) !== sessionId;
1098
+ if (branch && !isJobBranchRun(runContext)) return no("no-session");
1088
1099
  if (peekLane(state, opts.laneKey || runLaneKey(runContext))?.isCompacting) return no("compacting");
1089
1100
  if (effectiveCompactThreshold(state, { conversationKey: key, tokenDiet: opts.tokenDiet }) === 0) return no("compaction-off");
1090
1101
  const settings = tokenDietSettings(opts.tokenDiet);
@@ -1099,7 +1110,44 @@ function coldResumeDecision(state, runContext, opts = {}) {
1099
1110
  const checkpointTime = Date.parse(usage?.compactionCheckpoint?.at || "");
1100
1111
  const lastCompactedAt = Number(usage?.lastCompactedAt) || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
1101
1112
  if (lastCompactedAt && now - lastCompactedAt < minInterval) return no("recently-compacted", { idleMinutes, contextTokens });
1102
- return { compact: true, reason: "cold-resume", key, idleMinutes, contextTokens };
1113
+ return { compact: true, reason: "cold-resume", key, idleMinutes, contextTokens, ...(branch ? { sessionId } : {}) };
1114
+ }
1115
+
1116
+ // A scheduled run that resumes the job's own conversation without becoming
1117
+ // its thread's live session (scheduler: attachSession false).
1118
+ function isJobBranchRun(runContext) {
1119
+ return !!runContext?.scheduledJobId && runContext.attachSession === false;
1120
+ }
1121
+
1122
+ // The threshold rule for a scheduled job's own conversation, checked after
1123
+ // its run: the thread's rule (shouldAutoCompact) reads only the thread's live
1124
+ // session, so a job's branch grew without limit (Kazee Bugs monitor, 5-7 Oct:
1125
+ // 201k to 894k, $54.90 a wake). Same threshold and the same spacing.
1126
+ function jobBranchThresholdDecision(state, runContext, result, opts = {}) {
1127
+ const key = runContext?.project?.name || null;
1128
+ const no = (reason, extra = {}) => ({ compact: false, reason, key, ...extra });
1129
+ if (!isJobBranchRun(runContext) || !runContext.sessionId) return no("not-a-job-branch");
1130
+ const provider = runContext.provider;
1131
+ const sessionId = result?.sessionId || runContext.sessionId;
1132
+ if (!key || getProviderSession(state, provider, key) === sessionId) return no("live");
1133
+ if (peekLane(state, opts.laneKey || runLaneKey(runContext))?.isCompacting) return no("compacting");
1134
+ const usage = getUsageForSession(state, key, provider, sessionId);
1135
+ const threshold = effectiveCompactThreshold(state, {
1136
+ conversationKey: key,
1137
+ tokenDiet: opts.tokenDiet,
1138
+ contextWindowTokens: usage?.contextWindowTokens,
1139
+ });
1140
+ if (threshold === 0) return no("compaction-off");
1141
+ const contextTokens = usage?.liveContextTokens || usage?.lastInputTokens || 0;
1142
+ if (contextTokens < threshold) return no("under", { contextTokens });
1143
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
1144
+ const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
1145
+ const checkpointTime = Date.parse(usage?.compactionCheckpoint?.at || "");
1146
+ const lastCompactedAt = Number(usage?.lastCompactedAt) || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
1147
+ if (contextTokens < threshold * 2 && lastCompactedAt && now - lastCompactedAt < minInterval) {
1148
+ return no("recently-compacted", { contextTokens });
1149
+ }
1150
+ return { compact: true, reason: "threshold", key, sessionId, contextTokens };
1103
1151
  }
1104
1152
 
1105
1153
  // Runs the cold-resume compaction for a turn that holds its lane, and returns
@@ -1121,12 +1169,14 @@ async function compactColdResume(runContext, state, lane, deps = {}) {
1121
1169
  }
1122
1170
  return runContext;
1123
1171
  }
1124
- console.log(`[token-diet] cold-resume compact ${decision.key} ctx=${decision.contextTokens} idle=${decision.idleMinutes}m`);
1172
+ const branch = decision.sessionId || null;
1173
+ console.log(`[token-diet] cold-resume compact ${decision.key} ctx=${decision.contextTokens} idle=${decision.idleMinutes}m${branch ? ` job-session=${String(branch).slice(0, 8)}` : ""}`);
1125
1174
  let result;
1126
1175
  try {
1127
1176
  result = await compact(runContext.project.dir, {
1128
1177
  notify: false, laneKey: lane.key, admissionHeld: true,
1129
1178
  compactReason: "cold-resume", contextTokensBefore: decision.contextTokens,
1179
+ ...(branch ? { sessionId: branch, provider: runContext.provider } : {}),
1130
1180
  });
1131
1181
  } catch (error) {
1132
1182
  console.warn(`[token-diet] cold-resume compaction failed: ${redactSensitive(error.message)}`);
@@ -1136,6 +1186,18 @@ async function compactColdResume(runContext, state, lane, deps = {}) {
1136
1186
  console.warn(`[token-diet] cold-resume compaction did not complete: ${result?.reason || "unknown"}`);
1137
1187
  return runContext;
1138
1188
  }
1189
+ if (branch) {
1190
+ // The job runs on its compacted conversation; the thread's live pointer
1191
+ // (previousSessionId) and the run's detachment from it stay as they were.
1192
+ return deepFreeze({
1193
+ ...runContext,
1194
+ sessionId: result.newSessionId,
1195
+ admittedSessionId: result.newSessionId,
1196
+ resumeSessionId: result.newSessionId,
1197
+ continueSession: false,
1198
+ fresh: false,
1199
+ });
1200
+ }
1139
1201
  return deepFreeze({
1140
1202
  ...runContext,
1141
1203
  sessionId: result.newSessionId,
@@ -1173,6 +1235,8 @@ function recordCompactionUsage(entry, deps = {}) {
1173
1235
  version: PKG_VERSION,
1174
1236
  kind: "compaction",
1175
1237
  compactReason: entry.compactReason || "manual",
1238
+ // A scheduled job's own conversation, not its thread's live session.
1239
+ ...(entry.branch ? { scope: "job" } : {}),
1176
1240
  backend: entry.provider,
1177
1241
  provider: entry.provider,
1178
1242
  model: entry.model || null,
@@ -1190,6 +1254,7 @@ function compactSummaryPrompt() {
1190
1254
  return [
1191
1255
  "Summarize this conversation for a fresh compacted continuation.",
1192
1256
  "Write a brief that lets your future self resume work without re-asking the user.",
1257
+ "Write it now, in this one reply, from what is already in this conversation. Do NOT use any tools or run any commands (no searches, file reads or status checks): this step allows one reply only, so a tool call ends it with no brief and the compaction fails. The live repo state is attached to the seed for you; if you are unsure of a fact, say so in the brief.",
1193
1258
  "",
1194
1259
  "Include:",
1195
1260
  "- Current user goal and any active side threads or sub-goals.",
@@ -1466,6 +1531,7 @@ function createCompactionService(dependencies = {}) {
1466
1531
  const captureContext = dependencies.captureRunContext || admitRunContext;
1467
1532
  const runCapture = dependencies.runCapture || runAgentCapture;
1468
1533
  const commitCompaction = dependencies.commitCompaction || commitProviderCompaction;
1534
+ const commitBranch = dependencies.commitBranchCompaction || commitBranchCompaction;
1469
1535
  const archiveBrief = dependencies.archiveBrief || archiveCompactionBrief;
1470
1536
  const appendDaySeed = dependencies.appendDaySeed || ((entry) => require("./day-seeds").appendDaySeed(entry));
1471
1537
  const collectRepoFacts = dependencies.collectRepoFacts || collectRepoStateFacts;
@@ -1484,9 +1550,14 @@ function createCompactionService(dependencies = {}) {
1484
1550
  if (lane.runningProcess || ((lane.preparingRun || lane.settling) && !opts.admissionHeld)) {
1485
1551
  return { compacted: false, reason: "Finish or stop the active run before compacting." };
1486
1552
  }
1487
- const provider = getActiveProvider(state);
1553
+ const provider = opts.provider || getActiveProvider(state);
1488
1554
  const project = getProjectKey(state);
1489
- const oldSessionId = getProviderSession(state, provider, project);
1555
+ const liveSessionId = getProviderSession(state, provider, project);
1556
+ // opts.sessionId names a scheduled job's own conversation (a branch beside
1557
+ // the thread's live session): that is what gets compacted, and only the
1558
+ // job follows it; the thread's pointer stays where it is.
1559
+ const branch = !!opts.sessionId && opts.sessionId !== liveSessionId;
1560
+ const oldSessionId = branch ? opts.sessionId : liveSessionId;
1490
1561
  if (!project || !oldSessionId) return { compacted: false, reason: "No conversation." };
1491
1562
  const summaryPrompt = compactSummaryPrompt();
1492
1563
  const admitted = captureContext(summaryPrompt, cwd, null, {
@@ -1511,7 +1582,7 @@ function createCompactionService(dependencies = {}) {
1511
1582
  // The recent turns come from THIS conversation's transcript: the captured
1512
1583
  // project, not the state's legacy "Workspace" session (which read another file).
1513
1584
  const recentTurns = collectRecent(capturedStateView(state, summaryContext));
1514
- const usageBefore = state.sessionUsage;
1585
+ const usageBefore = branch ? getUsageForSession(state, project, provider, oldSessionId) : state.sessionUsage;
1515
1586
  const contextTokensBefore = Number.isFinite(opts.contextTokensBefore)
1516
1587
  ? opts.contextTokensBefore
1517
1588
  : (usageBefore?.liveContextTokens || usageBefore?.lastInputTokens || 0);
@@ -1566,14 +1637,14 @@ function createCompactionService(dependencies = {}) {
1566
1637
  }
1567
1638
  let commit;
1568
1639
  try {
1569
- commit = await commitCompaction(state, {
1640
+ commit = await (branch ? commitBranch : commitCompaction)(state, {
1570
1641
  operationId,
1571
1642
  canonicalUserId: summaryContext.canonicalUserId,
1572
1643
  project: summaryContext.project.name,
1573
1644
  provider: summaryContext.provider,
1574
1645
  previousSessionId: oldSessionId,
1575
1646
  newSessionId: seedRun.sessionId,
1576
- title: `Compacted ${new Date().toLocaleDateString()}`,
1647
+ title: branch ? null : `Compacted ${new Date().toLocaleDateString()}`,
1577
1648
  seedUsage: seedRun.usage || null,
1578
1649
  });
1579
1650
  } catch (error) {
@@ -1600,6 +1671,7 @@ function createCompactionService(dependencies = {}) {
1600
1671
  summary,
1601
1672
  commit,
1602
1673
  compactReason: opts.compactReason || "manual",
1674
+ ...(branch ? { branch: true } : {}),
1603
1675
  };
1604
1676
  } finally {
1605
1677
  lane.isCompacting = false;
@@ -1607,6 +1679,7 @@ function createCompactionService(dependencies = {}) {
1607
1679
  try {
1608
1680
  recordCompaction({
1609
1681
  compactReason: opts.compactReason || "manual",
1682
+ branch,
1610
1683
  provider: summaryContext.provider,
1611
1684
  model: summaryContext.providerSettings?.model || null,
1612
1685
  userId: summaryContext.canonicalUserId,
@@ -1662,6 +1735,58 @@ function capturedTranscriptMetadata(runContext, extra = {}) {
1662
1735
  };
1663
1736
  }
1664
1737
 
1738
+ // Plan limits (core/usage-limits.js, core/limit-pickup.js). Worked out once
1739
+ // per run and kept on the result (not enumerable: never part of its shape).
1740
+ function keepOnResult(result, key, value) {
1741
+ try { Object.defineProperty(result, key, { value, enumerable: false, configurable: true, writable: true }); } catch (_) {}
1742
+ return value;
1743
+ }
1744
+
1745
+ // Each plan window's use after this run: Claude reports it in the stream;
1746
+ // Codex in its rollout (read for the ledger on success, here on a failure).
1747
+ function planLimitWindows(result, runContext) {
1748
+ if (result.planWindows !== undefined) return result.planWindows;
1749
+ let windows = [];
1750
+ try {
1751
+ if (runContext.provider === "claude") windows = result.rateLimit?.windows || [];
1752
+ else if (runContext.provider === "codex") {
1753
+ const rateLimits = result.codexRateLimits !== undefined
1754
+ ? result.codexRateLimits
1755
+ : (result.sessionId ? readCodexRollout({ threadId: result.sessionId, includeSubagents: false })?.rateLimits || null : null);
1756
+ windows = usageLimits.codexWindows(rateLimits);
1757
+ }
1758
+ } catch (_) { windows = []; }
1759
+ return keepOnResult(result, "planWindows", windows);
1760
+ }
1761
+
1762
+ // What a turn a usage limit stopped does: the notice it posts and, when the
1763
+ // reset time is known, the pick-up deliverForegroundResult arms.
1764
+ function limitPlanFor(result, runContext) {
1765
+ if (result.limitPlan !== undefined) return result.limitPlan;
1766
+ let plan = null;
1767
+ if (!result.ok && result.error?.code === "PROVIDER_USAGE_LIMIT") {
1768
+ let guarded = false;
1769
+ try { guarded = require("./relationship").isCurrentSpeakerGuarded(); } catch (_) {}
1770
+ try { plan = limitPickup.planFor(result, runContext, { windows: planLimitWindows(result, runContext), guarded }); }
1771
+ catch (error) { console.warn(`[limit-pickup] plan failed: ${redactSensitive(String(error.message || error))}`); }
1772
+ }
1773
+ return keepOnResult(result, "limitPlan", plan);
1774
+ }
1775
+
1776
+ // Remember each window's use; the warnings now due (80% / 95%, once per
1777
+ // window). A run that cannot show them only updates the use, so the warning
1778
+ // is still due on the next turn that can.
1779
+ function recordPlanLimits(result, runContext, { warn = true } = {}) {
1780
+ try {
1781
+ const windows = planLimitWindows(result, runContext);
1782
+ if (!windows.length) return [];
1783
+ return usageLimits.recordWindows({ provider: runContext.provider, windows, ...(warn ? {} : { percents: [] }) });
1784
+ } catch (error) {
1785
+ console.warn(`[usage-limits] ${redactSensitive(String(error.message || error))}`);
1786
+ return [];
1787
+ }
1788
+ }
1789
+
1665
1790
  function providerRunDiagnostic(result, runContext) {
1666
1791
  if (result.ok) return null;
1667
1792
  let provider;
@@ -1674,6 +1799,8 @@ function providerRunDiagnostic(result, runContext) {
1674
1799
  return `${label} authentication failed.${help ? `\n\n${help}` : ""}`;
1675
1800
  }
1676
1801
  if (code === "PROVIDER_USAGE_LIMIT") {
1802
+ const plan = limitPlanFor(result, runContext);
1803
+ if (plan?.notice) return plan.notice;
1677
1804
  return `${label} reported a usage or rate limit. Check that provider's account status or select another configured provider.`;
1678
1805
  }
1679
1806
  if (code === "PROVIDER_REFUSAL") {
@@ -1777,6 +1904,7 @@ async function persistForegroundUsage(result, runContext, state = currentState()
1777
1904
  liveContextTokens = rollout && rollout.lastContextTokens > 0
1778
1905
  ? rollout.lastContextTokens
1779
1906
  : usageParts(rawUsage, "codex").context;
1907
+ keepOnResult(result, "codexRateLimits", rollout ? rollout.rateLimits || null : null);
1780
1908
  }
1781
1909
  const rolloutOpts = rollout ? {
1782
1910
  contextSource: "rollout_last_request",
@@ -1902,6 +2030,20 @@ async function finalizeStreamPreview(lane, result, runContext = null) {
1902
2030
  // words. That is never an answer, so it is never posted.
1903
2031
  const PLACEHOLDER_REPLY = /^no response requested\.?$/i;
1904
2032
 
2033
+ // "⚠️ Claude: 82% of the 5-hour limit is used. It resets at 17:20." goes out
2034
+ // after the answer that crossed it. Never to an outside contact (plan
2035
+ // internals), never on a live call, never after an unasked group reply.
2036
+ function limitWarningsVisible(result, runContext) {
2037
+ if (!result.ok || runContext.passive === true || runContext.liveCall || statusChatterMuted()) return false;
2038
+ try { return !require("./relationship").isCurrentSpeakerGuarded(); } catch (_) { return false; }
2039
+ }
2040
+
2041
+ async function sendLimitWarnings(due) {
2042
+ for (const item of due) {
2043
+ await send(usageLimits.formatWarning(item)).catch(() => {});
2044
+ }
2045
+ }
2046
+
1905
2047
  async function deliverForegroundResult(result, runContext, state = currentState()) {
1906
2048
  const lane = laneForRun(state, runContext);
1907
2049
  if (runContext.lastInputWasVoice) lane.lastInputWasVoice = false;
@@ -1915,6 +2057,17 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1915
2057
  if (stoppedHere) lane.cancelRequested = false;
1916
2058
  return { ok: true, suppressed: "cancelled" };
1917
2059
  }
2060
+ // Plan limits: each window's use is remembered (a warning is due once per
2061
+ // window at 80% and 95%), and a turn a usage limit stopped is picked up
2062
+ // when the limit resets, unless the person writes again first.
2063
+ const limitWarnings = recordPlanLimits(result, runContext, { warn: limitWarningsVisible(result, runContext) });
2064
+ const limitPlan = limitPlanFor(result, runContext);
2065
+ if (limitPlan?.armable) {
2066
+ try { limitPickup.arm(runContext, limitPlan); }
2067
+ catch (error) { console.warn(`[limit-pickup] could not arm: ${redactSensitive(String(error.message || error))}`); }
2068
+ }
2069
+ // Nobody asked for a passive reply: a limit that stopped one is not news.
2070
+ if (limitPlan && runContext.passive === true) return { ok: true, held: true, suppressed: "passive" };
1918
2071
  const diagnostic = providerRunDiagnostic(result, runContext);
1919
2072
  if (diagnostic) result.diagnostic = diagnostic;
1920
2073
  const cleanAnswer = String(result.finalAnswerText || "").trim();
@@ -1966,6 +2119,7 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1966
2119
  if (!sent.ok) sent = await send(chunks[index], telegramHtmlOpts({ botTags: true }));
1967
2120
  if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
1968
2121
  }
2122
+ if (limitWarnings.length) await sendLimitWarnings(limitWarnings);
1969
2123
 
1970
2124
  const shouldSpeak = shouldReplyWithVoice(VOICE_REPLY_MODE, runContext.lastInputWasVoice);
1971
2125
  if (result.ok && shouldSpeak) {
@@ -2712,10 +2866,23 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2712
2866
  // kill keeps it journaled; see turn-journal.end.)
2713
2867
  if (tracksMainRun) turnJournal.end(runContext);
2714
2868
  // Auto-compaction is part of the lane's turn: only when nothing waits in
2715
- // this conversation, and it compacts this conversation only.
2716
- if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state, { laneKey: lane.key })) {
2717
- try { await compactActiveSession(runContext.project.dir, { notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock, compactReason: "threshold" }); }
2718
- catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2869
+ // this conversation, and it compacts this conversation only. A scheduled
2870
+ // job on its own conversation (a branch) is held to the same threshold.
2871
+ if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext)) {
2872
+ let jobBranch = { compact: false };
2873
+ try { jobBranch = jobBranchThresholdDecision(state, runContext, result, { laneKey: lane.key }); }
2874
+ catch (error) { console.warn(`[proactive-compact] job conversation check failed: ${redactSensitive(error.message)}`); }
2875
+ if (jobBranch.compact) console.log(`[proactive-compact] job conversation ${jobBranch.key} job-session=${String(jobBranch.sessionId).slice(0, 8)} ctx=${jobBranch.contextTokens}`);
2876
+ if (jobBranch.compact || shouldAutoCompact(state, { laneKey: lane.key })) {
2877
+ try {
2878
+ const outcome = await compactActiveSession(runContext.project.dir, {
2879
+ notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock, compactReason: "threshold",
2880
+ ...(jobBranch.compact ? { sessionId: jobBranch.sessionId, provider: runContext.provider, contextTokensBefore: jobBranch.contextTokens } : {}),
2881
+ });
2882
+ // A compaction that runs but does not commit returns its reason instead of throwing.
2883
+ if (outcome && outcome.compacted === false) console.warn(`[proactive-compact] did not complete: ${redactSensitive(String(outcome.reason || "unknown"))}`);
2884
+ } catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2885
+ }
2719
2886
  }
2720
2887
  return result;
2721
2888
  }
@@ -2798,6 +2965,11 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2798
2965
  });
2799
2966
 
2800
2967
  if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
2968
+ // A turn the person starts here (a button, a restored message) drops their
2969
+ // pick-up waiting for a usage limit to reset; the pick-up's own run does not.
2970
+ if (runContext.purpose === "foreground" && !runContext.limitPickup && !runContext.liveCall) {
2971
+ try { limitPickup.cancel(runContext.canonicalUserId, runLaneKey(runContext), "a new turn"); } catch (_) {}
2972
+ }
2801
2973
  // A planned restart is draining: no new turn starts now. The message is
2802
2974
  // journaled as queued and runs right after the restart.
2803
2975
  if (turnJournal.isDraining() && turnJournal.journalable(runContext)) {
@@ -2975,6 +3147,7 @@ module.exports = {
2975
3147
  defaultCompactTokens,
2976
3148
  coldResumeDecision,
2977
3149
  compactColdResume,
3150
+ jobBranchThresholdDecision,
2978
3151
  recordCompactionUsage,
2979
3152
  effectiveCompactThreshold,
2980
3153
  usageParts,