@link-assistant/agent 0.16.17 → 0.16.18

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.16.17",
3
+ "version": "0.16.18",
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",
@@ -2,6 +2,7 @@ import crypto from 'crypto';
2
2
  import path from 'path';
3
3
  import { Global } from '../global';
4
4
  import { Log } from '../util/log';
5
+ import { createVerboseFetch } from '../util/verbose-fetch';
5
6
  import z from 'zod';
6
7
 
7
8
  /**
@@ -24,6 +25,7 @@ import z from 'zod';
24
25
  */
25
26
  export namespace ClaudeOAuth {
26
27
  const log = Log.create({ service: 'claude-oauth' });
28
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'claude-oauth' });
27
29
 
28
30
  /**
29
31
  * OAuth Configuration
@@ -218,7 +220,7 @@ export namespace ClaudeOAuth {
218
220
  message: 'exchanging authorization code for tokens',
219
221
  }));
220
222
 
221
- const response = await fetch(Config.tokenUrl, {
223
+ const response = await verboseFetch(Config.tokenUrl, {
222
224
  method: 'POST',
223
225
  headers: {
224
226
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -402,7 +404,7 @@ export namespace ClaudeOAuth {
402
404
  log.info(() => ({ message: 'refreshing access token' }));
403
405
 
404
406
  try {
405
- const response = await fetch(Config.tokenUrl, {
407
+ const response = await verboseFetch(Config.tokenUrl, {
406
408
  method: 'POST',
407
409
  headers: {
408
410
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -446,7 +448,7 @@ export namespace ClaudeOAuth {
446
448
  } else {
447
449
  headers.set('anthropic-beta', Config.betaHeader);
448
450
  }
449
- return fetch(url, { ...init, headers });
451
+ return verboseFetch(url, { ...init, headers });
450
452
  };
451
453
  }
452
454
  }
@@ -3,6 +3,7 @@ import * as http from 'node:http';
3
3
  import * as net from 'node:net';
4
4
  import { Auth } from './index';
5
5
  import { Log } from '../util/log';
6
+ import { createVerboseFetch } from '../util/verbose-fetch';
6
7
 
7
8
  /**
8
9
  * Auth Plugins Module
@@ -12,6 +13,7 @@ import { Log } from '../util/log';
12
13
  */
13
14
 
14
15
  const log = Log.create({ service: 'auth-plugins' });
16
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'auth-plugins' });
15
17
 
