@sema-agent/core 2.12.0 → 3.0.0

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 (115) hide show
  1. package/dist/agents/agent-transcript-tool.d.ts +1 -0
  2. package/dist/agents/agent-transcript-tool.js +1 -1
  3. package/dist/agents/cascade.d.ts +4 -0
  4. package/dist/agents/cascade.js +54 -9
  5. package/dist/agents/repair-loop.d.ts +2 -0
  6. package/dist/agents/repair-loop.js +101 -2
  7. package/dist/agents/retain-ledger.d.ts +18 -2
  8. package/dist/agents/retain-ledger.js +37 -8
  9. package/dist/agents/roster-store.d.ts +4 -0
  10. package/dist/agents/roster-store.js +22 -1
  11. package/dist/agents/teacher.js +4 -2
  12. package/dist/agents/team.js +7 -4
  13. package/dist/agents/verify.d.ts +15 -2
  14. package/dist/agents/verify.js +36 -1
  15. package/dist/bin/sema-tb.js +2 -2
  16. package/dist/brain/circuit-breaker.d.ts +1 -0
  17. package/dist/brain/circuit-breaker.js +26 -9
  18. package/dist/brain/degrading.js +13 -1
  19. package/dist/brain/failover.js +2 -0
  20. package/dist/brain/repetition.js +10 -1
  21. package/dist/brain/routing.js +5 -1
  22. package/dist/brain/status-sink.js +5 -1
  23. package/dist/brain/stream-engine.js +4 -1
  24. package/dist/brain/tool-call-repair.js +32 -14
  25. package/dist/core/auto-compaction.js +17 -7
  26. package/dist/core/auto-promote.js +2 -1
  27. package/dist/core/checkpoint-store.d.ts +1 -0
  28. package/dist/core/checkpoint-store.js +28 -10
  29. package/dist/core/consolidate-scope.js +4 -3
  30. package/dist/core/context-edit.js +3 -0
  31. package/dist/core/context-guard.js +3 -0
  32. package/dist/core/git-worktree-env.d.ts +6 -1
  33. package/dist/core/git-worktree-env.js +17 -1
  34. package/dist/core/hooks.d.ts +1 -0
  35. package/dist/core/hooks.js +40 -2
  36. package/dist/core/lsp-diagnostics.js +13 -7
  37. package/dist/core/lsp-protocol.d.ts +1 -1
  38. package/dist/core/lsp-protocol.js +20 -8
  39. package/dist/core/lsp-session.d.ts +12 -2
  40. package/dist/core/lsp-session.js +114 -49
  41. package/dist/core/lsp.d.ts +13 -1
  42. package/dist/core/lsp.js +64 -9
  43. package/dist/core/mcp.d.ts +1 -0
  44. package/dist/core/mcp.js +4 -3
  45. package/dist/core/memory-engine/data-plane.js +4 -0
  46. package/dist/core/memory-engine/engine.d.ts +2 -1
  47. package/dist/core/memory-engine/engine.js +82 -7
  48. package/dist/core/memory-engine/file-backend.d.ts +1 -0
  49. package/dist/core/memory-engine/file-backend.js +13 -3
  50. package/dist/core/memory-engine/layout.js +12 -1
  51. package/dist/core/memory.js +3 -0
  52. package/dist/core/permission-rules.d.ts +2 -1
  53. package/dist/core/permission-rules.js +24 -3
  54. package/dist/core/runner/active-skill-scope.js +34 -6
  55. package/dist/core/runner/assemble-result.d.ts +1 -1
  56. package/dist/core/runner/assemble-result.js +0 -1
  57. package/dist/core/runner/prepare-task.js +23 -3
  58. package/dist/core/runner/runtask.js +48 -30
  59. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  60. package/dist/core/runner/tool-disclosure.js +26 -7
  61. package/dist/core/runner/usage-accounting.d.ts +6 -0
  62. package/dist/core/runner/usage-accounting.js +9 -8
  63. package/dist/core/secret-env.js +5 -1
  64. package/dist/core/sensitive-path-policy.js +9 -3
  65. package/dist/core/session-store.js +15 -4
  66. package/dist/core/skill-tool-specifier.d.ts +8 -0
  67. package/dist/core/skill-tool-specifier.js +58 -0
  68. package/dist/core/skills-directory.d.ts +1 -1
  69. package/dist/core/skills-directory.js +16 -4
  70. package/dist/core/store-contracts/session-repo-contract.js +40 -0
  71. package/dist/core/task-registry-monitor.js +7 -1
  72. package/dist/core/tool-policy.js +65 -17
  73. package/dist/core/trace.d.ts +1 -0
  74. package/dist/core/types.d.ts +3 -5
  75. package/dist/core/with-retry.js +0 -1
  76. package/dist/engine/compaction/compaction.d.ts +1 -0
  77. package/dist/engine/compaction/compaction.js +14 -5
  78. package/dist/engine/lsp/frame-decoder.js +6 -3
  79. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  80. package/dist/engine/lsp/node-lsp-manager.js +103 -26
  81. package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
  82. package/dist/index.d.ts +1 -1
  83. package/dist/internal/harness.d.ts +1 -1
  84. package/dist/internal/harness.js +1 -1
  85. package/dist/internal/llm.d.ts +1 -1
  86. package/dist/orchestration/workflow-governance.js +43 -9
  87. package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
  88. package/dist/orchestration/workflow-script-store.js +34 -4
  89. package/dist/orchestration/workflow.js +10 -6
  90. package/dist/prompt-assembly/artifact-store.d.ts +2 -0
  91. package/dist/prompt-assembly/artifact-store.js +39 -24
  92. package/dist/prompt-assembly/assemble.js +38 -7
  93. package/dist/prompt-assembly/epoch.js +19 -2
  94. package/dist/prompt-assembly/event-registry.js +3 -3
  95. package/dist/prompt-assembly/explain.js +1 -1
  96. package/dist/prompt-assembly/tool-catalog.js +16 -1
  97. package/dist/prompt-assembly/turn-snapshot.js +4 -1
  98. package/dist/stores/file/fs-atomic.d.ts +3 -0
  99. package/dist/stores/file/fs-atomic.js +53 -8
  100. package/dist/stores/file/index.d.ts +1 -0
  101. package/dist/stores/file/index.js +7 -0
  102. package/dist/stores/file/session-store.d.ts +2 -0
  103. package/dist/stores/file/session-store.js +28 -1
  104. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  105. package/dist/tools/fs/bash-readonly-classifier.js +57 -8
  106. package/dist/tools/fs/fs-bash.d.ts +1 -1
  107. package/dist/tools/fs/fs-bash.js +93 -5
  108. package/dist/tools/loop-tick.js +1 -1
  109. package/dist/tools/monitor.js +55 -9
  110. package/dist/tools/scheduler-tools.js +9 -3
  111. package/dist/tools/web.d.ts +8 -2
  112. package/dist/tools/web.js +46 -17
  113. package/dist/tools/worktree.d.ts +2 -0
  114. package/dist/tools/worktree.js +233 -143
  115. package/package.json +5 -2
