@link-assistant/agent 0.16.14 → 0.16.17

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.14",
3
+ "version": "0.16.17",
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"
@@ -377,15 +377,24 @@ export async function runContinuousServerMode(
377
377
 
378
378
  // Wait for stdin to end (EOF or close)
379
379
  await new Promise((resolve) => {
380
+ let resolved = false;
381
+ const safeResolve = () => {
382
+ if (!resolved) {
383
+ resolved = true;
384
+ resolve();
385
+ }
386
+ };
387
+
380
388
  const checkRunning = setInterval(() => {
381
389
  if (!stdinReader.isRunning()) {
382
390
  clearInterval(checkRunning);
383
391
  // Wait for any pending messages to complete
384
392
  const waitForPending = () => {
385
393
  if (!isProcessing && pendingMessages.length === 0) {
386
- resolve();
394
+ safeResolve();
387
395
  } else {
388
- setTimeout(waitForPending, 100);
396
+ // Use .unref() to prevent keeping the event loop alive (#213)
397
+ setTimeout(waitForPending, 100).unref();
389
398
  }
390
399
  };
391
400
  waitForPending();
@@ -394,8 +403,8 @@ export async function runContinuousServerMode(
394
403
  // Allow process to exit naturally when no other work remains
395
404
  checkRunning.unref();
396
405
 
397
- // Also handle SIGINT
398
- process.on('SIGINT', () => {
406
+ // Handle SIGINT — use 'once' to avoid accumulating handlers (#213)
407
+ const sigintHandler = () => {
399
408
  outputStatus(
400
409
  {
401
410
  type: 'status',
@@ -404,8 +413,9 @@ export async function runContinuousServerMode(
404
413
  compactJson
405
414
  );
406
415
  clearInterval(checkRunning);
407
- resolve();
408
- });
416
+ safeResolve();
417
+ };
418
+ process.once('SIGINT', sigintHandler);
409
419
  });
410
420
  } finally {
411
421
  if (stdinReader) {
@@ -596,15 +606,24 @@ export async function runContinuousDirectMode(
596
606
 
597
607
  // Wait for stdin to end (EOF or close)
598
608
  await new Promise((resolve) => {
609
+ let resolved = false;
610
+ const safeResolve = () => {
611
+ if (!resolved) {
612
+ resolved = true;
613
+ resolve();
614
+ }
615
+ };
616
+
599
617
  const checkRunning = setInterval(() => {
600
618
  if (!stdinReader.isRunning()) {
601
619
  clearInterval(checkRunning);
602
620
  // Wait for any pending messages to complete
603
621
  const waitForPending = () => {
604
622
  if (!isProcessing && pendingMessages.length === 0) {
605
- resolve();
623
+ safeResolve();
606
624
  } else {
607
- setTimeout(waitForPending, 100);
625
+ // Use .unref() to prevent keeping the event loop alive (#213)
626
+ setTimeout(waitForPending, 100).unref();
608
627
  }
609
628
  };
610
629
  waitForPending();
@@ -613,8 +632,8 @@ export async function runContinuousDirectMode(
613
632
  // Allow process to exit naturally when no other work remains
614
633
  checkRunning.unref();
615
634
 
616
- // Also handle SIGINT
617
- process.on('SIGINT', () => {
635
+ // Handle SIGINT — use 'once' to avoid accumulating handlers (#213)
636
+ const sigintHandler = () => {
618
637
  outputStatus(
619
638
  {
620
639
  type: 'status',
@@ -623,8 +642,9 @@ export async function runContinuousDirectMode(
623
642
  compactJson
624
643
  );
625
644
  clearInterval(checkRunning);
626
- resolve();
627
- });
645
+ safeResolve();
646
+ };
647
+ process.once('SIGINT', sigintHandler);
628
648
  });
629
649
  } finally {
630
650
  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
  };
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
@@ -309,7 +309,12 @@ async function runAgentMode(argv, request) {
309
309
  },
310
310
  });
311
311
 
312
- // Explicitly exit to ensure process terminates
312
+ // Explicitly exit to ensure process terminates (#213)
313
+ Log.Default.info(() => ({
314
+ message: 'Agent exiting',
315
+ hasError,
316
+ uptimeSeconds: Math.round(process.uptime()),
317
+ }));
313
318
  process.exit(hasError ? 1 : 0);
314
319
  }
315
320
 
@@ -387,7 +392,12 @@ async function runContinuousAgentMode(argv) {
387
392
  },
388
393
  });
389
394
 
390
- // Explicitly exit to ensure process terminates
395
+ // Explicitly exit to ensure process terminates (#213)
396
+ Log.Default.info(() => ({
397
+ message: 'Agent exiting',
398
+ hasError,
399
+ uptimeSeconds: Math.round(process.uptime()),
400
+ }));
391
401
  process.exit(hasError ? 1 : 0);
392
402
  }
393
403
 
@@ -708,6 +718,12 @@ async function main() {
708
718
  description:
709
719
  'Maximum total retry time in seconds for rate limit errors (default: 604800 = 7 days)',
710
720
  })
721
+ .option('retry-on-rate-limits', {
722
+ type: 'boolean',
723
+ description:
724
+ '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.',
725
+ default: true,
726
+ })
711
727
  .option('output-response-model', {
712
728
  type: 'boolean',
713
729
  description: 'Include model info in step_finish output',
@@ -902,6 +918,10 @@ async function main() {
902
918
  if (argv['summarize-session'] === true) {
903
919
  Flag.setSummarizeSession(true);
904
920
  }
921
+ // retry-on-rate-limits is enabled by default, only set if explicitly disabled
922
+ if (argv['retry-on-rate-limits'] === false) {
923
+ Flag.setRetryOnRateLimits(false);
924
+ }
905
925
  await Log.init({
906
926
  print: Flag.OPENCODE_VERBOSE,
907
927
  level: Flag.OPENCODE_VERBOSE ? 'DEBUG' : 'INFO',
@@ -1272,22 +1272,26 @@ export namespace Provider {
1272
1272
  }
1273
1273
  }
1274
1274
 
1275
- log.info(() => ({
1276
- message: 'HTTP request',
1275
+ // Use direct (non-lazy) logging for HTTP request/response to ensure output
1276
+ // is not lost when piped through external process wrappers (e.g., solve.mjs).
1277
+ // The verbose check is already done above, so lazy evaluation is not needed here.
1278
+ // See: https://github.com/link-assistant/agent/issues/211
1279
+ log.info('HTTP request', {
1277
1280
  providerID: provider.id,
1278
1281
  method,
1279
1282
  url,
1280
1283
  headers: sanitizedHeaders,
1281
1284
  bodyPreview,
1282
- }));
1285
+ });
1283
1286
 
1284
1287
  const startMs = Date.now();
1285
1288
  try {
1286
1289
  const response = await innerFetch(input, init);
1287
1290
  const durationMs = Date.now() - startMs;
1288
1291
 
1289
- log.info(() => ({
1290
- message: 'HTTP response',
1292
+ // Use direct (non-lazy) logging to ensure HTTP response details are captured
1293
+ // See: https://github.com/link-assistant/agent/issues/211
1294
+ log.info('HTTP response', {
1291
1295
  providerID: provider.id,
1292
1296
  method,
1293
1297
  url,
@@ -1295,7 +1299,7 @@ export namespace Provider {
1295
1299
  statusText: response.statusText,
1296
1300
  durationMs,
1297
1301
  responseHeaders: Object.fromEntries(response.headers.entries()),
1298
- }));
1302
+ });
1299
1303
 
1300
1304
  // Log response body for debugging provider failures
1301
1305
  // For streaming responses (SSE/event-stream), tee() the stream so the AI SDK
@@ -1335,14 +1339,15 @@ export namespace Provider {
1335
1339
  }
1336
1340
  }
1337
1341
  }
1338
- log.info(() => ({
1339
- message: 'HTTP response body (stream)',
1342
+ // Use direct (non-lazy) logging for stream body
1343
+ // See: https://github.com/link-assistant/agent/issues/211
1344
+ log.info('HTTP response body (stream)', {
1340
1345
  providerID: provider.id,
1341
1346
  url,
1342
1347
  bodyPreview: truncated
1343
1348
  ? bodyPreview + `... [truncated]`
1344
1349
  : bodyPreview,
1345
- }));
1350
+ });
1346
1351
  } catch {
1347
1352
  // Ignore logging errors — do not affect the SDK stream
1348
1353
  }
@@ -1362,12 +1367,13 @@ export namespace Provider {
1362
1367
  ? bodyText.slice(0, responseBodyMaxChars) +
1363
1368
  `... [truncated, total ${bodyText.length} chars]`
1364
1369
  : bodyText;
1365
- log.info(() => ({
1366
- message: 'HTTP response body',
1370
+ // Use direct (non-lazy) logging for non-streaming body
1371
+ // See: https://github.com/link-assistant/agent/issues/211
1372
+ log.info('HTTP response body', {
1367
1373
  providerID: provider.id,
1368
1374
  url,
1369
1375
  bodyPreview,
1370
- }));
1376
+ });
1371
1377
  return new Response(bodyText, {
1372
1378
  status: response.status,
1373
1379
  statusText: response.statusText,
@@ -1379,8 +1385,9 @@ export namespace Provider {
1379
1385
  return response;
1380
1386
  } catch (error) {
1381
1387
  const durationMs = Date.now() - startMs;
1382
- log.error(() => ({
1383
- message: 'HTTP request failed',
1388
+ // Use direct (non-lazy) logging for error path
1389
+ // See: https://github.com/link-assistant/agent/issues/211
1390
+ log.error('HTTP request failed', {
1384
1391
  providerID: provider.id,
1385
1392
  method,
1386
1393
  url,
@@ -1389,7 +1396,7 @@ export namespace Provider {
1389
1396
  error instanceof Error
1390
1397
  ? { name: error.name, message: error.message }
1391
1398
  : String(error),
1392
- }));
1399
+ });
1393
1400
  throw error;
1394
1401
  }
1395
1402
  };
@@ -166,6 +166,8 @@ export namespace RetryFetch {
166
166
  }
167
167
 
168
168
  const timeout = setTimeout(resolve, ms);
169
+ // Prevent sleep timer from keeping event loop alive (#213)
170
+ if (timeout.unref) timeout.unref();
169
171
 
170
172
  if (signal) {
171
173
  const abortHandler = () => {
@@ -224,6 +226,8 @@ export namespace RetryFetch {
224
226
  )
225
227
  );
226
228
  }, remainingTimeout);
229
+ // Prevent timer from keeping event loop alive after cleanup (#213)
230
+ if (globalTimeoutId.unref) globalTimeoutId.unref();
227
231
  timers.push(globalTimeoutId);
228
232
 
229
233
  // Periodically check if user canceled (every 10 seconds)
@@ -244,6 +248,8 @@ export namespace RetryFetch {
244
248
  // Check immediately and then every 10 seconds
245
249
  checkUserCancellation();
246
250
  const intervalId = setInterval(checkUserCancellation, 10_000);
251
+ // Prevent interval from keeping event loop alive after cleanup (#213)
252
+ if ((intervalId as any).unref) (intervalId as any).unref();
247
253
  timers.push(intervalId as unknown as NodeJS.Timeout);
248
254
  }
249
255
 
@@ -364,6 +370,16 @@ export namespace RetryFetch {
364
370
  return response;
365
371
  }
366
372
 
373
+ // If retry on rate limits is disabled, return 429 immediately
374
+ if (!Flag.RETRY_ON_RATE_LIMITS) {
375
+ log.info(() => ({
376
+ message:
377
+ 'rate limit retry disabled (--no-retry-on-rate-limits), returning 429',
378
+ sessionID,
379
+ }));
380
+ return response;
381
+ }
382
+
367
383
  // Check if we're within the global retry timeout
368
384
  const elapsed = Date.now() - startTime;
369
385
  if (elapsed >= maxRetryTimeout) {
@@ -246,7 +246,9 @@ export namespace Server {
246
246
  const server = Bun.serve({
247
247
  port: opts.port,
248
248
  hostname: opts.hostname,
249
- idleTimeout: 0,
249
+ // Use default idle timeout (255s) instead of 0 (infinite) to prevent
250
+ // keeping the event loop alive after server.stop(). See #213.
251
+ idleTimeout: 255,
250
252
  fetch: App().fetch,
251
253
  });
252
254
  return server;
@@ -630,9 +630,14 @@ export namespace Session {
630
630
  }
631
631
  | undefined;
632
632
 
633
+ // Check if standard usage has valid data (inputTokens or outputTokens defined)
634
+ // Also check for zero-valued tokens (some providers set them to 0 instead of undefined)
633
635
  const standardUsageIsEmpty =
634
- input.usage.inputTokens === undefined &&
635
- input.usage.outputTokens === undefined;
636
+ (input.usage.inputTokens === undefined &&
637
+ input.usage.outputTokens === undefined) ||
638
+ (input.usage.inputTokens === 0 &&
639
+ input.usage.outputTokens === 0 &&
640
+ !input.usage.totalTokens);
636
641
 
637
642
  // If standard usage is empty but openrouter metadata has usage, use it as source
638
643
  let effectiveUsage = input.usage;
@@ -658,6 +663,41 @@ export namespace Session {
658
663
  };
659
664
  }
660
665
 
666
+ // If still empty, try providerMetadata.anthropic.usage as fallback
667
+ // Some providers (e.g., opencode using @ai-sdk/anthropic) return usage in
668
+ // Anthropic-specific metadata with snake_case keys (input_tokens, output_tokens)
669
+ // while the standard AI SDK usage object remains empty.
670
+ // See: https://github.com/link-assistant/agent/issues/211
671
+ if (standardUsageIsEmpty && !openrouterUsage) {
672
+ const anthropicUsage = input.metadata?.['anthropic']?.['usage'] as
673
+ | {
674
+ input_tokens?: number;
675
+ output_tokens?: number;
676
+ cache_creation_input_tokens?: number;
677
+ cache_read_input_tokens?: number;
678
+ }
679
+ | undefined;
680
+
681
+ if (
682
+ anthropicUsage &&
683
+ (anthropicUsage.input_tokens || anthropicUsage.output_tokens)
684
+ ) {
685
+ if (Flag.OPENCODE_VERBOSE) {
686
+ log.debug(() => ({
687
+ message:
688
+ 'Standard usage empty, falling back to anthropic provider metadata',
689
+ anthropicUsage: JSON.stringify(anthropicUsage),
690
+ }));
691
+ }
692
+ effectiveUsage = {
693
+ ...input.usage,
694
+ inputTokens: anthropicUsage.input_tokens ?? 0,
695
+ outputTokens: anthropicUsage.output_tokens ?? 0,
696
+ cachedInputTokens: anthropicUsage.cache_read_input_tokens ?? 0,
697
+ };
698
+ }
699
+ }
700
+
661
701
  // Extract top-level cachedInputTokens
662
702
  const topLevelCachedInputTokens = safeNum(
663
703
  toNumber(effectiveUsage.cachedInputTokens, 'cachedInputTokens')
@@ -123,6 +123,8 @@ export namespace SessionRetry {
123
123
  export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
124
124
  return new Promise((resolve, reject) => {
125
125
  const timeout = setTimeout(resolve, ms);
126
+ // Prevent sleep timer from keeping event loop alive (#213)
127
+ if (timeout.unref) timeout.unref();
126
128
  signal.addEventListener(
127
129
  'abort',
128
130
  () => {
@@ -9,6 +9,8 @@ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
9
9
  timeout = setTimeout(() => {
10
10
  reject(new Error(`Operation timed out after ${ms}ms`));
11
11
  }, ms);
12
+ // Prevent timeout from keeping the event loop alive (#213)
13
+ timeout.unref();
12
14
  }),
13
15
  ]);
14
16
  }