@link-assistant/agent 0.26.15 → 0.27.1

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.
Files changed (65) hide show
  1. package/README.md +49 -7
  2. package/package.json +51 -42
  3. package/src/agent/agent.ts +12 -7
  4. package/src/auth/azure-login.ts +241 -0
  5. package/src/auth/plugins.ts +9 -0
  6. package/src/auth/provider-logins.ts +804 -0
  7. package/src/cli/argv.ts +1 -1
  8. package/src/cli/cmd/auth.ts +4 -0
  9. package/src/cli/cmd/models.ts +68 -13
  10. package/src/cli/cmd/run.ts +7 -1
  11. package/src/cli/continuous-mode.js +38 -12
  12. package/src/cli/event-handler.js +3 -1
  13. package/src/cli/model-config.js +27 -8
  14. package/src/cli/run-options.js +144 -78
  15. package/src/config/config.ts +306 -105
  16. package/src/config/defaults.ts +13 -5
  17. package/src/config/file-config.ts +16 -0
  18. package/src/file/ripgrep.ts +38 -5
  19. package/src/file/time.ts +86 -9
  20. package/src/index.js +42 -33
  21. package/src/mcp/index.ts +370 -106
  22. package/src/permission/index.ts +22 -0
  23. package/src/project/instance.ts +5 -1
  24. package/src/project/project.ts +35 -2
  25. package/src/provider/env-template.ts +115 -0
  26. package/src/provider/extra-providers.ts +347 -0
  27. package/src/provider/free-tier-error.ts +1 -1
  28. package/src/provider/kilo-deprecation.ts +30 -0
  29. package/src/provider/kilo.ts +44 -7
  30. package/src/provider/model-discovery.ts +690 -0
  31. package/src/provider/models.ts +11 -1
  32. package/src/provider/parity-loaders.ts +595 -0
  33. package/src/provider/provider.ts +309 -203
  34. package/src/provider/retry-fetch.ts +5 -0
  35. package/src/provider/transform.ts +168 -17
  36. package/src/session/compaction.ts +18 -2
  37. package/src/session/index.ts +30 -3
  38. package/src/session/message-v2.ts +318 -32
  39. package/src/session/processor.ts +274 -140
  40. package/src/session/prompt.ts +154 -128
  41. package/src/session/retry.ts +26 -13
  42. package/src/session/summary.ts +4 -1
  43. package/src/tool/bash.ts +232 -45
  44. package/src/tool/bash.txt +1 -1
  45. package/src/tool/codesearch.ts +15 -78
  46. package/src/tool/edit.ts +111 -30
  47. package/src/tool/glob.ts +2 -1
  48. package/src/tool/grep.ts +175 -57
  49. package/src/tool/ls.ts +2 -1
  50. package/src/tool/multiedit.ts +3 -1
  51. package/src/tool/read.ts +169 -41
  52. package/src/tool/task.ts +26 -18
  53. package/src/tool/tool.ts +4 -1
  54. package/src/tool/webfetch.ts +68 -56
  55. package/src/tool/webfetch.txt +4 -4
  56. package/src/tool/websearch.ts +20 -75
  57. package/src/tool/websearch.txt +2 -0
  58. package/src/tool/write.ts +23 -6
  59. package/src/util/bom.ts +32 -0
  60. package/src/util/exa-mcp.ts +82 -0
  61. package/src/util/legacy-error-names.ts +39 -0
  62. package/src/util/read-bounded.ts +32 -0
  63. package/src/util/sse-usage-extractor.ts +67 -13
  64. package/src/util/timeout.ts +3 -4
  65. package/src/util/verbose-fetch.ts +40 -1
package/src/tool/bash.ts CHANGED
@@ -6,12 +6,23 @@ import { Branding } from '../branding';
6
6
  import { Log } from '../util/log';
7
7
  import { Instance } from '../project/instance';
8
8
  import { lazy } from '../util/lazy';
9
- import { Language } from 'web-tree-sitter';
9
+ import { Language, type Node } from 'web-tree-sitter';
10
10
  import { Permission } from '../permission';
