@link-assistant/agent 0.25.5 → 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.5",
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",
@@ -264,7 +264,11 @@ export const RunCommand = cmd({
264
264
  err = String(props.error.data.message);
265
265
  }
266
266
  errorMsg = errorMsg ? errorMsg + EOL + err : err;
267
- if (outputJsonEvent('error', { error: props.error })) continue;
267
+ // Always include the human-readable string alongside the
268
+ // machine-readable object so consumers never print
269
+ // "[object Object]" (#289).
270
+ if (outputJsonEvent('error', { message: err, error: props.error }))
271
+ continue;
268
272
  UI.error(err);
269
273
  }
270
274
 
@@ -11,6 +11,7 @@ import { SessionPrompt } from '../session/prompt.ts';
11
11
  import { createEventHandler, serializeOutput } from '../json-standard/index.ts';
12
12
  import { createContinuousStdinReader } from './input-queue.js';
13
13
  import { outputBusEvent } from './event-handler.js';
14
+ import { errorText } from '../util/error-text.ts';
14
15
  import { Permission } from '../permission/index.ts';
15
16
  import { Log } from '../util/log.ts';
16
17
  import { config } from '../config/config.ts';
@@ -365,7 +366,7 @@ export async function runContinuousServerMode(
365
366
  });
366
367
 
367
368
  // Send message
368
- fetch(
369
+ const requestPromise = fetch(
369
370
  `http://${server.hostname}:${server.port}/session/${sessionID}/message`,
370
371
  {
371
372
  method: 'POST',
@@ -385,11 +386,15 @@ export async function runContinuousServerMode(
385
386
  type: 'error',
386
387
  timestamp: Date.now(),
387
388
  sessionID,
389
+ message: errorText(error),
388
390
  error: error instanceof Error ? error.message : String(error),
389
391
  });
390
392
  });
391
393
 
394
+ // See the direct-mode comment below: the request rejection must be
395
+ // observed before the process can exit (#290).
392
396
  await messagePromise;
397
+ await requestPromise;
393
398
  isProcessing = false;
394
399
 
395
400
  // Process next pending message if any
@@ -584,7 +589,7 @@ export async function runContinuousDirectMode(
584
589
  });
585
590
 
586
591
  // Send message directly
587
- SessionPrompt.prompt({
592
+ const promptPromise = SessionPrompt.prompt({
588
593
  sessionID,
589
594
  parts,
590
595
  model: { providerID, modelID },
@@ -598,11 +603,17 @@ export async function runContinuousDirectMode(
598
603
  type: 'error',
599
604
  timestamp: Date.now(),
600
605
  sessionID,
606
+ message: errorText(error),
601
607
  error: error instanceof Error ? error.message : String(error),
602
608
  });
603
609
  });
604
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).
605
615
  await messagePromise;
616
+ await promptPromise;
606
617
  isProcessing = false;
607
618
 
608
619
  // Process next pending message if any
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { Bus } from '../bus/index.ts';
7
+ import { errorText } from '../util/error-text.ts';
7
8
 
