@link-assistant/agent 0.26.8 → 0.26.10

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.26.8",
3
+ "version": "0.26.10",
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",
@@ -51,18 +51,18 @@
51
51
  ],
52
52
  "dependencies": {
53
53
  "@agentclientprotocol/sdk": "^1.5.1",
54
- "@ai-sdk/amazon-bedrock": "^5.0.101",
55
- "@ai-sdk/anthropic": "^4.0.68",
56
- "@ai-sdk/azure": "^4.0.85",
57
- "@ai-sdk/google": "^4.0.85",
58
- "@ai-sdk/google-vertex": "^5.0.98",
59
- "@ai-sdk/groq": "^4.0.52",
60
- "@ai-sdk/mcp": "^2.0.62",
61
- "@ai-sdk/mistral": "^4.0.54",
62
- "@ai-sdk/openai": "^4.0.81",
63
- "@ai-sdk/openai-compatible": "^3.0.59",
64
- "@ai-sdk/provider": "^4.0.19",
65
- "@ai-sdk/xai": "^5.0.12",
54
+ "@ai-sdk/amazon-bedrock": "^5.0.102",
55
+ "@ai-sdk/anthropic": "^4.0.69",
56
+ "@ai-sdk/azure": "^4.0.86",
57
+ "@ai-sdk/google": "^4.0.86",
58
+ "@ai-sdk/google-vertex": "^5.0.99",
59
+ "@ai-sdk/groq": "^4.0.53",
60
+ "@ai-sdk/mcp": "^2.0.63",
61
+ "@ai-sdk/mistral": "^4.0.55",
62
+ "@ai-sdk/openai": "^4.0.82",
63
+ "@ai-sdk/openai-compatible": "^3.0.60",
64
+ "@ai-sdk/provider": "^4.0.20",
65
+ "@ai-sdk/xai": "^5.0.13",
66
66
  "@clack/prompts": "^1.8.1",
67
67
  "@hono/standard-validator": "^0.4.0",
68
68
  "@hono/zod-validator": "^0.9.1",
@@ -72,14 +72,14 @@
72
72
  "@openauthjs/openauth": "^0.4.3",
73
73
  "@opencode-ai/sdk": "^1.18.33",
74
74
  "@openrouter/ai-sdk-provider": "^3.1.0",
75
- "@opentui/core": "0.5.12",
76
- "@opentui/solid": "0.5.12",
75
+ "@opentui/core": "0.5.13",
76
+ "@opentui/solid": "0.5.13",
77
77
  "@parcel/watcher": "^2.6.0",
78
78
  "@solid-primitives/event-bus": "^1.1.4",
79
79
  "@standard-community/standard-json": "^0.3.6",
80
80
  "@standard-community/standard-openapi": "^0.2.10",
81
81
  "@zip.js/zip.js": "^2.18.2",
82
- "ai": "^7.0.122",
82
+ "ai": "^7.0.123",
83
83
  "chokidar": "^5.0.0",
84
84
  "clipboardy": "^5.3.2",
85
85
  "command-stream": "^1.3.0",
@@ -89,7 +89,7 @@
89
89
  "glob": "^13.0.6",
90
90
  "gpt-tokenizer": "^4.0.0",
91
91
  "gray-matter": "^4.0.3",
92
- "hono": "^4.13.11",
92
+ "hono": "^4.13.12",
93
93
  "hono-openapi": "^1.3.3",
94
94
  "ignore": "^7.0.10",
95
95
  "jsonc-parser": "^3.3.1",
@@ -338,6 +338,8 @@ export namespace MessageV2 {
338
338
  status: z.literal('error'),
339
339
  input: z.record(z.string(), z.any()),
340
340
  error: z.string(),
341
+ title: z.string().optional(),
342
+ attachments: FilePart.array().optional(),
341
343
  metadata: z.record(z.string(), z.any()).optional(),
342
344
  time: z.object({
343
345
  start: z.number(),
@@ -734,7 +736,10 @@ export namespace MessageV2 {
734
736
  type: 'step-start',
735
737
  });
736
738
  if (part.type === 'tool') {
737
- if (part.state.status === 'completed') {
739
+ if (
740
+ part.state.status === 'completed' ||
741
+ part.state.status === 'error'
742
+ ) {
738
743
  if (part.state.attachments?.length) {
739
744
  result.push({
740
745
  id: Identifier.ascending('message'),
@@ -753,6 +758,8 @@ export namespace MessageV2 {
753
758
  ],
754
759
  });
755
760
  }
761
+ }
762
+ if (part.state.status === 'completed') {
756
763
  assistantMessage.parts.push({
757
764
  type: ('tool-' + part.tool) as `tool-${string}`,
758
765
  state: 'output-available',
@@ -19,6 +19,7 @@ import { SessionStatus } from './status';
19
19
  import { config, isVerbose } from '../config/config';
20
20
  import { SessionCompaction } from './compaction';
21
21
  import { SSEUsageExtractor } from '../util/sse-usage-extractor';
22
+ import { Tool } from '../tool/tool';
22
23
 
23
24
  export namespace SessionProcessor {
24
25
  const DOOM_LOOP_THRESHOLD = 3;
@@ -233,18 +234,13 @@ export namespace SessionProcessor {
233
234
  if (match && match.state.status === 'running') {
234
235
  await Session.updatePart({
235
236
  ...match,
236
- state: {
237
- status: 'completed',
237
+ state: Tool.toState(value.output, {
238
238
  input: value.input,
239
- output: value.output.output,
240
- metadata: value.output.metadata,
241
- title: value.output.title,
242
239
  time: {
243
240
  start: match.state.time.start,
244
241
  end: Date.now(),
245
242
  },
246
- attachments: value.output.attachments,
247
- },
243
+ }),
248
244
  });
249
245
 
250
246
  delete toolcalls[value.toolCallId];
@@ -48,6 +48,8 @@ import { fn } from '../util/fn';
48
48
  import { SessionProcessor } from './processor';
49
49
  import { TaskTool } from '../tool/task';
50
50
  import { SessionStatus } from './status';
51
+ import { Tool } from '../tool/tool';
52
+ import { processResult } from '../tool/process-result';
51
53
 
52
54
  export namespace SessionPrompt {
53
55
  const log = Log.create({ service: 'session.prompt' });
@@ -1198,12 +1200,7 @@ export namespace SessionPrompt {
1198
1200
  });
1199
1201
  return result;
1200
1202
  },
1201
- toModelOutput(result) {
1202
- return {
1203
- type: 'text',
1204
- value: result.output,
1205
- };
1206
- },
1203
+ toModelOutput: Tool.toModelOutput,
1207
1204
  });
1208
1205
  }
1209
1206
 
@@ -1260,14 +1257,10 @@ export namespace SessionPrompt {
1260
1257
  output: textParts.join('\n\n'),
1261
1258
  attachments,
1262
1259
  content: result.content, // directly return content to preserve ordering when outputting to model
1260
+ isError: result.isError,
1263
1261
  };
1264
1262
  };
1265
- item.toModelOutput = (result) => {
1266
- return {
1267
- type: 'text',
1268
- value: result.output,
1269
- };
1270
- };
1263
+ item.toModelOutput = Tool.toModelOutput;
1271
1264
  tools[key] = item;
1272
1265
  }
1273
1266
  return tools;
@@ -1723,20 +1716,29 @@ export namespace SessionPrompt {
1723
1716
  msg.time.completed = Date.now();
1724
1717
  await Session.updateMessage(msg);
1725
1718
  if (part.state.status === 'running') {
1726
- part.state = {
1727
- status: 'completed',
1728
- time: {
1729
- ...part.state.time,
1730
- end: Date.now(),
1731
- },
1732
- input: part.state.input,
1733
- title: '',
1734
- metadata: {
1735
- output,
1736
- description: '',
1719
+ part.state = Tool.toState(
1720
+ {
1721
+ title: '',
1722
+ ...processResult({
1723
+ output,
1724
+ exit: proc.exitCode,
1725
+ signal: proc.signalCode,
1726
+ }),
1727
+ metadata: {
1728
+ output,
1729
+ exit: proc.exitCode,
1730
+ signal: proc.signalCode,
1731
+ description: '',
1732
+ },
1737
1733
  },
1738
- output,
1739
- };
1734
+ {
1735
+ time: {
1736
+ ...part.state.time,
1737
+ end: Date.now(),
1738
+ },
1739
+ input: part.state.input,
1740
+ }
1741
+ );
1740
1742
  await Session.updatePart(part);
1741
1743
  }
1742
1744
  return { info: msg, parts: [part] };
@@ -1794,7 +1796,11 @@ export namespace SessionPrompt {
1794
1796
  const results = await Promise.all(
1795
1797
  shell.map(async ([, cmd]) => {
1796
1798
  try {
1797
- return await $`${{ raw: cmd }}`.nothrow().text();
1799
+ const result = await $`${{ raw: cmd }}`.nothrow();
1800
+ return processResult({
1801
+ output: result.text(),
1802
+ exit: result.exitCode,
1803
+ }).output;
1798
1804
  } catch (error) {
1799
1805
  return `Error executing command: ${error instanceof Error ? error.message : String(error)}`;
1800
1806
  }
package/src/tool/bash.ts CHANGED
@@ -11,6 +11,7 @@ import { $ } from 'bun';
11
11
  import { Filesystem } from '../util/filesystem';
12
12
  import { Permission } from '../permission';
13
13
  import { fileURLToPath } from 'url';
14
+ import { processResult } from './process-result';
14
15
 
15
16
  const MAX_OUTPUT_LENGTH = 30_000;
16
17
  const DEFAULT_TIMEOUT = 1 * 60 * 1000;
@@ -28,13 +29,18 @@ const resolveWasm = (asset: string) => {
28
29
 
29
30
  const parser = lazy(async () => {
30
31
  const { Parser } = await import('web-tree-sitter');
31
- const { default: treeWasm } = await import(
32
- 'web-tree-sitter/tree-sitter.wasm' as string,
33
- {
34
- with: { type: 'wasm' },
35
- }
36
- );
37
- const treePath = resolveWasm(treeWasm);
32
+ // web-tree-sitter renamed its runtime WASM in 0.26. Resolve the current
33
+ // export while retaining 0.25 compatibility until OpenTUI lifts its peer pin.
34
+ let treePath: string;
35
+ try {
36
+ treePath = resolveWasm(
37
+ import.meta.resolve('web-tree-sitter/web-tree-sitter.wasm')
38
+ );
39
+ } catch {
40
+ treePath = resolveWasm(
41
+ import.meta.resolve('web-tree-sitter/tree-sitter.wasm')
42
+ );
43
+ }
38
44
  await Parser.init({
39
45
  locateFile() {
40
46
  return treePath;
@@ -149,10 +155,11 @@ export const BashTool = Tool.define('bash', {
149
155
  let timedOut = false;
150
156
  let aborted = false;
151
157
  let exited = false;
158
+ let closed = false;
152
159
 
153
160
  const killTree = async () => {
154
161
  const pid = proc.pid;
155
- if (!pid || exited) {
162
+ if (!pid || closed) {
156
163
  return;
157
164
  }
158
165
 
@@ -170,7 +177,9 @@ export const BashTool = Tool.define('bash', {
170
177
  try {
171
178
  process.kill(-pid, 'SIGTERM');
172
179
  await Bun.sleep(SIGKILL_TIMEOUT_MS);
173
- if (!exited) {
180
+ // The shell may exit before a child that ignores SIGTERM. The
181
+ // process group still needs SIGKILL until its output pipes close.
182
+ if (!closed) {
174
183
  process.kill(-pid, 'SIGKILL');
175
184
  }
176
185
  } catch (_e) {
@@ -182,11 +191,6 @@ export const BashTool = Tool.define('bash', {
182
191
  }
183
192
  };
184
193
 
185
- if (ctx.abort.aborted) {
186
- aborted = true;
187
- await killTree();
188
- }
189
-
190
194
  const abortHandler = () => {
191
195
  aborted = true;
192
196
  void killTree();
@@ -199,7 +203,7 @@ export const BashTool = Tool.define('bash', {
199
203
  void killTree();
200
204
  }, timeout);
201
205
 
202
- await new Promise<void>((resolve, reject) => {
206
+ const completion = new Promise<void>((resolve, reject) => {
203
207
  const cleanup = () => {
204
208
  clearTimeout(timeoutTimer);
205
209
  ctx.abort.removeEventListener('abort', abortHandler);
@@ -207,6 +211,12 @@ export const BashTool = Tool.define('bash', {
207
211
 
208
212
  proc.once('exit', () => {
209
213
  exited = true;
214
+ });
215
+
216
+ // close follows exit after stdout/stderr have drained. Install these
217
+ // listeners before killing an already-aborted process.
218
+ proc.once('close', () => {
219
+ closed = true;
210
220
  cleanup();
211
221
  resolve();
212
222
  });
@@ -218,6 +228,9 @@ export const BashTool = Tool.define('bash', {
218
228
  });
219
229
  });
220
230
 
231
+ if (ctx.abort.aborted) abortHandler();
232
+ await completion;
233
+
221
234
  if (output.length > MAX_OUTPUT_LENGTH) {
222
235
  output = output.slice(0, MAX_OUTPUT_LENGTH);
223
236
  output += '\n\n(Output was truncated due to length limit)';
@@ -231,14 +244,34 @@ export const BashTool = Tool.define('bash', {
231
244
  output += '\n\n(Command was aborted)';
232
245
  }
233
246
 
247
+ const result = processResult({
248
+ output,
249
+ exit: proc.exitCode,
250
+ signal: proc.signalCode,
251
+ timedOut,
252
+ aborted,
253
+ });
254
+
255
+ log.debug(() => ({
256
+ message: 'command finished',
257
+ exit: proc.exitCode,
258
+ signal: proc.signalCode,
259
+ timedOut,
260
+ aborted,
261
+ isError: result.isError,
262
+ }));
263
+
234
264
  return {
235
265
  title: params.command,
236
266
  metadata: {
237
267
  output,
238
268
  exit: proc.exitCode,
269
+ signal: proc.signalCode,
270
+ timedOut,
271
+ aborted,
239
272
  description: params.description,
240
273
  },
241
- output,
274
+ ...result,
242
275
  };
243
276
  },
244
277
  });
package/src/tool/batch.ts CHANGED
@@ -101,21 +101,18 @@ export const BatchTool = Tool.define('batch', async () => {
101
101
  type: 'tool',
102
102
  tool: call.tool,
103
103
  callID: partID,
104
- state: {
105
- status: 'completed',
104
+ state: Tool.toState(result, {
106
105
  input: call.parameters,
107
- output: result.output,
108
- title: result.title,
109
- metadata: result.metadata,
110
- attachments: result.attachments,
111
106
  time: {
112
107
  start: callStartTime,
113
108
  end: Date.now(),
114
109
  },
115
- },
110
+ }),
116
111
  });
117
112
 
118
- return { success: true as const, tool: call.tool, result };
113
+ return result.isError
114
+ ? { success: false as const, tool: call.tool, error: result.output }
115
+ : { success: true as const, tool: call.tool, result };
119
116
  } catch (error) {
120
117
  await Session.updatePart({
121
118
  id: partID,
@@ -179,6 +176,7 @@ export const BatchTool = Tool.define('batch', async () => {
179
176
  return {
180
177
  title: `Batch execution (${successfulCalls}/${results.length} successful)`,
181
178
  output: outputMessage,
179
+ isError: failedCalls > 0,
182
180
  attachments: results
183
181
  .filter((result) => result.success)
184
182
  .flatMap((r) => r.result.attachments ?? []),
package/src/tool/grep.ts CHANGED
@@ -62,7 +62,7 @@ export const GrepTool = Tool.define('grep', {
62
62
  }
63
63
 
64
64
  if (exitCode !== 0) {
65
- throw new Error(`ripgrep failed: ${errorOutput}`);
65
+ throw new Error(`Exit code ${exitCode}\nripgrep failed: ${errorOutput}`);
66
66
  }
67
67
 
68
68
  const matches = [];
@@ -0,0 +1,18 @@
1
+ /** Keep process status in the text sent to every provider, including those
2
+ * whose tool protocol has no separate error flag. */
3
+ export function processResult(input: {
4
+ output: string;
5
+ exit: number | null;
6
+ signal?: string | null;
7
+ timedOut?: boolean;
8
+ aborted?: boolean;
9
+ }) {
10
+ const isError = input.exit !== 0 || !!input.timedOut || !!input.aborted;
11
+ const status = input.signal ? ` (signal ${input.signal})` : '';
12
+ return {
13
+ output: isError
14
+ ? `Exit code ${input.exit ?? 'unavailable'}${status}\n${input.output}`
15
+ : input.output,
16
+ isError,
17
+ };
18
+ }
package/src/tool/tool.ts CHANGED
@@ -1,11 +1,56 @@
1
1
  import z from 'zod';
2
2
  import type { MessageV2 } from '../session/message-v2';
3
+ import type { ToolResultOutput } from 'ai';
3
4
 
4
5
  export namespace Tool {
5
6
  interface Metadata {
6
7
  [key: string]: any;
7
8
  }
8
9
 
10
+ export interface Result<M extends Metadata = Metadata> {
11
+ title: string;
12
+ metadata: M;
13
+ output: string;
14
+ isError?: boolean;
15
+ attachments?: MessageV2.FilePart[];
16
+ }
17
+
18
+ // AI SDK 7 passes an options object containing the execution output.
19
+ export function toModelOutput({
20
+ output: result,
21
+ }: {
22
+ output: Pick<Result, 'output' | 'isError'>;
23
+ }): ToolResultOutput {
24
+ return {
25
+ type: result.isError ? 'error-text' : 'text',
26
+ value: result.output,
27
+ };
28
+ }
29
+
30
+ export function toState(
31
+ result: Result,
32
+ input: { input: Record<string, any>; time: { start: number; end: number } }
33
+ ): MessageV2.ToolStateCompleted | MessageV2.ToolStateError {
34
+ if (result.isError) {
35
+ return {
36
+ ...input,
37
+ status: 'error',
38
+ error: result.output,
39
+ metadata: result.metadata,
40
+ title: result.title,
41
+ attachments: result.attachments,
42
+ };
43
+ }
44
+ return {
45
+ ...input,
46
+ status: 'completed',
47
+ output: result.output,
48
+ title: result.title,
49
+ metadata: result.metadata,
50
+ attachments: result.attachments,
51
+ };
52
+ }
53
+
9
54
  export type Context<M extends Metadata = Metadata> = {
10
55
  sessionID: string;
11
56
  messageID: string;
@@ -23,15 +68,7 @@ export namespace Tool {
23
68
  init: () => Promise<{
24
69
  description: string;
25
70
  parameters: Parameters;
26
- execute(
27
- args: z.infer<Parameters>,
28
- ctx: Context
29
- ): Promise<{
30
- title: string;
31
- metadata: M;
32
- output: string;
33
- attachments?: MessageV2.FilePart[];
34
- }>;
71
+ execute(args: z.infer<Parameters>, ctx: Context): Promise<Result<M>>;
35
72
  formatValidationError?(error: z.ZodError): string;
36
73
  }>;
37
74
  }