11
11
  import { fileURLToPath } from 'url';
12
+ import path from 'path';
12
13
  import { processResult } from './process-result';
14
+ import { config } from '../config/config';
13
15
 
14
- const MAX_OUTPUT_LENGTH = 30_000;
16
+ // Output is limited to its last MAX_LINES lines and MAX_BYTES bytes, and the
17
+ // metadata preview to its last MAX_METADATA_LENGTH characters (upstream
18
+ // 307251bf3c #22660, 754ea99d86 #39403, 2092350cfa #41007).
19
+ const MAX_BYTES = 30_000;
20
+ const MAX_LINES = 2000;
21
+ const MAX_METADATA_LENGTH = 30_000;
22
+ // --legacy-tool-output: the head of the output was kept, up to this many
23
+ // characters, followed by LEGACY_TRUNCATED.
24
+ const LEGACY_MAX_OUTPUT_LENGTH = 30_000;
25
+ const LEGACY_TRUNCATED = '\n\n(Output was truncated due to length limit)';
15
26
  const DEFAULT_TIMEOUT = 1 * 60 * 1000;
16
27
  const MAX_TIMEOUT = 10 * 60 * 1000;
17
28
  const KILL_SIGNAL = 'SIGTERM';
@@ -30,6 +41,113 @@ const killWindowsTree = async (pid: number | undefined) => {
30
41
  ).catch(() => {});
31
42
  };
32
43
 