8
9
  /**
9
10
  * Create a subscription for session bus events that outputs events in the selected JSON format.
@@ -90,11 +91,13 @@ export function outputBusEvent({
90
91
 
91
92
  // If tool failed, also output an error event
92
93
  if (part.state?.status === 'error') {
94
+ const stateError = part.state.error;
93
95
  eventHandler.output({
94
96
  type: 'error',
95
97
  timestamp: Date.now(),
96
98
  sessionID,
97
- error: part.state.error || 'Tool execution failed',
99
+ message: errorText(stateError, 'Tool execution failed'),
100
+ error: stateError || 'Tool execution failed',
98
101
  });
99
102
  }
100
103
  }
@@ -145,6 +148,7 @@ export function outputBusEvent({
145
148
  type: 'error',
146
149
  timestamp: Date.now(),
147
150
  sessionID,
151
+ message: errorText(props.error),
148
152
  error: props.error,
149
153
  });
150
154
  }
package/src/index.js CHANGED
@@ -27,6 +27,7 @@ import { McpCommand } from './cli/cmd/mcp.ts';
27
27
  import { AuthCommand } from './cli/cmd/auth.ts';
28
28
  import { FormatError } from './cli/error.ts';
29
29
  import { UI } from './cli/ui.ts';
30
+ import { errorText } from './util/error-text.ts';
30
31
  import {
31
32
  createVerboseFetch,
32
33
  registerPendingStreamLogExitHandler,
@@ -35,6 +36,7 @@ import {
35
36
  runContinuousServerMode,
36
37
  runContinuousDirectMode,
37
38
  resolveResumeSession,
39
+ getHasError,
38
40
  } from './cli/continuous-mode.js';
39
41
  import { parseStreamJsonInput } from './cli/input-queue.js';
40
42
  import { createBusEventSubscription } from './cli/event-handler.js';
@@ -424,6 +426,12 @@ async function runContinuousAgentMode(argv) {
424
426
  },
425
427
  });
426
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
+
427
435
  // Explicitly exit to ensure process terminates (#213)
428
436
  Log.Default.info(() => ({
429
437
  message: 'Agent exiting',
@@ -496,8 +504,8 @@ async function runServerMode(
496
504
  const message = request.message || 'hi';
497
505
  const parts = [{ type: 'text', text: message }];
498
506
 
499
- // Start the prompt (don't wait for response, events come via Bus)
500
- fetch(
507
+ // Start the prompt (don't wait for the body, events come via Bus)
508
+ const requestPromise = fetch(
501
509
  `http://${server.hostname}:${server.port}/session/${sessionID}/message`,
502
510
  {
503
511
  method: 'POST',
@@ -520,12 +528,15 @@ async function runServerMode(
520
528
  type: 'error',
521
529
  timestamp: Date.now(),
522
530
  sessionID,
531
+ message: errorText(error),
523
532
  error: error instanceof Error ? error.message : String(error),
524
533
  });
525
534
  });
526
535
 
527
- // 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).
528
538
  await eventPromise;
539
+ await requestPromise;
529
540
  } finally {
530
541
  // Always clean up resources
531
542
  if (unsub) {
@@ -588,7 +599,7 @@ async function runDirectMode(
588
599
  const parts = [{ type: 'text', text: message }];
589
600
 
590
601
  // Start the prompt directly without HTTP
591
- SessionPrompt.prompt({
602
+ const promptPromise = SessionPrompt.prompt({
592
603
  sessionID,
593
604
  parts,
594
605
  model: {
@@ -605,12 +616,17 @@ async function runDirectMode(
605
616
  type: 'error',
606
617
  timestamp: Date.now(),
607
618
  sessionID,
619
+ message: errorText(error),
608
620
  error: error instanceof Error ? error.message : String(error),
609
621
  });
610
622
  });
611
623
 
612
- // 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).
613
628
  await eventPromise;
629
+ await promptPromise;
614
630
  } finally {
615
631
  // Always clean up resources
616
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,82 @@
1
+ /**
2
+ * Human-readable rendering of arbitrary error values (issue #289).
3
+ *
4
+ * JSON `error` events used to carry only the machine-readable
5
+ * `NamedError.toObject()` shape (`{name, data: {message}}`), so consumers doing
6
+ * `` `${record.error}` `` published `[object Object]` and the real cause was
7
+ * lost. Every emitted `error` event now also carries a `message` string
8
+ * produced by `stringifyErrorValue`.
9
+ *
10
+ * The renderer is circular-safe and depth-limited, and never invents a
11
+ * placeholder: when nothing readable can be derived it returns an empty
12
+ * string so the caller can pick its own fallback.
13
+ */
14
+
15
+ const MAX_DEPTH = 5;
16
+
17
+ function fromParts(name: string | undefined, message: string | undefined) {
18
+ if (name && message) return `${name}: ${message}`;
19
+ return message || name || '';
20
+ }
21
+
22
+ function render(value: unknown, depth: number, seen: Set<object>): string {
23
+ if (value === null || value === undefined) return '';
24
+ if (typeof value === 'string') return value.trim();
25
+ if (typeof value === 'number' || typeof value === 'boolean')
26
+ return String(value);
27
+ if (typeof value !== 'object') return '';
28
+
29
+ const object = value as Record<string, unknown>;
30
+ if (seen.has(object)) return '';
31
+ if (depth >= MAX_DEPTH) return '';
32
+ seen.add(object);
33
+
34
+ if (Array.isArray(object)) {
35
+ const rendered = object
36
+ .map((item) => render(item, depth + 1, seen))
37
+ .filter((item) => item.length > 0);
38
+ return rendered.join('; ');
39
+ }
40
+
41
+ if (object instanceof Error) {
42
+ return fromParts(object.name, object.message) || String(object);
43
+ }
44
+
45
+ // NamedError.toObject(): { name, data: { message, ... } }
46
+ const name = typeof object.name === 'string' ? object.name : undefined;
47
+ const data = object.data;
48
+ if (data && typeof data === 'object') {
49
+ const dataMessage = render(
50
+ (data as Record<string, unknown>).message,
51
+ depth + 1,
52
+ seen
53
+ );
54
+ if (dataMessage) return fromParts(name, dataMessage);
55
+ }
56
+
57
+ const message = render(object.message, depth + 1, seen);
58
+ if (message) return fromParts(name, message);
59
+
60
+ // Envelope shapes: { error: … }, { cause: … }
61
+ const nested =
62
+ render(object.error, depth + 1, seen) ||
63
+ render(object.cause, depth + 1, seen);
64
+ if (nested) return name ? fromParts(name, nested) : nested;
65
+
66
+ return name || '';
67
+ }
68
+
69
+ /**
70
+ * Render any error-ish value as a human-readable string.
71
+ * Returns an empty string when nothing readable can be derived.
72
+ */
73
+ export function stringifyErrorValue(value: unknown): string {
74
+ return render(value, 0, new Set<object>());
75
+ }
76
+
77
+ /**
78
+ * Same as {@link stringifyErrorValue} but guarantees a non-empty string.
79
+ */
80
+ export function errorText(value: unknown, fallback = 'Unknown error'): string {
81
+ return stringifyErrorValue(value) || fallback;
82
+ }
@@ -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
+ }