@link-assistant/agent 0.25.6 → 0.25.7

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.25.6",
3
+ "version": "0.25.7",
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",
@@ -366,7 +366,7 @@ export async function runContinuousServerMode(
366
366
  });
367
367
 
368
368
  // Send message
369
- fetch(
369
+ const requestPromise = fetch(
370
370
  `http://${server.hostname}:${server.port}/session/${sessionID}/message`,
371
371
  {
372
372
  method: 'POST',
@@ -391,7 +391,10 @@ export async function runContinuousServerMode(
391
391
  });
392
392
  });
393
393
 
394
+ // See the direct-mode comment below: the request rejection must be
395
+ // observed before the process can exit (#290).
394
396
  await messagePromise;
397
+ await requestPromise;
395
398
  isProcessing = false;
396
399
 
397
400
  // Process next pending message if any
@@ -586,7 +589,7 @@ export async function runContinuousDirectMode(
586
589
  });
587
590
 
588
591
  // Send message directly
589
- SessionPrompt.prompt({
592
+ const promptPromise = SessionPrompt.prompt({
590
593
  sessionID,
591
594
  parts,
592
595
  model: { providerID, modelID },
@@ -605,7 +608,12 @@ export async function runContinuousDirectMode(
605
608
  });
606
609
  });
607
610
 
611
+ // Await BOTH the idle event and the prompt promise. A fatal failure
612
+ // publishes `session.idle` while unwinding, so waiting only on the idle
613
+ // event let the process exit before the rejection handler above ran —
614
+ // no error record, exit code 0 (#290).
608
615
  await messagePromise;
616
+ await promptPromise;
609
617
  isProcessing = false;
610
618
 
611
619
  // Process next pending message if any
package/src/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  runContinuousServerMode,
37
37
  runContinuousDirectMode,
38
38
  resolveResumeSession,
39
+ getHasError,
39
40
  } from './cli/continuous-mode.js';
40
41
  import { parseStreamJsonInput } from './cli/input-queue.js';
41
42
  import { createBusEventSubscription } from './cli/event-handler.js';
@@ -425,6 +426,12 @@ async function runContinuousAgentMode(argv) {
425
426
  },
426
427
  });
427
428
 
429
+ // Continuous mode tracks its own error state in its module scope — fold it
430
+ // in here, otherwise a fatal run always exits 0 (#290, recurrence of #22).
431
+ if (getHasError()) {
432
+ hasError = true;
433
+ }
434
+
428
435
  // Explicitly exit to ensure process terminates (#213)
