@link-assistant/agent 0.13.2 → 0.13.5

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.13.2",
3
+ "version": "0.13.5",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/bun/index.ts CHANGED
@@ -141,6 +141,14 @@ export namespace BunProc {
141
141
  return new Promise((resolve) => setTimeout(resolve, ms));
142
142
  }
143
143
 
144
+ /**
145
+ * Staleness threshold for 'latest' version packages (24 hours).
146
+ * Packages installed as 'latest' will be refreshed after this period.
147
+ * This ensures users get updated packages with bug fixes and new features.
148
+ * @see https://github.com/link-assistant/agent/issues/177
149
+ */
150
+ const LATEST_VERSION_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
151
+
144
152
  export async function install(pkg: string, version = 'latest') {
145
153
  const mod = path.join(Global.Path.cache, 'node_modules', pkg);
146
154
 
@@ -150,11 +158,41 @@ export namespace BunProc {
150
158
 
151
159
  const pkgjson = Bun.file(path.join(Global.Path.cache, 'package.json'));
152
160
  const parsed = await pkgjson.json().catch(async () => {
153
- const result = { dependencies: {} };
161
+ const result = { dependencies: {}, _installTime: {} };
154
162
  await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2));
155
163
  return result;
156
164
  });
157
- if (parsed.dependencies[pkg] === version) return mod;
165
+
166
+ // Initialize _installTime tracking if not present
167
+ if (!parsed._installTime) {
168
+ parsed._installTime = {};
169
+ }
170
+
171
+ // Check if package is already installed with the requested version
172
+ const installedVersion = parsed.dependencies[pkg];
173
+ const installTime = parsed._installTime[pkg] as number | undefined;
174
+
175
+ if (installedVersion === version) {
176
+ // For 'latest' version, check if installation is stale and needs refresh
177
+ // This ensures users get updated packages with important fixes
178
+ // @see https://github.com/link-assistant/agent/issues/177 (specificationVersion v3 support)
179
+ if (version === 'latest' && installTime) {
180
+ const age = Date.now() - installTime;
181
+ if (age < LATEST_VERSION_STALE_THRESHOLD_MS) {
182
+ return mod;
183
+ }
184
+ log.info(() => ({
185
+ message: 'refreshing stale latest package',
186
+ pkg,
187
+ version,
188
+ ageMs: age,
189
+ threshold: LATEST_VERSION_STALE_THRESHOLD_MS,
190
+ }));
191
+ } else if (version !== 'latest') {
192
+ // For explicit versions, don't reinstall
193
+ return mod;
194
+ }
195
+ }
158
196
 
159
197
  // Check for dry-run mode
160
198
  if (Flag.OPENCODE_DRY_RUN) {
@@ -205,6 +243,8 @@ export namespace BunProc {
205
243
  attempt,
206
244
  }));
207
245
  parsed.dependencies[pkg] = version;
246
+ // Track installation time for 'latest' version staleness checks
247
+ parsed._installTime[pkg] = Date.now();
208
248
  await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2));
209
249
  return mod;
