@link-assistant/agent 0.13.0 → 0.13.2

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.0",
3
+ "version": "0.13.2",
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
@@ -13,26 +13,69 @@ export namespace BunProc {
13
13
  // Lock key for serializing package installations to prevent race conditions
14
14
  const INSTALL_LOCK_KEY = 'bun-install';
15
15
 
16
+ // Default timeout for subprocess commands (2 minutes)
17
+ // This prevents indefinite hangs from known Bun issues:
18
+ // - HTTP 304 response handling (https://github.com/oven-sh/bun/issues/5831)
19
+ // - Failed dependency fetch (https://github.com/oven-sh/bun/issues/26341)
20
+ // - IPv6 configuration issues
21
+ const DEFAULT_TIMEOUT_MS = 120000;
22
+
23
+ // Timeout specifically for package installation (60 seconds)
24
+ // Package installations should complete within this time for typical packages
25
+ const INSTALL_TIMEOUT_MS = 60000;
26
+
27
+ export const TimeoutError = NamedError.create(
28
+ 'BunTimeoutError',
29
+ z.object({
30
+ cmd: z.array(z.string()),
31
+ timeoutMs: z.number(),
32
+ })
33
+ );
34
+
16
35
  export async function run(
17
36
  cmd: string[],
18
- options?: Bun.SpawnOptions.OptionsObject<any, any, any>
37
+ options?: Bun.SpawnOptions.OptionsObject<any, any, any> & {
38
+ timeout?: number;
39
+ }
19
40
  ) {
41
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
42
+
20
43
  log.info(() => ({
21
44
  message: 'running',
22
45
  cmd: [which(), ...cmd],
23
- ...options,
46
+ timeout,
47
+ cwd: options?.cwd,
24
48
  }));
49
+
25
50
  const result = Bun.spawn([which(), ...cmd], {
26
51
  ...options,
27
52
  stdout: 'pipe',
28
53
  stderr: 'pipe',
54
+ timeout, // Automatically kills process after timeout
55
+ killSignal: 'SIGTERM', // Graceful termination signal
29
56
  env: {
30
57
  ...process.env,
31
58
  ...options?.env,
32
59
  BUN_BE_BUN: '1',
33
60
  },
34
61
  });
62
+
35
63
  const code = await result.exited;
64
+
65
+ // Check if process was killed due to timeout
66
+ if (result.signalCode === 'SIGTERM' && code !== 0) {
67
+ log.error(() => ({
68
+ message: 'command timed out',
69
+ cmd: [which(), ...cmd],
70
+ timeout,
71
+ signalCode: result.signalCode,
72
+ }));
73
+ throw new TimeoutError({
74
+ cmd: [which(), ...cmd],
75
+ timeoutMs: timeout,
76
+ });
77
+ }
78
+
36
79
  const stdout = result.stdout
37
80
  ? typeof result.stdout === 'number'
38
81
  ? result.stdout
@@ -84,6 +127,13 @@ export namespace BunProc {
84
127
  );
85
128
  }
86
129
 
130
+ /**
131
+ * Check if an error is a timeout error
132
+ */
133
+ function isTimeoutError(error: unknown): boolean {
134
+ return error instanceof TimeoutError;
135
+ }
136
+
87
137
  /**
88
138
  * Wait for a specified duration
89
139
  */
@@ -139,12 +189,13 @@ export namespace BunProc {
139
189
  version,
140
190
  }));
141
191
 
142
- // Retry logic for cache-related errors
192
+ // Retry logic for cache-related errors and timeout errors
143
193
  let lastError: Error | undefined;