44
+ // Shells known to run the POSIX `-c` scripts models write. Anything else in
45
+ // $SHELL (fish, nu, tcsh, csh, xonsh, ...) would misparse them
46
+ // (upstream b5d0c56b4c #3494, 238b907dd8 #5205).
47
+ const POSIX_SHELLS = new Set([
48
+ 'sh',
49
+ 'bash',
50
+ 'zsh',
51
+ 'dash',
52
+ 'ksh',
53
+ 'mksh',
54
+ 'ash',
55
+ ]);
56
+ const shells = new Map<string, string | undefined>();
57
+
58
+ // Resolve the shell commands run in (--bash-shell /
59
+ // LINK_ASSISTANT_AGENT_BASH_SHELL):
60
+ // - "auto" (default): $SHELL when it is a POSIX shell, otherwise zsh on
61
+ // macOS and bash elsewhere, falling back to /bin/sh;
62
+ // - "legacy": Node's platform shell (`spawn(command, { shell: true })`,
63
+ // i.e. /bin/sh), the behaviour before issue #335;
64
+ // - anything else: that shell, run with `-c`.
65
+ // Windows always keeps the platform shell.
66
+ export function selectShell(
67
+ mode: string = config.bashShell
68
+ ): string | undefined {
69
+ if (process.platform === 'win32') return undefined;
70
+ const choice = (mode || 'auto').trim();
71
+ if (choice === 'legacy') return undefined;
72
+ const preferred = choice === 'auto' ? (process.env.SHELL ?? '') : choice;
73
+ const key = `${choice}\0${preferred}`;
74
+ if (shells.has(key)) return shells.get(key);
75
+ let shell: string | undefined;
76
+ if (choice !== 'auto') {
77
+ // An explicit shell is used as given; an unknown one fails loudly when
78
+ // spawned instead of silently running another shell.
79
+ shell = Bun.which(preferred) ?? preferred;
80
+ } else {
81
+ const name = path.basename(preferred).toLowerCase();
82
+ if (preferred && POSIX_SHELLS.has(name)) {
83
+ shell = Bun.which(preferred) ?? undefined;
84
+ }
85
+ shell ??=
86
+ process.platform === 'darwin'
87
+ ? '/bin/zsh'
88
+ : (Bun.which('bash') ?? '/bin/sh');
89
+ }
90
+ shells.set(key, shell);
91
+ return shell;
92
+ }
93
+
94
+ const WRITE_REDIRECTS = new Set(['>', '>>', '>|', '&>', '&>>', '>&']);
95
+
96
+ // Output redirects that write a file. They are siblings of the command node,
97
+ // so the token scan below does not see them, and `echo x > file` would match
98
+ // an `echo*` allow rule in plan/readonly mode. Discarding output into
99
+ // /dev/null and duplicating descriptors (`2>&1`) write nothing.
100
+ // Adapted from upstream e7ff7143b6 (#6737), which has no readonly mode.
101
+ function writeRedirects(root: Node): string[] {
102
+ const found: string[] = [];
103
+ for (const node of root.descendantsOfType('file_redirect')) {
104
+ if (!node) continue;
105
+ let operator = '';
106
+ for (let i = 0; i < node.childCount; i++) {
107
+ const child = node.child(i);
108
+ if (child && WRITE_REDIRECTS.has(child.type)) operator = child.type;
109
+ }
110
+ if (!operator) continue;
111
+ const destination =
112
+ node.childForFieldName('destination')?.text ?? node.lastChild?.text;
113
+ if (destination === '/dev/null') continue;
114
+ if (operator === '>&' && /^(\d+|-)$/.test(destination ?? '')) continue;
115
+ found.push(node.text);
116
+ }
117
+ return found;
118
+ }
119
+
120
+ // Keep the end of the output, where errors are printed, cutting at line
121
+ // boundaries and never inside a UTF-8 sequence.
122
+ function tail(text: string, maxLines: number, maxBytes: number) {
123
+ const lines = text.split('\n');
124
+ if (lines.length <= maxLines && Buffer.byteLength(text, 'utf-8') <= maxBytes)
125
+ return { text, cut: false };
126
+ const out: string[] = [];
127
+ let bytes = 0;
128
+ for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
129
+ const size =
130
+ Buffer.byteLength(lines[i], 'utf-8') + (out.length > 0 ? 1 : 0);
131
+ if (bytes + size > maxBytes) {
132
+ if (out.length === 0) {
133
+ const buf = Buffer.from(lines[i], 'utf-8');
134
+ let start = Math.max(0, buf.length - maxBytes);
135
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++;
136
+ out.unshift(buf.subarray(start).toString('utf-8'));
137
+ }
138
+ break;
139
+ }
140
+ out.unshift(lines[i]);
141
+ bytes += size;
142
+ }
143
+ return { text: out.join('\n'), cut: true };
144
+ }
145
+
146
+ function preview(text: string) {
147
+ if (text.length <= MAX_METADATA_LENGTH) return text;
148
+ return '...\n\n' + text.slice(-MAX_METADATA_LENGTH);
149
+ }
150
+
33
151
  const resolveWasm = (asset: string) => {
34
152
  if (asset.startsWith('file://')) return fileURLToPath(asset);
35
153
  if (asset.startsWith('/') || /^[a-z]:/i.test(asset)) return asset;
@@ -101,24 +219,34 @@ export const BashTool = Tool.define('bash', {
101
219
  throw new Error('Failed to parse command');
102
220
  }
103
221
  const commands: string[][] = [];
104
- for (const node of tree.rootNode.descendantsOfType('command')) {
105
- if (!node) continue;
106
- const command: string[] = [];
107
- for (let i = 0; i < node.childCount; i++) {
108
- const child = node.child(i);
109
- if (!child) continue;
110
- if (
111
- child.type !== 'command_name' &&
112
- child.type !== 'word' &&
113
- child.type !== 'string' &&
114
- child.type !== 'raw_string' &&
115
- child.type !== 'concatenation'
116
- ) {
117
- continue;
222
+ // Trees live in the WASM heap and are not garbage-collected; free each
223
+ // one before a permission ask can wait (upstream d4bf70be06, #24861).
224
+ try {
225
+ for (const node of tree.rootNode.descendantsOfType('command')) {
226
+ if (!node) continue;
227
+ const command: string[] = [];
228
+ for (let i = 0; i < node.childCount; i++) {
229
+ const child = node.child(i);
230
+ if (!child) continue;
231
+ if (
232
+ child.type !== 'command_name' &&
233
+ child.type !== 'word' &&
234
+ child.type !== 'string' &&
235
+ child.type !== 'raw_string' &&
236
+ child.type !== 'concatenation'
237
+ ) {
238
+ continue;
239
+ }
240
+ command.push(child.text);
118
241
  }
119
- command.push(child.text);
242
+ commands.push(command);
120
243
  }
121
- commands.push(command);
244
+ // A `>` head matches only the catch-all pattern, so a write redirect
245
+ // gets the mode's fallback action: deny in readonly, ask in plan.
246
+ const redirects = writeRedirects(tree.rootNode);
247
+ if (redirects.length > 0) commands.push(['>', ...redirects]);
248
+ } finally {
249
+ tree.delete();
122
250
  }
123
251
  await Permission.checkBashTokens({
124
252
  commands,
@@ -132,7 +260,40 @@ export const BashTool = Tool.define('bash', {
132
260
  const timeoutSignal = AbortSignal.timeout(timeout);
133
261
  const cancel = AbortSignal.any([ctx.abort, timeoutSignal]);
134
262
 
135
- let output = '';
263
+ // Only the last 2 * MAX_BYTES of output are kept in memory.
264
+ const keep = MAX_BYTES * 2;
265
+ const chunks: { text: string; size: number }[] = [];
266
+ let used = 0;
267
+ let dropped = false;
268
+ let last = '';
269
+ // --legacy-tool-output keeps the head instead, so only the first
270
+ // LEGACY_MAX_OUTPUT_LENGTH + 1 characters are needed.
271
+ const legacy = config.legacyToolOutput;
272
+ let head = '';
273
+ // A multi-byte character can be split across chunks of one stream.
274
+ const decoders = new Map<string, TextDecoder>();
275
+ const append = (text: string) => {
276
+ if (!text) return;
277
+ if (legacy && head.length <= LEGACY_MAX_OUTPUT_LENGTH) {
278
+ head += text.slice(0, LEGACY_MAX_OUTPUT_LENGTH + 1 - head.length);
279
+ }
280
+ const size = Buffer.byteLength(text, 'utf-8');
281
+ chunks.push({ text, size });
282
+ used += size;
283
+ while (used > keep && chunks.length > 1) {
284
+ used -= chunks.shift()!.size;
285
+ dropped = true;
286
+ }
287
+ // Each update is stored and published, so send a bounded preview
288
+ // instead of the whole output so far.
289
+ last = preview(last + text);
290
+ ctx.metadata({
291
+ metadata: {
292
+ output: last,
293
+ description: params.description,
294
+ },
295
+ });
296
+ };
136
297
 
137
298
  // Initialize metadata with empty output
138
299
  ctx.metadata({
@@ -150,50 +311,65 @@ export const BashTool = Tool.define('bash', {
150
311
  // (link-foundation/command-stream#207).
151
312
  // A cancelled command does not need to start at all.
152
313
  if (!cancel.aborted) {
314
+ // The runner gets its own signal. command-stream 2.4.0 never removes
315
+ // the abort listener it adds to options.signal, so passing the turn's
316
+ // long-lived signal would keep every runner (and its output) reachable
317
+ // until the turn ends or the timeout fires. Only the link below touches
318
+ // `cancel`, and it is removed when the command finishes.
153
319
  // On Windows the runner is stopped only after taskkill has stopped the
154
320
  // shell's process tree.
155
- const stop =
156
- process.platform === 'win32' ? new AbortController() : undefined;
321
+ const stop = new AbortController();
157
322
 
158
- // The file/args shell form spawns the command string through Node's
159
- // platform shell, as `spawn(command, { shell: true })` does. A command
160
- // string spec would route simple commands and pipelines through
323
+ // The file/args forms run the command string in a real shell. A
324
+ // command string spec would route simple commands and pipelines through
161
325
  // command-stream's JavaScript builtins (`ls`, `exit`, `cd`, ...) instead.
326
+ // Windows keeps Node's platform shell (`spawn(command, { shell: true
327
+ // })`); elsewhere the selected shell runs it with `-c`.
328
+ const shell = selectShell();
162
329
  const command = new ProcessRunner(
163
- { mode: 'shell', file: params.command, args: [] },
330
+ shell
331
+ ? { mode: 'exec', file: shell, args: ['-c', params.command] }
332
+ : { mode: 'shell', file: params.command, args: [] },
164
333
  {
165
334
  cwd: Instance.directory,
166
335
  env: { ...process.env },
167
- signal: stop?.signal ?? cancel,
336
+ signal: stop.signal,
168
337
  killSignal: KILL_SIGNAL,
169
338
  killGrace: 200,
170
339
  mirror: false,
171
- capture: true,
340
+ // Output is read from stream() into the bounded buffer above;
341
+ // capturing would also keep the whole output in the runner.
342
+ capture: false,
172
343
  stdin: 'ignore',
173
344
  }
174
345
  );
175
346
 
176
347
  const stopTree = () => {
177
- void killWindowsTree(command.child?.pid).finally(() => stop?.abort());
348
+ if (process.platform !== 'win32') return stop.abort();
349
+ void killWindowsTree(command.child?.pid).finally(() => stop.abort());
178
350
  };
179
- if (stop) cancel.addEventListener('abort', stopTree, { once: true });
351
+ cancel.addEventListener('abort', stopTree, { once: true });
180
352
 
181
- for await (const chunk of command.stream()) {
182
- if (chunk.type === 'exit') {
183
- exit = chunk.code;
184
- } else {
185
- output += chunk.data.toString();
186
- ctx.metadata({
187
- metadata: {
188
- output,
189
- description: params.description,
190
- },
191
- });
353
+ try {
354
+ for await (const chunk of command.stream()) {
355
+ if (chunk.type === 'exit') {
356
+ exit = chunk.code;
357
+ } else {
358
+ let decoder = decoders.get(chunk.type);
359
+ if (!decoder) {
360
+ decoder = new TextDecoder();
361
+ decoders.set(chunk.type, decoder);
362
+ }
363
+ append(decoder.decode(chunk.data, { stream: true }));
364
+ }
192
365
  }
366
+ for (const decoder of decoders.values()) append(decoder.decode());
367
+ } finally {
368
+ // ctx.abort outlives the call, so a listener left on it would keep
369
+ // this runner and its output reachable until the session ends.
370
+ cancel.removeEventListener('abort', stopTree);
193
371
  }
194
372
 
195
- cancel.removeEventListener('abort', stopTree);
196
-
197
373
  // command-stream reports a shell that died from a signal as exit 0
198
374
  // (link-foundation/command-stream#208).
199
375
  // Its result keeps the native child, which has the signal name.
@@ -211,9 +387,20 @@ export const BashTool = Tool.define('bash', {
211
387
  if (timedOut || aborted) signal = KILL_SIGNAL;
212
388
  if (signal) exit = null;
213
389
 
214
- if (output.length > MAX_OUTPUT_LENGTH) {
215
- output = output.slice(0, MAX_OUTPUT_LENGTH);
216
- output += '\n\n(Output was truncated due to length limit)';
390
+ let output: string;
391
+ if (legacy) {
392
+ output =
393
+ head.length > LEGACY_MAX_OUTPUT_LENGTH
394
+ ? head.slice(0, LEGACY_MAX_OUTPUT_LENGTH) + LEGACY_TRUNCATED
395
+ : head;
396
+ } else {
397
+ const end = tail(
398
+ chunks.map((item) => item.text).join(''),
399
+ MAX_LINES,
400
+ MAX_BYTES
401
+ );
402
+ output =
403
+ (dropped || end.cut ? '...output truncated...\n\n' : '') + end.text;
217
404
  }
218
405
 
219
406
  if (timedOut) {
package/src/tool/bash.txt CHANGED
@@ -20,7 +20,7 @@ Usage notes:
20
20
  - The command argument is required.
21
21
  - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
22
22
  - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
23
- - If the output exceeds 30000 characters, output will be truncated before being returned to you.
23
+ - If the output exceeds 2000 lines or 30000 bytes, the beginning of the output is dropped and only the end, where errors usually are, is returned to you.
24
24
  - VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and List to read files.
25
25
  - If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
26
26
  - When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
@@ -2,16 +2,10 @@ import z from 'zod';
2
2
  import { Tool } from './tool';
3
3
  import DESCRIPTION from './codesearch.txt';
4
4
  import { createVerboseFetch } from '../util/verbose-fetch';
5
+ import { ExaMcp } from '../util/exa-mcp';
5
6
 
6
7
  const verboseFetch = createVerboseFetch(fetch, { caller: 'codesearch' });
7
8
 
8
- const API_CONFIG = {
9
- BASE_URL: 'https://mcp.exa.ai',
10
- ENDPOINTS: {
11
- CONTEXT: '/mcp',
12
- },
13
- } as const;
14
-
15
9
  interface McpCodeRequest {
16
10
  jsonrpc: string;
17
11
  id: number;
@@ -25,16 +19,6 @@ interface McpCodeRequest {
25
19
  };
26
20
  }
27
21
 
28
- interface McpCodeResponse {
29
- jsonrpc: string;
30
- result: {
31
- content: Array<{
32
- type: string;
33
- text: string;
34
- }>;
35
- };
36
- }
37
-
38
22
  export const CodeSearchTool = Tool.define('codesearch', {
39
23
  description: DESCRIPTION,
40
24
  parameters: z.object({
@@ -67,67 +51,20 @@ export const CodeSearchTool = Tool.define('codesearch', {
67
51
  },
68
52
  };
69
53
 
70
- const controller = new AbortController();
71
- const timeoutId = setTimeout(() => controller.abort(), 30000);
72
-
73
- try {
74
- const headers: Record<string, string> = {
75
- accept: 'application/json, text/event-stream',
76
- 'content-type': 'application/json',
77
- };
78
-
79
- const response = await verboseFetch(
80
- `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`,
81
- {
82
- method: 'POST',
83
- headers,
84
- body: JSON.stringify(codeRequest),
85
- signal: AbortSignal.any([controller.signal, ctx.abort]),
86
- }
87
- );
54
+ const output = await ExaMcp.call({
55
+ fetch: verboseFetch,
56
+ body: codeRequest,
57
+ timeout: 30000,
58
+ abort: ctx.abort,
59
+ label: 'Code search',
60
+ });
88
61
 
89
- clearTimeout(timeoutId);
90
-
91
- if (!response.ok) {
92
- const errorText = await response.text();
93
- throw new Error(`Code search error (${response.status}): ${errorText}`);
94
- }
95
-
96
- const responseText = await response.text();
97
-
98
- // Parse SSE response
99
- const lines = responseText.split('\n');
100
- for (const line of lines) {
101
- if (line.startsWith('data: ')) {
102
- const data: McpCodeResponse = JSON.parse(line.substring(6));
103
- if (
104
- data.result &&
105
- data.result.content &&
106
- data.result.content.length > 0
107
- ) {
108
- return {
109
- output: data.result.content[0].text,
110
- title: `Code search: ${params.query}`,
111
- metadata: {},
112
- };
113
- }
114
- }
115
- }
116
-
117
- return {
118
- output:
119
- 'No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.',
120
- title: `Code search: ${params.query}`,
121
- metadata: {},
122
- };
123
- } catch (error) {
124
- clearTimeout(timeoutId);
125
-
126
- if (error instanceof Error && error.name === 'AbortError') {
127
- throw new Error('Code search request timed out');
128
- }
129
-
130
- throw error;
131
- }
62
+ return {
63
+ output:
64
+ output ??
65
+ 'No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.',
66
+ title: `Code search: ${params.query}`,
67
+ metadata: {},
68
+ };
132
69
  },
133
70
  });