@link-assistant/agent 0.20.0 → 0.21.0

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": "@link-assistant/agent",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli/argv.ts CHANGED
@@ -4,15 +4,15 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Extract a named argument directly from process.argv.
7
+ * Extract a named argument from an arbitrary argv-like array.
8
8
  * Supports --name=value, --name value, and optional short aliases (-x=value, -x value).
9
- * @returns The argument value from CLI or null if not found
9
+ * @returns The argument value or null if not found
10
10
  */
11
- function getArgFromProcessArgv(
11
+ function extractArgFromArray(
12
+ args: string[],
12
13
  longFlag: string,
13
14
  shortFlag?: string
14
15
  ): string | null {
15
- const args = process.argv;
16
16
  const longPrefix = `--${longFlag}=`;
17
17
  const shortPrefix = shortFlag ? `-${shortFlag}=` : null;
18
18
  for (let i = 0; i < args.length; i++) {
@@ -43,6 +43,38 @@ function getArgFromProcessArgv(
43
43
  return null;
44
44
  }
45
45
 
46
+ /**
47
+ * Extract a named argument directly from process.argv, falling back to Bun.argv.
48
+ * Bun global installs and compiled binaries may have different process.argv structures
49
+ * (see oven-sh/bun#22157), so we check both sources. (#192, #239)
50
+ * @returns The argument value from CLI or null if not found
51
+ */
52
+ function getArgFromProcessArgv(
53
+ longFlag: string,
54
+ shortFlag?: string
55
+ ): string | null {
56
+ // Try process.argv first (standard Node.js / Bun behavior)
57
+ const fromProcess = extractArgFromArray(process.argv, longFlag, shortFlag);
58
+ if (fromProcess !== null) {
59
+ return fromProcess;
60
+ }
61
+
62
+ // Fallback: try Bun.argv if available — Bun global installs may have
63
+ // different process.argv structure (extra elements, shifted indices) (#239)
64
+ if (typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv) {
65
+ const fromBun = extractArgFromArray(
66
+ globalThis.Bun.argv,
67
+ longFlag,
68
+ shortFlag
69
+ );
70
+ if (fromBun !== null) {
71
+ return fromBun;
72
+ }
73
+ }
74
+
75
+ return null;
76
+ }
77
+
46
78
  /**
47
79
  * Extract model argument directly from process.argv
48
80
  * This is a safeguard against yargs caching issues (#192)
@@ -73,7 +105,7 @@ export function getCompactionSafetyMarginFromProcessArgv(): string | null {
73
105
  /**
74
106
  * Extract --compaction-models argument directly from process.argv
75
107
  * The value is a links notation references sequence, e.g.:
76
- * "(big-pickle nemotron-3-super-free minimax-m2.5-free gpt-5-nano qwen3.6-plus-free same)"
108
+ * "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
77
109
  * @returns The compaction models argument from CLI or null if not found
78
110
  * @see https://github.com/link-assistant/agent/issues/232
79
111
  */
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  /** Default model used when no `--model` CLI argument is provided. */
9
- export const DEFAULT_MODEL = 'opencode/qwen3.6-plus-free';
9
+ export const DEFAULT_MODEL = 'opencode/nemotron-3-super-free';
10
10
 
11
11
  /** Default provider ID extracted from DEFAULT_MODEL. */
12
12
  export const DEFAULT_PROVIDER_ID = DEFAULT_MODEL.split('/')[0];
@@ -31,20 +31,21 @@ export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
31
31
  * The special value "same" means use the same model as `--model`.
32
32
  *
33
33
  * Parsed as links notation references sequence (single anonymous link):
34
- * "(big-pickle nemotron-3-super-free minimax-m2.5-free gpt-5-nano qwen3.6-plus-free same)"
34
+ * "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
35
35
  *
36
36
  * Context limits (approximate):
37
37
  * big-pickle: ~200K
38
- * nemotron-3-super-free: ~262K
39
38
  * minimax-m2.5-free: ~200K
39
+ * nemotron-3-super-free: ~262K (default model)
40
40
  * gpt-5-nano: ~400K
41
- * qwen3.6-plus-free: ~1M
42
41
  * same: (base model's context)
43
42
  *
43
+ * Note: qwen3.6-plus-free was removed — free promotion ended April 2026.
44
+ * @see https://github.com/link-assistant/agent/issues/242
44
45
  * @see https://github.com/link-assistant/agent/issues/232
45
46
  */
46
47
  export const DEFAULT_COMPACTION_MODELS =
47
- '(big-pickle nemotron-3-super-free minimax-m2.5-free gpt-5-nano qwen3.6-plus-free same)';
48
+ '(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)';
48
49
 
49
50
  /**
50
51
  * Default compaction safety margin as a percentage of usable context window.
@@ -21,12 +21,25 @@ import {
21
21
  * @returns {Promise<{providerID: string, modelID: string}>}
22
22
  */
23
23
  export async function parseModelConfig(argv, outputError, outputStatus) {
24
- // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192, #196)
24
+ // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192, #196, #239)
25
25
  // This is critical because yargs under Bun may fail to parse --model correctly,
26
26
  // returning the default value instead of the user's CLI argument.
27
27
  const cliModelArg = getModelFromProcessArgv();
28
28
  let modelArg = argv.model;
29
29
 
30
+ // Diagnostic logging: always log raw argv sources when debugging model resolution (#239)
31
+ // Bun global installs may have different process.argv structure (oven-sh/bun#22157)
32
+ Log.Default.info(() => ({
33
+ message: 'model resolution: argv sources',
34
+ processArgv: process.argv,
35
+ bunArgv:
36
+ typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv
37
+ ? globalThis.Bun.argv
38
+ : '(not available)',
39
+ cliModelArg: cliModelArg ?? '(null - not found in argv)',
40
+ yargsModel: modelArg,
41
+ }));
42
+
30
43
  // ALWAYS prefer the CLI value over yargs when available (#196)
31
44
  // The yargs default (DEFAULT_MODEL) can silently override user's --model argument
32
45
  if (cliModelArg) {
@@ -41,6 +54,29 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
41
54
  // Always use CLI value when available, even if it matches yargs
42
55
  // This ensures we use the actual CLI argument, not a cached/default yargs value
43
56
  modelArg = cliModelArg;
57
+ } else if (modelArg === `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`) {
58
+ // cliModelArg is null AND yargs returned the default — check if process.argv
59
+ // actually contains --model to detect silent yargs/Bun mismatch (#239)
60
+ const rawArgvStr = process.argv.join(' ');
61
+ if (
62
+ rawArgvStr.includes('--model ') ||
63
+ rawArgvStr.includes('--model=') ||
64
+ rawArgvStr.includes('-m ') ||
65
+ rawArgvStr.includes('-m=')
66
+ ) {
67
+ Log.Default.error(() => ({
68
+ message:
69
+ 'CRITICAL: --model flag detected in process.argv but both getModelFromProcessArgv() and yargs returned default. ' +
70
+ 'This is likely a Bun/yargs argument parsing bug (oven-sh/bun#22157). ' +
71
+ 'The requested model will NOT be used — the default model will be used instead.',
72
+ processArgv: process.argv,
73
+ bunArgv:
74
+ typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv
75
+ ? globalThis.Bun.argv
76
+ : '(not available)',
77
+ defaultModel: `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`,
78
+ }));
79
+ }
44
80
  }
45
81
 
46
82
  let providerID;
@@ -73,26 +109,41 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
73
109
  // Validate that the model exists in the provider (#196, #231)
74
110
  // If user explicitly specified provider/model and the model is not found,
75
111
  // fail immediately instead of silently falling back to a different model.
112
+ // However, if the model is the default (no --model CLI flag), warn but proceed (#239).
113
+ // The models.dev API may lag behind the provider's actual model availability.
114
+ const isDefaultModel = !cliModelArg;
76
115
  try {
77
116
  const { Provider } = await import('../provider/provider.ts');
78
117
  const s = await Provider.state();
79
118
  const provider = s.providers[providerID];
80
119
  if (provider && !provider.info.models[modelID]) {
81
- // Provider exists but model doesn't — fail with a clear error (#231)
82
- // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
83
120
  const availableModels = Object.keys(provider.info.models).slice(0, 10);
84
- Log.Default.error(() => ({
85
- message:
86
- 'model not found in provider refusing to proceed with explicit provider/model',
87
- providerID,
88
- modelID,
89
- availableModels,
90
- }));
91
- throw new Error(
92
- `Model "${modelID}" not found in provider "${providerID}". ` +
93
- `Available models include: ${availableModels.join(', ')}. ` +
94
- `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
95
- );
121
+ if (isDefaultModel) {
122
+ // Default model not in models.dev catalog — warn but proceed (#239)
123
+ // The provider may still accept it; models.dev can lag behind actual availability.
124
+ Log.Default.warn(() => ({
125
+ message:
126
+ 'default model not found in models.dev catalog — proceeding anyway',
127
+ providerID,
128
+ modelID,
129
+ availableModels,
130
+ }));
131
+ } else {
132
+ // User explicitly specified provider/model — fail with a clear error (#231)
133
+ // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
134
+ Log.Default.error(() => ({
135
+ message:
136
+ 'model not found in provider — refusing to proceed with explicit provider/model',
137
+ providerID,
138
+ modelID,
139
+ availableModels,
140
+ }));
141
+ throw new Error(
142
+ `Model "${modelID}" not found in provider "${providerID}". ` +
143
+ `Available models include: ${availableModels.join(', ')}. ` +
144
+ `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
145
+ );
146
+ }
96
147
  }
97
148
  } catch (validationError) {
98
149
  // Re-throw if this is our own validation error (not an infrastructure issue)
package/src/index.js CHANGED
@@ -87,12 +87,22 @@ process.stderr.write = function (chunk, encoding, callback) {
87
87
  }
88
88
  }
89
89
 
90
- // Wrap non-JSON stderr output in JSON envelope
91
- const wrapped = `${JSON.stringify({
92
- type: 'error',
93
- errorType: 'RuntimeError',
94
- message: trimmed,
95
- })}\n`;
90
+ // Wrap non-JSON stderr output in JSON envelope.
91
+ // Verbose/debug messages should use "type": "log", not "type": "error" (#235).
92
+ const isVerboseMsg =
93
+ trimmed.startsWith('[verbose]') || trimmed.startsWith('[debug]');
94
+ const wrapped = isVerboseMsg
95
+ ? `${JSON.stringify({
96
+ type: 'log',
97
+ level: 'debug',
98
+ service: 'stderr',
99
+ message: trimmed,
100
+ })}\n`
101
+ : `${JSON.stringify({
102
+ type: 'error',
103
+ errorType: 'RuntimeError',
104
+ message: trimmed,
105
+ })}\n`;
96
106
  return originalStderrWrite(wrapped, encoding, callback);
97
107
  };
98
108
 
@@ -7,7 +7,12 @@ import { data } from './models-macro';
7
7
 
8
8
  export namespace ModelsDev {
9
9
  const log = Log.create({ service: 'models.dev' });
10
- const verboseFetch = createVerboseFetch(fetch, { caller: 'models.dev' });
10
+ const verboseFetch = createVerboseFetch(fetch, {
11
+ caller: 'models.dev',
12
+ // models.dev/api.json response can be 200KB+; logging the full body
13
+ // crashes the subprocess in CI (#239). Keep preview small.
14
+ responseBodyMaxChars: 2000,
15
+ });
11
16
  const filepath = path.join(Global.Path.cache, 'models.json');
12
17
 
13
18
  export const Model = z
@@ -1241,17 +1241,13 @@ export namespace Provider {
1241
1241
 
1242
1242
  // Log a one-time confirmation that the verbose wrapper is active for this provider.
1243
1243
  // This diagnostic breadcrumb confirms the wrapper is in the fetch chain.
1244
- // Also write to stderr as a redundant channel — stdout JSON may be filtered by wrappers.
1245
1244
  // See: https://github.com/link-assistant/agent/issues/215
1245
+ // See: https://github.com/link-assistant/agent/issues/235
1246
1246
  if (!verboseWrapperConfirmed) {
1247
1247
  verboseWrapperConfirmed = true;
1248
- log.info('verbose HTTP logging active', {
1248
+ log.debug('verbose HTTP logging active', {
1249
1249
  providerID: provider.id,
1250
1250
  });
1251
- // Redundant stderr confirmation — visible even if stdout is piped/filtered
1252
- process.stderr.write(
1253
- `[verbose] HTTP logging active for provider: ${provider.id}\n`
1254
- );
1255
1251
  }
1256
1252
 
1257
1253
  const url =
@@ -1623,30 +1619,51 @@ export namespace Provider {
1623
1619
  }
1624
1620
 
1625
1621
  if (!isSyntheticProvider && !info) {
1626
- // Model not found even after cache refresh — fail with a clear error (#231)
1627
- // Previously this created synthetic fallback info, which allowed the API call
1628
- // to proceed with the wrong model (e.g., kimi-k2.5-free routed to minimax-m2.5-free)
1622
+ // Model not found even after cache refresh.
1623
+ // Check if this is the default model — if so, create synthetic info and proceed (#239).
1624
+ // The models.dev API can lag behind the provider's actual model availability.
1625
+ // For user-specified models, fail with a clear error (#231) to prevent silent substitution.
1626
+ const { DEFAULT_PROVIDER_ID, DEFAULT_MODEL_ID } =
1627
+ await import('../cli/defaults.ts');
1628
+ const isDefaultModel =
1629
+ providerID === DEFAULT_PROVIDER_ID && modelID === DEFAULT_MODEL_ID;
1629
1630
  const availableInProvider = Object.keys(provider.info.models).slice(
1630
1631
  0,
1631
1632
  10
1632
1633
  );
1633
- log.error(() => ({
1634
- message:
1635
- 'model not found in provider catalog after refresh — refusing to proceed',
1636
- providerID,
1637
- modelID,
1638
- availableModels: availableInProvider,
1639
- totalModels: Object.keys(provider.info.models).length,
1640
- }));
1641
1634
 
1642
- throw new ModelNotFoundError({
1643
- providerID,
1644
- modelID,
1645
- suggestion:
1646
- `Model "${modelID}" not found in provider "${providerID}" (checked ${Object.keys(provider.info.models).length} models). ` +
1647
- `Available models include: ${availableInProvider.join(', ')}. ` +
1648
- `Use --model ${providerID}/<model-id> with a valid model.`,
1649
- });
1635
+ if (isDefaultModel) {
1636
+ // Default model not in models.dev catalog — create synthetic info and try anyway (#239)
1637
+ log.warn(() => ({
1638
+ message:
1639
+ 'default model not in provider catalog creating synthetic info to proceed',
1640
+ providerID,
1641
+ modelID,
1642
+ availableModels: availableInProvider,
1643
+ }));
1644
+ info = {
1645
+ id: modelID,
1646
+ name: modelID,
1647
+ } as typeof info;
1648
+ } else {
1649
+ log.error(() => ({
1650
+ message:
1651
+ 'model not found in provider catalog after refresh — refusing to proceed',
1652
+ providerID,
1653
+ modelID,
1654
+ availableModels: availableInProvider,
1655
+ totalModels: Object.keys(provider.info.models).length,
1656
+ }));
1657
+
1658
+ throw new ModelNotFoundError({
1659
+ providerID,
1660
+ modelID,
1661
+ suggestion:
1662
+ `Model "${modelID}" not found in provider "${providerID}" (checked ${Object.keys(provider.info.models).length} models). ` +
1663
+ `Available models include: ${availableInProvider.join(', ')}. ` +
1664
+ `Use --model ${providerID}/<model-id> with a valid model.`,
1665
+ });
1666
+ }
1650
1667
  }
1651
1668
 
1652
1669
  try {
@@ -1732,10 +1749,9 @@ export namespace Provider {
1732
1749
  }
1733
1750
  if (providerID === 'opencode' || providerID === 'local') {
1734
1751
  priority = [
1735
- 'qwen3.6-plus-free',
1752
+ 'nemotron-3-super-free',
1736
1753
  'minimax-m2.5-free',
1737
1754
  'gpt-5-nano',
1738
- 'nemotron-3-super-free',
1739
1755
  'big-pickle',
1740
1756
  ];
1741
1757
  }
@@ -1764,9 +1780,8 @@ export namespace Provider {
1764
1780
  }
1765
1781
 
1766
1782
  const priority = [
1767
- 'qwen3.6-plus-free',
1768
- 'glm-5-free',
1769
1783
  'nemotron-3-super-free',
1784
+ 'glm-5-free',
1770
1785
  'minimax-m2.5-free',
1771
1786
  'gpt-5-nano',
1772
1787
  'big-pickle',
@@ -1849,7 +1864,7 @@ export namespace Provider {
1849
1864
  * 1. If model is uniquely available in one provider, use that provider
1850
1865
  * 2. If model is available in multiple providers, prioritize based on free model availability:
1851
1866
  * - kilo: glm-5-free, glm-4.5-air-free, minimax-m2.5-free, giga-potato-free, deepseek-r1-free (unique to Kilo)
1852
- * - opencode: big-pickle, gpt-5-nano, qwen3.6-plus-free, nemotron-3-super-free (unique to OpenCode)
1867
+ * - opencode: big-pickle, gpt-5-nano, nemotron-3-super-free (unique to OpenCode)
1853
1868
  * 3. For shared models, prefer OpenCode first, then fall back to Kilo on rate limit
1854
1869
  *
1855
1870
  * @param modelID - Short model name without provider prefix
@@ -304,15 +304,30 @@ export namespace SessionPrompt {
304
304
  // continue the loop to execute them instead of prematurely exiting.
305
305
  // See: https://github.com/link-assistant/agent/issues/194
306
306
  if (lastAssistant.finish === 'unknown') {
307
+ // First check for tool calls BEFORE checking zero tokens (#239)
308
+ // Some providers (e.g., OpenCode Zen / OpenRouter) return zero tokens and
309
+ // unknown finish reason even when the model successfully executed tool calls.
310
+ // We must check for tool calls first to avoid prematurely terminating
311
+ // a session that is actually making progress.
312
+ const lastAssistantParts = msgs.find(
313
+ (m) => m.info.id === lastAssistant.id
314
+ )?.parts;
315
+ const hasToolCalls = lastAssistantParts?.some(
316
+ (p) =>
317
+ p.type === 'tool' &&
318
+ (p.state.status === 'completed' || p.state.status === 'running')
319
+ );
320
+
307
321
  // SAFETY CHECK for issue #196: Detect zero-token responses as provider failures
308
322
  // When all tokens are 0 and finish reason is 'unknown', this indicates the provider
309
323
  // returned an empty/error response (e.g., rate limit, model unavailable, API failure).
310
- // Log a clear error message so the problem is visible in logs.
324
+ // But ONLY treat this as fatal if there are NO tool calls (#239).
311
325
  const tokens = lastAssistant.tokens;
312
326
  if (
313
327
  tokens.input === 0 &&
314
328
  tokens.output === 0 &&
315
- tokens.reasoning === 0
329
+ tokens.reasoning === 0 &&
330
+ !hasToolCalls
316
331
  ) {
317
332
  const errorMessage =
318
333
  `Provider returned zero tokens with unknown finish reason. ` +
@@ -340,20 +355,13 @@ export namespace SessionPrompt {
340
355
  break;
341
356
  }
342
357
 
343
- const lastAssistantParts = msgs.find(
344
- (m) => m.info.id === lastAssistant.id
345
- )?.parts;
346
- const hasToolCalls = lastAssistantParts?.some(
347
- (p) =>
348
- p.type === 'tool' &&
349
- (p.state.status === 'completed' || p.state.status === 'running')
350
- );
351
358
  if (hasToolCalls) {
352
359
  log.info(() => ({
353
360
  message:
354
361
  'continuing loop despite unknown finish reason - tool calls detected',
355
362
  sessionID,
356
363
  finishReason: lastAssistant.finish,
364
+ zeroTokens: tokens.input === 0 && tokens.output === 0,
357
365
  hint: 'Provider returned undefined finishReason but made tool calls',
358
366
  }));
359
367
  // Don't break - continue the loop to handle tool call results
@@ -22,8 +22,15 @@ export namespace Storage {
22
22
 
23
23
  const MIGRATIONS: Migration[] = [
24
24
  async (dir) => {
25
- const project = path.resolve(dir, '../project');
26
- if (!fs.exists(project)) return;
25
+ // Sanitize path: strip null bytes that may appear in Bun runtime path operations (#239)
26
+ const project = path.resolve(dir, '../project').replace(/\0/g, '');
27
+ if (
28
+ !(await fs
29
+ .stat(project)
30
+ .then((s) => s.isDirectory())
31
+ .catch(() => false))
32
+ )
33
+ return;
27
34
  for await (const projectDir of new Bun.Glob('*').scan({
28
35
  cwd: project,
29
36
  onlyFiles: false,
@@ -45,7 +52,13 @@ export namespace Storage {
45
52
  if (worktree) break;
46
53
  }
47
54
  if (!worktree) continue;
48
- if (!(await fs.exists(worktree))) continue;
55
+ if (
56
+ !(await fs
57
+ .stat(worktree)
58
+ .then((s) => s.isDirectory())
59
+ .catch(() => false))
60
+ )
61
+ continue;
49
62
  const [id] = await $`git rev-list --max-parents=0 --all`
50
63
  .quiet()
51
64
  .nothrow()
@@ -174,8 +187,8 @@ export namespace Storage {
174
187
  const state = lazy(async () => {
175
188
  const dir = path.join(Global.Path.data, 'storage');
176
189
  const migration = await Bun.file(path.join(dir, 'migration'))
177
- .json()
178
- .then((x) => parseInt(x))
190
+ .text()
191
+ .then((x) => parseInt(x.trim(), 10))
179
192
  .catch(() => 0);
180
193
  for (let index = migration; index < MIGRATIONS.length; index++) {
181
194
  log.info(() => ({ message: 'running migration', index }));
@@ -79,7 +79,7 @@ export function sanitizeHeaders(
79
79
  */
80
80
  export function bodyPreview(
81
81
  body: BodyInit | null | undefined,
82
- maxChars = 200000
82
+ maxChars = 4000
83
83
  ): string | undefined {
84
84
  if (!body) return undefined;
85
85
 
@@ -128,8 +128,8 @@ export function createVerboseFetch(
128
128
  ): typeof fetch {
129
129
  const {
130
130
  caller,
131
- responseBodyMaxChars = 200000,
132
- requestBodyMaxChars = 200000,
131
+ responseBodyMaxChars = 4000,
132
+ requestBodyMaxChars = 4000,
133
133
  } = options;
134
134
 
135
135
  return async (
@@ -330,10 +330,15 @@ export function resetHttpCallCount(): void {
330
330
  export function registerPendingStreamLogExitHandler(): void {
331
331
  process.once('exit', () => {
332
332
  if (pendingStreamLogs > 0) {
333
- // Use stderr directly since the process is exiting and log infrastructure may be unavailable
334
- process.stderr.write(
335
- `[verbose] warning: ${pendingStreamLogs} HTTP stream response log(s) were still pending at process exit — response bodies may be missing from logs\n`
336
- );
333
+ // Use stderr directly since the process is exiting and log infrastructure may be unavailable.
334
+ // Write as JSON to avoid the stderr interceptor wrapping it as "type": "error" (#235).
335
+ const warning = JSON.stringify({
336
+ type: 'log',
337
+ level: 'warn',
338
+ service: 'http',
339
+ message: `${pendingStreamLogs} HTTP stream response log(s) were still pending at process exit — response bodies may be missing from logs`,
340
+ });
341
+ process.stderr.write(warning + '\n');
337
342
  }
338
343
  });
339
344
  }