144
194
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
145
195
  try {
146
196
  await BunProc.run(args, {
147
197
  cwd: Global.Path.cache,
198
+ timeout: INSTALL_TIMEOUT_MS, // Use specific timeout for package installation
148
199
  });
149
200
 
150
201
  log.info(() => ({
@@ -159,6 +210,7 @@ export namespace BunProc {
159
210
  } catch (e) {
160
211
  const errorMsg = e instanceof Error ? e.message : String(e);
161
212
  const isCacheError = isCacheRelatedError(errorMsg);
213
+ const isTimeout = isTimeoutError(e);
162
214
 
163
215
  log.warn(() => ({
164
216
  message: 'package installation attempt failed',
@@ -168,11 +220,15 @@ export namespace BunProc {
168
220
  maxRetries: MAX_RETRIES,
169
221
  error: errorMsg,
170
222
  isCacheError,
223
+ isTimeout,
171
224
  }));
172
225
 
173
- if (isCacheError && attempt < MAX_RETRIES) {
226
+ // Retry on cache-related errors or timeout errors
227
+ if ((isCacheError || isTimeout) && attempt < MAX_RETRIES) {
174
228
  log.info(() => ({
175
- message: 'retrying installation after cache-related error',
229
+ message: isTimeout
230
+ ? 'retrying installation after timeout (possible network issue)'
231
+ : 'retrying installation after cache-related error',
176
232
  pkg,
177
233
  version,
178
234
  attempt,
@@ -184,7 +240,7 @@ export namespace BunProc {
184
240
  continue;
185
241
  }
186
242
 
187
- // Non-cache error or final attempt - log and throw
243
+ // Non-retriable error or final attempt - log and throw
188
244
  log.error(() => ({
189
245
  message: 'package installation failed',
190
246
  pkg,
@@ -192,10 +248,11 @@ export namespace BunProc {
192
248
  error: errorMsg,
193
249
  stack: e instanceof Error ? e.stack : undefined,
194
250
  possibleCacheCorruption: isCacheError,
251
+ timedOut: isTimeout,
195
252
  attempts: attempt,
196
253
  }));
197
254
 
198
- // Provide helpful recovery instructions for cache-related errors
255
+ // Provide helpful recovery instructions
199
256
  if (isCacheError) {
200
257
  log.error(() => ({
201
258
  message:
@@ -203,6 +260,15 @@ export namespace BunProc {
203
260
  }));
204
261
  }
205
262
 
263
+ if (isTimeout) {
264
+ log.error(() => ({
265
+ message:
266
+ 'Package installation timed out. This may be due to network issues or Bun hanging. ' +
267
+ 'Try: 1) Check network connectivity, 2) Run "bun pm cache rm" to clear cache, ' +
268
+ '3) Check for IPv6 issues (try disabling IPv6)',
269
+ }));
270
+ }
271
+
206
272
  throw new InstallFailedError(
207
273
  { pkg, version, details: errorMsg },
208
274
  {
@@ -67,11 +67,74 @@ export namespace ModelsDev {
67
67
 
68
68
  export type Provider = z.infer<typeof Provider>;
69
69
 
70
+ /**
71
+ * Cache staleness threshold in milliseconds (1 hour).
72
+ * If the cache is older than this, we await the refresh before using the data.
73
+ */
74
+ const CACHE_STALE_THRESHOLD_MS = 60 * 60 * 1000;
75
+
76
+ /**
77
+ * Get the models database, refreshing from models.dev if needed.
78
+ *
79
+ * This function handles cache staleness properly:
80
+ * - If cache doesn't exist: await refresh to ensure fresh data
81
+ * - If cache is stale (> 1 hour old): await refresh to ensure up-to-date models
82
+ * - If cache is fresh: trigger background refresh but use cached data immediately
83
+ *
84
+ * This prevents ProviderModelNotFoundError when:
85
+ * - User runs agent for the first time (no cache)
86
+ * - User has outdated cache missing new models like kimi-k2.5-free
87
+ *
88
+ * @see https://github.com/link-assistant/agent/issues/175
89
+ */
70
90
  export async function get() {
71
- refresh();
72
91
  const file = Bun.file(filepath);
92
+
93
+ // Check if cache exists and get its modification time
94
+ const exists = await file.exists();
95
+
96
+ if (!exists) {
97
+ // No cache - must await refresh to get initial data
98
+ log.info(() => ({
99
+ message: 'no cache found, awaiting refresh',
100
+ path: filepath,
101
+ }));
102
+ await refresh();
103
+ } else {
104
+ // Check if cache is stale
105
+ const stats = await file.stat().catch(() => null);
106
+ const mtime = stats?.mtime?.getTime() ?? 0;
107
+ const isStale = Date.now() - mtime > CACHE_STALE_THRESHOLD_MS;
108
+
109
+ if (isStale) {
110
+ // Stale cache - await refresh to get updated model list
111
+ log.info(() => ({
112
+ message: 'cache is stale, awaiting refresh',
113
+ path: filepath,
114
+ age: Date.now() - mtime,
115
+ threshold: CACHE_STALE_THRESHOLD_MS,
116
+ }));
117
+ await refresh();
118
+ } else {
119
+ // Fresh cache - trigger background refresh but don't wait
120
+ log.info(() => ({
121
+ message: 'cache is fresh, triggering background refresh',
122
+ path: filepath,
123
+ age: Date.now() - mtime,
124
+ }));
125
+ refresh();
126
+ }
127
+ }
128
+
129
+ // Now read the cache file
73
130
  const result = await file.json().catch(() => {});
74
131
  if (result) return result as Record<string, Provider>;
132
+
133
+ // Fallback to bundled data if cache read failed
134
+ log.warn(() => ({
135
+ message: 'cache read failed, using bundled data',
136
+ path: filepath,
137
+ }));
75
138
  const json = await data();
76
139
  return JSON.parse(json) as Record<string, Provider>;
77
140
  }