@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/file/time.ts CHANGED
@@ -1,41 +1,118 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
1
3
  import { Instance } from '../project/instance';
2
4
  import { Log } from '../util/log';
3
5
 
4
6
  export namespace FileTime {
5
7
  const log = Log.create({ service: 'file.time' });
8
+
9
+ // What we knew about a file when the session last read (or wrote) it.
10
+ // Comparing mtime/size for equality instead of `mtime > readTime` avoids
11
+ // false "modified since read" errors for files whose mtime is in the
12
+ // future (clock skew, archives, network mounts) and still catches edits
13
+ // made within the same millisecond as the read (upstream #17835).
14
+ export type Stamp = {
15
+ read: Date;
16
+ mtime: number | undefined;
17
+ size: number | undefined;
18
+ };
19
+
6
20
  export const state = Instance.state(() => {
7
21
  const read: {
8
22
  [sessionID: string]: {
9
- [path: string]: Date | undefined;
23
+ [path: string]: Stamp | undefined;
10
24
  };
11
25
  } = {};
26
+ const locks = new Map<string, Promise<void>>();
12
27
  return {
13
28
  read,
29
+ locks,
14
30
  };
15
31
  });
16
32
 
33
+ // `dir/./a.txt`, `dir/a.txt`, relative paths and symlinked paths of the
34
+ // same file must share one entry (and one lock) (upstream #20367).
35
+ export function normalize(file: string) {
36
+ const resolved = path.resolve(Instance.directory, file);
37
+ try {
38
+ return fs.realpathSync(resolved);
39
+ } catch {
40
+ // Not created yet: canonicalize the parent so the key does not change
41
+ // once the file exists (e.g. /tmp -> /private/tmp on macOS).
42
+ try {
43
+ return path.join(
44
+ fs.realpathSync(path.dirname(resolved)),
45
+ path.basename(resolved)
46
+ );
47
+ } catch {
48
+ return resolved;
49
+ }
50
+ }
51
+ }
52
+
53
+ function stamp(file: string): Stamp {
54
+ try {
55
+ const info = fs.statSync(file);
56
+ return { read: new Date(), mtime: info.mtimeMs, size: info.size };
57
+ } catch {
58
+ return { read: new Date(), mtime: undefined, size: undefined };
59
+ }
60
+ }
61
+
17
62
  export function read(sessionID: string, file: string) {
63
+ file = normalize(file);
18
64
  log.info(() => ({ message: 'read', sessionID, file }));
19
65
  const { read } = state();
20
66
  read[sessionID] = read[sessionID] || {};
21
- read[sessionID][file] = new Date();
67
+ read[sessionID][file] = stamp(file);
68
+ }
69
+
70
+ /** Forgets a deleted session's read stamps (issue #335 leak audit). */
71
+ export function clear(sessionID: string) {
72
+ delete state().read[sessionID];
22
73
  }
23
74
 
24
75
  export function get(sessionID: string, file: string) {
25
- return state().read[sessionID]?.[file];
76
+ return state().read[sessionID]?.[normalize(file)]?.read;
26
77
  }
27
78
 
28
79
  export async function assert(sessionID: string, filepath: string) {
29
- const time = get(sessionID, filepath);
80
+ filepath = normalize(filepath);
81
+ const time = state().read[sessionID]?.[filepath];
30
82
  if (!time)
31
83
  throw new Error(
32
84
  `You must read the file ${filepath} before overwriting it. Use the Read tool first`
33
85
  );
34
- const stats = await Bun.file(filepath).stat();
35
- if (stats.mtime.getTime() > time.getTime()) {
36
- throw new Error(
37
- `File ${filepath} has been modified since it was last read.\nLast modification: ${stats.mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`
38
- );
86
+ const next = stamp(filepath);
87
+ if (next.mtime === time.mtime && next.size === time.size) return;
88
+ throw new Error(
89
+ `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`
90
+ );
91
+ }
92
+
93
+ // Serializes read-modify-write cycles on one file. Tool calls of a single
94
+ // step run concurrently (and `batch` uses Promise.all), so without this two
95
+ // edits of the same file both read the old content and the last write wins,
96
+ // silently dropping the other edit (upstream 5cf126d489).
97
+ export async function withLock<T>(
98
+ filepath: string,
99
+ fn: () => Promise<T>
100
+ ): Promise<T> {
101
+ filepath = normalize(filepath);
102
+ const { locks } = state();
103
+ const current = locks.get(filepath) ?? Promise.resolve();
104
+ let release: () => void = () => {};
105
+ const next = new Promise<void>((resolve) => {
106
+ release = resolve;
107
+ });
108
+ const chained = current.then(() => next);
109
+ locks.set(filepath, chained);
110
+ await current;
111
+ try {
112
+ return await fn();
113
+ } finally {
114
+ release();
115
+ if (locks.get(filepath) === chained) locks.delete(filepath);
39
116
  }
40
117
  }
41
118
  }
package/src/index.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from './json-standard/index.ts';
27
27
  import { McpCommand } from './cli/cmd/mcp.ts';
28
28
  import { AuthCommand } from './cli/cmd/auth.ts';
29
+ import { ModelsCommand } from './cli/cmd/models.ts';
29
30
  import { FormatError } from './cli/error.ts';
30
31
  import { UI } from './cli/ui.ts';
31
32
  import { errorText } from './util/error-text.ts';
@@ -494,14 +495,13 @@ async function runServerMode(
494
495
  });
