opencode-qoder-bridge 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +110 -37
  3. package/bin/statusline.mjs +30 -6
  4. package/bin/usage.mjs +6 -5
  5. package/dist/async-utils.d.ts +8 -0
  6. package/dist/async-utils.d.ts.map +1 -0
  7. package/dist/async-utils.js +27 -0
  8. package/dist/async-utils.js.map +1 -0
  9. package/dist/auth.d.ts +2 -1
  10. package/dist/auth.d.ts.map +1 -1
  11. package/dist/auth.js +26 -7
  12. package/dist/auth.js.map +1 -1
  13. package/dist/cost.d.ts +2 -1
  14. package/dist/cost.d.ts.map +1 -1
  15. package/dist/cost.js +163 -63
  16. package/dist/cost.js.map +1 -1
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +5 -2
  19. package/dist/errors.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +152 -37
  23. package/dist/index.js.map +1 -1
  24. package/dist/language-model.d.ts +22 -0
  25. package/dist/language-model.d.ts.map +1 -1
  26. package/dist/language-model.js +931 -183
  27. package/dist/language-model.js.map +1 -1
  28. package/dist/logger.d.ts +1 -0
  29. package/dist/logger.d.ts.map +1 -1
  30. package/dist/logger.js +35 -5
  31. package/dist/logger.js.map +1 -1
  32. package/dist/mcp-bridge.d.ts.map +1 -1
  33. package/dist/mcp-bridge.js +71 -7
  34. package/dist/mcp-bridge.js.map +1 -1
  35. package/dist/models.d.ts +8 -1
  36. package/dist/models.d.ts.map +1 -1
  37. package/dist/models.js +155 -44
  38. package/dist/models.js.map +1 -1
  39. package/dist/prompt-builder.d.ts.map +1 -1
  40. package/dist/prompt-builder.js +241 -43
  41. package/dist/prompt-builder.js.map +1 -1
  42. package/dist/sdk-auth.d.ts +3 -3
  43. package/dist/sdk-auth.d.ts.map +1 -1
  44. package/dist/sdk-auth.js +13 -9
  45. package/dist/sdk-auth.js.map +1 -1
  46. package/dist/sdk-session.d.ts.map +1 -1
  47. package/dist/sdk-session.js +26 -2
  48. package/dist/sdk-session.js.map +1 -1
  49. package/dist/session-store.d.ts +14 -3
  50. package/dist/session-store.d.ts.map +1 -1
  51. package/dist/session-store.js +451 -46
  52. package/dist/session-store.js.map +1 -1
  53. package/dist/state-dir.d.ts.map +1 -1
  54. package/dist/state-dir.js +2 -1
  55. package/dist/state-dir.js.map +1 -1
  56. package/dist/tool-normalizer.d.ts.map +1 -1
  57. package/dist/tool-normalizer.js +11 -2
  58. package/dist/tool-normalizer.js.map +1 -1
  59. package/dist/tui-register.d.ts.map +1 -1
  60. package/dist/tui-register.js +88 -37
  61. package/dist/tui-register.js.map +1 -1
  62. package/dist/tui.d.ts.map +1 -1
  63. package/dist/tui.js +42 -17
  64. package/dist/tui.js.map +1 -1
  65. package/dist/types.d.ts +14 -4
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/usage.d.ts.map +1 -1
  68. package/dist/usage.js +54 -29
  69. package/dist/usage.js.map +1 -1
  70. package/package.json +12 -4
@@ -1,35 +1,303 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
2
4
  import { query } from "@qoder-ai/qoder-agent-sdk";
3
- import { getModel, DEFAULT_MODEL_ID } from "./models.js";
5
+ import { getModel, DEFAULT_MODEL_ID, applyLiveModelUpdates } from "./models.js";
4
6
  import { findQoderCLI } from "./auth.js";
5
7
  import { buildPromptString, buildPromptIterable, latestPrompt, promptHasImage } from "./prompt-builder.js";
6
8
  import { normalizeToolName, normalizeToolInputString } from "./tool-normalizer.js";
7
9
  import { recordTurn } from "./cost.js";
8
- import { ensureQoderSession, getQoderSession } from "./session-store.js";
10
+ import { deleteQoderSession, ensureQoderSession, getQoderSessionForCwd, getQoderSessionResetEpoch, withQoderSessionLease } from "./session-store.js";
9
11
  import { hasQoderCredential, qoderAuth } from "./sdk-auth.js";
10
12
  import { QoderAuthError, QoderSdkResultError } from "./errors.js";
