@link-assistant/agent 0.16.16 → 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.16",
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",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "bun run src/index.js",
12
- "test": "bun test",
12
+ "test": "bun test tests/*.test.js tests/*.test.ts",
13
+ "test:integration": "bun test tests/integration/basic.test.js",
13
14
  "lint": "eslint .",
14
15
  "lint:fix": "eslint . --fix",
15
16
  "format": "prettier --write .",
@@ -117,6 +118,7 @@
117
118
  "eslint": "^9.38.0",
118
119
  "eslint-config-prettier": "^10.1.8",
119
120
  "eslint-plugin-prettier": "^5.5.4",
121
+ "eslint-plugin-promise": "^7.2.1",
120
122
  "husky": "^9.1.7",
121
123
  "lint-staged": "^16.2.6",
122
124
  "prettier": "^3.6.2"
@@ -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',
@@ -377,15 +381,24 @@ export async function runContinuousServerMode(
377
381
 
378
382
  // Wait for stdin to end (EOF or close)
379
383
  await new Promise((resolve) => {
384
+ let resolved = false;
385
+ const safeResolve = () => {
386
+ if (!resolved) {
387
+ resolved = true;
388
+ resolve();
389
+ }
390
+ };
391
+
380
392
  const checkRunning = setInterval(() => {
381
393
  if (!stdinReader.isRunning()) {
382
394
  clearInterval(checkRunning);
383
395
  // Wait for any pending messages to complete
384
396
  const waitForPending = () => {
385
397
  if (!isProcessing && pendingMessages.length === 0) {
386
- resolve();
398
+ safeResolve();
387
399
  } else {
388
- setTimeout(waitForPending, 100);
400
+ // Use .unref() to prevent keeping the event loop alive (#213)
401
+ setTimeout(waitForPending, 100).unref();
389
402
  }
390
403
  };
391
404
  waitForPending();
@@ -394,8 +407,8 @@ export async function runContinuousServerMode(
394
407
  // Allow process to exit naturally when no other work remains
395
408
  checkRunning.unref();
396
409
 
397
- // Also handle SIGINT
398
- process.on('SIGINT', () => {
410
+ // Handle SIGINT — use 'once' to avoid accumulating handlers (#213)
411
+ const sigintHandler = () => {
399
412
  outputStatus(
400
413
  {
401
414
  type: 'status',
@@ -404,8 +417,9 @@ export async function runContinuousServerMode(
404
417
  compactJson
405
418
  );
406
419
  clearInterval(checkRunning);
407
- resolve();
408
- });
420
+ safeResolve();
421
+ };
422
+ process.once('SIGINT', sigintHandler);
409
423
  });
410
424
  } finally {
411
425
  if (stdinReader) {
@@ -596,15 +610,24 @@ export async function runContinuousDirectMode(
596
610
 
597
611
  // Wait for stdin to end (EOF or close)
598
612
  await new Promise((resolve) => {
613
+ let resolved = false;
614
+ const safeResolve = () => {
615
+ if (!resolved) {
616
+ resolved = true;
617
+ resolve();
618
+ }
619
+ };
620
+
599
621
  const checkRunning = setInterval(() => {
600
622
  if (!stdinReader.isRunning()) {
601
623
  clearInterval(checkRunning);
602
624
  // Wait for any pending messages to complete
603
625
  const waitForPending = () => {
604
626
  if (!isProcessing && pendingMessages.length === 0) {
605
- resolve();
627
+ safeResolve();
606
628
  } else {
607
- setTimeout(waitForPending, 100);
629
+ // Use .unref() to prevent keeping the event loop alive (#213)
630
+ setTimeout(waitForPending, 100).unref();
608
631
  }
609
632
  };
610
633
  waitForPending();
@@ -613,8 +636,8 @@ export async function runContinuousDirectMode(
613
636
  // Allow process to exit naturally when no other work remains
614
637
  checkRunning.unref();
615
638
 
616
- // Also handle SIGINT
617
- process.on('SIGINT', () => {
639
+ // Handle SIGINT — use 'once' to avoid accumulating handlers (#213)
640
+ const sigintHandler = () => {
618
641
  outputStatus(
619
642
  {
620
643
  type: 'status',
@@ -623,8 +646,9 @@ export async function runContinuousDirectMode(
623
646
  compactJson
624
647
  );
625
648
  clearInterval(checkRunning);
626
- resolve();
627
- });
649
+ safeResolve();
650
+ };
651
+ process.once('SIGINT', sigintHandler);
628
652
  });
629
653
  } finally {
630
654
  if (stdinReader) {
@@ -178,11 +178,13 @@ export function createContinuousStdinReader(options = {}) {
178
178
  isRunning = false;
179
179
  };
180
180
 
181
+ const handleError = () => {
182
+ isRunning = false;
183
+ };
184
+
181
185
  process.stdin.on('data', handleData);
182
186
  process.stdin.on('end', handleEnd);
183
- process.stdin.on('error', () => {
184
- isRunning = false;
185
- });
187
+ process.stdin.on('error', handleError);
186
188
 
187
189
  return {
188
190
  queue: inputQueue,
@@ -191,6 +193,7 @@ export function createContinuousStdinReader(options = {}) {
191
193
  inputQueue.flush();
192
194
  process.stdin.removeListener('data', handleData);
193
195
  process.stdin.removeListener('end', handleEnd);
196
+ process.stdin.removeListener('error', handleError);
194
197
  },
195
198
  isRunning: () => isRunning,
196
199
  };
@@ -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/flag/flag.ts CHANGED
@@ -194,4 +194,13 @@ export namespace Flag {
194
194
  export function setCompactJson(value: boolean) {
195
195
  _compactJson = value;
196
196
  }
197
+
198
+ // Retry on rate limits - when disabled, 429 responses are returned immediately without retrying
199
+ // Enabled by default. Use --no-retry-on-rate-limits in integration tests to avoid waiting for rate limits.
200
+ export let RETRY_ON_RATE_LIMITS = true;
201
+
202
+ // Allow setting retry-on-rate-limits mode programmatically (e.g., from CLI --retry-on-rate-limits flag)
203
+ export function setRetryOnRateLimits(value: boolean) {
204
+ RETRY_ON_RATE_LIMITS = value;
205
+ }
197
206
  }
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,
@@ -309,7 +310,12 @@ async function runAgentMode(argv, request) {
309
310
  },
310
311
  });
311
312
 
312
- // Explicitly exit to ensure process terminates
313
+ // Explicitly exit to ensure process terminates (#213)
314
+ Log.Default.info(() => ({
315
+ message: 'Agent exiting',
316
+ hasError,
317
+ uptimeSeconds: Math.round(process.uptime()),
318
+ }));
313
319
  process.exit(hasError ? 1 : 0);
314
320
  }
315
321
 
@@ -387,7 +393,12 @@ async function runContinuousAgentMode(argv) {
387
393
  },
388
394
  });
389
395
 
390
- // Explicitly exit to ensure process terminates
396
+ // Explicitly exit to ensure process terminates (#213)
397
+ Log.Default.info(() => ({
398
+ message: 'Agent exiting',
399
+ hasError,
400
+ uptimeSeconds: Math.round(process.uptime()),
401
+ }));
391
402
  process.exit(hasError ? 1 : 0);
392
403
  }
393
404
 
@@ -417,7 +428,8 @@ async function runServerMode(
417
428
  sessionID = resumeInfo.sessionID;
418
429
  } else {
419
430
  // Create a new session
420
- const createRes = await fetch(
431
+ const localVerboseFetch = createVerboseFetch(fetch, { caller: 'cli' });
432
+ const createRes = await localVerboseFetch(
421
433
  `http://${server.hostname}:${server.port}/session`,
422
434
  {
423
435
  method: 'POST',
@@ -708,6 +720,12 @@ async function main() {
708
720
  description:
709
721
  'Maximum total retry time in seconds for rate limit errors (default: 604800 = 7 days)',
710
722
  })
723
+ .option('retry-on-rate-limits', {
724
+ type: 'boolean',
725
+ description:
726
+ 'Retry AI completions API requests when rate limited (HTTP 429). Use --no-retry-on-rate-limits in integration tests to fail fast instead of waiting.',
727
+ default: true,
728
+ })
711
729
  .option('output-response-model', {
712
730
  type: 'boolean',
713
731
  description: 'Include model info in step_finish output',
@@ -902,6 +920,10 @@ async function main() {
902
920
  if (argv['summarize-session'] === true) {
903
921
  Flag.setSummarizeSession(true);
904
922
  }
923
+ // retry-on-rate-limits is enabled by default, only set if explicitly disabled
924
+ if (argv['retry-on-rate-limits'] === false) {
925
+ Flag.setRetryOnRateLimits(false);
926
+ }
905
927
  await Log.init({
906
928
  print: Flag.OPENCODE_VERBOSE,
907
929
  level: Flag.OPENCODE_VERBOSE ? 'DEBUG' : 'INFO',
@@ -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',