495
496
 
496
497
  // Subscribe to all bus events and output in selected format
497
- const { unsub: eventUnsub, idlePromise: eventPromise } =
498
- createBusEventSubscription({
499
- sessionID,
500
- eventHandler,
501
- onError: () => {
502
- hasError = true;
503
- },
504
- });
498
+ const { unsub: eventUnsub } = createBusEventSubscription({
499
+ sessionID,
500
+ eventHandler,
501
+ onError: () => {
502
+ hasError = true;
503
+ },
504
+ });
505
505
  unsub = eventUnsub;
506
506
 
507
507
  // Send message to session with specified model
@@ -526,20 +526,26 @@ async function runServerMode(
526
526
  temperature,
527
527
  }),
528
528
  }
529
- ).catch((error) => {
530
- hasError = true;
531
- eventHandler.output({
532
- type: 'error',
533
- timestamp: Date.now(),
534
- sessionID,
535
- message: errorText(error),
536
- error: error instanceof Error ? error.message : String(error),
529
+ )
530
+ // Read the reply to the end: it completes only after the prompt has
531
+ // returned, successful or not (issue #335).
532
+ .then((response) => response.text())
533
+ .catch((error) => {
534
+ hasError = true;
535
+ eventHandler.output({
536
+ type: 'error',
537
+ timestamp: Date.now(),
538
+ sessionID,
539
+ message: errorText(error),
540
+ error: error instanceof Error ? error.message : String(error),
541
+ });
537
542
  });
538
- });
539
543
 
540
- // Wait for session to become idle, then for the request rejection to be
541
- // observed (see runDirectMode, #290).
542
- await eventPromise;
544
+ // Wait for the request (its rejection must be observed, see
545
+ // runDirectMode, #290). `session.idle` is published before the reply is
546
+ // written, so the idle event has been handled by then; if the prompt
547
+ // failed before its loop started there is no idle event at all, and
548
+ // waiting for it hung the process (issue #335).
543
549
  await requestPromise;
544
550
  } finally {
545
551
  // Always clean up resources
@@ -591,14 +597,13 @@ async function runDirectMode(
591
597
  });
592
598
 
593
599
  // Subscribe to all bus events and output in selected format
594
- const { unsub: eventUnsub, idlePromise: eventPromise } =
595
- createBusEventSubscription({
596
- sessionID,
597
- eventHandler,
598
- onError: () => {
599
- hasError = true;
600
- },
601
- });
600
+ const { unsub: eventUnsub } = createBusEventSubscription({
601
+ sessionID,
602
+ eventHandler,
603
+ onError: () => {
604
+ hasError = true;
605
+ },
606
+ });
602
607
  unsub = eventUnsub;
603
608
 
604
609
  // Send message to session directly
@@ -628,11 +633,13 @@ async function runDirectMode(
628
633
  });
629
634
  });
630
635
 
631
- // Wait for session to become idle, then for the prompt itself. A fatal
632
- // failure publishes `session.idle` while unwinding, so awaiting only the
633
- // idle event let the process exit before the rejection handler above ran
634
- // — no error record, exit code 0 (#290).
635
- await eventPromise;
636
+ // Wait for the prompt itself. A fatal failure publishes `session.idle`
637
+ // while unwinding, so awaiting only the idle event let the process exit
638
+ // before the rejection handler above ran — no error record, exit code 0
639
+ // (#290). `session.idle` is published synchronously before `prompt`
640
+ // settles, so its output has been written by then; and a prompt that
641
+ // fails before its loop starts never goes idle, so also waiting for the
642
+ // idle event hung the process (issue #335).
636
643
  await promptPromise;
637
644
  } finally {
638
645
  // Always clean up resources
@@ -654,6 +661,8 @@ async function main() {
654
661
  .command(McpCommand)
655
662
  // Auth subcommand
656
663
  .command(AuthCommand)
664
+ // Models subcommand (list, --discover, --refresh, --free)
665
+ .command(ModelsCommand)
657
666
  // Default command for agent mode (when no subcommand specified)
658
667
  .command({
659
668
  command: '$0',