@sema-agent/core 2.13.0 → 4.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 (184) hide show
  1. package/CHANGELOG.md +2345 -0
  2. package/dist/agents/cascade.d.ts +6 -1
  3. package/dist/agents/cascade.js +76 -32
  4. package/dist/agents/cumulative-stats.d.ts +29 -0
  5. package/dist/agents/cumulative-stats.js +29 -0
  6. package/dist/agents/observer.d.ts +4 -0
  7. package/dist/agents/observer.js +11 -10
  8. package/dist/agents/repair-loop.d.ts +2 -0
  9. package/dist/agents/repair-loop.js +110 -3
  10. package/dist/agents/retain-ledger.d.ts +20 -2
  11. package/dist/agents/retain-ledger.js +38 -8
  12. package/dist/agents/roster-store.d.ts +4 -0
  13. package/dist/agents/roster-store.js +20 -1
  14. package/dist/agents/subagent.js +66 -64
  15. package/dist/agents/teacher.d.ts +1 -9
  16. package/dist/agents/teacher.js +23 -24
  17. package/dist/agents/team.js +7 -4
  18. package/dist/agents/verify.d.ts +15 -2
  19. package/dist/agents/verify.js +39 -3
  20. package/dist/bin/sema-tb.js +2 -2
  21. package/dist/brain/anthropic.js +24 -21
  22. package/dist/brain/circuit-breaker.d.ts +1 -0
  23. package/dist/brain/circuit-breaker.js +26 -9
  24. package/dist/brain/degrading.js +13 -1
  25. package/dist/brain/failover.js +2 -0
  26. package/dist/brain/openai.js +24 -21
  27. package/dist/brain/repetition.js +10 -1
  28. package/dist/brain/routing.js +5 -1
  29. package/dist/brain/status-sink.js +5 -1
  30. package/dist/brain/stream-engine.js +5 -2
  31. package/dist/brain/stream-shared.d.ts +1 -0
  32. package/dist/brain/stream-shared.js +14 -0
  33. package/dist/brain/terminal-cause.d.ts +4 -0
  34. package/dist/brain/terminal-cause.js +12 -0
  35. package/dist/brain/tool-call-id.d.ts +1 -0
  36. package/dist/brain/tool-call-id.js +3 -0
  37. package/dist/brain/tool-call-repair.js +34 -15
  38. package/dist/core/auto-compaction.js +33 -32
  39. package/dist/core/background-agent-store.d.ts +22 -22
  40. package/dist/core/background-agent-store.js +19 -23
  41. package/dist/core/checkpoint-store.d.ts +7 -13
  42. package/dist/core/checkpoint-store.js +39 -15
  43. package/dist/core/consolidate-scope.js +4 -3
  44. package/dist/core/context-edit.js +3 -0
  45. package/dist/core/context-guard.js +3 -0
  46. package/dist/core/file-snapshot-store.d.ts +8 -0
  47. package/dist/core/file-snapshot-store.js +4 -3
  48. package/dist/core/git-worktree-env.d.ts +6 -1
  49. package/dist/core/git-worktree-env.js +12 -1
  50. package/dist/core/hooks.d.ts +4 -1
  51. package/dist/core/hooks.js +35 -7
  52. package/dist/core/lsp-diagnostics.js +13 -7
  53. package/dist/core/lsp-protocol.d.ts +1 -1
  54. package/dist/core/lsp-protocol.js +20 -8
  55. package/dist/core/lsp-session.d.ts +12 -2
  56. package/dist/core/lsp-session.js +114 -49
  57. package/dist/core/lsp.d.ts +13 -1
  58. package/dist/core/lsp.js +65 -9
  59. package/dist/core/mailbox-store.d.ts +7 -10
  60. package/dist/core/mcp.d.ts +7 -10
  61. package/dist/core/mcp.js +7 -3
  62. package/dist/core/memory-engine/data-plane.js +4 -0
  63. package/dist/core/memory-engine/engine.d.ts +2 -2
  64. package/dist/core/memory-engine/engine.js +96 -31
  65. package/dist/core/memory-engine/file-backend.d.ts +2 -2
  66. package/dist/core/memory-engine/file-backend.js +38 -39
  67. package/dist/core/memory-engine/layout.d.ts +8 -0
  68. package/dist/core/memory-engine/layout.js +71 -4
  69. package/dist/core/memory-engine/scope-contract.js +9 -3
  70. package/dist/core/memory-engine/types.d.ts +1 -1
  71. package/dist/core/memory.js +3 -0
  72. package/dist/core/permission-rules.d.ts +2 -1
  73. package/dist/core/permission-rules.js +24 -3
  74. package/dist/core/runner/active-skill-scope.d.ts +0 -1
  75. package/dist/core/runner/active-skill-scope.js +1 -15
  76. package/dist/core/runner/assemble-result.d.ts +3 -8
  77. package/dist/core/runner/assemble-result.js +3 -5
  78. package/dist/core/runner/compaction-call-options.d.ts +7 -0
  79. package/dist/core/runner/compaction-call-options.js +30 -0
  80. package/dist/core/runner/prepare-task.d.ts +6 -23
  81. package/dist/core/runner/prepare-task.js +31 -53
  82. package/dist/core/runner/runtask.d.ts +1 -0
  83. package/dist/core/runner/runtask.js +86 -178
  84. package/dist/core/runner/session-rule-policy.js +4 -1
  85. package/dist/core/runner/teardown-bounded.d.ts +7 -0
  86. package/dist/core/runner/teardown-bounded.js +36 -0
  87. package/dist/core/runner/tool-disclosure.d.ts +2 -5
  88. package/dist/core/runner/turn-attachments.d.ts +5 -15
  89. package/dist/core/runner/turn-attachments.js +1 -1
  90. package/dist/core/runner/usage-accounting.d.ts +6 -0
  91. package/dist/core/runner/usage-accounting.js +9 -8
  92. package/dist/core/safe-notify.d.ts +17 -0
  93. package/dist/core/safe-notify.js +59 -0
  94. package/dist/core/secret-env.js +5 -1
  95. package/dist/core/sensitive-path-policy.js +11 -4
  96. package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
  97. package/dist/core/store-contracts/session-repo-contract.js +40 -0
  98. package/dist/core/stub-env.d.ts +2 -6
  99. package/dist/core/surrogate-safe-slice.d.ts +4 -0
  100. package/dist/core/surrogate-safe-slice.js +18 -0
  101. package/dist/core/task-registry-monitor.js +15 -14
  102. package/dist/core/task-registry-shared.d.ts +22 -0
  103. package/dist/core/task-registry-shared.js +16 -0
  104. package/dist/core/task-registry.d.ts +1 -0
  105. package/dist/core/task-registry.js +40 -57
  106. package/dist/core/tool-errors.js +5 -4
  107. package/dist/core/tool-name-aliases.js +2 -1
  108. package/dist/core/tool-policy.js +72 -19
  109. package/dist/core/tool-result-store.d.ts +1 -0
  110. package/dist/core/tool-result-store.js +17 -1
  111. package/dist/core/trace.d.ts +3 -9
  112. package/dist/core/types.d.ts +16 -12
  113. package/dist/core/untrusted-egress.js +7 -5
  114. package/dist/core/workflow-journal-store.d.ts +9 -20
  115. package/dist/core/workflow-run-store.d.ts +11 -10
  116. package/dist/core/workflow-run-store.js +22 -16
  117. package/dist/engine/compaction/compaction.d.ts +1 -0
  118. package/dist/engine/compaction/compaction.js +14 -5
  119. package/dist/engine/compaction/utils.js +4 -2
  120. package/dist/engine/execution-env/node-execution-env.d.ts +2 -24
  121. package/dist/engine/harness/agent-harness.d.ts +1 -0
  122. package/dist/engine/harness/agent-harness.js +13 -33
  123. package/dist/engine/harness/types.d.ts +29 -67
  124. package/dist/engine/llm/types.d.ts +1 -1
  125. package/dist/engine/loop/agent-loop.js +11 -4
  126. package/dist/engine/loop/types.d.ts +11 -9
  127. package/dist/engine/lsp/frame-decoder.js +6 -3
  128. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  129. package/dist/engine/lsp/node-lsp-manager.js +103 -26
  130. package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
  131. package/dist/engine/session/memory-repo.d.ts +2 -6
  132. package/dist/engine/session/session.d.ts +5 -30
  133. package/dist/index.d.ts +3 -1
  134. package/dist/index.js +2 -1
  135. package/dist/internal/harness-types.d.ts +3 -1
  136. package/dist/internal/harness.d.ts +1 -1
  137. package/dist/internal/harness.js +1 -1
  138. package/dist/orchestration/run-spec.js +7 -4
  139. package/dist/orchestration/run-workflow-tool.js +3 -35
  140. package/dist/orchestration/workflow-governance.js +43 -9
  141. package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
  142. package/dist/orchestration/workflow-script-store.js +34 -4
  143. package/dist/orchestration/workflow.d.ts +3 -11
  144. package/dist/orchestration/workflow.js +164 -204
  145. package/dist/prompt-assembly/artifact-store.d.ts +2 -0
  146. package/dist/prompt-assembly/artifact-store.js +39 -24
  147. package/dist/prompt-assembly/assemble.js +38 -7
  148. package/dist/prompt-assembly/epoch.js +19 -2
  149. package/dist/prompt-assembly/event-registry.js +3 -3
  150. package/dist/prompt-assembly/explain.js +1 -1
  151. package/dist/prompt-assembly/tool-catalog.d.ts +10 -9
  152. package/dist/prompt-assembly/tool-catalog.js +16 -1
  153. package/dist/prompt-assembly/turn-snapshot.d.ts +2 -5
  154. package/dist/prompt-assembly/turn-snapshot.js +4 -1
  155. package/dist/stores/file/background-agent-store.d.ts +3 -12
  156. package/dist/stores/file/background-agent-store.js +3 -41
  157. package/dist/stores/file/checkpoint-store.js +6 -6
  158. package/dist/stores/file/fs-atomic.d.ts +3 -0
  159. package/dist/stores/file/fs-atomic.js +53 -8
  160. package/dist/stores/file/index.d.ts +1 -0
  161. package/dist/stores/file/index.js +7 -0
  162. package/dist/stores/file/mailbox-store.d.ts +2 -6
  163. package/dist/stores/file/session-policy-store.js +15 -1
  164. package/dist/stores/file/session-store.d.ts +4 -6
  165. package/dist/stores/file/session-store.js +28 -1
  166. package/dist/stores/file/workflow-journal-store.d.ts +3 -11
  167. package/dist/stores/file/workflow-run-store.d.ts +3 -7
  168. package/dist/stores/file/workflow-run-store.js +4 -17
  169. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -0
  170. package/dist/tools/fs/bash-readonly-classifier.js +73 -21
  171. package/dist/tools/fs/fs-bash.d.ts +1 -1
  172. package/dist/tools/fs/fs-bash.js +407 -91
  173. package/dist/tools/fs/fs-pdf.d.ts +2 -12
  174. package/dist/tools/fs/fs-shared.js +2 -2
  175. package/dist/tools/fs/notebook.d.ts +2 -5
  176. package/dist/tools/fs/safety.d.ts +3 -0
  177. package/dist/tools/fs/safety.js +43 -14
  178. package/dist/tools/fs/search.js +5 -5
  179. package/dist/tools/loop-tick.js +1 -1
  180. package/dist/tools/monitor.d.ts +1 -0
  181. package/dist/tools/monitor.js +57 -9
  182. package/dist/tools/worktree.d.ts +4 -7
  183. package/dist/tools/worktree.js +235 -143
  184. package/package.json +9 -2