210
250
  } catch (e) {
package/src/flag/flag.ts CHANGED
@@ -4,6 +4,11 @@ export namespace Flag {
4
4
  return process.env[newKey] ?? process.env[oldKey];
5
5
  }
6
6
 
7
+ function truthy(key: string) {
8
+ const value = process.env[key]?.toLowerCase();
9
+ return value === 'true' || value === '1';
10
+ }
11
+
7
12
  function truthyCompat(newKey: string, oldKey: string): boolean {
8
13
  const value = (getEnv(newKey, oldKey) ?? '').toLowerCase();
9
14
  return value === 'true' || value === '1';
@@ -155,9 +160,4 @@ export namespace Flag {
155
160
  export function setCompactJson(value: boolean) {
156
161
  _compactJson = value;
157
162
  }
158
-
159
- function truthy(key: string) {
160
- const value = process.env[key]?.toLowerCase();
161
- return value === 'true' || value === '1';
162
- }
163
163
  }
package/src/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env bun
2
-
2
+ import { Flag } from './flag/flag.ts';
3
3
  import { setProcessName } from './cli/process-name.ts';
4
-
5
4
  setProcessName('agent');
6
5
 
7
6
  import { Server } from './server/server.ts';
@@ -19,7 +18,6 @@ import {
19
18
  } from './json-standard/index.ts';
20
19
  import { McpCommand } from './cli/cmd/mcp.ts';
21
20
  import { AuthCommand } from './cli/cmd/auth.ts';
22
- import { Flag } from './flag/flag.ts';
23
21
  import { FormatError } from './cli/error.ts';
24
22
  import { UI } from './cli/ui.ts';
25
23
  import {
@@ -131,8 +131,11 @@ export namespace ModelsDev {
131
131
  if (result) return result as Record<string, Provider>;
132
132
 
133
133
  // Fallback to bundled data if cache read failed
134
- log.warn(() => ({
135
- message: 'cache read failed, using bundled data',
134
+ // This is expected behavior when the cache is unavailable or corrupted
135
+ // Using info level since bundled data is a valid fallback mechanism
136
+ // @see https://github.com/link-assistant/agent/issues/177
137
+ log.info(() => ({
138
+ message: 'cache unavailable, using bundled data',
136
139
  path: filepath,
137
140
  }));
138
141
  const json = await data();
@@ -20,7 +20,12 @@ import { Flag } from '../flag/flag';
20
20
  * By wrapping fetch, we handle rate limits at the HTTP layer with time-based retries,
21
21
  * ensuring the agent's 7-week global timeout is respected.
22
22
  *
23
+ * Important: Rate limit waits use ISOLATED AbortControllers that are NOT subject to
24
+ * provider/stream timeouts. This prevents long rate limit waits (e.g., 15 hours) from
25
+ * being aborted by short provider timeouts (e.g., 5 minutes).
26
+ *
23
27
  * @see https://github.com/link-assistant/agent/issues/167
28
+ * @see https://github.com/link-assistant/agent/issues/183
24
29
  * @see https://github.com/vercel/ai/issues/12585
25
30
  */
26
31
 
@@ -125,8 +130,8 @@ export namespace RetryFetch {
125
130
  log.info(() => ({
126
131
  message: 'using retry-after value',
127
132
  retryAfterMs,
128
- delay,
129
- minInterval,
133
+ delayMs: delay,
134
+ minIntervalMs: minInterval,
130
135
  }));
131
136
  return addJitter(delay);
132
137
  }
@@ -140,33 +145,119 @@ export namespace RetryFetch {
140
145
  log.info(() => ({
141
146
  message: 'no retry-after header, using exponential backoff',
142
147
  attempt,
143
- backoffDelay,
144
- delay,
145
- minInterval,
146
- maxBackoffDelay,
148
+ backoffDelayMs: backoffDelay,
149
+ delayMs: delay,
150
+ minIntervalMs: minInterval,
151
+ maxBackoffDelayMs: maxBackoffDelay,
147
152
  }));
148
153
  return addJitter(delay);
149
154
  }
150
155
 
151
156
  /**
152
157
  * Sleep for the specified duration, but respect abort signals.
158
+ * Properly cleans up event listeners to prevent memory leaks.
153
159
  */
154
160
  async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
155
161
  return new Promise((resolve, reject) => {
162
+ // Check if already aborted before starting
163
+ if (signal?.aborted) {
164
+ reject(new DOMException('Aborted', 'AbortError'));
165
+ return;
166
+ }
167
+
156
168
  const timeout = setTimeout(resolve, ms);
169
+
157
170
  if (signal) {
158
- signal.addEventListener(
159
- 'abort',
160
- () => {
161
- clearTimeout(timeout);
162
- reject(new DOMException('Aborted', 'AbortError'));
163
- },
164
- { once: true }
165
- );
171
+ const abortHandler = () => {
172
+ clearTimeout(timeout);
173
+ reject(new DOMException('Aborted', 'AbortError'));
174
+ };
175
+
176
+ signal.addEventListener('abort', abortHandler, { once: true });
177
+
178
+ // Clean up the listener when the timeout completes normally
179
+ // This prevents memory leaks on long-running processes
180
+ const originalResolve = resolve;
181
+ // eslint-disable-next-line no-param-reassign
182
+ resolve = (value) => {
183
+ signal.removeEventListener('abort', abortHandler);
184
+ originalResolve(value);
185
+ };
166
186
  }
167
187
  });
168
188
  }
169
189
 
190
+ /**
191
+ * Create an isolated AbortController for rate limit waits.
192
+ *
193
+ * This controller is NOT connected to the request's AbortSignal, so it won't be
194
+ * affected by provider timeouts (default 5 minutes) or stream timeouts.
195
+ * It only respects the global AGENT_RETRY_TIMEOUT.
196
+ *
197
+ * However, it DOES check the user's abort signal periodically (every 10 seconds)
198
+ * to allow user cancellation during long rate limit waits.
199
+ *
200
+ * This solves issue #183 where long rate limit waits (e.g., 15 hours) were being
201
+ * aborted by the provider timeout (5 minutes).
202
+ *
203
+ * @param remainingTimeout Maximum time allowed for this wait (ms)
204
+ * @param userSignal Optional user abort signal to check periodically
205
+ * @returns An object with the signal and a cleanup function
206
+ * @see https://github.com/link-assistant/agent/issues/183
207
+ */
208
+ function createIsolatedRateLimitSignal(
209
+ remainingTimeout: number,
210
+ userSignal?: AbortSignal
211
+ ): {
212
+ signal: AbortSignal;
213
+ cleanup: () => void;
214
+ } {
215
+ const controller = new AbortController();
216
+ const timers: NodeJS.Timeout[] = [];
217
+
218
+ // Set a timeout based on the global AGENT_RETRY_TIMEOUT (not provider timeout)
219
+ const globalTimeoutId = setTimeout(() => {
220
+ controller.abort(
221
+ new DOMException(
222
+ 'Rate limit wait exceeded global timeout',
223
+ 'TimeoutError'
224
+ )
225
+ );
226
+ }, remainingTimeout);
227
+ timers.push(globalTimeoutId);
228
+
229
+ // Periodically check if user canceled (every 10 seconds)
230
+ // This allows user cancellation during long rate limit waits
231
+ // without being affected by provider timeouts
232
+ if (userSignal) {
233
+ const checkUserCancellation = () => {
234
+ if (userSignal.aborted) {
235
+ controller.abort(
236
+ new DOMException(
237
+ 'User canceled during rate limit wait',
238
+ 'AbortError'
239
+ )
240
+ );
241
+ }
242
+ };
243
+
244
+ // Check immediately and then every 10 seconds
245
+ checkUserCancellation();
246
+ const intervalId = setInterval(checkUserCancellation, 10_000);
247
+ timers.push(intervalId as unknown as NodeJS.Timeout);
248
+ }
249
+
250
+ return {
251
+ signal: controller.signal,
252
+ cleanup: () => {
253
+ for (const timer of timers) {
254
+ clearTimeout(timer);
255
+ clearInterval(timer as unknown as NodeJS.Timeout);
256
+ }
257
+ },
258
+ };
259
+ }
260
+
170
261
  /**
171
262
  * Check if an error is retryable (network issues, temporary failures).
172
263
  */
@@ -243,8 +334,8 @@ export namespace RetryFetch {
243
334
  message:
244
335
  'network error retry timeout exceeded, re-throwing error',
245
336
  sessionID,
246
- elapsed,
247
- maxRetryTimeout,
337
+ elapsedMs: elapsed,
338
+ maxRetryTimeoutMs: maxRetryTimeout,
248
339
  error: (error as Error).message,
249
340
  }));
250
341
  throw error;
@@ -259,7 +350,7 @@ export namespace RetryFetch {
259
350
  message: 'network error, retrying',
260
351
  sessionID,
261
352
  attempt,
262
- delay,
353
+ delayMs: delay,
263
354
  error: (error as Error).message,
264
355
  }));
265
356
  await sleep(delay, init?.signal ?? undefined);
@@ -279,8 +370,8 @@ export namespace RetryFetch {
279
370
  log.warn(() => ({
280
371
  message: 'retry timeout exceeded in fetch wrapper, returning 429',
281
372
  sessionID,
282
- elapsed,
283
- maxRetryTimeout,
373
+ elapsedMs: elapsed,
374
+ maxRetryTimeoutMs: maxRetryTimeout,
284
375
  }));
285
376
  return response; // Let higher-level handling take over
286
377
  }
@@ -299,8 +390,8 @@ export namespace RetryFetch {
299
390
  message:
300
391
  'retry-after exceeds remaining timeout, returning 429 response',
301
392
  sessionID,
302
- elapsed,
303
- remainingTimeout: maxRetryTimeout - elapsed,
393
+ elapsedMs: elapsed,
394
+ remainingTimeoutMs: maxRetryTimeout - elapsed,
304
395
  }));
