bunnyquery 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The `ai_agent` service option, parsed and serialized in ONE place.
3
+ *
4
+ * Stored on the skapi service record as up to three '#'-delimited segments:
5
+ *
6
+ * none AI chat disabled
7
+ * claude platform chosen, no model saved yet
8
+ * claude#claude-sonnet-4-6 platform + model
9
+ * claude#claude-sonnet-4-6#400000 platform + model + context-window override
10
+ *
11
+ * The third segment is new and optional, so every value written before it
12
+ * existed parses unchanged with `contextWindow: null`. Nothing writes a third
13
+ * segment without a model, because the window is meaningless without one.
14
+ *
15
+ * This lived as four separate copies (agent.vue, dbfile.vue, service.vue, and
16
+ * the widget's index.js), which is how the format drifts. New callers should
17
+ * import from here rather than re-deriving the split.
18
+ */
19
+
20
+ export type AiAgentPlatform = 'claude' | 'openai' | null;
21
+
22
+ export type ParsedAiAgent = {
23
+ /** null when unset or explicitly 'none'. */
24
+ platform: AiAgentPlatform;
25
+ /** '' when no model has been saved. */
26
+ model: string;
27
+ /** Per-project context-window override in tokens, or null to use the model's. */
28
+ contextWindow: number | null;
29
+ /** True when a real platform is configured (i.e. not unset and not 'none'). */
30
+ hasPlatform: boolean;
31
+ };
32
+
33
+ function normalizePlatform(raw: string): AiAgentPlatform {
34
+ var p = (raw || '').trim().toLowerCase();
35
+ return p === 'claude' || p === 'openai' ? p : null;
36
+ }
37
+
38
+ export function parseAiAgentValue(value: string | null | undefined): ParsedAiAgent {
39
+ var raw = (value || '').trim();
40
+ if (!raw || raw.toLowerCase() === 'none') {
41
+ return { platform: null, model: '', contextWindow: null, hasPlatform: false };
42
+ }
43
+
44
+ // Split into at most three parts so a '#' inside a model id (none today, but
45
+ // the delimiter should not be load-bearing on the tail) cannot shift fields.
46
+ var firstHash = raw.indexOf('#');
47
+ if (firstHash === -1) {
48
+ var only = normalizePlatform(raw);
49
+ return { platform: only, model: '', contextWindow: null, hasPlatform: !!only };
50
+ }
51
+
52
+ var platform = normalizePlatform(raw.slice(0, firstHash));
53
+ var rest = raw.slice(firstHash + 1);
54
+ var secondHash = rest.indexOf('#');
55
+ var model = (secondHash === -1 ? rest : rest.slice(0, secondHash)).trim();
56
+ var windowRaw = secondHash === -1 ? '' : rest.slice(secondHash + 1).trim();
57
+
58
+ var parsedWindow = windowRaw ? Number(windowRaw) : NaN;
59
+ var contextWindow = Number.isFinite(parsedWindow) && parsedWindow > 0
60
+ ? Math.floor(parsedWindow)
61
+ : null;
62
+
63
+ return { platform: platform, model: model, contextWindow: contextWindow, hasPlatform: !!platform };
64
+ }
65
+
66
+ export function buildAiAgentValue(
67
+ platform: string | null | undefined,
68
+ model?: string | null,
69
+ contextWindow?: number | null,
70
+ ): string {
71
+ var p = (platform || '').trim().toLowerCase();
72
+ if (!p || p === 'none') return 'none';
73
+
74
+ var m = (model || '').trim();
75
+ if (!m) return p;
76
+
77
+ var n = Number(contextWindow);
78
+ if (!Number.isFinite(n) || n <= 0) return p + '#' + m;
79
+ return p + '#' + m + '#' + Math.floor(n);
80
+ }
@@ -7,15 +7,76 @@
7
7
  import { sanitizeAttachmentLinksForHistory } from './links';
8
8
 
9
9
  export var CONTEXT_WINDOW_DEFAULT: Record<string, number> = { claude: 200000, openai: 128000 };
10
+ // Exact model ids first, then family keys (see getContextWindow: a suffixed id
11
+ // falls back to its family rather than to the platform default). Claude figures
12
+ // come from Anthropic's published model table; `max_input_tokens` from
13
+ // /v1/models overrides all of this at runtime when a listing has been seen.
14
+ // 'claude-opus-4-7' read 200000 here, which was wrong by 5x, and the default
15
+ // Claude model 'claude-sonnet-4-6' was absent entirely so it fell through to
16
+ // CONTEXT_WINDOW_DEFAULT.claude. Neither was observable, because the history
17
+ // budget and the Claude per-request cap below bind long before the window does.
10
18
  export var CONTEXT_WINDOW_BY_MODEL: Record<string, number> = {
11
- 'claude-opus-4-7': 200000, 'claude-sonnet-4': 200000, 'gpt-5.4': 128000,
19
+ // exact ids
20
+ 'claude-opus-5': 1000000, 'claude-opus-4-8': 1000000, 'claude-opus-4-7': 1000000,
21
+ 'claude-sonnet-5': 1000000, 'claude-sonnet-4-6': 1000000, 'claude-sonnet-4': 200000,
22
+ 'claude-haiku-4-5': 200000, 'gpt-5.4': 128000, 'gpt-5.6-luna': 128000,
23
+ // family keys
24
+ 'claude-opus': 1000000, 'claude-sonnet': 1000000, 'claude-haiku': 200000,
25
+ 'gpt-5.6': 128000, 'gpt-5': 128000,
12
26
  };
27
+
28
+ // Context windows reported by a provider's own models listing, keyed by model id.
29
+ // Anthropic's GET /v1/models returns `max_input_tokens` per model, which is the
30
+ // authoritative context window; OpenAI's listing carries no equivalent field, so
31
+ // OpenAI models resolve from the table above. Populated by
32
+ // registerModelContextWindows() when a client fetches its model list.
33
+ var apiReportedContextWindows: Record<string, number> = {};
34
+
35
+ /**
36
+ * Record context windows from a provider models listing. Accepts the raw list
37
+ * items and reads `max_input_tokens` (Anthropic); items without it are skipped,
38
+ * so passing an OpenAI listing is a no-op rather than an error.
39
+ */
40
+ export function registerModelContextWindows(models: Array<{ id?: string; max_input_tokens?: number }> | null | undefined): void {
41
+ if (!Array.isArray(models)) return;
42
+ for (var i = 0; i < models.length; i++) {
43
+ var m = models[i];
44
+ var id = (m && m.id ? String(m.id) : '').trim().toLowerCase();
45
+ var reported = m ? Number(m.max_input_tokens) : NaN;
46
+ if (id && Number.isFinite(reported) && reported > 0) {
47
+ apiReportedContextWindows[id] = Math.floor(reported);
48
+ }
49
+ }
50
+ }
51
+
52
+ /** Per-project override, keyed by service id. Set from the project settings. */
53
+ var projectContextWindows: Record<string, number> = {};
54
+
55
+ export function setProjectContextWindow(serviceId: string, tokens: number | null | undefined): void {
56
+ var key = (serviceId || '').trim();
57
+ if (!key) return;
58
+ var n = Number(tokens);
59
+ if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
60
+ else delete projectContextWindows[key];
61
+ }
62
+
63
+ export function getProjectContextWindow(serviceId: string): number | null {
64
+ var key = (serviceId || '').trim();
65
+ return key && projectContextWindows[key] ? projectContextWindows[key] : null;
66
+ }
13
67
  export var OUTPUT_TOKEN_RESERVE = 22000;
14
68
  export var TOOL_AND_RESPONSE_BUFFER = 4000;
15
69
  export var MIN_INPUT_TOKEN_BUDGET = 8000;
16
70
  export var CLAUDE_PER_REQUEST_INPUT_CAP = 28000;
17
71
  export var MAX_HISTORY_MESSAGES = 20;
18
72
  export var HISTORY_TOKEN_BUDGET = 8000;
73
+ // Ratios that scale the two ceilings above off the resolved context window.
74
+ // Calibrated to reproduce the previous fixed values at the default windows:
75
+ // claude 200000 -> cap 27840 (was 28000, so the 28000 floor holds), and
76
+ // openai 128000 -> history 8160 (~the previous 8000). Both are floored at the
77
+ // old constants, so these can only ever raise a budget, never lower one.
78
+ export var CLAUDE_INPUT_CAP_RATIO = 0.16;
79
+ export var HISTORY_BUDGET_RATIO = 0.08;
19
80
 
20
81
  export function estimateTextTokens(text: string): number {
21
82
  return Math.ceil((text || '').length / 3);
@@ -25,9 +86,34 @@ export function estimateMessageTokens(msg: { role: string; content: string }): n
25
86
  return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
26
87
  }
27
88
 
28
- export function getContextWindow(platform: string, model?: string): number {
89
+ /**
90
+ * Resolve a model's context window, most specific source first:
91
+ * 1. per-project override (project settings)
92
+ * 2. the provider's own models listing (Anthropic `max_input_tokens`)
93
+ * 3. an exact entry in CONTEXT_WINDOW_BY_MODEL
94
+ * 4. a family entry, by dropping trailing '-' segments off the id
95
+ * 5. the platform default
96
+ *
97
+ * Step 4 is why a new or suffixed id no longer drops straight to the platform
98
+ * default: 'gpt-5.6-luna' resolves via 'gpt-5.6', and a dated Claude snapshot
99
+ * such as 'claude-opus-4-7-20260101' resolves via 'claude-opus-4-7'. The walk
100
+ * stops at the first hit, so a more specific entry always wins over its family.
101
+ */
102
+ export function getContextWindow(platform: string, model?: string, serviceId?: string): number {
103
+ var override = serviceId ? getProjectContextWindow(serviceId) : null;
104
+ if (override) return override;
105
+
29
106
  var normalized = (model || '').trim().toLowerCase();
30
- if (normalized && CONTEXT_WINDOW_BY_MODEL[normalized]) return CONTEXT_WINDOW_BY_MODEL[normalized];
107
+ if (normalized) {
108
+ if (apiReportedContextWindows[normalized]) return apiReportedContextWindows[normalized];
109
+ if (CONTEXT_WINDOW_BY_MODEL[normalized]) return CONTEXT_WINDOW_BY_MODEL[normalized];
110
+
111
+ var parts = normalized.split('-');
112
+ for (var end = parts.length - 1; end > 0; end--) {
113
+ var family = parts.slice(0, end).join('-');
114
+ if (CONTEXT_WINDOW_BY_MODEL[family]) return CONTEXT_WINDOW_BY_MODEL[family];
115
+ }
116
+ }
31
117
  return CONTEXT_WINDOW_DEFAULT[platform];
32
118
  }
33
119
 
@@ -46,14 +132,34 @@ export type BoundedChatOptions = {
46
132
  };
47
133
 
48
134
  export function buildBoundedChatMessages(options: BoundedChatOptions) {
49
- var contextWindow = getContextWindow(options.platform, options.model);
135
+ var contextWindow = getContextWindow(options.platform, options.model, options.serviceId);
50
136
  var contextBasedBudget = Math.max(MIN_INPUT_TOKEN_BUDGET,
51
137
  contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER);
138
+ // Scaling is gated on an EXPLICIT per-project window set from project
139
+ // settings. Without one, the fixed ceilings apply exactly as before, so every
140
+ // existing project keeps byte-identical behavior and nobody's token spend
141
+ // moves because a table value was corrected. With one, the Claude per-request
142
+ // cap and the history budget scale off the window, so raising it genuinely
143
+ // sends more history instead of being absorbed by a hardcoded ceiling.
144
+ // Both derive from contextBasedBudget (pre-Claude-cap) so the two platforms
145
+ // scale symmetrically rather than the Claude cap compounding the ratio down.
146
+ var scaled = !!(options.serviceId && getProjectContextWindow(options.serviceId));
147
+ var claudeInputCap = scaled
148
+ ? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO))
149
+ : CLAUDE_PER_REQUEST_INPUT_CAP;
52
150
  var availableInputBudget = options.platform === 'claude'
53
- ? Math.min(contextBasedBudget, CLAUDE_PER_REQUEST_INPUT_CAP) : contextBasedBudget;
151
+ ? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
54
152
  var systemCost = estimateTextTokens(options.systemPrompt) + 12;
55
- var budgetForHistory = Math.max(1000, Math.min(HISTORY_TOKEN_BUDGET, availableInputBudget - systemCost));
56
- var windowed = options.history.slice(-MAX_HISTORY_MESSAGES);
153
+ var historyAllowance = scaled
154
+ ? Math.max(HISTORY_TOKEN_BUDGET, Math.round(contextBasedBudget * HISTORY_BUDGET_RATIO))
155
+ : HISTORY_TOKEN_BUDGET;
156
+ var budgetForHistory = Math.max(1000, Math.min(historyAllowance, availableInputBudget - systemCost));
157
+ // The message count scales alongside the token budget; otherwise 20 messages
158
+ // is a second ceiling that swallows the extra budget on a raised window.
159
+ var maxHistoryMessages = scaled
160
+ ? Math.max(MAX_HISTORY_MESSAGES, Math.round(MAX_HISTORY_MESSAGES * (budgetForHistory / HISTORY_TOKEN_BUDGET)))
161
+ : MAX_HISTORY_MESSAGES;
162
+ var windowed = options.history.slice(-maxHistoryMessages);
57
163
  var latestIndex = windowed.length - 1;
58
164
  var trimmed = windowed.map(function (m, i) {
59
165
  if (i === latestIndex) return m;
@@ -40,6 +40,46 @@ export function extractLastUserTextFromRequest(requestBody: any): string {
40
40
  return '';
41
41
  }
42
42
 
43
+ /** The two openings an indexing prompt can have. A bg-queue item that starts with
44
+ * neither is an ordinary chat that happened to be routed onto that queue. */
45
+ export function isIndexingRequestText(userText: any): boolean {
46
+ if (typeof userText !== 'string') return false;
47
+ return userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0;
48
+ }
49
+
50
+ export type IndexingRequestRef = {
51
+ name: string;
52
+ path?: string;
53
+ mime?: string;
54
+ size?: number;
55
+ /** A CONTINUE pass rather than the run's first. */
56
+ continued: boolean;
57
+ };
58
+
59
+ /**
60
+ * The file an indexing prompt is about, read back out of the prompt itself.
61
+ *
62
+ * The prompt is the only description of the pass that survives on the server, so
63
+ * this is how BOTH a history rebuild and a worker-minted pass the client never
64
+ * dispatched (ChatSession._adoptWorkerIndexingPasses) recover the file. Shared so
65
+ * the two produce the same `_indexFile`, which is what makes them group together.
66
+ */
67
+ export function parseIndexingRequestText(userText: any): IndexingRequestRef | null {
68
+ if (typeof userText !== 'string' || !userText) return null;
69
+ var nameMatch = userText.match(/^- name: (.+)$/m);
70
+ if (!nameMatch) return null;
71
+ var mimeMatch = userText.match(/^- mime type: (.+)$/m);
72
+ var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
73
+ var pathMatch = userText.match(/^- storage path: (.+)$/m);
74
+ return {
75
+ name: nameMatch[1].trim(),
76
+ path: pathMatch ? pathMatch[1].trim() : undefined,
77
+ mime: mimeMatch ? mimeMatch[1].trim() : undefined,
78
+ size: sizeMatch ? Number(sizeMatch[1]) : undefined,
79
+ continued: userText.indexOf('CONTINUE indexing') === 0,
80
+ };
81
+ }
82
+
43
83
  export type MapHistoryOptions = {
44
84
  clearedAt: number;
45
85
  serviceId: string;
@@ -78,31 +118,21 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
78
118
  // alongside the formatted label so grouping never has to parse it back.
79
119
  var indexFile: any = undefined;
80
120
  if (item._isBgTask) {
81
- var nameMatch = userText.match(/^- name: (.+)$/m);
82
- if (nameMatch) {
83
- var mimeMatch = userText.match(/^- mime type: (.+)$/m);
84
- var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
85
- var pathMatch = userText.match(/^- storage path: (.+)$/m);
121
+ var ref = parseIndexingRequestText(userText);
122
+ if (ref) {
86
123
  // A CONTINUE pass ("CONTINUE indexing …") gets the compact
87
124
  // continuation label; a first pass ("A new file …") gets the full
88
125
  // one. Mirrors agent.vue's mapHistoryListToMessages so a big file's
89
126
  // windows read as progress, not the same task repeating.
90
- var isContinuePass = userText.indexOf('CONTINUE indexing') === 0;
91
127
  displayContent = opts.formatIndexingLabel(
92
- nameMatch[1].trim(),
93
- mimeMatch ? mimeMatch[1].trim() : '',
94
- sizeMatch ? Number(sizeMatch[1]) : null,
95
- pathMatch ? pathMatch[1].trim() : undefined,
128
+ ref.name,
129
+ ref.mime || '',
130
+ typeof ref.size === 'number' ? ref.size : null,
131
+ ref.path,
96
132
  false,
97
- isContinuePass
133
+ ref.continued
98
134
  );
99
- indexFile = {
100
- name: nameMatch[1].trim(),
101
- path: pathMatch ? pathMatch[1].trim() : undefined,
102
- mime: mimeMatch ? mimeMatch[1].trim() : undefined,
103
- size: sizeMatch ? Number(sizeMatch[1]) : undefined,
104
- continued: isContinuePass,
105
- };
135
+ indexFile = ref;
106
136
  } else {
107
137
  displayContent = userText;
108
138
  }
@@ -47,11 +47,19 @@ export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryabl
47
47
  export * from './budget';
48
48
  export * from './links';
49
49
  export * from './time';
50
+ export * from './ai_agent';
50
51
  export {
51
52
  filterListByClearHorizon,
52
53
  normalizeTextContent,
53
54
  extractLastUserTextFromRequest,
54
55
  mapHistoryListToMessages,
56
+ // The indexing-prompt reader. Exported because the prompt is the only record
57
+ // of what a pass is about, so anything that meets a pass without a bubble
58
+ // (a history rebuild, an adopted worker pass, agent.vue's own mapper) has to
59
+ // read it the same way or the two will not group together.
60
+ isIndexingRequestText,
61
+ parseIndexingRequestText,
62
+ type IndexingRequestRef,
55
63
  type MapHistoryOptions,
56
64
  } from './history';
57
65
 
@@ -833,8 +833,16 @@ export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
833
833
  // a small cap suffices.
834
834
  export const MAX_INDEXING_RESUME_PASSES = 6;
835
835
 
836
+ /**
837
+ * `queue` narrows the fetch to one processing chain; `status` narrows it to items
838
+ * in one state. Passing both is how the client asks "is there still unresolved
839
+ * work on the background-indexing queue?" without pulling a page of chat history
840
+ * (see ChatSession._adoptWorkerIndexingPasses) — the server answers that from a
841
+ * status-keyed index, so the reply carries only the live items, not the bodies of
842
+ * everything already finished.
843
+ */
836
844
  export async function getChatHistory(
837
- params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },
845
+ params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string; status?: 'pending' | 'running' | 'resolved' | 'failed' },
838
846
  fetchOptions: Record<string, any>,
839
847
  ) {
840
848
  const url =
@@ -848,10 +856,11 @@ export async function getChatHistory(
848
856
  },
849
857
  { service: params.service, owner: params.owner },
850
858
  params.queue ? { queue: params.queue } : {},
859
+ params.status ? { status: params.status } : {},
851
860
  );
852
861
 
853
862
  return chatEngineConfig().clientSecretRequestHistory(
854
- p as { url: string; method: 'POST'; queue?: string },
863
+ p as { url: string; method: 'POST'; queue?: string; status?: string },
855
864
  Object.assign({ ascending: false }, fetchOptions),
856
865
  );
857
866
  }
@@ -39,7 +39,7 @@ import { windowedIndexingEnabled } from './config';
39
39
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
40
40
  import { buildBoundedChatMessages } from './budget';
41
41
  import { createInlineLinkRegex } from './links';
42
- import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
42
+ import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
43
43
  import { wallClockNow } from './time';
44
44
  import { parseAttachmentContent } from './attachment_parsers';
45
45
  import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
@@ -49,6 +49,16 @@ function sleep(ms: number): Promise<void> {
49
49
  return new Promise(function (r) { setTimeout(r, ms); });
50
50
  }
51
51
 
52
+ // How many live items one look at the background-indexing queue asks for. The
53
+ // queue is FIFO per user, so a healthy chain has one running plus at most a
54
+ // couple queued; the cap only bounds a pathological backlog.
55
+ const WORKER_PASS_ADOPT_LIMIT = 20;
56
+ // Delays before each look (the first is immediate). More than one because the
57
+ // worker writes the next pass's row a few milliseconds AFTER it resolves the
58
+ // current one, so a look that wins that race sees an empty queue for a chain
59
+ // that is still alive. Fixed and finite: this must never become a poll.
60
+ const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
61
+
52
62
  // requestAnimationFrame / high-res clock, reached through globalThis so the
53
63
  // engine stays DOM-free at the type level (and degrades gracefully in non-DOM
54
64
  // / test environments where these globals are absent).
@@ -1111,8 +1121,29 @@ export class ChatSession {
1111
1121
 
1112
1122
  // --- background-task resolution + drain -------------------------------
1113
1123
  handleHistoryItemResolution(itemId: string, response: any, platform: string): void {
1124
+ // Which file this turn was indexing, read BEFORE the resolution rewrites the
1125
+ // bubbles. Every settling turn funnels through here — the bg drain's own
1126
+ // poll, the history poll, and agent.vue's forked history poll, which calls
1127
+ // straight into this method — so hooking the chain follow-up here is what
1128
+ // makes it work in both clients without either of them forking a call site.
1129
+ var indexRef = this._indexRefOfItem(itemId);
1114
1130
  this.applyHistoryItemResolution(itemId, response, platform);
1115
1131
  this.promoteNextBgQueuedToRunning();
1132
+ // A worker-driven chain has no client-side record of its next pass, so a
1133
+ // settling pass is the only moment there is to go looking for one.
1134
+ if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
1135
+ }
1136
+
1137
+ /** The file an already-rendered background pass is about, off its request
1138
+ * bubble. Null for an ordinary turn, which is most of them. */
1139
+ private _indexRefOfItem(itemId: string): { name?: string; mime?: string } | null {
1140
+ if (!itemId) return null;
1141
+ for (var i = 0; i < this.state.messages.length; i++) {
1142
+ var m = this.state.messages[i];
1143
+ if (m._serverItemId !== itemId || m.role !== 'user' || !m.isBackgroundTask) continue;
1144
+ return m._indexFile || null;
1145
+ }
1146
+ return null;
1116
1147
  }
1117
1148
 
1118
1149
  applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
@@ -1261,6 +1292,175 @@ export class ChatSession {
1261
1292
  });
1262
1293
  }
1263
1294
 
1295
+ /**
1296
+ * True when the WORKER, not this client, drives the rest of this file's chain.
1297
+ * The mirror image of the early returns in maybeResumeIndexing: whatever that
1298
+ * refuses to continue is exactly what nothing client-side is tracking.
1299
+ */
1300
+ private _isWorkerDrivenIndexing(filename?: string, mime?: string): boolean {
1301
+ if (!isPagedReadFile(filename, mime)) return false;
1302
+ if (isImageVisionFile(filename, mime)) return true;
1303
+ return windowedIndexingEnabled() && isWindowedReadFile(filename, mime);
1304
+ }
1305
+
1306
+ /**
1307
+ * Pick up indexing passes the WORKER minted, which no client ever dispatched.
1308
+ *
1309
+ * For a PDF (and for text/grid when windowed indexing is on) the worker writes
1310
+ * pass N+1's row itself, inside pass N's invocation, right after saving pass
1311
+ * N's result. That row reaches this client through nothing at all: it is not in
1312
+ * bgTaskQueue (the client never asked for it) and it is not in state.messages
1313
+ * (only a first-page history load maps it in, which happens on mount, project
1314
+ * switch or tab return). So between two worker passes every pass the client
1315
+ * knows about is settled, and the collapsed row renders "Indexed N passes"
1316
+ * with no spinner and no Stop, for a file that is still being read. A user who
1317
+ * believes that then asks questions against a half-indexed file — which is what
1318
+ * this exists to prevent.
1319
+ *
1320
+ * So when a background indexing pass settles, ask the bg queue what is still
1321
+ * unresolved on it and adopt anything unknown as an ordinary BgTaskEntry.
1322
+ * drainBgTaskQueue then treats it exactly like a pass this client dispatched:
1323
+ * same bubble, same `_indexFile` (so it joins the file's collapsed row), same
1324
+ * poll — and that poll settling runs this again, so the chain is followed to
1325
+ * its end. `status`-scoped so the reply carries the live items only, never a
1326
+ * page of finished ones with their bodies.
1327
+ *
1328
+ * Termination: the only trigger is a pass SETTLING, which happens once per
1329
+ * pass. An adopted item that is still running is not re-adopted (its id is
1330
+ * already polled), and when the queue holds nothing unknown the chain stops on
1331
+ * its own. Nothing here is periodic — a timer that re-reads history is the
1332
+ * shape that previously looped fetchHistoryPage after an already-DONE index.
1333
+ */
1334
+ private _adoptingWorkerPasses = false;
1335
+ private _adoptWorkerIndexingPasses(attempt: number): void {
1336
+ var self = this;
1337
+ // One in flight at a time. The query is queue-WIDE, so a second file's pass
1338
+ // settling in the same moment is covered by the query already running.
1339
+ if (this._adoptingWorkerPasses) return;
1340
+ var id = this.host.getIdentity();
1341
+ var platform = id.platform;
1342
+ if (!id.serviceId || (platform !== 'claude' && platform !== 'openai')) return;
1343
+ if (this.isPollingPaused() || !this.host.isViewMounted()) return;
1344
+ var svcId = id.serviceId, owner = id.owner;
1345
+ var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
1346
+ var ask = function (status: 'pending' | 'running') {
1347
+ return Promise.resolve(getChatHistory(
1348
+ { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
1349
+ { limit: WORKER_PASS_ADOPT_LIMIT },
1350
+ )).catch(function () { return null; });
1351
+ };
1352
+ this._adoptingWorkerPasses = true;
1353
+ Promise.all([ask('running'), ask('pending')]).then(function (results: any[]) {
1354
+ self._adoptingWorkerPasses = false;
1355
+ // The chat may have changed under the query; adopting into another
1356
+ // project's session is the cross-project bubble leak all over again.
1357
+ var now = self.host.getIdentity();
1358
+ if (now.serviceId !== svcId || now.platform !== platform) return;
1359
+ if (!self.host.isViewMounted()) return;
1360
+ var adoptedIds: string[] = [];
1361
+ for (var ri = 0; ri < results.length; ri++) {
1362
+ var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
1363
+ for (var i = 0; i < list.length; i++) {
1364
+ if (self._adoptWorkerIndexingItem(list[i], svcId, platform as 'claude' | 'openai')) adoptedIds.push(list[i].id);
1365
+ }
1366
+ }
1367
+ if (adoptedIds.length) {
1368
+ self.drainBgTaskQueue();
1369
+ // Only an item the drain KEPT means the chain is still going. One it
1370
+ // threw straight back out is not progress and must not end the ladder:
1371
+ // the status index is eventually consistent, so a look taken the
1372
+ // instant a pass settles can still report that pass as running, and a
1373
+ // pass belonging to a file the user stopped is dropped on sight. Both
1374
+ // used to read as "found the next pass", which ended the search for a
1375
+ // pass that had not been written yet.
1376
+ if (self._isTrackingAny(adoptedIds)) return;
1377
+ }
1378
+ // Nothing yet. The worker writes pass N+1 a few milliseconds AFTER it
1379
+ // flips pass N to resolved, so a poll that lands in that gap sees an
1380
+ // empty queue for a chain that is very much alive — and with no pass
1381
+ // left to settle, nothing would ever ask again. Look once or twice more
1382
+ // before believing the file is finished.
1383
+ if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) return;
1384
+ setTimeout(function () {
1385
+ var later = self.host.getIdentity();
1386
+ if (later.serviceId !== svcId || later.platform !== platform) return;
1387
+ if (self.isPollingPaused() || !self.host.isViewMounted()) return;
1388
+ self._adoptWorkerIndexingPasses(attempt + 1);
1389
+ }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
1390
+ }, function () { self._adoptingWorkerPasses = false; });
1391
+ }
1392
+
1393
+ /** Any of these ids still queued or still polled, i.e. surviving work. */
1394
+ private _isTrackingAny(ids: string[]): boolean {
1395
+ for (var i = 0; i < ids.length; i++) {
1396
+ if (this.historyItemPolls.has(ids[i])) return true;
1397
+ for (var q = 0; q < this.bgTaskQueue.length; q++) {
1398
+ if (this.bgTaskQueue[q].id === ids[i]) return true;
1399
+ }
1400
+ }
1401
+ return false;
1402
+ }
1403
+
1404
+ /** One live bg-queue item -> a BgTaskEntry, if it is an indexing pass this
1405
+ * client is not already tracking. Returns whether it was adopted. */
1406
+ private _adoptWorkerIndexingItem(item: any, svcId: string, platform: 'claude' | 'openai'): boolean {
1407
+ if (!item || typeof item.id !== 'string' || !item.id) return false;
1408
+ if (item.status !== 'pending' && item.status !== 'running') return false;
1409
+ if (typeof item.poll !== 'function') return false;
1410
+ // Already known: a pass this client dispatched, or one adopted earlier that
1411
+ // has not settled yet. Adopting twice would stack a second poll on one item.
1412
+ if (this.historyItemPolls.has(item.id)) return false;
1413
+ for (var q = 0; q < this.bgTaskQueue.length; q++) {
1414
+ if (this.bgTaskQueue[q].id === item.id) return false;
1415
+ }
1416
+ // Already SETTLED here. The status index is eventually consistent, so the
1417
+ // pass that just finished can still come back as running; re-adopting it
1418
+ // would re-poll a dead item and, worse, read as the chain continuing.
1419
+ for (var m = 0; m < this.state.messages.length; m++) {
1420
+ var msg = this.state.messages[m];
1421
+ if (msg._serverItemId !== item.id) continue;
1422
+ if (!(msg.isPending || msg.isPendingInProcess || msg.isPendingQueued)) return false;
1423
+ }
1424
+ // The bg queue also carries ordinary chats routed onto it (_isOnBgQueue), and
1425
+ // both platforms share one queue name, so check the shape as well: a claude
1426
+ // request body has `messages`, an openai one has `input`. Adopting the other
1427
+ // platform's item would poll it against this platform's url.
1428
+ var body = item.request_body;
1429
+ if (!body || typeof body !== 'object') return false;
1430
+ if (platform === 'claude' ? !Array.isArray((body as any).messages) : !Array.isArray((body as any).input)) return false;
1431
+ var userText = extractLastUserTextFromRequest(body);
1432
+ if (!isIndexingRequestText(userText)) return false;
1433
+ var ref = parseIndexingRequestText(userText);
1434
+ if (!ref || !ref.name) return false;
1435
+ // Only chains the worker drives. A client-driven file is already tracked by
1436
+ // the entry that dispatched it, and adopting a copy of it would hand
1437
+ // maybeResumeIndexing an entry whose resumePass no longer counts that
1438
+ // chain's passes — which is the cap that stops it running forever.
1439
+ if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
1440
+ this.bgTaskQueue.push({
1441
+ serviceId: svcId,
1442
+ platform: platform,
1443
+ id: item.id,
1444
+ filename: ref.name,
1445
+ storagePath: ref.path,
1446
+ mime: ref.mime,
1447
+ size: ref.size,
1448
+ status: item.status === 'running' ? 'running' : 'pending',
1449
+ poll: item.poll,
1450
+ // Drives the "(continuing)" label and marks this as a continuation for
1451
+ // _applyIndexCancellations, which must not read a CONTINUE pass as the
1452
+ // fresh first pass that lifts a stop.
1453
+ resumePass: ref.continued ? 1 : 0,
1454
+ });
1455
+ return true;
1456
+ }
1457
+
1458
+ /** Follow the chain on from a background indexing pass that just settled. */
1459
+ private _followWorkerIndexingChain(filename?: string, mime?: string): void {
1460
+ if (!this._isWorkerDrivenIndexing(filename, mime)) return;
1461
+ this._adoptWorkerIndexingPasses(0);
1462
+ }
1463
+
1264
1464
  /** Best-effort server-side cancel of a bg-queue item that has no bubble (so
1265
1465
  * cancelQueuedMessage, which drives one, has nothing to act on). */
1266
1466
  private _cancelServerItem(serverId: string): void {
@@ -1485,8 +1685,7 @@ export class ChatSession {
1485
1685
  var chatList = history && Array.isArray(history.list) ? history.list : [];
1486
1686
  chatList.forEach(function (item: any) {
1487
1687
  if (isBgIndexingQueue(item.queue_name)) {
1488
- var userText = extractLastUserTextFromRequest(item.request_body);
1489
- if (typeof userText === 'string' && (userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0)) item._isBgTask = true;
1688
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) item._isBgTask = true;
1490
1689
  else item._isOnBgQueue = true;
1491
1690
  }
1492
1691
  });