@@ -7,7 +7,9 @@ export interface Hooks {
7
7
  userPromptSubmit?(prompt: string): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
8
8
  stop?(ctx: StopHookContext): StopHookResult | undefined | Promise<StopHookResult | undefined>;
9
9
  postToolUseFailure?(toolName: string, input: unknown, failure: HookToolFailure, ctx: HookToolContext): PostToolUseFailureResult | undefined | Promise<PostToolUseFailureResult | undefined>;
10
- postToolBatch?(batch: PostToolBatchCall[]): PostToolBatchResult | undefined | Promise<PostToolBatchResult | undefined>;
10
+ postToolBatch?(batch: PostToolBatchCall[], meta?: {
11
+ injectedThisTurn: "final_verification" | "finalize";
12
+ }): PostToolBatchResult | undefined | Promise<PostToolBatchResult | undefined>;
11
13
  preCompact?(ctx: PreCompactContext): PreCompactResult | undefined | Promise<PreCompactResult | undefined>;
12
14
  postCompact?(ctx: PostCompactContext): void | Promise<void>;
13
15
  stopFailure?(ctx: StopFailureContext): void | Promise<void>;
@@ -121,6 +123,7 @@ export interface ToolGateInput {
121
123
  approvalTimeoutMs?: number;
122
124
  abortSignal?: AbortSignal;
123
125
  permissionDenied?: (payload: PermissionDeniedPayload) => void | Promise<void>;
126
+ onHookError?: (err: unknown) => void;
124
127
  shellGated?: boolean;
125
128
  autoMode?: {
126
129
  decider: import("./auto-mode.js").AutoModeDecider;
@@ -1,6 +1,7 @@
1
1
  import { decisionText } from "./tool-policy.js";
2
2
  import { inlineUntrusted } from "./untrusted-text.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
4
+ import { createSafeNotifier } from "./safe-notify.js";
4
5
  export function cloneObserverInput(input) {
5
6
  try {
6
7
  return structuredClone(input);
@@ -16,6 +17,16 @@ export function cloneObserverInput(input) {
16
17
  export function formatHookFeedback(text) {
17
18
  return `<system-reminder>\n${text}\n</system-reminder>`;
18
19
  }
20
+ function preToolUseCrashReason(subject, err) {
21
+ const raw = err instanceof Error ? err.message.trim() || err.name : String(err);
22
+ const cause = inlineUntrusted(raw, 200);
23
+ return (`a PreToolUse hook crashed while screening ${subject}; the call was NOT executed (fail-closed). ` +
24
+ `This is a failure of the deployment's hook, NOT of the tool itself — an identical retry reaches the ` +
25
+ `same hook and fails the same way. Hook error: ${cause || "(no message)"}`);
26
+ }
27
+ function traceHookCrash(input, err, notifier) {
28
+ notifier.notify(() => input.onHookError?.(err), "toolGate.onHookError");
29
+ }
19
30
  function withProbeTimeout(p, ms, signal) {
20
31
  if (ms === undefined && signal === undefined)
21
32
  return p;
@@ -48,8 +59,20 @@ export async function runToolGate(input) {
48
59
  let currentInput = event.input;
49
60
  const preToolContext = [];
50
61
  let hookAsk;
62
+ const notifier = createSafeNotifier();
51
63
  if (preToolUse) {
52
- const r = await preToolUse(toolName, currentInput, { toolCallId, toolName });
64
+ let r;
65
+ try {
66
+ r = await preToolUse(toolName, currentInput, { toolCallId, toolName });
67
+ }
68
+ catch (err) {
69
+ const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
70
+ traceHookCrash(input, err, notifier);
71
+ if (input.permissionDenied) {
72
+ await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook" }), "toolGate.permissionDenied");
73
+ }
74
+ return { block: true, reason: formatHookFeedback(reason), preToolContext };
75
+ }
53
76
  if (r) {
54
77
  if (r.additionalContext) {
55
78
  preToolContext.push(r.additionalContext);
@@ -182,7 +205,16 @@ export async function runToolGate(input) {
182
205
  break;
183
206
  }
184
207
  if (preToolUse) {
185
- const hr = await preToolUse(toolName, editArgs, { toolCallId, toolName });
208
+ let hr;
209
+ try {
210
+ hr = await preToolUse(toolName, editArgs, { toolCallId, toolName });
211
+ }
212
+ catch (err) {
213
+ traceHookCrash(input, err, notifier);
214
+ editDenied = { action: "deny", reason: preToolUseCrashReason(`the approved edit for "${toolName}"`, err) };
215
+ denySource = "hook";
216
+ break;
217
+ }
186
218
  if (hr) {
187
219
  if (hr.additionalContext)
188
220
  preToolContext.push(hr.additionalContext);
@@ -250,11 +282,7 @@ export async function runToolGate(input) {
250
282
  currentInput = decision.updatedInput;
251
283
  }
252
284
  if (input.permissionDenied) {
253
- try {
254
- await input.permissionDenied({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource });
255
- }
256
- catch {
257
- }
285
+ await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource }), "toolGate.permissionDenied");
258
286
  }
259
287
  return {
260
288
  block: true,
@@ -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
@@ -1,4 +1,5 @@
1
1
  import { uriToPath } from "./lsp-protocol.js";
2
+ import { foldRedundantPathSegments, isAbsolutePathForm } from "../tools/fs/safety.js";
2
3
  import { Type } from "typebox";
3
4
  import { defineTool, errorResult } from "./tools.js";
4
5
  export const LSP_OPERATIONS = [
@@ -8,6 +9,7 @@ export const LSP_OPERATIONS = [
8
9
  export const LSP_FAILURE_BRAND = "lspFailureReason";
9
10
  const LSP_NONE_REASONS = new Set([
10
11
  "unsupported_operation", "server_error", "timeout", "cancelled", "server_terminated", "no_call_hierarchy_item",
12
+ "file_sync_failed",
11
13
  ]);
12
14
  export function brandLspFailure(e, reason, detail) {
13
15
  const carrier = e;
@@ -23,6 +25,54 @@ export function lspFailureOf(e) {
23
25
  const d = e.lspFailureDetail;
24
26
  return { reason: r, ...(typeof d === "string" ? { detail: d } : {}) };
25
27
  }
28
+ export class SharedAbortScope {
29
+ controller = new AbortController();
30
+ joiners = 0;
31
+ abandoned = 0;
32
+ get signal() {
33
+ return this.controller.signal;
34
+ }
35
+ join(signal) {
36
+ this.joiners += 1;
37
+ if (!signal)
38
+ return;
39
+ if (signal.aborted) {
40
+ this.abandon();
41
+ return;
42
+ }
43
+ signal.addEventListener("abort", () => this.abandon(), { once: true });
44
+ }
45
+ abandon() {
46
+ this.abandoned += 1;
47
+ if (this.abandoned >= this.joiners)
48
+ this.controller.abort();
49
+ }
50
+ }
51
+ export function settleOnAbort(job, signal, onAbort) {
52
+ if (!signal)
53
+ return job;
54
+ if (signal.aborted) {
55
+ void job.catch(() => undefined);
56
+ return Promise.resolve(onAbort());
57
+ }
58
+ return new Promise((resolve, reject) => {
59
+ const onAbortEvent = () => resolve(onAbort());
60
+ signal.addEventListener("abort", onAbortEvent, { once: true });
61
+ job.then((v) => {
62
+ signal.removeEventListener("abort", onAbortEvent);
63
+ resolve(v);
64
+ }, (e) => {
65
+ signal.removeEventListener("abort", onAbortEvent);
66
+ reject(e);
67
+ });
68
+ });
69
+ }
70
+ export function resolveLspPath(filePath, cwd) {
71
+ const isAbsForm = isAbsolutePathForm(filePath);
72
+ if (isAbsForm || !cwd)
73
+ return foldRedundantPathSegments(filePath);
74
+ return foldRedundantPathSegments(cwd.replace(/[\\/]+$/, "") + (cwd.includes("\\") ? "\\" : "/") + filePath);
75
+ }
26
76
  export const MAX_LSP_FILE_BYTES = 10 * 1000 * 1000;
27
77
  function fmt(result, ctx) {
28
78
  switch (result.kind) {
@@ -37,7 +87,9 @@ function fmt(result, ctx) {
37
87
  return result.symbols.length === 0
38
88
  ? "No symbols found."
39
89
  : `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");
90
+ result.symbols
91
+ .map((s) => `- ${s.name}${s.kind ? ` (${s.kind})` : ""} — ${s.uri}${s.positionUnknown ? " (no position reported by the language server)" : `:${s.line}:${s.character}`}`)
92
+ .join("\n");
41
93
  case "none":
42
94
  return fmtNone(result, ctx);
43
95
  }
@@ -60,9 +112,14 @@ function fmtNone(result, ctx) {
60
112
  return `Error performing ${operation}: the request was cancelled before the language server answered. This is NOT an empty result.`;
61
113
  case "server_error":
62
114
  return `Error performing ${operation}: ${detail ?? "the language server returned an error"}. This is NOT an empty result — fall back to Grep/Read.`;
115
+ case "file_sync_failed":
116
+ 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
117
  }
64
118
  }
65
- const LSP_ERROR_REASONS = new Set(["server_error", "timeout", "cancelled", "server_terminated"]);
119
+ const LSP_ERROR_REASONS = new Set([
120
+ "server_error", "timeout", "cancelled", "server_terminated",
121
+ "file_sync_failed",
122
+ ]);
66
123
  async function filterIgnored(result, isIgnored) {
67
124
  if (!isIgnored)
68
125
  return result;
@@ -129,11 +186,9 @@ export function createLspTool(manager, opts = {}) {
129
186
  if (operation === "workspaceSymbol" && wsQuery === undefined) {
130
187
  return errorResult(`Error (LSP): the "workspaceSymbol" operation needs a query.`);
131
188
  }
189
+ const target = resolveLspPath(filePath, opts.env?.cwd);
132
190
  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);
191
+ const info = await opts.env.fileInfo(target, ctx.signal);
137
192
  if (!info.ok && info.error.code === "not_found")
138
193
  return errorResult(`Error (LSP): File does not exist: ${filePath}`);
139
194
  if (!info.ok && info.error.code === "invalid")
@@ -144,12 +199,13 @@ export function createLspTool(manager, opts = {}) {
144
199
  return `File too large for LSP analysis (${Math.ceil(info.value.size / 1e6)}MB exceeds 10MB limit)`;
145
200
  }
146
201
  }
147
- const session = await manager.sessionFor(filePath, ctx.signal, opts.env);
202
+ const session = await manager.sessionFor(target, ctx.signal, opts.env);
148
203
  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.`;
204
+ const why = manager.unavailableReason?.(target, opts.env) ?? "no language server for its language in this environment";
205
+ return `LSP is unavailable for "${filePath}" (${why}). Fall back to Grep/Read for this lookup.`;
150
206
  }
151
207
  const seamCharacter = character !== undefined ? character - 1 : undefined;
152
- const result = await session.request(operation, { filePath, line, character: seamCharacter, symbol: wsQuery }, ctx.signal);
208
+ const result = await session.request(operation, { filePath: target, line, character: seamCharacter, symbol: wsQuery }, ctx.signal);
153
209
  const final = toModelBase(await filterIgnored(result, opts.isPathIgnored));
154
210
  const text = fmt(final, { operation, filePath });
155
211
  return final.kind === "none" && final.reason !== undefined && LSP_ERROR_REASONS.has(final.reason) ? errorResult(text) : text;
@@ -8,12 +8,13 @@ export interface MailboxLease {
8
8
  messages: MailboxMessage[];
9
9
  maxSeq: number;
10
10
  }
11
+ export interface MailboxAppendMessage {
12
+ from?: string;
13
+ content: string;
14
+ sentAt: number;
15
+ }
11
16
  export interface MailboxStore {
12
- append(scope: string, handle: string, msg: {
13
- from?: string;
14
- content: string;
15
- sentAt: number;
16
- }): Promise<number>;
17
+ append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
17
18
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
18
19
  ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
19
20
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
@@ -28,11 +29,7 @@ export declare class InMemoryMailboxStore implements MailboxStore {
28
29
  private boxes;
29
30
  private key;
30
31
  private box;
31
- append(scope: string, handle: string, msg: {
32
- from?: string;
33
- content: string;
34
- sentAt: number;
35
- }): Promise<number>;
32
+ append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
36
33
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
37
34
  ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
38
35
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;