305
396
  return response;
306
397
  }
@@ -310,33 +401,64 @@ export namespace RetryFetch {
310
401
  log.warn(() => ({
311
402
  message: 'delay would exceed retry timeout, returning 429 response',
312
403
  sessionID,
313
- elapsed,
314
- delay,
315
- maxRetryTimeout,
404
+ elapsedMs: elapsed,
405
+ delayMs: delay,
406
+ maxRetryTimeoutMs: maxRetryTimeout,
316
407
  }));
317
408
  return response;
318
409
  }
319
410
 
411
+ const remainingTimeout = maxRetryTimeout - elapsed;
412
+
320
413
  log.info(() => ({
321
414
  message: 'rate limited, will retry',
322
415
  sessionID,
323
416
  attempt,
324
- delay,
417
+ delayMs: delay,
325
418
  delayMinutes: (delay / 1000 / 60).toFixed(2),
326
- elapsed,
327
- remainingTimeout: maxRetryTimeout - elapsed,
419
+ delayHours: (delay / 1000 / 3600).toFixed(2),
420
+ elapsedMs: elapsed,
421
+ remainingTimeoutMs: remainingTimeout,
422
+ remainingTimeoutHours: (remainingTimeout / 1000 / 3600).toFixed(2),
423
+ isolatedSignal: true, // Indicates we're using isolated signal for this wait
328
424
  }));
