@link-assistant/agent 0.27.0 → 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 (54) hide show
  1. package/README.md +47 -5
  2. package/package.json +41 -42
  3. package/src/cli/argv.ts +1 -1
  4. package/src/cli/cmd/run.ts +7 -1
  5. package/src/cli/continuous-mode.js +38 -12
  6. package/src/cli/event-handler.js +3 -1
  7. package/src/cli/model-config.js +20 -4
  8. package/src/cli/run-options.js +144 -78
  9. package/src/config/config.ts +306 -105
  10. package/src/config/defaults.ts +13 -5
  11. package/src/config/file-config.ts +16 -0
  12. package/src/file/ripgrep.ts +38 -5
  13. package/src/file/time.ts +86 -9
  14. package/src/index.js +39 -33
  15. package/src/mcp/index.ts +370 -106
  16. package/src/permission/index.ts +22 -0
  17. package/src/project/instance.ts +5 -1
  18. package/src/project/project.ts +35 -2
  19. package/src/provider/free-tier-error.ts +1 -1
  20. package/src/provider/kilo-deprecation.ts +30 -0
  21. package/src/provider/kilo.ts +25 -7
  22. package/src/provider/provider.ts +53 -22
  23. package/src/provider/retry-fetch.ts +5 -0
  24. package/src/provider/transform.ts +139 -17
  25. package/src/session/compaction.ts +18 -2
  26. package/src/session/index.ts +30 -3
  27. package/src/session/message-v2.ts +318 -32
  28. package/src/session/processor.ts +274 -140
  29. package/src/session/prompt.ts +154 -128
  30. package/src/session/retry.ts +26 -13
  31. package/src/session/summary.ts +4 -1
  32. package/src/tool/bash.ts +232 -45
  33. package/src/tool/bash.txt +1 -1
  34. package/src/tool/codesearch.ts +15 -78
  35. package/src/tool/edit.ts +111 -30
  36. package/src/tool/glob.ts +2 -1
  37. package/src/tool/grep.ts +175 -57
  38. package/src/tool/ls.ts +2 -1
  39. package/src/tool/multiedit.ts +3 -1
  40. package/src/tool/read.ts +169 -41
  41. package/src/tool/task.ts +26 -18
  42. package/src/tool/tool.ts +4 -1
  43. package/src/tool/webfetch.ts +68 -56
  44. package/src/tool/webfetch.txt +4 -4
  45. package/src/tool/websearch.ts +20 -75
  46. package/src/tool/websearch.txt +2 -0
  47. package/src/tool/write.ts +23 -6
  48. package/src/util/bom.ts +32 -0
  49. package/src/util/exa-mcp.ts +82 -0
  50. package/src/util/legacy-error-names.ts +39 -0
  51. package/src/util/read-bounded.ts +32 -0
  52. package/src/util/sse-usage-extractor.ts +67 -13
  53. package/src/util/timeout.ts +3 -4
  54. 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
@@ -495,14 +495,13 @@ async function runServerMode(
495
495
  });
496
496
 
497
497
  // Subscribe to all bus events and output in selected format
498
- const { unsub: eventUnsub, idlePromise: eventPromise } =
499
- createBusEventSubscription({
500
- sessionID,
501
- eventHandler,
502
- onError: () => {
503
- hasError = true;
504
- },
505
- });
498
+ const { unsub: eventUnsub } = createBusEventSubscription({
499
+ sessionID,
500
+ eventHandler,
501
+ onError: () => {
502
+ hasError = true;
503
+ },
504
+ });
506
505
  unsub = eventUnsub;
507
506
 
508
507
  // Send message to session with specified model
@@ -527,20 +526,26 @@ async function runServerMode(
527
526
  temperature,
528
527
  }),
529
528
  }
530
- ).catch((error) => {
531
- hasError = true;
532
- eventHandler.output({
533
- type: 'error',
534
- timestamp: Date.now(),
535
- sessionID,
536
- message: errorText(error),
537
- 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
+ });
538
542
  });
539
- });
540
543
 
541
- // Wait for session to become idle, then for the request rejection to be
542
- // observed (see runDirectMode, #290).
543
- 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).
544
549
  await requestPromise;
545
550
  } finally {
546
551
  // Always clean up resources
@@ -592,14 +597,13 @@ async function runDirectMode(
592
597
  });
593
598
 
594
599
  // Subscribe to all bus events and output in selected format
595
- const { unsub: eventUnsub, idlePromise: eventPromise } =
596
- createBusEventSubscription({
597
- sessionID,
598
- eventHandler,
599
- onError: () => {
600
- hasError = true;
601
- },
602
- });
600
+ const { unsub: eventUnsub } = createBusEventSubscription({
601
+ sessionID,
602
+ eventHandler,
603
+ onError: () => {
604
+ hasError = true;
605
+ },
606
+ });
603
607
  unsub = eventUnsub;
604
608
 
605
609
  // Send message to session directly
@@ -629,11 +633,13 @@ async function runDirectMode(
629
633
  });
630
634
  });
631
635
 
632
- // Wait for session to become idle, then for the prompt itself. A fatal
633
- // failure publishes `session.idle` while unwinding, so awaiting only the
634
- // idle event let the process exit before the rejection handler above ran
635
- // — no error record, exit code 0 (#290).
636
- 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).
637
643
  await promptPromise;
638
644
  } finally {
639
645
  // Always clean up resources