@link-assistant/agent 0.16.16 → 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.16",
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',
@@ -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;
@@ -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
  }