329
425
 
330
- // Wait before retrying
426
+ // Wait before retrying using ISOLATED signal
427
+ // This is critical for issue #183: Rate limit waits can be hours long (e.g., 15 hours),
428
+ // but provider timeouts are typically 5 minutes. By using an isolated AbortController
429
+ // that only respects AGENT_RETRY_TIMEOUT, we prevent the provider timeout from
430
+ // aborting long rate limit waits.
431
+ //
432
+ // The isolated signal periodically checks the user's abort signal (every 10 seconds)
433
+ // to allow user cancellation during long waits.
434
+ const { signal: isolatedSignal, cleanup } =
435
+ createIsolatedRateLimitSignal(
436
+ remainingTimeout,
437
+ init?.signal ?? undefined
438
+ );
439
+
331
440
  try {
332
- await sleep(delay, init?.signal ?? undefined);
333
- } catch {
334
- // Aborted - return the last response
441
+ await sleep(delay, isolatedSignal);
442
+ } catch (sleepError) {
443
+ // Check if the original request was aborted (user cancellation)
444
+ // In that case, we should stop retrying
445
+ if (init?.signal?.aborted) {
446
+ log.info(() => ({
447
+ message: 'rate limit wait aborted by user cancellation',
448
+ sessionID,
449
+ }));
450
+ return response;
451
+ }
452
+
453
+ // Otherwise, it was the isolated timeout - log and return
335
454
  log.info(() => ({
336
- message: 'retry sleep aborted, returning last response',
455
+ message: 'rate limit wait exceeded global timeout',
337
456
  sessionID,
457
+ sleepError: String(sleepError),
338
458
  }));
339
459
  return response;
460
+ } finally {
461
+ cleanup();
340
462
  }
341
463
  }
342
464
  };
@@ -94,8 +94,8 @@ export namespace SessionRetry {
94
94
  message: 'retry timeout exceeded',
95
95
  sessionID,
96
96
  errorType,
97
- elapsedTime,
98
- maxTime,
97
+ elapsedTimeMs: elapsedTime,
98
+ maxTimeMs: maxTime,
99
99
  }));
100
100
  return { shouldRetry: false, elapsedTime, maxTime };
101
101
  }
@@ -245,8 +245,8 @@ export namespace SessionRetry {
245
245
  log.info(() => ({
246
246
  message: 'no retry-after header, using exponential backoff',
247
247
  attempt,
248
- backoffDelay,
249
- maxBackoffDelay,
248
+ backoffDelayMs: backoffDelay,
249
+ maxBackoffDelayMs: maxBackoffDelay,
250
250
  }));
251
251
  return addJitter(backoffDelay);
252
252
  }
@@ -260,8 +260,8 @@ export namespace SessionRetry {
260
260
  message:
261
261
  'no response headers, using exponential backoff with conservative cap',
262
262
  attempt,
263
- backoffDelay,
264
- maxCap: RETRY_MAX_DELAY_NO_HEADERS,
263
+ backoffDelayMs: backoffDelay,
264
+ maxCapMs: RETRY_MAX_DELAY_NO_HEADERS,
265
265
  }));
266
266
  return addJitter(backoffDelay);
267
267
  }