16
18
  /**
17
19
  * OAuth callback result types
@@ -142,7 +144,7 @@ const AnthropicPlugin: AuthPlugin = {
142
144
  if (!code) return { type: 'failed' };
143
145
 
144
146
  const splits = code.split('#');
145
- const result = await fetch(
147
+ const result = await verboseFetch(
146
148
  'https://console.anthropic.com/v1/oauth/token',
147
149
  {
148
150
  method: 'POST',
@@ -210,7 +212,7 @@ const AnthropicPlugin: AuthPlugin = {
210
212
  if (!code) return { type: 'failed' };
211
213
 
212
214
  const splits = code.split('#');
213
- const tokenResult = await fetch(
215
+ const tokenResult = await verboseFetch(
214
216
  'https://console.anthropic.com/v1/oauth/token',
215
217
  {
216
218
  method: 'POST',
@@ -240,7 +242,7 @@ const AnthropicPlugin: AuthPlugin = {
240
242
  const credentials = await tokenResult.json();
241
243
 
242
244
  // Create API key using the access token
243
- const apiKeyResult = await fetch(
245
+ const apiKeyResult = await verboseFetch(
244
246
  'https://api.anthropic.com/api/oauth/claude_cli/create_api_key',
245
247
  {
246
248
  method: 'POST',
@@ -291,7 +293,7 @@ const AnthropicPlugin: AuthPlugin = {
291
293
  log.info(() => ({
292
294
  message: 'refreshing anthropic oauth token',
293
295
  }));
294
- const response = await fetch(
296
+ const response = await verboseFetch(
295
297
  'https://console.anthropic.com/v1/oauth/token',
296
298
  {
297
299
  method: 'POST',
@@ -446,7 +448,7 @@ const GitHubCopilotPlugin: AuthPlugin = {
446
448
 
447
449
  const urls = getCopilotUrls(domain);
448
450
 
449
- const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
451
+ const deviceResponse = await verboseFetch(urls.DEVICE_CODE_URL, {
450
452
  method: 'POST',
451
453
  headers: {
452
454
  Accept: 'application/json',
@@ -476,7 +478,7 @@ const GitHubCopilotPlugin: AuthPlugin = {
476
478
  method: 'auto',
477
479
  async callback(): Promise<AuthResult> {
478
480
  while (true) {
479
- const response = await fetch(urls.ACCESS_TOKEN_URL, {
481
+ const response = await verboseFetch(urls.ACCESS_TOKEN_URL, {
480
482
  method: 'POST',
481
483
  headers: {
482
484
  Accept: 'application/json',
@@ -571,7 +573,7 @@ const GitHubCopilotPlugin: AuthPlugin = {
571
573
  const urls = getCopilotUrls(domain);
572
574
 
573
575
  log.info(() => ({ message: 'refreshing github copilot token' }));
574
- const response = await fetch(urls.COPILOT_API_KEY_URL, {
576
+ const response = await verboseFetch(urls.COPILOT_API_KEY_URL, {
575
577
  headers: {
576
578
  Accept: 'application/json',
577
579
  Authorization: `Bearer ${currentInfo.refresh}`,
@@ -731,7 +733,7 @@ const OpenAIPlugin: AuthPlugin = {
731
733
  }
732
734
 
733
735
  // Exchange authorization code for tokens
734
- const tokenResult = await fetch(OPENAI_TOKEN_URL, {
736
+ const tokenResult = await verboseFetch(OPENAI_TOKEN_URL, {
735
737
  method: 'POST',
736
738
  headers: {
737
739
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -797,7 +799,7 @@ const OpenAIPlugin: AuthPlugin = {
797
799
  // Refresh token if expired
798
800
  if (!currentAuth.access || currentAuth.expires < Date.now()) {
799
801
  log.info(() => ({ message: 'refreshing openai oauth token' }));
800
- const response = await fetch(OPENAI_TOKEN_URL, {
802
+ const response = await verboseFetch(OPENAI_TOKEN_URL, {
801
803
  method: 'POST',
802
804
  headers: {
803
805
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -1091,7 +1093,7 @@ const GooglePlugin: AuthPlugin = {
1091
1093
  const { code } = await authPromise;
1092
1094
 
1093
1095
  // Exchange authorization code for tokens
1094
- const tokenResult = await fetch(GOOGLE_TOKEN_URL, {
1096
+ const tokenResult = await verboseFetch(GOOGLE_TOKEN_URL, {
1095
1097
  method: 'POST',
1096
1098
  headers: {
1097
1099
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -1187,7 +1189,7 @@ const GooglePlugin: AuthPlugin = {
1187
1189
 
1188
1190
  try {
1189
1191
  // Exchange authorization code for tokens
1190
- const tokenResult = await fetch(GOOGLE_TOKEN_URL, {
1192
+ const tokenResult = await verboseFetch(GOOGLE_TOKEN_URL, {
1191
1193
  method: 'POST',
1192
1194
  headers: {
1193
1195
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -1348,7 +1350,7 @@ const GooglePlugin: AuthPlugin = {
1348
1350
  // Call loadCodeAssist to discover project and tier
1349
1351
  try {
1350
1352
  const loadUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}:loadCodeAssist`;
1351
- const loadRes = await fetch(loadUrl, {
1353
+ const loadRes = await verboseFetch(loadUrl, {
1352
1354
  method: 'POST',
1353
1355
  headers: {
1354
1356
  'Content-Type': 'application/json',
@@ -1440,7 +1442,7 @@ const GooglePlugin: AuthPlugin = {
1440
1442
  },
1441
1443
  };
1442
1444
 
1443
- let lroRes = await fetch(onboardUrl, {
1445
+ let lroRes = await verboseFetch(onboardUrl, {
1444
1446
  method: 'POST',
1445
1447
  headers: {
1446
1448
  'Content-Type': 'application/json',
@@ -1456,11 +1458,11 @@ const GooglePlugin: AuthPlugin = {
1456
1458
  if (lroRes.name) {
1457
1459
  // Poll operation status
1458
1460
  const opUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}/${lroRes.name}`;
1459
- lroRes = await fetch(opUrl, {
1461
+ lroRes = await verboseFetch(opUrl, {
1460
1462
  headers: { Authorization: `Bearer ${accessToken}` },
1461
1463
  }).then((r) => r.json());
1462
1464
  } else {
1463
- lroRes = await fetch(onboardUrl, {
1465
+ lroRes = await verboseFetch(onboardUrl, {
1464
1466
  method: 'POST',
1465
1467
  headers: {
1466
1468
  'Content-Type': 'application/json',
@@ -1924,7 +1926,7 @@ const GooglePlugin: AuthPlugin = {
1924
1926
  // Invalidate project cache when token changes
1925
1927
  cachedProjectContext = null;
1926
1928
 
1927
- const response = await fetch(GOOGLE_TOKEN_URL, {
1929
+ const response = await verboseFetch(GOOGLE_TOKEN_URL, {
1928
1930
  method: 'POST',
1929
1931
  headers: {
1930
1932
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -2043,7 +2045,7 @@ const GooglePlugin: AuthPlugin = {
2043
2045
  await new Promise((resolve) => setTimeout(resolve, delay));
2044
2046
  }
2045
2047
 
2046
- const cloudCodeResponse = await fetch(finalCloudCodeUrl, {
2048
+ const cloudCodeResponse = await verboseFetch(finalCloudCodeUrl, {
2047
2049
  ...init,
2048
2050
  body,
2049
2051
  headers,
@@ -2135,7 +2137,7 @@ const GooglePlugin: AuthPlugin = {
2135
2137
  };
2136
2138
  delete headers['x-goog-api-key'];
2137
2139
 
2138
- const oauthResponse = await fetch(input, {
2140
+ const oauthResponse = await verboseFetch(input, {
2139
2141
  ...init,
2140
2142
  headers,
2141
2143
  });
@@ -2248,19 +2250,22 @@ const QwenPlugin: AuthPlugin = {
2248
2250
  const codeChallenge = generateCodeChallenge(codeVerifier);
2249
2251
 
2250
2252
  // Request device code
2251
- const deviceResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
2252
- method: 'POST',
2253
- headers: {
2254
- 'Content-Type': 'application/x-www-form-urlencoded',
2255
- Accept: 'application/json',
2256
- },
2257
- body: new URLSearchParams({
2258
- client_id: QWEN_OAUTH_CLIENT_ID,
2259
- scope: QWEN_OAUTH_SCOPE,
2260
- code_challenge: codeChallenge,
2261
- code_challenge_method: 'S256',
2262
- }).toString(),
2263
- });
2253
+ const deviceResponse = await verboseFetch(
2254
+ QWEN_OAUTH_DEVICE_CODE_ENDPOINT,
2255
+ {
2256
+ method: 'POST',
2257
+ headers: {
2258
+ 'Content-Type': 'application/x-www-form-urlencoded',
2259
+ Accept: 'application/json',
2260
+ },
2261
+ body: new URLSearchParams({
2262
+ client_id: QWEN_OAUTH_CLIENT_ID,
2263
+ scope: QWEN_OAUTH_SCOPE,
2264
+ code_challenge: codeChallenge,
2265
+ code_challenge_method: 'S256',
2266
+ }).toString(),
2267
+ }
2268
+ );
2264
2269
 
2265
2270
  if (!deviceResponse.ok) {
2266
2271
  const errorText = await deviceResponse.text();
@@ -2308,19 +2313,22 @@ const QwenPlugin: AuthPlugin = {
2308
2313
  async callback(): Promise<AuthResult> {
2309
2314
  // Poll for authorization completion
2310
2315
  for (let attempt = 0; attempt < maxPollAttempts; attempt++) {
2311
- const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2312
- method: 'POST',
2313
- headers: {
2314
- 'Content-Type': 'application/x-www-form-urlencoded',
2315
- Accept: 'application/json',
2316
- },
2317
- body: new URLSearchParams({
2318
- client_id: QWEN_OAUTH_CLIENT_ID,
2319
- device_code: deviceData.device_code,
2320
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
2321
- code_verifier: codeVerifier,
2322
- }).toString(),
2323
- });
2316
+ const tokenResponse = await verboseFetch(
2317
+ QWEN_OAUTH_TOKEN_ENDPOINT,
2318
+ {
2319
+ method: 'POST',
2320
+ headers: {
2321
+ 'Content-Type': 'application/x-www-form-urlencoded',
2322
+ Accept: 'application/json',
2323
+ },
2324
+ body: new URLSearchParams({
2325
+ client_id: QWEN_OAUTH_CLIENT_ID,
2326
+ device_code: deviceData.device_code,
2327
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
2328
+ code_verifier: codeVerifier,
2329
+ }).toString(),
2330
+ }
2331
+ );
2324
2332
 
2325
2333
  if (!tokenResponse.ok) {
2326
2334
  const errorText = await tokenResponse.text();
@@ -2426,7 +2434,7 @@ const QwenPlugin: AuthPlugin = {
2426
2434
  : 'token expiring soon',
2427
2435
  }));
2428
2436
 
2429
- const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2437
+ const response = await verboseFetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2430
2438
  method: 'POST',
2431
2439
  headers: {
2432
2440
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -2575,7 +2583,7 @@ const AlibabaPlugin: AuthPlugin = {
2575
2583
  message: 'refreshing qwen oauth token (alibaba provider)',
2576
2584
  }));
2577
2585
 
2578
- const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2586
+ const response = await verboseFetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2579
2587
  method: 'POST',
2580
2588
  headers: {
2581
2589
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -2651,7 +2659,7 @@ const KiloPlugin: AuthPlugin = {
2651
2659
  type: 'oauth',
2652
2660
  async authorize() {
2653
2661
  // Initiate device authorization
2654
- const initResponse = await fetch(
2662
+ const initResponse = await verboseFetch(
2655
2663
  `${KILO_API_BASE}/api/device-auth/codes`,
2656
2664
  {
2657
2665
  method: 'POST',
@@ -2704,7 +2712,7 @@ const KiloPlugin: AuthPlugin = {
2704
2712
  setTimeout(resolve, KILO_POLL_INTERVAL_MS)
2705
2713
  );
2706
2714
 
2707
- const pollResponse = await fetch(
2715
+ const pollResponse = await verboseFetch(
2708
2716
  `${KILO_API_BASE}/api/device-auth/codes/${authData.code}`
2709
2717
  );
2710
2718
 
@@ -9,6 +9,9 @@ import path from 'path';
9
9
  import os from 'os';
10
10
  import { Global } from '../../global';
11
11
  import { map, pipe, sortBy, values } from 'remeda';
12
+ import { createVerboseFetch } from '../../util/verbose-fetch';
13
+
14
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'auth-cmd' });
12
15
 
13
16
  /**
14
17
  * Auth Command
@@ -86,9 +89,9 @@ export const AuthLoginCommand = cmd({
86
89
  // Handle wellknown URL login
87
90
  if (args.url) {
88
91
  try {
89
- const wellknown = await fetch(`${args.url}/.well-known/opencode`).then(
90
- (x) => x.json() as any
91
- );
92
+ const wellknown = await verboseFetch(
93
+ `${args.url}/.well-known/opencode`
94
+ ).then((x) => x.json() as any);
92
95
  prompts.log.info(`Running \`${wellknown.auth.command.join(' ')}\``);
93
96
  const proc = Bun.spawn({
94
97
  cmd: wellknown.auth.command,
@@ -12,6 +12,7 @@ import { createEventHandler } from '../json-standard/index.ts';
12
12
  import { createContinuousStdinReader } from './input-queue.js';
13
13
  import { Log } from '../util/log.ts';
14
14
  import { Flag } from '../flag/flag.ts';
15
+ import { createVerboseFetch } from '../util/verbose-fetch.ts';
15
16
  import { outputStatus, outputError, outputInput } from './output.ts';
16
17
 
17
18
  // Shared error tracking
@@ -215,7 +216,10 @@ export async function runContinuousServerMode(
215
216
  sessionID = resumeInfo.sessionID;
216
217
  } else {
217
218
  // Create a new session
218
- const createRes = await fetch(
219
+ const localVerboseFetch = createVerboseFetch(fetch, {
220
+ caller: 'continuous-mode',
221
+ });
222
+ const createRes = await localVerboseFetch(
219
223
  `http://${server.hostname}:${server.port}/session`,
220
224
  {
221
225
  method: 'POST',
@@ -11,6 +11,7 @@ import { lazy } from '../util/lazy';
11
11
  import { NamedError } from '../util/error';
12
12
  import { Flag } from '../flag/flag';
13
13
  import { Auth } from '../auth';
14
+ import { createVerboseFetch } from '../util/verbose-fetch';
14
15
  import {
15
16
  type ParseError as JsoncParseError,
16
17
  parse as parseJsonc,
@@ -21,6 +22,7 @@ import { ConfigMarkdown } from './markdown';
21
22
 
22
23
  export namespace Config {
23
24
  const log = Log.create({ service: 'config' });
25
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'config' });
24
26
 
25
27
  /**
26
28
  * Automatically migrate .opencode directories to .link-assistant-agent
@@ -163,9 +165,9 @@ export namespace Config {
163
165
  for (const [key, value] of Object.entries(auth)) {
164
166
  if (value.type === 'wellknown') {
165
167
  process.env[value.key] = value.token;
166
- const wellknown = (await fetch(`${key}/.well-known/opencode`).then(
167
- (x) => x.json()
168
- )) as any;
168
+ const wellknown = (await verboseFetch(
169
+ `${key}/.well-known/opencode`
170
+ ).then((x) => x.json())) as any;
169
171
  result = mergeDeep(
170
172
  result,
171
173
  await load(JSON.stringify(wellknown.config ?? {}), process.cwd())
@@ -9,9 +9,11 @@ import { $ } from 'bun';
9
9
 
10
10
  import { ZipReader, BlobReader, BlobWriter } from '@zip.js/zip.js';
11
11
  import { Log } from '../util/log';
12
+ import { createVerboseFetch } from '../util/verbose-fetch';
12
13
 
13
14
  export namespace Ripgrep {
14
15
  const log = Log.create({ service: 'ripgrep' });
16
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'ripgrep' });
15
17
  const Stats = z.object({
16
18
  elapsed: z.object({
17
19
  secs: z.number(),
@@ -142,7 +144,7 @@ export namespace Ripgrep {
142
144
  const filename = `ripgrep-${version}-${config.platform}.${config.extension}`;
143
145
  const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`;
144
146
 
145
- const response = await fetch(url);
147
+ const response = await verboseFetch(url);
146
148
  if (!response.ok)
147
149
  throw new DownloadFailedError({ url, status: response.status });
148
150
 
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ import { McpCommand } from './cli/cmd/mcp.ts';
21
21
  import { AuthCommand } from './cli/cmd/auth.ts';
22
22
  import { FormatError } from './cli/error.ts';
23
23
  import { UI } from './cli/ui.ts';
24
+ import { createVerboseFetch } from './util/verbose-fetch.ts';
24
25
  import {
25
26
  runContinuousServerMode,
26
27
  runContinuousDirectMode,
@@ -427,7 +428,8 @@ async function runServerMode(
427
428
  sessionID = resumeInfo.sessionID;
428
429
  } else {
429
430
  // Create a new session
430
- const createRes = await fetch(
431
+ const localVerboseFetch = createVerboseFetch(fetch, { caller: 'cli' });
432
+ const createRes = await localVerboseFetch(
431
433
  `http://${server.hostname}:${server.port}/session`,
432
434
  {
433
435
  method: 'POST',
@@ -15,8 +15,10 @@
15
15
 
16
16
  import { Log } from '../util/log';
17
17
  import { Auth } from '../auth';
18
+ import { createVerboseFetch } from '../util/verbose-fetch';
18
19
 
19
20
  const log = Log.create({ service: 'google-cloudcode' });
21
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'google-cloudcode' });
20
22
 
21
23
  // Cloud Code API endpoints (from gemini-cli)
22
24
  // Configurable via environment variables for testing or alternative endpoints
@@ -179,7 +181,7 @@ export class CloudCodeClient {
179
181
  message: 'refreshing google oauth token for cloud code',
180
182
  }));
181
183
 
182
- const response = await fetch(GOOGLE_TOKEN_URL, {
184
+ const response = await verboseFetch(GOOGLE_TOKEN_URL, {
183
185
  method: 'POST',
184
186
  headers: {
185
187
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -223,7 +225,7 @@ export class CloudCodeClient {
223
225
  const baseUrl = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`;
224
226
  const url = options.stream ? `${baseUrl}?alt=sse` : baseUrl;
225
227
 
226
- const response = await fetch(url, {
228
+ const response = await verboseFetch(url, {
227
229
  method: 'POST',
228
230
  headers: {
229
231
  'Content-Type': 'application/json',
@@ -1,11 +1,13 @@
1
1
  import { Global } from '../global';
2
2
  import { Log } from '../util/log';
3
+ import { createVerboseFetch } from '../util/verbose-fetch';
3
4
  import path from 'path';
4
5
  import z from 'zod';
5
6
  import { data } from './models-macro';
6
7
 
7
8
  export namespace ModelsDev {
8
9
  const log = Log.create({ service: 'models.dev' });
10
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'models.dev' });
9
11
  const filepath = path.join(Global.Path.cache, 'models.json');
10
12
 
11
13
  export const Model = z
@@ -145,7 +147,7 @@ export namespace ModelsDev {
145
147
  export async function refresh() {
146
148
  const file = Bun.file(filepath);
147
149
  log.info(() => ({ message: 'refreshing', file }));
148
- const result = await fetch('https://models.dev/api.json', {
150
+ const result = await verboseFetch('https://models.dev/api.json', {
149
151
  headers: {
150
152
  'User-Agent': 'agent-cli/1.0.0',
151
153
  },
@@ -1207,8 +1207,22 @@ export namespace Provider {
1207
1207
  // When verbose is disabled, the wrapper is a no-op passthrough with negligible overhead.
1208
1208
  // See: https://github.com/link-assistant/agent/issues/200
1209
1209
  // See: https://github.com/link-assistant/agent/issues/206
1210
+ // See: https://github.com/link-assistant/agent/issues/215
1210
1211
  {
1211
1212
  const innerFetch = options['fetch'];
1213
+ let verboseWrapperConfirmed = false;
1214
+ let httpCallCount = 0;
1215
+
1216
+ // Log at SDK creation time that the fetch wrapper is installed.
1217
+ // This runs once per provider SDK creation (not per request).
1218
+ // If verbose is off at creation time, the per-request check still applies.
1219
+ // See: https://github.com/link-assistant/agent/issues/215
1220
+ log.info('verbose HTTP fetch wrapper installed', {
1221
+ providerID: provider.id,
1222
+ pkg,
1223
+ verboseAtCreation: Flag.OPENCODE_VERBOSE,
1224
+ });
1225
+
1212
1226
  options['fetch'] = async (
1213
1227
  input: RequestInfo | URL,
1214
1228
  init?: RequestInit
@@ -1218,6 +1232,24 @@ export namespace Provider {
1218
1232
  return innerFetch(input, init);
1219
1233
  }
1220
1234
 
1235
+ httpCallCount++;
1236
+ const callNum = httpCallCount;
1237
+
1238
+ // Log a one-time confirmation that the verbose wrapper is active for this provider.
1239
+ // This diagnostic breadcrumb confirms the wrapper is in the fetch chain.
1240
+ // Also write to stderr as a redundant channel — stdout JSON may be filtered by wrappers.
1241
+ // See: https://github.com/link-assistant/agent/issues/215
1242
+ if (!verboseWrapperConfirmed) {
1243
+ verboseWrapperConfirmed = true;
1244
+ log.info('verbose HTTP logging active', {
1245
+ providerID: provider.id,
1246
+ });
1247
+ // Redundant stderr confirmation — visible even if stdout is piped/filtered
1248
+ process.stderr.write(
1249
+ `[verbose] HTTP logging active for provider: ${provider.id}\n`
1250
+ );
1251
+ }
1252
+
1221
1253
  const url =
1222
1254
  typeof input === 'string'
1223
1255
  ? input
@@ -1226,50 +1258,64 @@ export namespace Provider {
1226
1258
  : input.url;
1227
1259
  const method = init?.method ?? 'GET';
1228
1260
 
1229
- // Sanitize headers - mask authorization values
1230
- const sanitizedHeaders: Record<string, string> = {};
1231
- const rawHeaders = init?.headers;
1232
- if (rawHeaders) {
1233
- const entries =
1234
- rawHeaders instanceof Headers
1235
- ? Array.from(rawHeaders.entries())
1236
- : Array.isArray(rawHeaders)
1237
- ? rawHeaders
1238
- : Object.entries(rawHeaders);
1239
- for (const [key, value] of entries) {
1240
- const lower = key.toLowerCase();
1241
- if (
1242
- lower === 'authorization' ||
1243
- lower === 'x-api-key' ||
1244
- lower === 'api-key'
1245
- ) {
1246
- sanitizedHeaders[key] =
1247
- typeof value === 'string' && value.length > 8
1248
- ? value.slice(0, 4) + '...' + value.slice(-4)
1249
- : '[REDACTED]';
1250
- } else {
1251
- sanitizedHeaders[key] = String(value);
1261
+ // Wrap all verbose logging in try/catch so it never breaks the actual HTTP request.
1262
+ // If logging fails, the request must still proceed — logging is observability, not control flow.
1263
+ // See: https://github.com/link-assistant/agent/issues/215
1264
+ let sanitizedHeaders: Record<string, string> = {};
1265
+ let bodyPreview: string | undefined;
1266
+ try {
1267
+ // Sanitize headers - mask authorization values
1268
+ const rawHeaders = init?.headers;
1269
+ if (rawHeaders) {
1270
+ const entries =
1271
+ rawHeaders instanceof Headers
1272
+ ? Array.from(rawHeaders.entries())
1273
+ : Array.isArray(rawHeaders)
1274
+ ? rawHeaders
1275
+ : Object.entries(rawHeaders);
1276
+ for (const [key, value] of entries) {
1277
+ const lower = key.toLowerCase();
1278
+ if (
1279
+ lower === 'authorization' ||
1280
+ lower === 'x-api-key' ||
1281
+ lower === 'api-key'
1282
+ ) {
1283
+ sanitizedHeaders[key] =
1284
+ typeof value === 'string' && value.length > 8
1285
+ ? value.slice(0, 4) + '...' + value.slice(-4)
1286
+ : '[REDACTED]';
1287
+ } else {
1288
+ sanitizedHeaders[key] = String(value);
1289
+ }
1252
1290
  }
1253
1291
  }
1254
- }
1255
1292
 
1256
- // Log request body preview (truncated)
1257
- let bodyPreview: string | undefined;
1258
- if (init?.body) {
1259
- const bodyStr =
1260
- typeof init.body === 'string'
1261
- ? init.body
1262
- : init.body instanceof ArrayBuffer ||
1263
- init.body instanceof Uint8Array
1264
- ? `[binary ${(init.body as ArrayBuffer).byteLength ?? (init.body as Uint8Array).length} bytes]`
1265
- : undefined;
1266
- if (bodyStr && typeof bodyStr === 'string') {
1267
- bodyPreview =
1268
- bodyStr.length > 2000
1269
- ? bodyStr.slice(0, 2000) +
1270
- `... [truncated, total ${bodyStr.length} chars]`
1271
- : bodyStr;
1293
+ // Log request body preview (truncated)
1294
+ if (init?.body) {
1295
+ const bodyStr =
1296
+ typeof init.body === 'string'
1297
+ ? init.body
1298
+ : init.body instanceof ArrayBuffer ||
1299
+ init.body instanceof Uint8Array
1300
+ ? `[binary ${(init.body as ArrayBuffer).byteLength ?? (init.body as Uint8Array).length} bytes]`
1301
+ : undefined;
1302
+ if (bodyStr && typeof bodyStr === 'string') {
1303
+ bodyPreview =
1304
+ bodyStr.length > 2000
1305
+ ? bodyStr.slice(0, 2000) +
1306
+ `... [truncated, total ${bodyStr.length} chars]`
1307
+ : bodyStr;
1308
+ }
1272
1309
  }
1310
+ } catch (prepError) {
1311
+ // If header/body processing fails, log the error but continue with the request
1312
+ log.warn('verbose logging: failed to prepare request details', {
1313
+ providerID: provider.id,
1314
+ error:
1315
+ prepError instanceof Error
1316
+ ? prepError.message
1317
+ : String(prepError),
1318
+ });
1273
1319
  }
1274
1320
 
1275
1321
  // Use direct (non-lazy) logging for HTTP request/response to ensure output
@@ -1277,7 +1323,9 @@ export namespace Provider {
1277
1323
  // The verbose check is already done above, so lazy evaluation is not needed here.
1278
1324
  // See: https://github.com/link-assistant/agent/issues/211
1279
1325
  log.info('HTTP request', {
1326
+ caller: `provider/${provider.id}`,
1280
1327
  providerID: provider.id,
1328
+ callNum,
1281
1329
  method,
1282
1330
  url,
1283
1331
  headers: sanitizedHeaders,
@@ -1286,13 +1334,21 @@ export namespace Provider {
1286
1334
 
1287
1335
  const startMs = Date.now();
1288
1336
  try {
1289
- const response = await innerFetch(input, init);
1337
+ // Pass Bun-specific verbose:true to get detailed connection debugging
1338
+ // (prints request/response headers to stderr on socket errors).
1339
+ // This is a no-op on non-Bun runtimes.
1340
+ // See: https://bun.sh/docs/api/fetch
1341
+ // See: https://github.com/link-assistant/agent/issues/215
1342
+ const verboseInit = { ...init, verbose: true } as RequestInit;
1343
+ const response = await innerFetch(input, verboseInit);
1290
1344
  const durationMs = Date.now() - startMs;
1291
1345
 
1292
1346
  // Use direct (non-lazy) logging to ensure HTTP response details are captured
1293
1347
  // See: https://github.com/link-assistant/agent/issues/211
1294
1348
  log.info('HTTP response', {
1349
+ caller: `provider/${provider.id}`,
1295
1350
  providerID: provider.id,
1351
+ callNum,
1296
1352
  method,
1297
1353
  url,
1298
1354
  status: response.status,
@@ -1342,7 +1398,9 @@ export namespace Provider {
1342
1398
  // Use direct (non-lazy) logging for stream body
1343
1399
  // See: https://github.com/link-assistant/agent/issues/211
1344
1400
  log.info('HTTP response body (stream)', {
1401
+ caller: `provider/${provider.id}`,
1345
1402
  providerID: provider.id,
1403
+ callNum,
1346
1404
  url,
1347
1405
  bodyPreview: truncated
1348
1406
  ? bodyPreview + `... [truncated]`
@@ -1370,7 +1428,9 @@ export namespace Provider {
1370
1428
  // Use direct (non-lazy) logging for non-streaming body
1371
1429
  // See: https://github.com/link-assistant/agent/issues/211
1372
1430
  log.info('HTTP response body', {
1431
+ caller: `provider/${provider.id}`,
1373
1432
  providerID: provider.id,
1433
+ callNum,
1374
1434
  url,
1375
1435
  bodyPreview,
1376
1436
  });
@@ -1386,15 +1446,29 @@ export namespace Provider {
1386
1446
  } catch (error) {
1387
1447
  const durationMs = Date.now() - startMs;
1388
1448
  // Use direct (non-lazy) logging for error path
1449
+ // Include stack trace and error cause for better debugging of socket errors
1389
1450
  // See: https://github.com/link-assistant/agent/issues/211
1451
+ // See: https://github.com/link-assistant/agent/issues/215
1390
1452
  log.error('HTTP request failed', {
1453
+ caller: `provider/${provider.id}`,
1391
1454
  providerID: provider.id,
1455
+ callNum,
1392
1456
  method,
1393
1457
  url,
1394
1458
  durationMs,
1395
1459
  error:
1396
1460
  error instanceof Error
1397
- ? { name: error.name, message: error.message }
1461
+ ? {
1462
+ name: error.name,
1463
+ message: error.message,
1464
+ stack: error.stack,
1465
+ cause:
1466
+ error.cause instanceof Error
1467
+ ? error.cause.message
1468
+ : error.cause
1469
+ ? String(error.cause)
1470
+ : undefined,
1471
+ }
1398
1472
  : String(error),
1399
1473
  });
1400
1474
  throw error;
@@ -1,6 +1,9 @@
1
1
  import z from 'zod';
2
2
  import { Tool } from './tool';
3
3
  import DESCRIPTION from './codesearch.txt';
4
+ import { createVerboseFetch } from '../util/verbose-fetch';
5
+
6
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'codesearch' });
4
7
 
5
8
  const API_CONFIG = {
6
9
  BASE_URL: 'https://mcp.exa.ai',
@@ -73,7 +76,7 @@ export const CodeSearchTool = Tool.define('codesearch', {
73
76
  'content-type': 'application/json',
74
77
  };
75
78
 
76
- const response = await fetch(
79
+ const response = await verboseFetch(
77
80
  `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`,
78
81
  {
79
82
  method: 'POST',
@@ -2,6 +2,9 @@ import z from 'zod';
2
2
  import { Tool } from './tool';
3
3
  import TurndownService from 'turndown';
4
4
  import DESCRIPTION from './webfetch.txt';
5
+ import { createVerboseFetch } from '../util/verbose-fetch';
6
+
7
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'webfetch' });
5
8
 
6
9
  const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB
7
10
  const DEFAULT_TIMEOUT = 30 * 1000; // 30 seconds
@@ -59,7 +62,7 @@ export const WebFetchTool = Tool.define('webfetch', {
59
62
  'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8';
60
63
  }
61
64
 
62
- const response = await fetch(params.url, {
65
+ const response = await verboseFetch(params.url, {
63
66
  signal: AbortSignal.any([controller.signal, ctx.abort]),
64
67
  headers: {
65
68
  'User-Agent':
@@ -2,6 +2,9 @@ import z from 'zod';
2
2
  import { Tool } from './tool';
3
3
  import DESCRIPTION from './websearch.txt';
4
4
  import { Config } from '../config/config';
5
+ import { createVerboseFetch } from '../util/verbose-fetch';
6
+
7
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'websearch' });
5
8
 
6
9
  const API_CONFIG = {
7
10
  BASE_URL: 'https://mcp.exa.ai',
@@ -91,7 +94,7 @@ export const WebSearchTool = Tool.define('websearch', {
91
94
  'content-type': 'application/json',
92
95
  };
93
96
 
94
- const response = await fetch(
97
+ const response = await verboseFetch(
95
98
  `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`,
96
99
  {
97
100
  method: 'POST',
@@ -0,0 +1,303 @@
1
+ import { Log } from './log';
2
+ import { Flag } from '../flag/flag';
3
+
4
+ /**
5
+ * Shared verbose HTTP fetch wrapper.
6
+ *
7
+ * Intercepts fetch() calls and logs request/response details as JSON objects
8
+ * when --verbose mode is enabled. Used across the entire codebase (tools, auth,
9
+ * config, providers) to ensure uniform and predictable HTTP logging.
10
+ *
11
+ * Features:
12
+ * - Logs HTTP request: method, URL, sanitized headers, body preview
13
+ * - Logs HTTP response: status, headers, duration, body preview
14
+ * - Logs HTTP errors: stack trace, error cause chain
15
+ * - Sequential call numbering for correlation
16
+ * - Error-resilient: logging failures never break the actual HTTP request
17
+ * - Runtime verbose check: respects Flag.OPENCODE_VERBOSE at call time
18
+ *
19
+ * @see https://github.com/link-assistant/agent/issues/215
20
+ */
21
+
22
+ const log = Log.create({ service: 'http' });
23
+
24
+ /** Global call counter shared across all verbose fetch wrappers */
25
+ let globalHttpCallCount = 0;
26
+
27
+ /**
28
+ * Sanitize HTTP headers by masking sensitive values.
29
+ * Masks authorization, x-api-key, and api-key headers.
30
+ */
31
+ export function sanitizeHeaders(
32
+ rawHeaders: HeadersInit | Record<string, string> | Headers | undefined
33
+ ): Record<string, string> {
34
+ const sanitized: Record<string, string> = {};
35
+ if (!rawHeaders) return sanitized;
36
+
37
+ const entries =
38
+ rawHeaders instanceof Headers
39
+ ? Array.from(rawHeaders.entries())
40
+ : Array.isArray(rawHeaders)
41
+ ? rawHeaders
42
+ : Object.entries(rawHeaders);
43
+
44
+ for (const [key, value] of entries) {
45
+ const lower = key.toLowerCase();
46
+ if (
47
+ lower === 'authorization' ||
48
+ lower === 'x-api-key' ||
49
+ lower === 'api-key'
50
+ ) {
51
+ sanitized[key] =
52
+ typeof value === 'string' && value.length > 8
53
+ ? value.slice(0, 4) + '...' + value.slice(-4)
54
+ : '[REDACTED]';
55
+ } else {
56
+ sanitized[key] = String(value);
57
+ }
58
+ }
59
+ return sanitized;
60
+ }
61
+
62
+ /**
63
+ * Create a body preview string, truncated to maxChars.
64
+ */
65
+ export function bodyPreview(
66
+ body: BodyInit | null | undefined,
67
+ maxChars = 2000
68
+ ): string | undefined {
69
+ if (!body) return undefined;
70
+
71
+ const bodyStr =
72
+ typeof body === 'string'
73
+ ? body
74
+ : body instanceof ArrayBuffer || body instanceof Uint8Array
75
+ ? `[binary ${(body as ArrayBuffer).byteLength ?? (body as Uint8Array).length} bytes]`
76
+ : body instanceof URLSearchParams
77
+ ? body.toString()
78
+ : undefined;
79
+
80
+ if (bodyStr && typeof bodyStr === 'string') {
81
+ return bodyStr.length > maxChars
82
+ ? bodyStr.slice(0, maxChars) +
83
+ `... [truncated, total ${bodyStr.length} chars]`
84
+ : bodyStr;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ export interface VerboseFetchOptions {
90
+ /** Identifier for the caller (e.g. 'webfetch', 'auth-plugins', 'config') */
91
+ caller: string;
92
+ /** Maximum chars for response body preview (default: 4000) */
93
+ responseBodyMaxChars?: number;
94
+ /** Maximum chars for request body preview (default: 2000) */
95
+ requestBodyMaxChars?: number;
96
+ }
97
+
98
+ /**
99
+ * Wrap a fetch function with verbose HTTP logging.
100
+ *
101
+ * When Flag.OPENCODE_VERBOSE is true, logs all HTTP requests and responses
102
+ * as JSON objects. When verbose is false, returns a no-op passthrough.
103
+ *
104
+ * All logging is wrapped in try/catch so it never breaks the actual HTTP request.
105
+ *
106
+ * @param innerFetch - The fetch function to wrap (defaults to global fetch)
107
+ * @param options - Configuration for the wrapper
108
+ * @returns A wrapped fetch function with verbose logging
109
+ */
110
+ export function createVerboseFetch(
111
+ innerFetch: typeof fetch = fetch,
112
+ options: VerboseFetchOptions
113
+ ): typeof fetch {
114
+ const {
115
+ caller,
116
+ responseBodyMaxChars = 4000,
117
+ requestBodyMaxChars = 2000,
118
+ } = options;
119
+
120
+ return async (
121
+ input: RequestInfo | URL,
122
+ init?: RequestInit
123
+ ): Promise<Response> => {
124
+ // Check verbose flag at call time
125
+ if (!Flag.OPENCODE_VERBOSE) {
126
+ return innerFetch(input, init);
127
+ }
128
+
129
+ globalHttpCallCount++;
130
+ const callNum = globalHttpCallCount;
131
+
132
+ const url =
133
+ typeof input === 'string'
134
+ ? input
135
+ : input instanceof URL
136
+ ? input.toString()
137
+ : input.url;
138
+ const method = init?.method ?? 'GET';
139
+
140
+ // Prepare request details for logging (error-resilient)
141
+ let sanitizedHdrs: Record<string, string> = {};
142
+ let reqBodyPreview: string | undefined;
143
+ try {
144
+ sanitizedHdrs = sanitizeHeaders(init?.headers as HeadersInit | undefined);
145
+ reqBodyPreview = bodyPreview(init?.body, requestBodyMaxChars);
146
+ } catch (prepError) {
147
+ log.warn('verbose logging: failed to prepare request details', {
148
+ caller,
149
+ error:
150
+ prepError instanceof Error ? prepError.message : String(prepError),
151
+ });
152
+ }
153
+
154
+ // Log request
155
+ log.info('HTTP request', {
156
+ caller,
157
+ callNum,
158
+ method,
159
+ url,
160
+ headers: sanitizedHdrs,
161
+ bodyPreview: reqBodyPreview,
162
+ });
163
+
164
+ const startMs = Date.now();
165
+ try {
166
+ const response = await innerFetch(input, init);
167
+ const durationMs = Date.now() - startMs;
168
+
169
+ // Log response
170
+ try {
171
+ log.info('HTTP response', {
172
+ caller,
173
+ callNum,
174
+ method,
175
+ url,
176
+ status: response.status,
177
+ statusText: response.statusText,
178
+ durationMs,
179
+ responseHeaders: Object.fromEntries(response.headers.entries()),
180
+ });
181
+ } catch {
182
+ // Ignore logging errors
183
+ }
184
+
185
+ // Log response body
186
+ const contentType = response.headers.get('content-type') ?? '';
187
+ const isStreaming =
188
+ contentType.includes('event-stream') ||
189
+ contentType.includes('octet-stream');
190
+
191
+ if (response.body) {
192
+ try {
193
+ if (isStreaming) {
194
+ const [sdkStream, logStream] = response.body.tee();
195
+
196
+ // Consume log stream asynchronously
197
+ (async () => {
198
+ try {
199
+ const reader = logStream.getReader();
200
+ const decoder = new TextDecoder();
201
+ let preview = '';
202
+ let truncated = false;
203
+ while (true) {
204
+ const { done, value } = await reader.read();
205
+ if (done) break;
206
+ if (!truncated) {
207
+ const chunk = decoder.decode(value, { stream: true });
208
+ preview += chunk;
209
+ if (preview.length > responseBodyMaxChars) {
210
+ preview = preview.slice(0, responseBodyMaxChars);
211
+ truncated = true;
212
+ }
213
+ }
214
+ }
215
+ log.info('HTTP response body (stream)', {
216
+ caller,
217
+ callNum,
218
+ url,
219
+ bodyPreview: truncated
220
+ ? preview + `... [truncated]`
221
+ : preview,
222
+ });
223
+ } catch {
224
+ // Ignore logging errors
225
+ }
226
+ })();
227
+
228
+ return new Response(sdkStream, {
229
+ status: response.status,
230
+ statusText: response.statusText,
231
+ headers: response.headers,
232
+ });
233
+ } else {
234
+ const bodyText = await response.text();
235
+ const preview =
236
+ bodyText.length > responseBodyMaxChars
237
+ ? bodyText.slice(0, responseBodyMaxChars) +
238
+ `... [truncated, total ${bodyText.length} chars]`
239
+ : bodyText;
240
+ log.info('HTTP response body', {
241
+ caller,
242
+ callNum,
243
+ url,
244
+ bodyPreview: preview,
245
+ });
246
+ return new Response(bodyText, {
247
+ status: response.status,
248
+ statusText: response.statusText,
249
+ headers: response.headers,
250
+ });
251
+ }
252
+ } catch {
253
+ // If body logging fails, return original response
254
+ return response;
255
+ }
256
+ }
257
+
258
+ return response;
259
+ } catch (error) {
260
+ const durationMs = Date.now() - startMs;
261
+ try {
262
+ log.error('HTTP request failed', {
263
+ caller,
264
+ callNum,
265
+ method,
266
+ url,
267
+ durationMs,
268
+ error:
269
+ error instanceof Error
270
+ ? {
271
+ name: error.name,
272
+ message: error.message,
273
+ stack: error.stack,
274
+ cause:
275
+ error.cause instanceof Error
276
+ ? error.cause.message
277
+ : error.cause
278
+ ? String(error.cause)
279
+ : undefined,
280
+ }
281
+ : String(error),
282
+ });
283
+ } catch {
284
+ // Ignore logging errors
285
+ }
286
+ throw error;
287
+ }
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Get the current global HTTP call count (for testing).
293
+ */
294
+ export function getHttpCallCount(): number {
295
+ return globalHttpCallCount;
296
+ }
297
+
298
+ /**
299
+ * Reset the global HTTP call count (for testing).
300
+ */
301
+ export function resetHttpCallCount(): void {
302
+ globalHttpCallCount = 0;
303
+ }