11
- import { debug, describeError } from "./logger.js";
13
+ import { debug, describeError, redactSensitiveText } from "./logger.js";
14
+ const UNSAFE_METADATA_KEYS = new Set(["__proto__", "prototype", "constructor"]);
15
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30 * 60 * 1000;
16
+ const MAX_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000;
17
+ const CLEANUP_GRACE_MS = 5_000;
18
+ const MAX_SEEN_MESSAGE_IDS = 100_000;
19
+ const MAX_TOOL_INPUT_CHARS = 4_000_000;
20
+ const MAX_OUTPUT_CHARS = 8_000_000;
21
+ const MAX_METADATA_NODES = 2_000;
22
+ const MAX_METADATA_STRING = 4_096;
23
+ const MAX_STOP_REASON_LENGTH = 256;
24
+ const QODER_BUILTIN_NAMES = {
25
+ read: "Read",
26
+ write: "Write",
27
+ edit: "Edit",
28
+ delete: "Delete",
29
+ view: "View",
30
+ bash: "Bash",
31
+ glob: "Glob",
32
+ grep: "Grep",
33
+ task: "Agent",
34
+ task_create: "TaskCreate",
35
+ taskcreate: "TaskCreate",
36
+ task_get: "TaskGet",
37
+ taskget: "TaskGet",
38
+ task_update: "TaskUpdate",
39
+ taskupdate: "TaskUpdate",
40
+ task_list: "TaskList",
41
+ tasklist: "TaskList",
42
+ question: "AskUserQuestion",
43
+ ask_user_question: "AskUserQuestion",
44
+ plan_exit: "ExitPlanMode",
45
+ exit_plan_mode: "ExitPlanMode",
46
+ skill: "Skill",
47
+ todo_write: "TodoWrite",
48
+ todowrite: "TodoWrite",
49
+ update_goal: "UpdateGoal",
50
+ updategoal: "UpdateGoal",
51
+ web_fetch: "WebFetch",
52
+ webfetch: "WebFetch",
53
+ web_search: "WebSearch",
54
+ websearch: "WebSearch",
55
+ image_gen: "ImageGen",
56
+ imagegen: "ImageGen",
57
+ image_search: "ImageSearch",
58
+ imagesearch: "ImageSearch",
59
+ notebook_edit: "NotebookEdit",
60
+ notebookedit: "NotebookEdit",
61
+ };
62
+ function toJsonValue(value, depth = 0, budget = { remaining: MAX_METADATA_NODES }) {
63
+ if (depth > 8)
64
+ return undefined;
65
+ if (budget.remaining-- <= 0)
66
+ return undefined;
67
+ if (value === null || typeof value === "boolean")
68
+ return value;
69
+ if (typeof value === "string")
70
+ return value.slice(0, MAX_METADATA_STRING);
71
+ if (typeof value === "number")
72
+ return Number.isFinite(value) ? value : undefined;
73
+ if (Array.isArray(value)) {
74
+ const out = [];
75
+ for (const item of value) {
76
+ const normalized = toJsonValue(item, depth + 1, budget);
77
+ if (normalized !== undefined)
78
+ out.push(normalized);
79
+ if (budget.remaining <= 0)
80
+ break;
81
+ }
82
+ return out;
83
+ }
84
+ if (!isRecord(value))
85
+ return undefined;
86
+ const out = {};
87
+ for (const [key, item] of Object.entries(value)) {
88
+ if (UNSAFE_METADATA_KEYS.has(key))
89
+ continue;
90
+ const normalized = toJsonValue(item, depth + 1, budget);
91
+ if (normalized !== undefined) {
92
+ Object.defineProperty(out, key.slice(0, 256), {
93
+ configurable: true,
94
+ enumerable: true,
95
+ value: normalized,
96
+ writable: true,
97
+ });
98
+ }
99
+ if (budget.remaining <= 0)
100
+ break;
101
+ }
102
+ return out;
103
+ }
104
+ function safeEnqueue(controller, part) {
105
+ try {
106
+ controller.enqueue(part);
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ function safeClose(controller) {
114
+ try {
115
+ controller.close();
116
+ }
117
+ catch {
118
+ // Controller already closed or cancelled
119
+ }
120
+ }
12
121
  function isRecord(v) {
13
122
  return typeof v === "object" && v !== null && !Array.isArray(v);
14
123
  }
15
124
  function makeFinishReason(unified, raw) {
16
- return { unified, raw };
125
+ return {
126
+ unified,
127
+ raw: raw === undefined ? undefined : redactSensitiveText(raw).slice(0, MAX_STOP_REASON_LENGTH),
128
+ };
129
+ }
130
+ function safeStopReason(value) {
131
+ return typeof value === "string"
132
+ ? redactSensitiveText(value).slice(0, MAX_STOP_REASON_LENGTH)
133
+ : null;
17
134
  }
18
135
  function makeUsage(input, output, cacheRead, cacheWrite) {
136
+ const safeInput = finiteNonNegative(input);
137
+ const safeOutput = finiteNonNegative(output);
138
+ const safeCacheRead = Math.min(safeInput, finiteNonNegative(cacheRead));
139
+ const safeCacheWrite = Math.min(safeInput - safeCacheRead, finiteNonNegative(cacheWrite));
19
140
  return {
20
141
  inputTokens: {
21
- total: input,
22
- noCache: Math.max(0, input - cacheRead - cacheWrite),
23
- cacheRead,
24
- cacheWrite,
142
+ total: safeInput,
143
+ noCache: Math.max(0, safeInput - safeCacheRead - safeCacheWrite),
144
+ cacheRead: safeCacheRead,
145
+ cacheWrite: safeCacheWrite,
25
146
  },
26
147
  outputTokens: {
27
- total: output,
28
- text: output,
148
+ total: safeOutput,
149
+ text: safeOutput,
29
150
  reasoning: undefined,
30
151
  },
31
152
  };
32
153
  }
154
+ function finiteNonNegative(value, fallback = 0) {
155
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
156
+ }
157
+ function requestTimeoutMs(value) {
158
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
159
+ return DEFAULT_REQUEST_TIMEOUT_MS;
160
+ return Math.min(MAX_REQUEST_TIMEOUT_MS, Math.max(1, Math.floor(value)));
161
+ }
162
+ function abortError() {
163
+ const error = new Error("Qoder request aborted");
164
+ error.name = "AbortError";
165
+ return error;
166
+ }
167
+ function safePublicError(error) {
168
+ if (error instanceof QoderAuthError || error instanceof QoderSdkResultError)
169
+ return error;
170
+ return new Error(describeError(error) || "Qoder request failed");
171
+ }
172
+ function tokenCount(value) {
173
+ return Math.floor(finiteNonNegative(value));
174
+ }
175
+ function ratio(value) {
176
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
177
+ ? Math.min(1, value)
178
+ : undefined;
179
+ }
180
+ function safeJsonStringify(value) {
181
+ try {
182
+ return JSON.stringify(value);
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
188
+ function messageDedupeKey(message) {
189
+ const uuid = typeof message.uuid === "string" && message.uuid.trim() ? message.uuid : undefined;
190
+ if (!uuid)
191
+ return undefined;
192
+ // Some SDK versions reuse an outer UUID for several stream frames, while
193
+ // replayed frames repeat the complete payload. Hashing the payload keeps
194
+ // the latter idempotent without dropping legitimate same-UUID frames.
195
+ const payload = safeJsonStringify(message) ?? `${message.type ?? ""}:${message.event ?? ""}`;
196
+ return `${uuid}\u0000${createHash("sha256").update(payload).digest("hex")}`;
197
+ }
198
+ function rememberId(seen, id, maxSize) {
199
+ if (seen.has(id))
200
+ return false;
201
+ if (seen.size >= maxSize) {
202
+ const oldest = seen.values().next().value;
203
+ if (typeof oldest === "string")
204
+ seen.delete(oldest);
205
+ }
206
+ seen.add(id);
207
+ return true;
208
+ }
209
+ function isAuthenticationError(value) {
210
+ if (typeof value !== "string")
211
+ return false;
212
+ return /auth|credential|token|unauthori|forbidden/i.test(value);
213
+ }
214
+ function resolveCwd(value) {
215
+ if (typeof value !== "string" || !value.trim())
216
+ return process.cwd();
217
+ try {
218
+ const resolved = resolve(value);
219
+ try {
220
+ return realpathSync(resolved);
221
+ }
222
+ catch {
223
+ return resolved;
224
+ }
225
+ }
226
+ catch {
227
+ return process.cwd();
228
+ }
229
+ }
230
+ function qoderEnvironment(environment) {
231
+ return environment ? { ...process.env, ...environment } : process.env;
232
+ }
233
+ const sessionTails = new Map();
234
+ function waitForTurn(previous, signal) {
235
+ if (!signal)
236
+ return previous.then(() => true, () => true);
237
+ if (signal.aborted)
238
+ return Promise.resolve(false);
239
+ return new Promise((resolveResult) => {
240
+ let settled = false;
241
+ const finish = (ready) => {
242
+ if (settled)
243
+ return;
244
+ settled = true;
245
+ signal.removeEventListener("abort", onAbort);
246
+ resolveResult(ready);
247
+ };
248
+ const onAbort = () => finish(false);
249
+ signal.addEventListener("abort", onAbort, { once: true });
250
+ previous.then(() => finish(true), () => finish(true));
251
+ if (signal.aborted)
252
+ finish(false);
253
+ });
254
+ }
255
+ async function nextWithAbort(iterator, signal) {
256
+ if (signal.aborted)
257
+ return undefined;
258
+ let onAbort;
259
+ const aborted = new Promise((resolveAbort) => {
260
+ onAbort = () => resolveAbort(undefined);
261
+ signal.addEventListener("abort", onAbort, { once: true });
262
+ });
263
+ try {
264
+ return await Promise.race([iterator.next(), aborted]);
265
+ }
266
+ finally {
267
+ signal.removeEventListener("abort", onAbort);
268
+ }
269
+ }
270
+ async function withSessionLock(key, signal, fn) {
271
+ if (!key) {
272
+ if (signal?.aborted)
273
+ return undefined;
274
+ return fn();
275
+ }
276
+ const previous = sessionTails.get(key) ?? Promise.resolve();
277
+ let release;
278
+ const current = new Promise((resolveRelease) => { release = resolveRelease; });
279
+ sessionTails.set(key, current);
280
+ const acquired = await waitForTurn(previous, signal);
281
+ const releaseAndClean = () => {
282
+ release();
283
+ if (sessionTails.get(key) === current)
284
+ sessionTails.delete(key);
285
+ };
286
+ if (!acquired) {
287
+ // Keep the queue closed behind the still-running request, even when this
288
+ // waiter is canceled before it reaches the SDK.
289
+ void previous.then(releaseAndClean, releaseAndClean);
290
+ return undefined;
291
+ }
292
+ try {
293
+ if (signal?.aborted)
294
+ return undefined;
295
+ return await fn();
296
+ }
297
+ finally {
298
+ releaseAndClean();
299
+ }
300
+ }
33
301
  function mapStopReason(stopReason, hasToolCalls) {
34
302
  if (hasToolCalls)
35
303
  return makeFinishReason("tool-calls", stopReason ?? undefined);
@@ -42,9 +310,33 @@ function mapStopReason(stopReason, hasToolCalls) {
42
310
  return makeFinishReason("stop", stopReason ?? undefined);
43
311
  }
44
312
  }
313
+ function trackOpenBlock(state, block) {
314
+ if (!state.openBlocks.some((item) => item.index === block.index))
315
+ state.openBlocks.push(block);
316
+ }
317
+ function untrackOpenBlock(state, index) {
318
+ const position = state.openBlocks.findIndex((item) => item.index === index);
319
+ if (position >= 0)
320
+ state.openBlocks.splice(position, 1);
321
+ }
45
322
  export function isProviderExecutedTool(name, functionToolNames) {
46
323
  return !functionToolNames.has(name);
47
324
  }
325
+ function isProviderOwnedTool(rawName, normalizedName, functionToolNames) {
326
+ // Bridged MCP servers are executed by Qoder. Do not normalize an MCP name
327
+ // into an unrelated OpenCode function and execute the same call twice.
328
+ if (rawName.trim().toLowerCase().startsWith("mcp__"))
329
+ return true;
330
+ return isProviderExecutedTool(normalizedName, functionToolNames);
331
+ }
332
+ function qoderToolNameForHost(rawName) {
333
+ const trimmed = rawName.trim();
334
+ const normalized = normalizeToolName(trimmed);
335
+ return QODER_BUILTIN_NAMES[normalized] ?? trimmed;
336
+ }
337
+ function isProviderOwnedToolName(name) {
338
+ return name.trim().toLowerCase().startsWith("mcp__");
339
+ }
48
340
  export class QoderLanguageModel {
49
341
  specificationVersion = "v3";
50
342
  provider = "qoder";
@@ -56,89 +348,150 @@ export class QoderLanguageModel {
56
348
  this.bridgeOptions = bridgeOptions;
57
349
  }
58
350
  async doGenerate(options) {
351
+ if (options.abortSignal?.aborted)
352
+ throw abortError();
59
353
  const { stream } = await this.doStream(options);
60
354
  const reader = stream.getReader();
61
355
  let text = "";
62
356
  let reasoning = "";
63
357
  let finishReason = makeFinishReason("stop");
64
358
  let usage = makeUsage(0, 0, 0, 0);
359
+ let providerMetadata;
360
+ let sawFinish = false;
65
361
  const toolCalls = [];
66
- for (;;) {
67
- const { value, done } = await reader.read();
68
- if (done)
69
- break;
70
- switch (value.type) {
71
- case "text-delta":
72
- text += value.delta;
362
+ try {
363
+ for (;;) {
364
+ const { value, done } = await reader.read();
365
+ if (done)
73
366
  break;
74
- case "reasoning-delta":
75
- reasoning += value.delta;
76
- break;
77
- case "tool-call":
78
- toolCalls.push({
79
- type: "tool-call",
80
- toolCallId: value.toolCallId,
81
- toolName: value.toolName,
82
- input: value.input,
83
- });
84
- break;
85
- case "finish":
86
- finishReason = value.finishReason;
87
- usage = value.usage;
88
- break;
89
- case "error":
90
- throw value.error instanceof Error ? value.error : new Error(String(value.error));
367
+ switch (value.type) {
368
+ case "text-delta":
369
+ text += value.delta;
370
+ break;
371
+ case "reasoning-delta":
372
+ reasoning += value.delta;
373
+ break;
374
+ case "tool-call":
375
+ toolCalls.push({
376
+ type: "tool-call",
377
+ toolCallId: value.toolCallId,
378
+ toolName: value.toolName,
379
+ input: value.input,
380
+ });
381
+ break;
382
+ case "finish":
383
+ sawFinish = true;
384
+ finishReason = value.finishReason;
385
+ usage = value.usage;
386
+ providerMetadata = value.providerMetadata;
387
+ break;
388
+ case "error":
389
+ throw safePublicError(value.error);
390
+ }
91
391
  }
92
392
  }
393
+ catch (error) {
394
+ try {
395
+ await reader.cancel();
396
+ }
397
+ catch { /* best-effort stream cleanup */ }
398
+ throw error;
399
+ }
400
+ finally {
401
+ try {
402
+ reader.releaseLock();
403
+ }
404
+ catch { /* reader may already be released */ }
405
+ }
406
+ if (!sawFinish) {
407
+ if (options.abortSignal?.aborted)
408
+ throw abortError();
409
+ throw new QoderSdkResultError("incomplete_stream", "Qoder stream closed before sending a finish message");
410
+ }
93
411
  const content = [];
94
412
  if (reasoning)
95
413
  content.push({ type: "reasoning", text: reasoning });
96
414
  if (text)
97
415
  content.push({ type: "text", text });
98
416
  content.push(...toolCalls);
99
- return { content, finishReason, usage, warnings: [] };
417
+ return { content, finishReason, usage, providerMetadata, warnings: [] };
100
418
  }
101
419
  async doStream(options) {
102
420
  const cli = findQoderCLI();
103
- if (!hasQoderCredential()) {
421
+ const childEnvironment = qoderEnvironment(this.bridgeOptions.env);
422
+ if (!hasQoderCredential(childEnvironment)) {
104
423
  throw new QoderAuthError("No Qoder credentials found. Run `qoder login` or set QODER_PERSONAL_ACCESS_TOKEN.");
105
424
  }
106
- const resolved = getModel(this.modelId) ?? getModel(DEFAULT_MODEL_ID);
107
- if (!getModel(this.modelId)) {
108
- debug(`Unknown model id "${this.modelId}"; falling back to "${resolved.id}"`);
425
+ const resolved = getModel(this.modelId);
426
+ const model = resolved ?? getModel(DEFAULT_MODEL_ID);
427
+ if (!resolved) {
428
+ // Use the default catalog entry only for conservative prompt limits.
429
+ // Preserve the requested ID on the SDK call so an unknown model is not
430
+ // silently replaced by a different model.
431
+ debug(`Unknown model id "${this.modelId}"; forwarding it with default prompt limits`);
109
432
  }
110
- const model = resolved;
111
433
  const sessionKey = this.bridgeOptions.sessionKey ?? this.bridgeOptions.sessionId;
112
- const persisted = this.bridgeOptions.sessionPersistence && sessionKey
113
- ? await getQoderSession(sessionKey)
114
- : null;
115
- const sessionId = this.bridgeOptions.sessionId ?? persisted?.qoderSessionId ?? randomUUID();
116
- const shouldResume = Boolean(this.bridgeOptions.sessionId || persisted);
117
- debug(`doStream model=${model.id} sessionId=${sessionId} resume=${shouldResume}`);
118
- const promptMessages = options.prompt;
119
- const promptInput = shouldResume
120
- ? latestPrompt(promptMessages)
121
- : promptMessages;
122
- const prompt = promptHasImage(promptInput)
123
- ? buildPromptIterable(promptInput, model.limit.context, sessionId)
124
- : buildPromptString(promptInput, model.limit.context);
434
+ const cwd = resolveCwd(this.bridgeOptions.cwd);
125
435
  const functionToolNames = new Set((options.tools ?? [])
126
436
  .filter((t) => t.type === "function")
127
437
  .map((t) => normalizeToolName(t.name)));
438
+ const hostToolNames = (options.tools ?? [])
439
+ .filter((tool) => tool.type === "function")
440
+ // MCP tools configured through mcpServers are executed by Qoder. They
441
+ // are provider-owned and must not be added to the native denylist.
442
+ .filter((tool) => !isProviderOwnedToolName(tool.name))
443
+ .map((tool) => qoderToolNameForHost(tool.name));
128
444
  const abortController = new AbortController();
129
445
  let qoderQuery = null;
130
- let cleaned = false;
131
446
  let externallyAborted = false;
447
+ let timedOut = false;
448
+ let requestTimer;
449
+ let cleanupPromise;
450
+ const timeoutMs = requestTimeoutMs(this.bridgeOptions.timeoutMs);
451
+ const timeoutError = () => new QoderSdkResultError("timeout", `Qoder request exceeded ${timeoutMs}ms`);
132
452
  const cleanup = () => {
133
- if (cleaned)
134
- return;
135
- cleaned = true;
136
- abortController.abort();
137
- void qoderQuery?.return(undefined).catch(() => { });
453
+ if (cleanupPromise)
454
+ return cleanupPromise;
455
+ if (requestTimer)
456
+ clearTimeout(requestTimer);
457
+ if (options.abortSignal) {
458
+ try {
459
+ options.abortSignal.removeEventListener("abort", markExternal);
460
+ }
461
+ catch { /* ignore */ }
462
+ }
463
+ try {
464
+ abortController.abort();
465
+ }
466
+ catch { /* best-effort cancellation */ }
467
+ const activeQuery = qoderQuery;
468
+ cleanupPromise = (async () => {
469
+ if (!activeQuery)
470
+ return;
471
+ let graceTimer;
472
+ try {
473
+ await Promise.race([
474
+ activeQuery.return(undefined).then(() => undefined, () => undefined),
475
+ new Promise((resolveCleanup) => {
476
+ graceTimer = setTimeout(resolveCleanup, CLEANUP_GRACE_MS);
477
+ if (typeof graceTimer.unref === "function")
478
+ graceTimer.unref();
479
+ }),
480
+ ]);
481
+ }
482
+ catch {
483
+ // A transport may reject/throw while it is being torn down.
484
+ }
485
+ finally {
486
+ if (graceTimer)
487
+ clearTimeout(graceTimer);
488
+ }
489
+ })();
490
+ return cleanupPromise;
138
491
  };
139
492
  const markExternal = () => {
140
493
  externallyAborted = true;
141
- cleanup();
494
+ void cleanup();
142
495
  };
143
496
  if (options.abortSignal) {
144
497
  if (options.abortSignal.aborted)
@@ -146,7 +499,6 @@ export class QoderLanguageModel {
146
499
  else
147
500
  options.abortSignal.addEventListener("abort", markExternal, { once: true });
148
501
  }
149
- const qoderOptions = this.buildQueryOptions(cli, sessionId, abortController, shouldResume);
150
502
  const stream = new ReadableStream({
151
503
  cancel: markExternal,
152
504
  start: async (controller) => {
@@ -157,6 +509,7 @@ export class QoderLanguageModel {
157
509
  activeText: new Set(),
158
510
  activeReasoning: new Set(),
159
511
  toolBlocks: new Map(),
512
+ openBlocks: [],
160
513
  sawStreamText: false,
161
514
  sawStreamTool: false,
162
515
  sawStreamReasoning: false,
@@ -164,82 +517,235 @@ export class QoderLanguageModel {
164
517
  pendingToolCalls: new Map(),
165
518
  lastStopReason: null,
166
519
  blockCounter: 0,
520
+ outputChars: 0,
167
521
  finished: false,
522
+ resultReceived: false,
523
+ seenToolCallIds: new Set(),
524
+ seenMessageIds: new Set(),
525
+ artifacts: [],
526
+ planMode: undefined,
527
+ skillEvolution: undefined,
168
528
  };
169
- controller.enqueue({ type: "stream-start", warnings: [] });
529
+ safeEnqueue(controller, { type: "stream-start", warnings: [] });
170
530
  try {
171
- qoderQuery = query({ prompt, options: qoderOptions });
172
- if (this.bridgeOptions.sessionPersistence && sessionKey) {
173
- await ensureQoderSession(sessionKey, sessionId, process.cwd());
531
+ if (externallyAborted) {
532
+ safeClose(controller);
533
+ return;
174
534
  }
175
- for await (const msg of qoderQuery) {
176
- handleSdkMessage(msg, state);
177
- if (state.finished)
178
- break;
535
+ requestTimer = setTimeout(() => {
536
+ timedOut = true;
537
+ debug(`Qoder request timed out after ${timeoutMs}ms`);
538
+ void cleanup();
539
+ }, timeoutMs);
540
+ if (typeof requestTimer.unref === "function")
541
+ requestTimer.unref();
542
+ const lockKey = sessionKey ? `${cwd}\u0000${sessionKey}` : undefined;
543
+ const leaseKey = [this.bridgeOptions.sessionId, sessionKey]
544
+ .find((value) => typeof value === "string" && value.trim().length > 0);
545
+ const runTurn = () => withSessionLock(lockKey, abortController.signal, async () => {
546
+ let persisted = null;
547
+ let resetEpoch;
548
+ try {
549
+ if (externallyAborted)
550
+ return;
551
+ if (this.bridgeOptions.sessionPersistence && sessionKey) {
552
+ resetEpoch = await getQoderSessionResetEpoch();
553
+ try {
554
+ persisted = await getQoderSessionForCwd(sessionKey, cwd);
555
+ }
556
+ catch (error) {
557
+ // Persistence contention or corruption must not make a fresh
558
+ // chat turn unavailable; the mapping can be repaired later.
559
+ debug("Could not read Qoder session mapping; starting fresh:", describeError(error));
560
+ }
561
+ }
562
+ const sessionId = this.bridgeOptions.sessionId ?? persisted?.qoderSessionId ?? randomUUID();
563
+ const shouldResume = Boolean(this.bridgeOptions.sessionId || persisted);
564
+ debug(`doStream model=${model.id} sessionId=${sessionId} cwd=${cwd} resume=${shouldResume}`);
565
+ const promptMessages = options.prompt;
566
+ const promptInput = shouldResume ? latestPrompt(promptMessages) : promptMessages;
567
+ const prompt = promptHasImage(promptInput)
568
+ ? buildPromptIterable(promptInput, model.limit.context, sessionId)
569
+ : buildPromptString(promptInput, model.limit.context);
570
+ const qoderOptions = this.buildQueryOptions(cli, sessionId, abortController, shouldResume, this.modelId, cwd, hostToolNames, () => {
571
+ state.authExpired = true;
572
+ try {
573
+ abortController.abort();
574
+ }
575
+ catch { /* best-effort cancellation */ }
576
+ });
577
+ if (externallyAborted || abortController.signal.aborted)
578
+ return;
579
+ qoderQuery = query({ prompt, options: qoderOptions });
580
+ const activeQuery = qoderQuery;
581
+ if (externallyAborted || abortController.signal.aborted)
582
+ return;
583
+ const iterator = activeQuery[Symbol.asyncIterator]();
584
+ for (;;) {
585
+ const next = await nextWithAbort(iterator, abortController.signal);
586
+ if (!next || next.done)
587
+ break;
588
+ if (externallyAborted || timedOut)
589
+ break;
590
+ handleSdkMessage(next.value, state);
591
+ if (state.authExpired || state.finished)
592
+ break;
593
+ }
594
+ if (state.authExpired && !state.finished) {
595
+ throw new QoderAuthError("Qoder authentication expired during the request. Re-authenticate with `qoder login` or refresh QODER_PERSONAL_ACCESS_TOKEN.");
596
+ }
597
+ if (timedOut)
598
+ throw timeoutError();
599
+ if (!externallyAborted && !state.resultReceived && !state.finished) {
600
+ throw new QoderSdkResultError("incomplete_stream", "Qoder ended the stream before sending a result message");
601
+ }
602
+ if (!externallyAborted && !state.failed && !abortController.signal.aborted && this.bridgeOptions.sessionPersistence && sessionKey) {
603
+ try {
604
+ await ensureQoderSession(sessionKey, sessionId, cwd, resetEpoch);
605
+ }
606
+ catch (error) {
607
+ debug("Could not persist Qoder session mapping:", describeError(error));
608
+ }
609
+ }
610
+ if (state.invalidSession && persisted && sessionKey) {
611
+ try {
612
+ await deleteQoderSession(sessionKey, cwd);
613
+ }
614
+ catch (error) {
615
+ debug("Could not clear invalid Qoder session mapping:", describeError(error));
616
+ }
617
+ }
618
+ }
619
+ catch (error) {
620
+ // A transport/query failure may arrive before Qoder can emit a
621
+ // structured result. Clear a persisted session when its error
622
+ // still identifies the resume target as invalid, so the next
623
+ // request can recover with a fresh Qoder session.
624
+ if (persisted && sessionKey && this.bridgeOptions.sessionPersistence && isLikelyInvalidSessionError(error)) {
625
+ try {
626
+ await deleteQoderSession(sessionKey, cwd);
627
+ }
628
+ catch (deleteError) {
629
+ debug("Could not clear invalid Qoder session mapping after transport failure:", describeError(deleteError));
630
+ }
631
+ }
632
+ throw error;
633
+ }
634
+ finally {
635
+ await cleanup();
636
+ }
637
+ });
638
+ if (leaseKey) {
639
+ await withQoderSessionLease(leaseKey, cwd, abortController.signal, runTurn);
179
640
  }
180
- if (!state.finished) {
641
+ else {
642
+ await runTurn();
643
+ }
644
+ if (!state.finished && !externallyAborted) {
645
+ if (timedOut)
646
+ throw timeoutError();
647
+ if (!state.resultReceived) {
648
+ throw new QoderSdkResultError("incomplete_stream", "Qoder ended the stream before sending a result message");
649
+ }
181
650
  emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("stop"), undefined);
182
651
  }
183
- cleanup();
184
- controller.close();
652
+ await cleanup();
653
+ safeClose(controller);
185
654
  }
186
655
  catch (err) {
187
- cleanup();
188
- if (externallyAborted && !state.finished) {
656
+ await cleanup();
657
+ if (externallyAborted) {
189
658
  debug("Stream aborted by caller; closing without finish");
190
- controller.close();
659
+ safeClose(controller);
191
660
  return;
192
661
  }
193
662
  debug("Stream failed:", describeError(err));
194
663
  if (!state.finished) {
195
- controller.enqueue({ type: "error", error: err instanceof Error ? err : new Error(String(err)) });
664
+ const streamError = state.authExpired
665
+ ? new QoderAuthError("Qoder authentication expired during the request. Re-authenticate with `qoder login` or refresh QODER_PERSONAL_ACCESS_TOKEN.")
666
+ : timedOut ? timeoutError()
667
+ : safePublicError(err);
668
+ closeOpenBlocks(state);
669
+ safeEnqueue(controller, { type: "error", error: streamError });
196
670
  emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error"), undefined);
197
671
  }
198
- controller.close();
672
+ safeClose(controller);
199
673
  }
200
674
  },
201
675
  });
202
676
  return { stream };
203
677
  }
204
- buildQueryOptions(cli, sessionId, abortController, shouldResume) {
678
+ buildQueryOptions(cli, sessionId, abortController, shouldResume, modelId = this.modelId, cwd = resolveCwd(this.bridgeOptions.cwd), hostToolNames = [], onAuthExpired) {
205
679
  const sessionKey = this.bridgeOptions.sessionKey ?? this.bridgeOptions.sessionId;
206
680
  const permissionMode = this.bridgeOptions.permissionMode ?? "default";
207
681
  const opts = {
208
- auth: qoderAuth(),
209
- model: this.modelId,
682
+ auth: qoderAuth(qoderEnvironment(this.bridgeOptions.env)),
683
+ model: modelId,
210
684
  allowDangerouslySkipPermissions: this.bridgeOptions.allowDangerouslySkipPermissions
211
685
  ?? (permissionMode === "bypassPermissions" ? true : undefined),
212
686
  permissionMode,
213
687
  includePartialMessages: true,
214
688
  sessionId,
215
- cwd: process.cwd(),
689
+ cwd,
216
690
  abortController,
217
691
  };
692
+ if (onAuthExpired)
693
+ opts.onAuthExpired = onAuthExpired;
218
694
  if (cli)
219
695
  opts.pathToQoderCLIExecutable = cli;
220
696
  if (this.bridgeOptions.env)
221
- opts.env = this.bridgeOptions.env;
222
- if ((this.bridgeOptions.sessionId || (this.bridgeOptions.sessionPersistence && sessionKey)) && shouldResume) {
697
+ opts.env = qoderEnvironment(this.bridgeOptions.env);
698
+ if (this.bridgeOptions.planMode !== undefined)
699
+ opts.planMode = this.bridgeOptions.planMode;
700
+ const proxy = this.bridgeOptions.proxy ?? process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY;
701
+ if (proxy)
702
+ opts.proxy = proxy;
703
+ if (this.bridgeOptions.evolution)
704
+ opts.evolution = this.bridgeOptions.evolution;
705
+ const persistSession = Boolean(this.bridgeOptions.sessionId || (this.bridgeOptions.sessionPersistence && sessionKey));
706
+ opts.persistSession = persistSession;
707
+ if (persistSession && shouldResume)
223
708
  opts.resume = sessionId;
224
- opts.persistSession = true;
225
- }
226
709
  if (this.bridgeOptions.allowedTools)
227
710
  opts.allowedTools = this.bridgeOptions.allowedTools;
228
- if (this.bridgeOptions.disallowedTools)
229
- opts.disallowedTools = this.bridgeOptions.disallowedTools;
711
+ const disallowedTools = [
712
+ ...(this.bridgeOptions.disallowedTools ?? []),
713
+ ...hostToolNames
714
+ .filter((name) => !isProviderOwnedToolName(name))
715
+ .map(qoderToolNameForHost),
716
+ ]
717
+ .filter((name, index, all) => typeof name === "string" && name.trim() && all.indexOf(name) === index);
718
+ if (disallowedTools.length > 0)
719
+ opts.disallowedTools = disallowedTools;
230
720
  const mcpServers = this.bridgeOptions.mcpServers;
231
721
  if (mcpServers && Object.keys(mcpServers).length > 0) {
232
722
  opts.mcpServers = mcpServers;
233
723
  }
234
724
  if (this.bridgeOptions.extraArgs && Object.keys(this.bridgeOptions.extraArgs).length > 0) {
235
- opts.extraArgs = this.bridgeOptions.extraArgs;
725
+ opts.extraArgs = Object.fromEntries(Object.entries(this.bridgeOptions.extraArgs).map(([key, value]) => [key.replace(/^--/, ""), value]));
236
726
  }
237
727
  return opts;
238
728
  }
239
729
  }
240
730
  export function handleSdkMessage(m, state) {
241
- const type = m.type;
731
+ if (state.finished || !isRecord(m))
732
+ return;
733
+ const messageId = messageDedupeKey(m);
734
+ if (messageId) {
735
+ const seenMessageIds = state.seenMessageIds ??= new Set();
736
+ if (!rememberId(seenMessageIds, messageId, MAX_SEEN_MESSAGE_IDS))
737
+ return;
738
+ }
739
+ const type = typeof m.type === "string" ? m.type : "";
740
+ if (!type.trim()) {
741
+ failStream(state, "malformed_stream", "Qoder sent a message without a type");
742
+ return;
743
+ }
242
744
  if (type === "stream_event") {
745
+ if (!isRecord(m.event)) {
746
+ failStream(state, "malformed_stream", "Qoder sent a stream message without an event");
747
+ return;
748
+ }
243
749
  handleStreamEvent(m.event, state);
244
750
  }
245
751
  else if (type === "assistant") {
@@ -248,151 +754,333 @@ export function handleSdkMessage(m, state) {
248
754
  else if (type === "result") {
249
755
  handleResult(m, state);
250
756
  }
757
+ else if (type === "system") {
758
+ handleSystem(m, state);
759
+ }
760
+ }
761
+ function handleSystem(m, state) {
762
+ const subtype = typeof m.subtype === "string" ? m.subtype : "";
763
+ if (subtype === "plan_mode_changed" && isRecord(m.plan_mode)) {
764
+ if (typeof m.plan_mode.active !== "boolean")
765
+ return;
766
+ state.planMode = m.plan_mode;
767
+ debug(`Plan mode changed: active=${state.planMode.active}`);
768
+ }
769
+ else if (subtype === "available_models_update" && Array.isArray(m.models)) {
770
+ debug(`Received live available_models_update with ${m.models.length} models`);
771
+ applyLiveModelUpdates(m.models);
772
+ }
773
+ else if (subtype === "artifacts_update" && Array.isArray(m.artifacts)) {
774
+ const incoming = m.artifacts.slice(0, 1_000);
775
+ for (const artifact of incoming) {
776
+ if (!isRecord(artifact))
777
+ continue;
778
+ const safeArtifact = toJsonValue(artifact);
779
+ if (!isRecord(safeArtifact))
780
+ continue;
781
+ const path = typeof safeArtifact.path === "string" ? safeArtifact.path : "";
782
+ const index = path ? state.artifacts.findIndex((item) => item.path === path) : -1;
783
+ if (index >= 0)
784
+ state.artifacts[index] = safeArtifact;
785
+ else if (state.artifacts.length < 1000)
786
+ state.artifacts.push(safeArtifact);
787
+ }
788
+ debug(`Artifacts updated: ${incoming.length} artifact(s)`);
789
+ }
790
+ else if (subtype === "skill_evolution" && isRecord(m.result)) {
791
+ state.skillEvolution = m.result;
792
+ debug(`Skill evolution result: status=${m.result.status}`);
793
+ }
251
794
  }
252
795
  function handleStreamEvent(ev, state) {
253
- if (!ev)
796
+ if (!isRecord(ev)) {
797
+ failStream(state, "malformed_stream", "Qoder sent a stream event that was not an object");
254
798
  return;
799
+ }
255
800
  const { controller } = state;
256
- const evType = ev.type;
257
- const idx = typeof ev.index === "number" ? ev.index : 0;
801
+ const evType = typeof ev.type === "string" ? ev.type : "";
802
+ if (!evType.trim()) {
803
+ failStream(state, "malformed_stream", "Qoder sent a stream event without a type");
804
+ return;
805
+ }
806
+ const isContentBlockEvent = evType === "content_block_start"
807
+ || evType === "content_block_delta"
808
+ || evType === "content_block_stop";
809
+ const idx = typeof ev.index === "number" && Number.isInteger(ev.index) && ev.index >= 0 && ev.index < 100_000
810
+ ? ev.index
811
+ : -1;
812
+ if (isContentBlockEvent && idx < 0) {
813
+ failStream(state, "malformed_stream", "Qoder sent a content block event without a valid index");
814
+ return;
815
+ }
816
+ if (evType === "content_block_start" && !isRecord(ev.content_block)) {
817
+ failStream(state, "malformed_stream", "Qoder sent a content block start without a block");
818
+ return;
819
+ }
258
820
  if (evType === "content_block_start" && isRecord(ev.content_block)) {
821
+ if (state.toolBlocks.has(idx)) {
822
+ const existing = state.toolBlocks.get(idx);
823
+ if (existing && ev.content_block.id === existing.id)
824
+ return;
825
+ failStream(state, "malformed_stream", "Qoder started a different tool block at an open index");
826
+ return;
827
+ }
828
+ if (state.activeReasoning.has(idx) || state.activeText.has(idx)) {
829
+ failStream(state, "malformed_stream", "Qoder started a content block that was already open");
830
+ return;
831
+ }
259
832
  const block = ev.content_block;
260
- const blockType = block.type;
261
- if (blockType === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
833
+ const blockType = typeof block.type === "string" ? block.type : "";
834
+ if (blockType === "tool_use" && typeof block.id === "string" && block.id.trim() && typeof block.name === "string" && block.name.trim()) {
835
+ const seenToolCallIds = state.seenToolCallIds ??= new Set();
836
+ if (!rememberId(seenToolCallIds, block.id, MAX_SEEN_MESSAGE_IDS) || state.toolBlocks.has(idx))
837
+ return;
262
838
  state.sawStreamTool = true;
263
839
  const name = normalizeToolName(block.name);
264
- const providerExecuted = isProviderExecutedTool(name, state.functionToolNames);
265
- state.toolBlocks.set(idx, { id: block.id, name, input: "", providerExecuted });
840
+ const providerExecuted = isProviderOwnedTool(block.name, name, state.functionToolNames);
841
+ const hasInput = Object.hasOwn(block, "input");
842
+ const initialInput = hasInput ? safeJsonStringify(block.input) : "";
843
+ if (hasInput && (initialInput === undefined || initialInput.length > MAX_TOOL_INPUT_CHARS)) {
844
+ failStream(state, "malformed_tool_input", `Qoder sent unserializable input for tool ${name}`);
845
+ return;
846
+ }
847
+ state.toolBlocks.set(idx, { id: block.id, name, input: initialInput ?? "", providerExecuted, hasInput });
848
+ trackOpenBlock(state, { kind: "tool", index: idx, id: block.id, providerExecuted });
266
849
  if (!providerExecuted) {
267
- controller.enqueue({ type: "tool-input-start", id: block.id, toolName: name });
850
+ safeEnqueue(controller, { type: "tool-input-start", id: block.id, toolName: name });
268
851
  }
269
852
  }
853
+ else if (blockType === "tool_use") {
854
+ failStream(state, "malformed_tool_call", "Qoder sent a tool call without a valid id or name");
855
+ }
270
856
  else if (blockType === "thinking") {
271
857
  state.activeReasoning.add(idx);
272
- controller.enqueue({ type: "reasoning-start", id: String(idx) });
858
+ trackOpenBlock(state, { kind: "reasoning", index: idx });
859
+ safeEnqueue(controller, { type: "reasoning-start", id: String(idx) });
273
860
  }
274
861
  else if (blockType === "text") {
275
862
  state.activeText.add(idx);
276
- controller.enqueue({ type: "text-start", id: String(idx) });
863
+ trackOpenBlock(state, { kind: "text", index: idx });
864
+ safeEnqueue(controller, { type: "text-start", id: String(idx) });
865
+ }
866
+ else {
867
+ failStream(state, "malformed_stream", `Qoder sent an unsupported content block type: ${blockType || "missing"}`);
277
868
  }
278
869
  return;
279
870
  }
280
871
  if (evType === "content_block_delta" && isRecord(ev.delta)) {
281
872
  const delta = ev.delta;
282
873
  const deltaType = delta.type;
283
- if (deltaType === "thinking_delta" && typeof delta.thinking === "string" && delta.thinking) {
874
+ if (deltaType === "thinking_delta" && typeof delta.thinking === "string") {
284
875
  state.sawStreamReasoning = true;
285
876
  if (!state.activeReasoning.has(idx)) {
286
877
  state.activeReasoning.add(idx);
287
- controller.enqueue({ type: "reasoning-start", id: String(idx) });
878
+ trackOpenBlock(state, { kind: "reasoning", index: idx });
879
+ safeEnqueue(controller, { type: "reasoning-start", id: String(idx) });
880
+ }
881
+ if (delta.thinking && appendOutput(state, delta.thinking)) {
882
+ safeEnqueue(controller, { type: "reasoning-delta", id: String(idx), delta: delta.thinking });
288
883
  }
289
- controller.enqueue({ type: "reasoning-delta", id: String(idx), delta: delta.thinking });
290
884
  }
291
- else if (deltaType === "text_delta" && typeof delta.text === "string" && delta.text) {
885
+ else if (deltaType === "text_delta" && typeof delta.text === "string") {
292
886
  state.sawStreamText = true;
293
887
  if (!state.activeText.has(idx)) {
294
888
  state.activeText.add(idx);
295
- controller.enqueue({ type: "text-start", id: String(idx) });
889
+ trackOpenBlock(state, { kind: "text", index: idx });
890
+ safeEnqueue(controller, { type: "text-start", id: String(idx) });
891
+ }
892
+ if (delta.text && appendOutput(state, delta.text)) {
893
+ safeEnqueue(controller, { type: "text-delta", id: String(idx), delta: delta.text });
296
894
  }
297
- controller.enqueue({ type: "text-delta", id: String(idx), delta: delta.text });
298
895
  }
299
896
  else if (deltaType === "input_json_delta" && typeof delta.partial_json === "string") {
300
897
  const tb = state.toolBlocks.get(idx);
301
- if (tb) {
302
- tb.input += delta.partial_json;
303
- if (!tb.providerExecuted) {
304
- controller.enqueue({ type: "tool-input-delta", id: tb.id, delta: delta.partial_json });
305
- }
898
+ if (!tb) {
899
+ failStream(state, "malformed_tool_input", "Qoder sent tool input for a block that was not started");
900
+ return;
306
901
  }
902
+ if (tb.input.length + delta.partial_json.length > MAX_TOOL_INPUT_CHARS) {
903
+ failStream(state, "tool_input_too_large", "Qoder sent a tool input larger than the bridge limit");
904
+ return;
905
+ }
906
+ tb.hasInput = true;
907
+ tb.input += delta.partial_json;
908
+ if (!tb.providerExecuted) {
909
+ safeEnqueue(controller, { type: "tool-input-delta", id: tb.id, delta: delta.partial_json });
910
+ }
911
+ }
912
+ else if (deltaType === "input_json_delta") {
913
+ failStream(state, "malformed_tool_input", "Qoder sent a tool input delta without JSON text");
307
914
  }
915
+ else {
916
+ failStream(state, "malformed_stream", "Qoder sent an unsupported content block delta");
917
+ }
918
+ return;
919
+ }
920
+ if (evType === "content_block_delta") {
921
+ failStream(state, "malformed_stream", "Qoder sent a content block delta without a delta object");
308
922
  return;
309
923
  }
310
924
  if (evType === "content_block_stop") {
311
925
  const tb = state.toolBlocks.get(idx);
312
926
  if (tb) {
313
- if (!tb.providerExecuted) {
314
- const input = normalizeToolInputString(tb.name, tb.input);
315
- controller.enqueue({ type: "tool-input-end", id: tb.id });
316
- controller.enqueue({ type: "tool-call", toolCallId: tb.id, toolName: tb.name, input });
927
+ if (!tb.hasInput) {
928
+ failStream(state, "malformed_tool_input", `Qoder closed tool ${tb.name} without input JSON`);
929
+ return;
930
+ }
931
+ if (!state.pendingToolCalls.has(tb.id) && !tb.providerExecuted) {
932
+ const input = normalizedToolInput(tb.name, tb.input);
933
+ if (input === null) {
934
+ failStream(state, "invalid_tool_input", `Qoder sent invalid JSON for tool ${tb.name}`);
935
+ return;
936
+ }
937
+ safeEnqueue(controller, { type: "tool-input-end", id: tb.id });
938
+ safeEnqueue(controller, { type: "tool-call", toolCallId: tb.id, toolName: tb.name, input });
317
939
  state.emittedToolCall = true;
318
940
  }
319
- state.pendingToolCalls.set(tb.id, { name: tb.name, providerExecuted: tb.providerExecuted });
941
+ if (!state.pendingToolCalls.has(tb.id)) {
942
+ state.pendingToolCalls.set(tb.id, { name: tb.name, providerExecuted: tb.providerExecuted });
943
+ }
320
944
  state.toolBlocks.delete(idx);
945
+ untrackOpenBlock(state, idx);
321
946
  }
322
947
  else if (state.activeReasoning.has(idx)) {
323
- controller.enqueue({ type: "reasoning-end", id: String(idx) });
948
+ safeEnqueue(controller, { type: "reasoning-end", id: String(idx) });
324
949
  state.activeReasoning.delete(idx);
950
+ untrackOpenBlock(state, idx);
325
951
  }
326
952
  else if (state.activeText.has(idx)) {
327
- controller.enqueue({ type: "text-end", id: String(idx) });
953
+ safeEnqueue(controller, { type: "text-end", id: String(idx) });
328
954
  state.activeText.delete(idx);
955
+ untrackOpenBlock(state, idx);
956
+ }
957
+ else {
958
+ failStream(state, "malformed_stream", "Qoder stopped a content block that was not started");
329
959
  }
330
960
  return;
331
961
  }
332
962
  if (evType === "message_delta" && isRecord(ev.delta) && typeof ev.delta.stop_reason === "string") {
333
- state.lastStopReason = ev.delta.stop_reason;
963
+ state.lastStopReason = safeStopReason(ev.delta.stop_reason);
334
964
  }
335
965
  }
336
966
  function handleAssistant(m, state) {
967
+ if (isAuthenticationError(m.error)) {
968
+ state.authExpired = true;
969
+ return;
970
+ }
337
971
  const message = m.message;
972
+ if (Object.hasOwn(message ?? {}, "stop_reason"))
973
+ state.lastStopReason = safeStopReason(message?.stop_reason);
338
974
  const content = message?.content;
339
- if (!Array.isArray(content))
975
+ if (!Array.isArray(content)) {
976
+ failStream(state, "malformed_stream", "Qoder sent an assistant message without content");
340
977
  return;
978
+ }
341
979
  const { controller } = state;
342
980
  for (const raw of content) {
343
- if (!isRecord(raw))
344
- continue;
981
+ if (!isRecord(raw)) {
982
+ failStream(state, "malformed_stream", "Qoder sent an assistant content block that was not an object");
983
+ break;
984
+ }
345
985
  const blockType = raw.type;
346
986
  if (blockType === "text" && typeof raw.text === "string" && raw.text && !state.sawStreamText) {
987
+ if (!appendOutput(state, raw.text))
988
+ break;
347
989
  const id = String(state.blockCounter++);
348
- controller.enqueue({ type: "text-start", id });
349
- controller.enqueue({ type: "text-delta", id, delta: raw.text });
350
- controller.enqueue({ type: "text-end", id });
990
+ safeEnqueue(controller, { type: "text-start", id });
991
+ safeEnqueue(controller, { type: "text-delta", id, delta: raw.text });
992
+ safeEnqueue(controller, { type: "text-end", id });
351
993
  }
352
994
  else if (blockType === "thinking" && typeof raw.thinking === "string" && raw.thinking && !state.sawStreamReasoning) {
995
+ if (!appendOutput(state, raw.thinking))
996
+ break;
353
997
  const id = String(state.blockCounter++);
354
- controller.enqueue({ type: "reasoning-start", id });
355
- controller.enqueue({ type: "reasoning-delta", id, delta: raw.thinking });
356
- controller.enqueue({ type: "reasoning-end", id });
998
+ safeEnqueue(controller, { type: "reasoning-start", id });
999
+ safeEnqueue(controller, { type: "reasoning-delta", id, delta: raw.thinking });
1000
+ safeEnqueue(controller, { type: "reasoning-end", id });
357
1001
  }
358
- else if (blockType === "tool_use" && typeof raw.id === "string" && typeof raw.name === "string" && !state.sawStreamTool) {
1002
+ else if (blockType === "tool_use" && typeof raw.id === "string" && raw.id.trim() && typeof raw.name === "string" && raw.name.trim()) {
1003
+ const seenToolCallIds = state.seenToolCallIds ??= new Set();
1004
+ if (!rememberId(seenToolCallIds, raw.id, MAX_SEEN_MESSAGE_IDS))
1005
+ continue;
359
1006
  const name = normalizeToolName(raw.name);
360
- const providerExecuted = isProviderExecutedTool(name, state.functionToolNames);
1007
+ const providerExecuted = isProviderOwnedTool(raw.name, name, state.functionToolNames);
361
1008
  state.pendingToolCalls.set(raw.id, { name, providerExecuted });
362
1009
  if (!providerExecuted) {
363
- const rawInput = typeof raw.input === "string" ? raw.input : JSON.stringify(raw.input ?? {});
364
- const input = normalizeToolInputString(name, rawInput);
365
- controller.enqueue({ type: "tool-input-start", id: raw.id, toolName: name });
366
- controller.enqueue({ type: "tool-input-delta", id: raw.id, delta: input });
367
- controller.enqueue({ type: "tool-input-end", id: raw.id });
368
- controller.enqueue({ type: "tool-call", toolCallId: raw.id, toolName: name, input });
1010
+ const input = normalizedToolInput(name, raw.input);
1011
+ if (input === null) {
1012
+ failStream(state, "invalid_tool_input", `Qoder sent invalid JSON for tool ${name}`);
1013
+ break;
1014
+ }
1015
+ safeEnqueue(controller, { type: "tool-input-start", id: raw.id, toolName: name });
1016
+ safeEnqueue(controller, { type: "tool-input-delta", id: raw.id, delta: input });
1017
+ safeEnqueue(controller, { type: "tool-input-end", id: raw.id });
1018
+ safeEnqueue(controller, { type: "tool-call", toolCallId: raw.id, toolName: name, input });
369
1019
  state.emittedToolCall = true;
370
1020
  }
371
1021
  }
1022
+ else if (blockType === "tool_use") {
1023
+ failStream(state, "malformed_tool_call", "Qoder sent a tool call without a valid id or name");
1024
+ break;
1025
+ }
1026
+ }
1027
+ }
1028
+ function normalizedToolInput(toolName, raw) {
1029
+ if (raw === undefined)
1030
+ return null;
1031
+ const serialized = typeof raw === "string" ? raw : safeJsonStringify(raw);
1032
+ if (serialized === undefined || serialized.length > MAX_TOOL_INPUT_CHARS)
1033
+ return null;
1034
+ try {
1035
+ const parsed = JSON.parse(serialized.trim() || "{}");
1036
+ if (!isRecord(parsed))
1037
+ return null;
1038
+ return normalizeToolInputString(toolName, serialized);
372
1039
  }
1040
+ catch {
1041
+ return null;
1042
+ }
1043
+ }
1044
+ function appendOutput(state, text) {
1045
+ const current = Number.isFinite(state.outputChars) ? state.outputChars : 0;
1046
+ if (current + text.length > MAX_OUTPUT_CHARS) {
1047
+ failStream(state, "output_too_large", "Qoder sent more output than the bridge limit");
1048
+ return false;
1049
+ }
1050
+ state.outputChars = current + text.length;
1051
+ return true;
1052
+ }
1053
+ function failStream(state, subtype, detail) {
1054
+ if (state.finished)
1055
+ return;
1056
+ state.failed = true;
1057
+ closeOpenBlocks(state);
1058
+ safeEnqueue(state.controller, { type: "error", error: new QoderSdkResultError(subtype, detail) });
1059
+ emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error", subtype), undefined);
373
1060
  }
374
1061
  function handleResult(m, state) {
1062
+ state.resultReceived = true;
375
1063
  const { controller } = state;
376
- for (const idx of state.activeReasoning)
377
- controller.enqueue({ type: "reasoning-end", id: String(idx) });
378
- state.activeReasoning.clear();
379
- for (const idx of state.activeText)
380
- controller.enqueue({ type: "text-end", id: String(idx) });
381
- state.activeText.clear();
382
- const isError = m.is_error === true || (typeof m.subtype === "string" && m.subtype !== "success");
383
- if (isError) {
384
- const subtype = typeof m.subtype === "string" ? m.subtype : "error_during_execution";
385
- const errors = Array.isArray(m.errors) ? JSON.stringify(m.errors) : "";
386
- controller.enqueue({ type: "error", error: new QoderSdkResultError(subtype, errors) });
387
- emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error", subtype), undefined);
1064
+ if (Object.hasOwn(m, "stop_reason")) {
1065
+ state.lastStopReason = safeStopReason(m.stop_reason);
1066
+ }
1067
+ if (state.activeReasoning.size > 0 || state.activeText.size > 0 || state.toolBlocks.size > 0) {
1068
+ state.failed = true;
1069
+ closeOpenBlocks(state);
1070
+ safeEnqueue(controller, {
1071
+ type: "error",
1072
+ error: new QoderSdkResultError("incomplete_stream", "Qoder sent a result before closing all content blocks"),
1073
+ });
1074
+ emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error", "incomplete_stream"), undefined);
388
1075
  return;
389
1076
  }
390
- const usage = (m.usage ?? {});
391
- let inputTokens = usage.input_tokens ?? 0;
392
- let outputTokens = usage.output_tokens ?? 0;
393
- const cachedInputTokens = usage.cache_read_input_tokens ?? 0;
394
- const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
395
- const contextUsageRatio = usage.context_usage_ratio;
1077
+ closeOpenBlocks(state);
1078
+ const usage = isRecord(m.usage) ? m.usage : {};
1079
+ let inputTokens = tokenCount(usage.input_tokens);
1080
+ let outputTokens = tokenCount(usage.output_tokens);
1081
+ const cachedInputTokens = tokenCount(usage.cache_read_input_tokens);
1082
+ const cacheWriteTokens = tokenCount(usage.cache_creation_input_tokens);
1083
+ const contextUsageRatio = ratio(usage.context_usage_ratio);
396
1084
  let usageEstimated = false;
397
1085
  // Qoder's first-party backend currently reports zero token counters, but it
398
1086
  // does report the fraction of the context window used. Convert that ratio
@@ -400,10 +1088,9 @@ function handleResult(m, state) {
400
1088
  // panel instead of permanently displaying 0 tokens / 0%.
401
1089
  if (inputTokens === 0
402
1090
  && outputTokens === 0
403
- && typeof contextUsageRatio === "number"
404
- && Number.isFinite(contextUsageRatio)
1091
+ && contextUsageRatio !== undefined
405
1092
  && contextUsageRatio > 0) {
406
- const totalTokens = Math.max(1, Math.round(Math.min(contextUsageRatio, 1) * state.contextWindow));
1093
+ const totalTokens = Math.max(1, Math.round(contextUsageRatio * state.contextWindow));
407
1094
  const resultText = typeof m.result === "string" ? m.result : "";
408
1095
  outputTokens = resultText ? Math.max(1, Math.ceil(Buffer.byteLength(resultText, "utf8") / 4)) : 0;
409
1096
  outputTokens = Math.min(outputTokens, totalTokens);
@@ -411,45 +1098,106 @@ function handleResult(m, state) {
411
1098
  usageEstimated = true;
412
1099
  debug(`Token counters absent; estimated ${inputTokens} in / ${outputTokens} out from context ratio`);
413
1100
  }
414
- const costUsd = typeof m.total_cost_usd === "number" ? m.total_cost_usd : 0;
1101
+ const costUsd = finiteNonNegative(m.total_cost_usd);
1102
+ const model = typeof m.model === "string" && m.model.trim() ? m.model : "unknown";
1103
+ const durationMs = finiteNonNegative(m.duration_ms);
1104
+ const turns = finiteNonNegative(m.num_turns, 1);
1105
+ const record = () => {
1106
+ try {
1107
+ recordTurn({
1108
+ model,
1109
+ usage: {
1110
+ input_tokens: inputTokens,
1111
+ output_tokens: outputTokens,
1112
+ cache_read_input_tokens: cachedInputTokens,
1113
+ cache_creation_input_tokens: cacheWriteTokens,
1114
+ },
1115
+ costUsd,
1116
+ durationMs,
1117
+ turns,
1118
+ });
1119
+ }
1120
+ catch (err) {
1121
+ debug("Cost ledger write skipped:", describeError(err));
1122
+ }
1123
+ };
1124
+ const isAuthError = state.authExpired;
1125
+ const subtype = isAuthError
1126
+ ? "authentication_failed"
1127
+ : typeof m.subtype === "string" ? m.subtype : "error_during_execution";
1128
+ const isError = isAuthError || m.is_error === true || m.subtype !== "success";
1129
+ if (isError) {
1130
+ state.failed = true;
1131
+ const detail = Array.isArray(m.errors)
1132
+ ? redactSensitiveText((safeJsonStringify(m.errors) ?? "").slice(0, 4096))
1133
+ : "";
1134
+ state.invalidSession = isInvalidSessionError(subtype, detail);
1135
+ const error = state.authExpired
1136
+ ? new QoderAuthError("Qoder authentication expired during the request. Re-authenticate with `qoder login` or refresh QODER_PERSONAL_ACCESS_TOKEN.")
1137
+ : new QoderSdkResultError(subtype, detail);
1138
+ record();
1139
+ safeEnqueue(controller, { type: "error", error });
1140
+ emitFinish(state, makeUsage(inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens), makeFinishReason("error", subtype), undefined);
1141
+ return;
1142
+ }
415
1143
  const hasToolCalls = state.emittedToolCall && state.pendingToolCalls.size > 0;
416
1144
  const finishReason = mapStopReason(state.lastStopReason, hasToolCalls);
417
- try {
418
- recordTurn({
419
- model: m.model ?? "unknown",
420
- usage: {
421
- input_tokens: inputTokens,
422
- output_tokens: outputTokens,
423
- cache_read_input_tokens: cachedInputTokens,
424
- cache_creation_input_tokens: cacheWriteTokens,
425
- },
426
- costUsd,
427
- durationMs: typeof m.duration_ms === "number" ? m.duration_ms : 0,
428
- turns: typeof m.num_turns === "number" ? m.num_turns : 1,
429
- modelUsage: m.modelUsage,
430
- });
431
- }
432
- catch (err) {
433
- debug("Cost ledger write skipped:", describeError(err));
434
- }
1145
+ record();
435
1146
  const qoderMeta = {};
436
- if (typeof m.total_cost_usd === "number")
437
- qoderMeta.totalCostUSD = m.total_cost_usd;
438
- if (isRecord(m.modelUsage))
439
- qoderMeta.modelUsage = m.modelUsage;
440
- if (typeof contextUsageRatio === "number")
1147
+ if (Number.isFinite(costUsd))
1148
+ qoderMeta.totalCostUSD = costUsd;
1149
+ const modelUsage = toJsonValue(m.modelUsage);
1150
+ if (modelUsage !== undefined)
1151
+ qoderMeta.modelUsage = modelUsage;
1152
+ if (contextUsageRatio !== undefined)
441
1153
  qoderMeta.contextUsageRatio = contextUsageRatio;
442
1154
  if (usageEstimated)
443
1155
  qoderMeta.usageEstimated = true;
1156
+ const planMode = toJsonValue(state.planMode);
1157
+ if (planMode !== undefined)
1158
+ qoderMeta.planMode = planMode;
1159
+ const artifacts = toJsonValue(state.artifacts);
1160
+ if (artifacts !== undefined && state.artifacts.length > 0)
1161
+ qoderMeta.artifacts = artifacts;
1162
+ const skillEvolution = toJsonValue(state.skillEvolution);
1163
+ if (skillEvolution !== undefined)
1164
+ qoderMeta.skillEvolution = skillEvolution;
444
1165
  emitFinish(state, makeUsage(inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens), finishReason, Object.keys(qoderMeta).length > 0 ? qoderMeta : undefined);
445
1166
  }
1167
+ function isInvalidSessionError(subtype, detail) {
1168
+ return /(?:session.*(?:invalid|not[_ ]found|missing|expired)|(?:invalid|unknown|missing).*session)/i.test(`${subtype} ${detail}`);
1169
+ }
1170
+ function isLikelyInvalidSessionError(error) {
1171
+ const detail = error instanceof Error ? `${error.name} ${error.message}` : String(error);
1172
+ return isInvalidSessionError("", detail);
1173
+ }
446
1174
  function emitFinish(state, usage, finishReason, qoderMeta) {
447
- state.controller.enqueue({
1175
+ if (state.finished)
1176
+ return;
1177
+ state.finished = true;
1178
+ closeOpenBlocks(state);
1179
+ safeEnqueue(state.controller, {
448
1180
  type: "finish",
449
1181
  finishReason,
450
1182
  usage,
451
1183
  ...(qoderMeta ? { providerMetadata: { qoder: qoderMeta } } : {}),
452
1184
  });
453
- state.finished = true;
1185
+ }
1186
+ function closeOpenBlocks(state) {
1187
+ for (const block of state.openBlocks ?? []) {
1188
+ if (block.kind === "reasoning") {
1189
+ safeEnqueue(state.controller, { type: "reasoning-end", id: String(block.index) });
1190
+ }
1191
+ else if (block.kind === "text") {
1192
+ safeEnqueue(state.controller, { type: "text-end", id: String(block.index) });
1193
+ }
1194
+ else if (block.kind === "tool" && !block.providerExecuted) {
1195
+ safeEnqueue(state.controller, { type: "tool-input-end", id: block.id });
1196
+ }
1197
+ }
1198
+ state.openBlocks.length = 0;
1199
+ state.activeReasoning.clear();
1200
+ state.activeText.clear();
1201
+ state.toolBlocks.clear();
454
1202
  }
455
1203
  //# sourceMappingURL=language-model.js.map