429
436
  Log.Default.info(() => ({
430
437
  message: 'Agent exiting',
@@ -497,8 +504,8 @@ async function runServerMode(
497
504
  const message = request.message || 'hi';
498
505
  const parts = [{ type: 'text', text: message }];
499
506
 
500
- // Start the prompt (don't wait for response, events come via Bus)
501
- fetch(
507
+ // Start the prompt (don't wait for the body, events come via Bus)
508
+ const requestPromise = fetch(
502
509
  `http://${server.hostname}:${server.port}/session/${sessionID}/message`,
503
510
  {
504
511
  method: 'POST',
@@ -526,8 +533,10 @@ async function runServerMode(
526
533
  });
527
534
  });
528
535
 
529
- // Wait for session to become idle
536
+ // Wait for session to become idle, then for the request rejection to be
537
+ // observed (see runDirectMode, #290).
530
538
  await eventPromise;
539
+ await requestPromise;
531
540
  } finally {
532
541
  // Always clean up resources
533
542
  if (unsub) {
@@ -590,7 +599,7 @@ async function runDirectMode(
590
599
  const parts = [{ type: 'text', text: message }];
591
600
 
592
601
  // Start the prompt directly without HTTP
593
- SessionPrompt.prompt({
602
+ const promptPromise = SessionPrompt.prompt({
594
603
  sessionID,
595
604
  parts,
596
605
  model: {
@@ -612,8 +621,12 @@ async function runDirectMode(
612
621
  });
613
622
  });
614
623
 
615
- // Wait for session to become idle
624
+ // Wait for session to become idle, then for the prompt itself. A fatal
625
+ // failure publishes `session.idle` while unwinding, so awaiting only the
626
+ // idle event let the process exit before the rejection handler above ran
627
+ // — no error record, exit code 0 (#290).
616
628
  await eventPromise;
629
+ await promptPromise;
617
630
  } finally {
618
631
  // Always clean up resources
619
632
  if (unsub) {
@@ -1,5 +1,6 @@
1
1
  import { Log } from '../util/log';
2
2
  import { config } from '../config/config';
3
+ import { isUnreachableNetworkError } from '../util/network-error';
3
4
 
4
5
  /**
5
6
  * Custom fetch wrapper that handles rate limits (HTTP 429) and server errors (HTTP 5xx)
@@ -295,12 +296,17 @@ export namespace RetryFetch {
295
296
  function isRetryableError(error: unknown): boolean {
296
297
  if (!(error instanceof Error)) return false;
297
298
 
299
+ // Connection refused / host not found is not transient — nothing will
300
+ // start listening as a side effect of retrying, so do not burn the
301
+ // (default 7 day) retry budget on it.
302
+ // See: https://github.com/link-assistant/agent/issues/290
303
+ if (isUnreachableNetworkError(error)) return false;
304
+
298
305
  // Socket/connection errors (Bun has known timeout issues)
299
306
  // See: https://github.com/oven-sh/bun/issues/14439
300
307
  if (
301
308
  error.message.includes('ConnectionClosed') ||
302
309
  error.message.includes('ECONNRESET') ||
303
- error.message.includes('ECONNREFUSED') ||
304
310
  error.message.includes('socket') ||
305
311
  error.message.includes('connection')
306
312
  ) {
@@ -13,6 +13,7 @@ import { Identifier } from '../id/id';
13
13
  import { Snapshot } from '../snapshot';
14
14
  import { fn } from '../util/fn';
15
15
  import { Storage } from '../storage/storage';
16
+ import { isUnreachableNetworkError } from '../util/network-error';
16
17
 
17
18
  export namespace MessageV2 {
18
19
  export const OutputLengthError = NamedError.create(
@@ -920,7 +921,10 @@ export namespace MessageV2 {
920
921
  {
921
922
  message: e.message,
922
923
  statusCode: e.statusCode,
923
- isRetryable: e.isRetryable,
924
+ // A refused connection / unresolvable host is not transient: the
925
+ // AI SDK marks it retryable, but retrying it for the 7-day global
926
+ // budget makes the CLI hang forever (#290).
927
+ isRetryable: isUnreachableNetworkError(e) ? false : e.isRetryable,
924
928
  responseHeaders: e.responseHeaders,
925
929
  responseBody: e.responseBody,
926
930
  },
@@ -457,6 +457,21 @@ export namespace SessionPrompt {
457
457
  stack: error instanceof Error ? error.stack : undefined,
458
458
  hint: 'Check that the model exists in the provider. Use --verbose for more details.',
459
459
  }));
460
+ // Publish the fatal error on the bus so streaming consumers see an
461
+ // `error` event instead of only a `log` record, and so the CLI can
462
+ // exit non-zero (#290, recurrence of #22).
463
+ const cause = error instanceof Error ? error : new Error(String(error));
464
+ const fatal = new Error(
465
+ `Failed to initialize model "${lastUser.model.providerID}/${lastUser.model.modelID}": ${cause.message || cause.name}`,
466
+ { cause }
467
+ );
468
+ fatal.name = cause.name;
469
+ Bus.publish(Session.Event.Error, {
470
+ sessionID,
471
+ error: MessageV2.fromError(fatal, {
472
+ providerID: lastUser.model.providerID,
473
+ }),
474
+ });
460
475
  // Re-throw the error so it can be handled by the caller
461
476
  throw error;
462
477
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Classification of network-level errors that are *not* transient.
3
+ *
4
+ * A refused connection or an unresolvable host will not start working as a
5
+ * side effect of retrying: nothing is listening on the port, or the name does
6
+ * not resolve. Retrying those for the global retry budget (7 days by default,
7
+ * see #167) makes the CLI hang forever instead of failing fast.
8
+ *
9
+ * Transient DNS failures (EAI_AGAIN) are deliberately NOT included — those can
10
+ * recover on their own and stay retryable.
11
+ *
12
+ * @see https://github.com/link-assistant/agent/issues/290
13
+ */
14
+
15
+ /** Error codes/markers that mean "the endpoint is unreachable, permanently". */
16
+ const UNREACHABLE_MARKERS = [
17
+ 'ConnectionRefused',
18
+ 'ECONNREFUSED',
19
+ 'ENOTFOUND',
20
+ 'EAI_NONAME',
21
+ 'ERR_NAME_NOT_RESOLVED',
22
+ 'failed to lookup address information',
23
+ 'Unable to connect. Is the computer able to access the url?',
24
+ ];
25
+
26
+ function collectStrings(value: unknown, depth: number, out: string[]): void {
27
+ if (depth > 5 || value === null || value === undefined) return;
28
+
29
+ if (typeof value === 'string') {
30
+ out.push(value);
31
+ return;
32
+ }
33
+
34
+ if (typeof value !== 'object') return;
35
+
36
+ const record = value as Record<string, unknown>;
37
+ for (const key of ['code', 'errno', 'name', 'message', 'syscall']) {
38
+ const field = record[key];
39
+ if (typeof field === 'string') out.push(field);
40
+ }
41
+ collectStrings(record['cause'], depth + 1, out);
42
+ collectStrings(record['error'], depth + 1, out);
43
+ collectStrings(record['data'], depth + 1, out);
44
+ }
45
+
46
+ /**
47
+ * Returns true when the error means the endpoint cannot be reached at all
48
+ * (connection refused, host not found) and retrying cannot help.
49
+ */
50
+ export function isUnreachableNetworkError(error: unknown): boolean {
51
+ const haystack: string[] = [];
52
+ collectStrings(error, 0, haystack);
53
+ const joined = haystack.join('\n');
54
+ if (!joined) return false;
55
+ return UNREACHABLE_MARKERS.some((marker) => joined.includes(marker));
56
+ }