@@ -28,6 +28,11 @@ export async function addWorktree(baseEnv, opts) {
28
28
  const detail = add.ok ? add.value.stderr || add.value.stdout : String(add.error);
29
29
  throw new Error(`git worktree add failed: ${detail}`);
30
30
  }
31
+ const excludeLine = shq(`/${WORKTREE_PARENT}/`);
32
+ await baseEnv
33
+ .exec(`ex="$(git rev-parse --git-path info/exclude)" && mkdir -p "$(dirname "$ex")" && ` +
34
+ `{ grep -qxF ${excludeLine} "$ex" 2>/dev/null || printf '%s\\n' ${excludeLine} >> "$ex"; }`, { cwd: opts.repoRoot })
35
+ .catch(() => undefined);
31
36
  let inner;
32
37
  try {
33
38
  inner = await opts.rootEnvAt(worktreeDir);
@@ -54,5 +59,16 @@ export async function addWorktree(baseEnv, opts) {
54
59
  return { env, worktreeDir };
55
60
  }
56
61
  export async function pruneWorktrees(baseEnv, repoRoot) {
57
- await baseEnv.exec("git worktree prune", { cwd: repoRoot });
62
+ try {
63
+ const r = await baseEnv.exec("git worktree prune", { cwd: repoRoot });
64
+ if (r.ok && r.value.exitCode === 0)
65
+ return { ok: true };
66
+ return {
67
+ ok: false,
68
+ detail: r.ok ? (r.value.stderr || r.value.stdout || `exit ${r.value.exitCode}`).trim() : String(r.error),
69
+ };
70
+ }
71
+ catch (err) {
72
+ return { ok: false, detail: String(err) };
73
+ }
58
74
  }
@@ -121,6 +121,7 @@ export interface ToolGateInput {
121
121
  approvalTimeoutMs?: number;
122
122
  abortSignal?: AbortSignal;
123
123
  permissionDenied?: (payload: PermissionDeniedPayload) => void | Promise<void>;
124
+ onHookError?: (err: unknown) => void;
124
125
  shellGated?: boolean;
125
126
  autoMode?: {
126
127
  decider: import("./auto-mode.js").AutoModeDecider;
@@ -16,6 +16,20 @@ export function cloneObserverInput(input) {
16
16
  export function formatHookFeedback(text) {
17
17
  return `<system-reminder>\n${text}\n</system-reminder>`;
18
18
  }
19
+ function preToolUseCrashReason(subject, err) {
20
+ const raw = err instanceof Error ? err.message.trim() || err.name : String(err);
21
+ const cause = inlineUntrusted(raw, 200);
22
+ return (`a PreToolUse hook crashed while screening ${subject}; the call was NOT executed (fail-closed). ` +
23
+ `This is a failure of the deployment's hook, NOT of the tool itself — an identical retry reaches the ` +
24
+ `same hook and fails the same way. Hook error: ${cause || "(no message)"}`);
25
+ }
26
+ function traceHookCrash(input, err) {
27
+ try {
28
+ input.onHookError?.(err);
29
+ }
30
+ catch {
31
+ }
32
+ }
19
33
  function withProbeTimeout(p, ms, signal) {
20
34
  if (ms === undefined && signal === undefined)
21
35
  return p;
@@ -49,7 +63,22 @@ export async function runToolGate(input) {
49
63
  const preToolContext = [];
50
64
  let hookAsk;
51
65
  if (preToolUse) {
52
- const r = await preToolUse(toolName, currentInput, { toolCallId, toolName });
66
+ let r;
67
+ try {
68
+ r = await preToolUse(toolName, currentInput, { toolCallId, toolName });
69
+ }
70
+ catch (err) {
71
+ const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
72
+ traceHookCrash(input, err);
73
+ if (input.permissionDenied) {
74
+ try {
75
+ await input.permissionDenied({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook" });
76
+ }
77
+ catch {
78
+ }
79
+ }
80
+ return { block: true, reason: formatHookFeedback(reason), preToolContext };
81
+ }
53
82
  if (r) {
54
83
  if (r.additionalContext) {
55
84
  preToolContext.push(r.additionalContext);
@@ -182,7 +211,16 @@ export async function runToolGate(input) {
182
211
  break;
183
212
  }
184
213
  if (preToolUse) {
185
- const hr = await preToolUse(toolName, editArgs, { toolCallId, toolName });
214
+ let hr;
215
+ try {
216
+ hr = await preToolUse(toolName, editArgs, { toolCallId, toolName });
217
+ }
218
+ catch (err) {
219
+ traceHookCrash(input, err);
220
+ editDenied = { action: "deny", reason: preToolUseCrashReason(`the approved edit for "${toolName}"`, err) };
221
+ denySource = "hook";
222
+ break;
223
+ }
186
224
  if (hr) {
187
225
  if (hr.additionalContext)
188
226
  preToolContext.push(hr.additionalContext);
@@ -35,22 +35,28 @@ export class LspDiagnosticsRegistry {
35
35
  }
36
36
  drain() {
37
37
  const out = [];
38
+ const requeued = new Map();
38
39
  let total = 0;
39
40
  for (const [uri, all] of this.pending) {
40
- if (total >= MAX_TOTAL)
41
- break;
42
41
  const fresh = all
43
42
  .filter((d) => !this.delivered.has(diagnosticKey(uri, d)))
44
- .sort((a, b) => (a.severity ?? 99) - (b.severity ?? 99))
45
- .slice(0, Math.min(MAX_PER_FILE, MAX_TOTAL - total));
43
+ .sort((a, b) => (a.severity ?? 99) - (b.severity ?? 99));
46
44
  if (fresh.length === 0)
47
45
  continue;
48
- for (const d of fresh)
46
+ const room = Math.max(0, Math.min(MAX_PER_FILE, MAX_TOTAL - total));
47
+ const take = fresh.slice(0, room);
48
+ if (fresh.length > take.length)
49
+ requeued.set(uri, fresh.slice(take.length));
50
+ if (take.length === 0)
51
+ continue;
52
+ for (const d of take)
49
53
  this.delivered.add(diagnosticKey(uri, d));
50
- total += fresh.length;
51
- out.push({ uri, diagnostics: fresh });
54
+ total += take.length;
55
+ out.push({ uri, diagnostics: take });
52
56
  }
53
57
  this.pending.clear();
58
+ for (const [uri, rest] of requeued)
59
+ this.pending.set(uri, rest);
54
60
  return out;
55
61
  }
56
62
  }
@@ -6,4 +6,4 @@ export declare function buildRequest(op: LspOperation, params: LspRequestParams)
6
6
  method: string;
7
7
  params: unknown;
8
8
  } | undefined;
9
- export declare function parseResult(op: LspOperation, raw: unknown): LspResult;
9
+ export declare function parseResult(op: LspOperation, raw: unknown, fallbackUri?: string): LspResult;
@@ -3,6 +3,11 @@ export function pathToUri(p) {
3
3
  if (p.startsWith("/")) {
4
4
  return `file://${p.split("/").map(encodeURIComponent).join("/")}`;
5
5
  }
6
+ const drive = /^([A-Za-z]):[\\/](.*)$/.exec(p);
7
+ if (drive)
8
+ return `file:///${drive[1]}:/${drive[2].split(/[\\/]/).map(encodeURIComponent).join("/")}`;
9
+ if (p.startsWith("\\\\"))
10
+ return `file://${p.slice(2).split(/[\\/]/).map(encodeURIComponent).join("/")}`;
6
11
  return pathToFileURL(p).href;
7
12
  }
8
13
  export function uriToPath(uri) {
@@ -47,7 +52,7 @@ export function buildRequest(op, params) {
47
52
  return { method: "textDocument/prepareCallHierarchy", params: { ...td, position } };
48
53
  }
49
54
  }
50
- export function parseResult(op, raw) {
55
+ export function parseResult(op, raw, fallbackUri) {
51
56
  switch (op) {
52
57
  case "goToDefinition":
53
58
  case "findReferences":
@@ -62,7 +67,7 @@ export function parseResult(op, raw) {
62
67
  case "documentSymbol":
63
68
  case "workspaceSymbol":
64
69
  case "prepareCallHierarchy": {
65
- const symbols = toSymbols(raw);
70
+ const symbols = toSymbols(raw, op === "documentSymbol" ? fallbackUri : undefined);
66
71
  return symbols.length ? { kind: "symbols", symbols } : { kind: "none" };
67
72
  }
68
73
  case "incomingCalls":
@@ -92,21 +97,28 @@ function toLocations(raw) {
92
97
  })
93
98
  .filter((l) => l !== undefined);
94
99
  }
95
- function toSymbols(raw) {
100
+ function toSymbols(raw, fallbackUri) {
96
101
  const out = [];
97
- const walk = (x, fallbackUri) => {
102
+ const walk = (x, inheritedUri) => {
98
103
  const loc = x.location;
99
- const uri = loc?.uri ?? (typeof x.uri === "string" ? x.uri : fallbackUri);
104
+ const uri = loc?.uri ?? (typeof x.uri === "string" ? x.uri : inheritedUri);
100
105
  const range = (loc?.range ?? x.selectionRange ?? x.range);
101
106
  if (typeof x.name === "string" && uri) {
102
- const s = range?.start ?? { line: 0, character: 0 };
103
- out.push({ name: x.name, ...(symbolKindName(x.kind) ? { kind: symbolKindName(x.kind) } : {}), uri, line: s.line + 1, character: s.character });
107
+ const s = range?.start;
108
+ out.push({
109
+ name: x.name,
110
+ ...(symbolKindName(x.kind) ? { kind: symbolKindName(x.kind) } : {}),
111
+ uri,
112
+ line: (s?.line ?? 0) + 1,
113
+ character: s?.character ?? 0,
114
+ ...(s === undefined ? { positionUnknown: true } : {}),
115
+ });
104
116
  }
105
117
  for (const child of asArray(x.children))
106
118
  walk(child, uri);
107
119
  };
108
120
  for (const x of asArray(raw))
109
- walk(x);
121
+ walk(x, fallbackUri);
110
122
  return out;
111
123
  }
112
124
  function toCallSymbols(raw) {
@@ -11,17 +11,27 @@ export declare class TransportLspSession implements LspSession {
11
11
  private readonly log;
12
12
  private readonly warmup;
13
13
  private readonly opened;
14
+ private readonly openedUris;
14
15
  private readonly syncing;
15
16
  private readonly createdAt;
16
- private warmupBarrierDone;
17
+ private warmupBarrier?;
18
+ private warmupDone;
19
+ private warmupAttempts;
17
20
  constructor(transport: LspTransport, languageId: string, readText: LspReadText, log?: (event: string, fields: Record<string, unknown>) => void, warmup?: SessionWarmup, diagnostics?: LspDiagnosticsRegistry);
21
+ get inFlight(): number;
22
+ private activeRequests;
18
23
  request(op: LspOperation, params: LspRequestParams, signal?: AbortSignal): Promise<LspResult>;
24
+ private requestOnce;
25
+ private warmupBarrierOnce;
19
26
  private syncOpenedFiles;
20
27
  private syncOne;
21
28
  private static classifyUnbranded;
22
29
  get closed(): boolean;
23
30
  openedFiles(): string[];
24
- warmOpen(filePaths: string[], signal?: AbortSignal): Promise<void>;
31
+ warmOpen(filePaths: string[], signal?: AbortSignal): Promise<{
32
+ opened: number;
33
+ failed: number;
34
+ }>;
25
35
  notifyFileChanged(filePath: string, signal?: AbortSignal): Promise<void>;
26
36
  close(): Promise<void>;
27
37
  }
@@ -1,5 +1,6 @@
1
1
  import { uriToPath, buildRequest, callHierarchyMethod, parseResult, pathToUri } from "./lsp-protocol.js";
2
- import { lspFailureOf } from "./lsp.js";
2
+ import { lspFailureOf, SharedAbortScope, settleOnAbort } from "./lsp.js";
3
+ const WARMUP_MAX_ATTEMPTS = 3;
3
4
  export class TransportLspSession {
4
5
  transport;
5
6
  languageId;
@@ -7,9 +8,12 @@ export class TransportLspSession {
7
8
  log;
8
9
  warmup;
9
10
  opened = new Map();
11
+ openedUris = new Map();
10
12
  syncing = new Map();
11
13
  createdAt = Date.now();
12
- warmupBarrierDone = false;
14
+ warmupBarrier;
15
+ warmupDone = false;
16
+ warmupAttempts = 0;
13
17
  constructor(transport, languageId, readText, log = () => { }, warmup = { windowMs: 10_000, retryMs: 1_000 }, diagnostics) {
14
18
  this.transport = transport;
15
19
  this.languageId = languageId;
@@ -24,7 +28,7 @@ export class TransportLspSession {
24
28
  if (!p || typeof p.uri !== "string" || !Array.isArray(p.diagnostics))
25
29
  return;
26
30
  if (typeof p.version === "number") {
27
- const filePath = uriToPath(p.uri);
31
+ const filePath = this.openedUris.get(p.uri) ?? uriToPath(p.uri);
28
32
  const known = this.opened.get(filePath);
29
33
  if (known && p.version < known.version) {
30
34
  this.log("lsp_diagnostics_stale_dropped", { uri: p.uri, version: p.version, current: known.version });
@@ -35,18 +39,34 @@ export class TransportLspSession {
35
39
  });
36
40
  }
37
41
  }
42
+ get inFlight() {
43
+ return this.activeRequests;
44
+ }
45
+ activeRequests = 0;
38
46
  async request(op, params, signal) {
47
+ this.activeRequests += 1;
48
+ try {
49
+ return await this.requestOnce(op, params, signal);
50
+ }
51
+ finally {
52
+ this.activeRequests -= 1;
53
+ }
54
+ }
55
+ async requestOnce(op, params, signal) {
39
56
  const built = buildRequest(op, params);
40
57
  if (!built)
41
58
  return { kind: "none", reason: "unsupported_operation" };
42
- await this.syncOpenedFiles(params.filePath, signal);
43
- if (!this.warmupBarrierDone && this.warmup.windowMs > 0) {
44
- this.warmupBarrierDone = true;
45
- if (op !== "goToDefinition") {
46
- await this.transport
47
- .request("textDocument/definition", { textDocument: { uri: pathToUri(params.filePath) }, position: { line: 0, character: 0 } }, signal)
48
- .catch(() => undefined);
49
- }
59
+ const sync = await this.syncOpenedFiles(params.filePath, signal);
60
+ if (!sync.ok) {
61
+ const reason = signal?.aborted ? "cancelled" : (sync.reason ?? "file_sync_failed");
62
+ this.log("lsp_op_degraded", { op, file: params.filePath, reason, err: sync.error });
63
+ return { kind: "none", reason, ...(reason === "file_sync_failed" ? { detail: sync.error } : {}) };
64
+ }
65
+ if (this.warmup.windowMs > 0 && !this.warmupDone) {
66
+ if (op === "goToDefinition")
67
+ this.warmupDone = true;
68
+ else
69
+ await this.warmupBarrierOnce(params.filePath, signal);
50
70
  }
51
71
  for (let attempt = 0;; attempt++) {
52
72
  let raw;
@@ -66,7 +86,7 @@ export class TransportLspSession {
66
86
  }
67
87
  const result = noHierarchyItem
68
88
  ? { kind: "none", reason: "no_call_hierarchy_item" }
69
- : parseResult(op, raw);
89
+ : parseResult(op, raw, pathToUri(params.filePath));
70
90
  if (result.kind === "none" && this.warmup.windowMs > 0 && Date.now() - this.createdAt < this.warmup.windowMs && !signal?.aborted) {
71
91
  await new Promise((r) => setTimeout(r, this.warmup.retryMs));
72
92
  continue;
@@ -84,47 +104,90 @@ export class TransportLspSession {
84
104
  return result;
85
105
  }
86
106
  }
107
+ async warmupBarrierOnce(filePath, signal) {
108
+ let entry = this.warmupBarrier;
109
+ if (entry && entry.scope.signal.aborted)
110
+ entry = undefined;
111
+ if (!entry) {
112
+ if (this.warmupAttempts >= WARMUP_MAX_ATTEMPTS)
113
+ return;
114
+ this.warmupAttempts += 1;
115
+ const scope = new SharedAbortScope();
116
+ const started = {};
117
+ const job = this.transport
118
+ .request("textDocument/definition", { textDocument: { uri: pathToUri(filePath) }, position: { line: 0, character: 0 } }, scope.signal)
119
+ .then(() => {
120
+ this.warmupDone = true;
121
+ })
122
+ .catch(() => {
123
+ if (this.warmupBarrier === started.self)
124
+ this.warmupBarrier = undefined;
125
+ });
126
+ entry = { job, scope };
127
+ started.self = entry;
128
+ this.warmupBarrier = entry;
129
+ }
130
+ entry.scope.join(signal);
131
+ await settleOnAbort(entry.job, signal, () => undefined);
132
+ }
87
133
  async syncOpenedFiles(queried, signal) {
88
134
  const files = new Set(this.opened.keys());
89
135
  files.add(queried);
90
- await Promise.all([...files].map((fp) => this.syncOne(fp, signal)));
136
+ const outcomes = await Promise.all([...files].map(async (fp) => [fp, await this.syncOne(fp, signal)]));
137
+ return outcomes.find(([fp]) => fp === queried)?.[1] ?? { ok: true };
91
138
  }
92
139
  syncOne(filePath, signal) {
93
140
  const inflight = this.syncing.get(filePath);
94
- if (inflight)
95
- return inflight;
96
- const job = (async () => {
97
- let text;
98
- try {
99
- text = await this.readText(filePath, signal);
100
- }
101
- catch (e) {
102
- this.log("lsp_sync_skipped", {
103
- file: filePath,
104
- error: e instanceof Error ? e.message : String(e),
105
- opened: this.opened.has(filePath),
106
- });
107
- return;
108
- }
109
- const cur = this.opened.get(filePath);
110
- if (!cur) {
111
- this.opened.set(filePath, { version: 1, text });
112
- this.transport.notify("textDocument/didOpen", {
113
- textDocument: { uri: pathToUri(filePath), languageId: this.languageId, version: 1, text },
114
- });
115
- }
116
- else if (cur.text !== text) {
117
- const version = cur.version + 1;
118
- this.opened.set(filePath, { version, text });
119
- this.transport.notify("textDocument/didChange", {
120
- textDocument: { uri: pathToUri(filePath), version },
121
- contentChanges: [{ text }],
122
- });
123
- this.log("lsp_file_resync", { file: filePath, version });
124
- }
125
- })();
126
- this.syncing.set(filePath, job);
127
- return job.finally(() => this.syncing.delete(filePath));
141
+ if (inflight && !inflight.scope.signal.aborted) {
142
+ inflight.scope.join(signal);
143
+ return settleOnAbort(inflight.job, signal, () => ({ ok: false, error: "aborted by the caller" }));
144
+ }
145
+ const scope = new SharedAbortScope();
146
+ scope.join(signal);
147
+ const entry = {
148
+ scope,
149
+ job: (async () => {
150
+ let text;
151
+ try {
152
+ text = await this.readText(filePath, scope.signal);
153
+ }
154
+ catch (e) {
155
+ const error = e instanceof Error ? e.message : String(e);
156
+ this.log("lsp_sync_skipped", { file: filePath, error, opened: this.opened.has(filePath) });
157
+ return { ok: false, error };
158
+ }
159
+ const cur = this.opened.get(filePath);
160
+ const uri = pathToUri(filePath);
161
+ if (!cur) {
162
+ this.transport.notify("textDocument/didOpen", {
163
+ textDocument: { uri, languageId: this.languageId, version: 1, text },
164
+ });
165
+ if (this.transport.closed === true) {
166
+ this.log("lsp_sync_notify_dropped", { file: filePath, kind: "didOpen" });
167
+ return { ok: false, error: "didOpen was not delivered (transport closed)", reason: "server_terminated" };
168
+ }
169
+ this.opened.set(filePath, { version: 1, text });
170
+ this.openedUris.set(uri, filePath);
171
+ }
172
+ else if (cur.text !== text) {
173
+ const version = cur.version + 1;
174
+ this.transport.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }] });
175
+ if (this.transport.closed === true) {
176
+ this.log("lsp_sync_notify_dropped", { file: filePath, kind: "didChange", version });
177
+ return { ok: false, error: "didChange was not delivered (transport closed)", reason: "server_terminated" };
178
+ }
179
+ this.opened.set(filePath, { version, text });
180
+ this.log("lsp_file_resync", { file: filePath, version });
181
+ }
182
+ return { ok: true };
183
+ })(),
184
+ };
185
+ this.syncing.set(filePath, entry);
186
+ const settled = entry.job.finally(() => {
187
+ if (this.syncing.get(filePath) === entry)
188
+ this.syncing.delete(filePath);
189
+ });
190
+ return settleOnAbort(settled, signal, () => ({ ok: false, error: "aborted by the caller" }));
128
191
  }
129
192
  static classifyUnbranded(signal, transport) {
130
193
  if (signal?.aborted)
@@ -140,10 +203,12 @@ export class TransportLspSession {
140
203
  return [...this.opened.keys()];
141
204
  }
142
205
  async warmOpen(filePaths, signal) {
143
- await Promise.all(filePaths.map((fp) => this.syncOne(fp, signal)));
206
+ const outcomes = await Promise.all(filePaths.map((fp) => this.syncOne(fp, signal)));
207
+ const failed = outcomes.filter((o) => !o.ok).length;
208
+ return { opened: outcomes.length - failed, failed };
144
209
  }
145
210
  notifyFileChanged(filePath, signal) {
146
- return this.syncOne(filePath, signal);
211
+ return this.syncOne(filePath, signal).then(() => undefined);
147
212
  }
148
213
  close() {
149
214
  return this.transport.close();
@@ -13,8 +13,9 @@ export interface LspSymbolInfo {
13
13
  uri: string;
14
14
  line: number;
15
15
  character: number;
16
+ positionUnknown?: boolean;
16
17
  }
17
- export type LspNoneReason = "unsupported_operation" | "server_error" | "timeout" | "cancelled" | "server_terminated" | "no_call_hierarchy_item";
18
+ export type LspNoneReason = "unsupported_operation" | "server_error" | "timeout" | "cancelled" | "server_terminated" | "no_call_hierarchy_item" | "file_sync_failed";
18
19
  export type LspResult = {
19
20
  kind: "locations";
20
21
  locations: LspLocation[];
@@ -35,6 +36,16 @@ export declare function lspFailureOf(e: unknown): {
35
36
  reason: LspNoneReason;
36
37
  detail?: string;
37
38
  } | undefined;
39
+ export declare class SharedAbortScope {
40
+ private readonly controller;
41
+ private joiners;
42
+ private abandoned;
43
+ get signal(): AbortSignal;
44
+ join(signal?: AbortSignal): void;
45
+ private abandon;
46
+ }
47
+ export declare function settleOnAbort<T>(job: Promise<T>, signal: AbortSignal | undefined, onAbort: () => T): Promise<T>;
48
+ export declare function resolveLspPath(filePath: string, cwd?: string): string;
38
49
  export interface LspRequestParams {
39
50
  filePath: string;
40
51
  line?: number;
@@ -55,6 +66,7 @@ export type LspReadText = (filePath: string, signal?: AbortSignal) => Promise<st
55
66
  export declare const MAX_LSP_FILE_BYTES: number;
56
67
  export interface LspServerManager {
57
68
  sessionFor(filePath: string, signal?: AbortSignal, env?: ExecutionEnv): Promise<LspSession | undefined>;
69
+ unavailableReason?(filePath: string, env?: ExecutionEnv): string | undefined;
58
70
  readonly diagnostics?: import("./lsp-diagnostics.js").LspDiagnosticsRegistry;
59
71
  }
60
72
  export interface LspToolOptions {
package/dist/core/lsp.js CHANGED
@@ -8,6 +8,7 @@ export const LSP_OPERATIONS = [
8
8
  export const LSP_FAILURE_BRAND = "lspFailureReason";
9
9
  const LSP_NONE_REASONS = new Set([
10
10
  "unsupported_operation", "server_error", "timeout", "cancelled", "server_terminated", "no_call_hierarchy_item",
11
+ "file_sync_failed",
11
12
  ]);
12
13
  export function brandLspFailure(e, reason, detail) {
13
14
  const carrier = e;
@@ -23,6 +24,54 @@ export function lspFailureOf(e) {
23
24
  const d = e.lspFailureDetail;
24
25
  return { reason: r, ...(typeof d === "string" ? { detail: d } : {}) };
25
26
  }
27
+ export class SharedAbortScope {
28
+ controller = new AbortController();
29
+ joiners = 0;
30
+ abandoned = 0;
31
+ get signal() {
32
+ return this.controller.signal;
33
+ }
34
+ join(signal) {
35
+ this.joiners += 1;
36
+ if (!signal)
37
+ return;
38
+ if (signal.aborted) {
39
+ this.abandon();
40
+ return;
41
+ }
42
+ signal.addEventListener("abort", () => this.abandon(), { once: true });
43
+ }
44
+ abandon() {
45
+ this.abandoned += 1;
46
+ if (this.abandoned >= this.joiners)
47
+ this.controller.abort();
48
+ }
49
+ }
50
+ export function settleOnAbort(job, signal, onAbort) {
51
+ if (!signal)
52
+ return job;
53
+ if (signal.aborted) {
54
+ void job.catch(() => undefined);
55
+ return Promise.resolve(onAbort());
56
+ }
57
+ return new Promise((resolve, reject) => {
58
+ const onAbortEvent = () => resolve(onAbort());
59
+ signal.addEventListener("abort", onAbortEvent, { once: true });
60
+ job.then((v) => {
61
+ signal.removeEventListener("abort", onAbortEvent);
62
+ resolve(v);
63
+ }, (e) => {
64
+ signal.removeEventListener("abort", onAbortEvent);
65
+ reject(e);
66
+ });
67
+ });
68
+ }
69
+ export function resolveLspPath(filePath, cwd) {
70
+ const isAbsForm = filePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
71
+ if (isAbsForm || !cwd)
72
+ return filePath;
73
+ return cwd.replace(/[\\/]+$/, "") + (cwd.includes("\\") ? "\\" : "/") + filePath;
74
+ }
26
75
  export const MAX_LSP_FILE_BYTES = 10 * 1000 * 1000;
27
76
  function fmt(result, ctx) {
28
77
  switch (result.kind) {
@@ -37,7 +86,9 @@ function fmt(result, ctx) {
37
86
  return result.symbols.length === 0
38
87
  ? "No symbols found."
39
88
  : `Found ${result.symbols.length} symbol(s):\n` +
40
- result.symbols.map((s) => `- ${s.name}${s.kind ? ` (${s.kind})` : ""} — ${s.uri}:${s.line}:${s.character}`).join("\n");
89
+ result.symbols
90
+ .map((s) => `- ${s.name}${s.kind ? ` (${s.kind})` : ""} — ${s.uri}${s.positionUnknown ? " (no position reported by the language server)" : `:${s.line}:${s.character}`}`)
91
+ .join("\n");
41
92
  case "none":
42
93
  return fmtNone(result, ctx);
43
94
  }
@@ -60,9 +111,14 @@ function fmtNone(result, ctx) {
60
111
  return `Error performing ${operation}: the request was cancelled before the language server answered. This is NOT an empty result.`;
61
112
  case "server_error":
62
113
  return `Error performing ${operation}: ${detail ?? "the language server returned an error"}. This is NOT an empty result — fall back to Grep/Read.`;
114
+ case "file_sync_failed":
115
+ return `Error performing ${operation}: "${filePath}" could not be read to synchronize it with the language server${detail ? ` (${detail})` : ""}. This is NOT an empty result — the server was never given the file's current text; re-check the path or fall back to Grep/Read.`;
63
116
  }
64
117
  }
65
- const LSP_ERROR_REASONS = new Set(["server_error", "timeout", "cancelled", "server_terminated"]);
118
+ const LSP_ERROR_REASONS = new Set([
119
+ "server_error", "timeout", "cancelled", "server_terminated",
120
+ "file_sync_failed",
121
+ ]);
66
122
  async function filterIgnored(result, isIgnored) {
67
123
  if (!isIgnored)
68
124
  return result;
@@ -129,11 +185,9 @@ export function createLspTool(manager, opts = {}) {
129
185
  if (operation === "workspaceSymbol" && wsQuery === undefined) {
130
186
  return errorResult(`Error (LSP): the "workspaceSymbol" operation needs a query.`);
131
187
  }
188
+ const target = resolveLspPath(filePath, opts.env?.cwd);
132
189
  if (opts.env) {
133
- const isAbsForm = filePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(filePath);
134
- const cwd = opts.env.cwd;
135
- const probe = isAbsForm || !cwd ? filePath : cwd.replace(/[\\/]+$/, "") + (cwd.includes("\\") ? "\\" : "/") + filePath;
136
- const info = await opts.env.fileInfo(probe, ctx.signal);
190
+ const info = await opts.env.fileInfo(target, ctx.signal);
137
191
  if (!info.ok && info.error.code === "not_found")
138
192
  return errorResult(`Error (LSP): File does not exist: ${filePath}`);
139
193
  if (!info.ok && info.error.code === "invalid")
@@ -144,12 +198,13 @@ export function createLspTool(manager, opts = {}) {
144
198
  return `File too large for LSP analysis (${Math.ceil(info.value.size / 1e6)}MB exceeds 10MB limit)`;
145
199
  }
146
200
  }
147
- const session = await manager.sessionFor(filePath, ctx.signal, opts.env);
201
+ const session = await manager.sessionFor(target, ctx.signal, opts.env);
148
202
  if (!session) {
149
- return `LSP is unavailable for "${filePath}" (no language server for its language in this environment). Fall back to Grep/Read for this lookup.`;
203
+ const why = manager.unavailableReason?.(target, opts.env) ?? "no language server for its language in this environment";
204
+ return `LSP is unavailable for "${filePath}" (${why}). Fall back to Grep/Read for this lookup.`;
150
205
  }
151
206
  const seamCharacter = character !== undefined ? character - 1 : undefined;
152
- const result = await session.request(operation, { filePath, line, character: seamCharacter, symbol: wsQuery }, ctx.signal);
207
+ const result = await session.request(operation, { filePath: target, line, character: seamCharacter, symbol: wsQuery }, ctx.signal);
153
208
  const final = toModelBase(await filterIgnored(result, opts.isPathIgnored));
154
209
  const text = fmt(final, { operation, filePath });
155
210
  return final.kind === "none" && final.reason !== undefined && LSP_ERROR_REASONS.has(final.reason) ? errorResult(text) : text;
@@ -71,6 +71,7 @@ export declare const MCP_IDLE_TIMEOUT_HTTP_DEFAULT_MS: number;
71
71
  export declare function mcpIdleTimeoutMs(kind: "stdio" | "http"): number;
72
72
  export declare function describeMcpSpecErrorCode(code: unknown): string | undefined;
73
73
  export declare function collapseMcpErrorPrefix(message: string): string;
74
+ export declare function networkErrorCode(err: unknown, depth?: number): string | undefined;
74
75
  export declare function normalizeMcpName(name: string): string;
75
76
  export declare function clampNameSegment(seg: string, max?: number): string;
76
77
  export * from "./image-downsample.js";
package/dist/core/mcp.js CHANGED
@@ -179,11 +179,11 @@ const NETWORK_CODES_NEVER_DELIVERED = new Set([
179
179
  "ENETUNREACH",
180
180
  "UND_ERR_CONNECT_TIMEOUT",
181
181
  ]);
182
- function networkErrorCode(err, depth = 0) {
182
+ export function networkErrorCode(err, depth = 0) {
183
183
  if (depth > 5 || !(err instanceof Error))
184
184
  return undefined;
185
185
  const code = err.code;
186
- if (typeof code === "string" && /^(?:E[A-Z]+|UND_ERR_[A-Z_]+)$/.test(code))
186
+ if (typeof code === "string" && /^(?:E[A-Z_]+|UND_ERR_[A-Z_]+)$/.test(code))
187
187
  return code;
188
188
  if (err instanceof AggregateError) {
189
189
  for (const inner of err.errors) {
@@ -786,7 +786,8 @@ async function readDirViaExtension(rs, uri, signal, timeoutMs, watchdog) {
786
786
  if (err instanceof McpError && err.code === ErrorCode.InvalidParams) {
787
787
  if (pages > 0)
788
788
  return { kind: "ok", resources, cursorInvalid: true };
789
- return { kind: classifyDirReadInvalidParams(err.message), detail: err.message };
789
+ const detail = collapseMcpErrorPrefix(err.message);
790
+ return { kind: classifyDirReadInvalidParams(detail), detail };
790
791
  }
791
792
  if (err instanceof McpError && err.code === ErrorCode.MethodNotFound)
792
793
  return { kind: "unsupported" };