@visual-z/xeno 0.5.0 → 0.7.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.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/blobs.ts +2 -1
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/git-status.ts +15 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
package/src/runtime/loop.ts
CHANGED
|
@@ -43,7 +43,10 @@ import { randomUUID } from "node:crypto";
|
|
|
43
43
|
import { pendingToolCalls } from "../core/execution.ts";
|
|
44
44
|
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
45
45
|
import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
|
|
46
|
-
import { executionOutcome, modelOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
46
|
+
import { executionOutcome, modelOutcome, type ExecutionOutcome, type ExecutionReason } from "../core/outcome.ts";
|
|
47
|
+
import { finalizationNotice, requestPhase } from "../core/request-budget.ts";
|
|
48
|
+
import { stopReport } from "../core/stop-report.ts";
|
|
49
|
+
import { supportsToolDisabling, type ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
47
50
|
import { commandCompletion } from "../core/command-contract.ts";
|
|
48
51
|
import { ExecutionControl } from "./execution-control.ts";
|
|
49
52
|
import { BranchJobs } from "./branch-jobs.ts";
|
|
@@ -153,7 +156,8 @@ export interface LoopEvent {
|
|
|
153
156
|
outcome?: ExecutionOutcome;
|
|
154
157
|
/** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
|
|
155
158
|
request?: RequestState;
|
|
156
|
-
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
|
|
159
|
+
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
|
|
160
|
+
| { kind: "waiting"; session: number; active: boolean };
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
/**
|
|
@@ -187,6 +191,12 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
|
|
|
187
191
|
};
|
|
188
192
|
|
|
189
193
|
export interface SessionConfig {
|
|
194
|
+
/** Runtime-only output contract, explicitly selected by the current caller. */
|
|
195
|
+
validateOutput?: (text: string) => { ok: boolean; error?: string };
|
|
196
|
+
/** Persist a selector, never persisted profile code or authorization (D6/D13). */
|
|
197
|
+
agentProfile?: { name: string; workflowExplicit: boolean };
|
|
198
|
+
/** An explicit mode command's capability default, independent of role authority. */
|
|
199
|
+
workflowToolProfile?: "foundation";
|
|
190
200
|
/** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
|
|
191
201
|
workspaceTracking?: "journal" | "legacy";
|
|
192
202
|
shellCheckpoints?: boolean;
|
|
@@ -330,7 +340,7 @@ export type RewindResult =
|
|
|
330
340
|
| { ok: false; error: string };
|
|
331
341
|
|
|
332
342
|
/** O.5: child execution returns facts; only the parent coordinator accepts them. */
|
|
333
|
-
type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent };
|
|
343
|
+
type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent; finalText?: string };
|
|
334
344
|
type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
|
|
335
345
|
type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
|
|
336
346
|
type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
|
|
@@ -350,6 +360,7 @@ export class Session {
|
|
|
350
360
|
private readonly hostPending = new Set<Promise<NativeCommandResult>>();
|
|
351
361
|
private stall: StallState | null = null;
|
|
352
362
|
private recovery: RecoveryState | null = null;
|
|
363
|
+
private processWake = new AbortController();
|
|
353
364
|
private pendingAdoption: number | null = null;
|
|
354
365
|
private readonly allocatedEventIds = new Set<string>();
|
|
355
366
|
private recordTail: Promise<void> = Promise.resolve();
|
|
@@ -371,8 +382,10 @@ export class Session {
|
|
|
371
382
|
stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
372
383
|
validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
|
|
373
384
|
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
385
|
+
requestPhase(0, config.maxTurns);
|
|
374
386
|
this.allocation = allocation;
|
|
375
|
-
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache()
|
|
387
|
+
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
|
|
388
|
+
processWait: { wake: () => this.processWake.signal } } };
|
|
376
389
|
// A child or restored Session must never inherit another live invocation scope.
|
|
377
390
|
delete this.config.tools.invoke;
|
|
378
391
|
delete this.config.tools.invokeHost;
|
|
@@ -384,6 +397,13 @@ export class Session {
|
|
|
384
397
|
// Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
|
|
385
398
|
if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
|
|
386
399
|
this.config.tools.outputStore ??= join(config.logDir, "artifacts");
|
|
400
|
+
this.config.tools.historyEvents = async () => {
|
|
401
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
402
|
+
if (!history.ok) throw Error(history.error);
|
|
403
|
+
return history.nodes.flatMap(node => node.node === this.state.node ? structuredClone(this.state.events) : node.events);
|
|
404
|
+
};
|
|
405
|
+
if (config.blobDir) this.config.tools.checkpointBlobs = config.blobDir;
|
|
406
|
+
this.config.tools.frozenSystem = () => this.config.frozen.system;
|
|
387
407
|
// R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
|
|
388
408
|
// Each child Session installs its own callback even when contexts are copied.
|
|
389
409
|
this.config.tools.observedFileVersion = async absolute => {
|
|
@@ -492,7 +512,16 @@ export class Session {
|
|
|
492
512
|
if (plan.cursor.context_ref) {
|
|
493
513
|
const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
|
|
494
514
|
if (saved === null) throw new Error("missing frozen session context");
|
|
495
|
-
const context = JSON.parse(saved) as { skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
|
|
515
|
+
const context = JSON.parse(saved) as { agentProfile?: { name: string; workflowExplicit: boolean }; workflowMode?: "build" | "plan"; skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
|
|
516
|
+
if (context.agentProfile !== undefined) {
|
|
517
|
+
if (!context.agentProfile || typeof context.agentProfile.name !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/.test(context.agentProfile.name) || typeof context.agentProfile.workflowExplicit !== "boolean") throw Error("unsupported saved agent profile selector");
|
|
518
|
+
this.config.agentProfile = { ...context.agentProfile };
|
|
519
|
+
}
|
|
520
|
+
if (context.workflowMode !== undefined) { if (!["build", "plan"].includes(context.workflowMode)) throw Error("unsupported saved workflow mode"); this.config.tools.workflowMode = context.workflowMode; }
|
|
521
|
+
if ("workflowToolProfile" in context && context.workflowToolProfile !== undefined) {
|
|
522
|
+
if (context.workflowToolProfile !== "foundation") throw Error("unsupported saved workflow tool profile");
|
|
523
|
+
this.config.workflowToolProfile = "foundation";
|
|
524
|
+
}
|
|
496
525
|
if (context.projectionVersion !== undefined && context.projectionVersion !== PROJECTION_VERSION) throw new Error("unsupported saved projection version");
|
|
497
526
|
this.config.workingSet = context.workingSet === true;
|
|
498
527
|
if (context.workingSetVersion !== undefined && ![1, 2].includes(context.workingSetVersion)) throw new Error("unsupported saved working-set version");
|
|
@@ -760,12 +789,18 @@ export class Session {
|
|
|
760
789
|
}
|
|
761
790
|
|
|
762
791
|
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
763
|
-
|
|
764
|
-
if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
|
|
792
|
+
assertResourceIdle(): void {
|
|
765
793
|
if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async reloadResources(frozen: FrozenPrefix, skills = this.config.skills, nextProvider?: Provider, forceCheckpoint = false): Promise<boolean> {
|
|
797
|
+
if (!nextProvider && !forceCheckpoint && JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
|
|
798
|
+
this.assertResourceIdle();
|
|
766
799
|
const contextRef = await this.persistContext(frozen, skills);
|
|
767
|
-
|
|
768
|
-
|
|
800
|
+
const provider = nextProvider ?? this.provider;
|
|
801
|
+
await this.performCut("model_checkpoint", provider.config.spec.id, undefined,
|
|
802
|
+
provider.config.spec.provider, provider.config.effort, contextRef);
|
|
803
|
+
this.provider = provider;
|
|
769
804
|
this.config.frozen = frozen;
|
|
770
805
|
if (skills !== undefined) this.config.skills = skills;
|
|
771
806
|
this.installSkillResources();
|
|
@@ -825,6 +860,9 @@ export class Session {
|
|
|
825
860
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
826
861
|
): Promise<UserInput | null> {
|
|
827
862
|
if (pull === undefined || this.isHalfRound()) return null;
|
|
863
|
+
// Rotate before pulling: an input queued during the async pull still wakes
|
|
864
|
+
// the next wait. A wake never cancels the process's lifetime signal.
|
|
865
|
+
this.processWake = new AbortController();
|
|
828
866
|
const text = await pull("after_tools");
|
|
829
867
|
if (text === null || typeof text === "string" && text.length === 0) return null;
|
|
830
868
|
if (this.turnTools) this.config.tools = { ...this.turnTools };
|
|
@@ -850,6 +888,9 @@ export class Session {
|
|
|
850
888
|
...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
|
|
851
889
|
...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
|
|
852
890
|
frozen, gates: this.config.tools.gates,
|
|
891
|
+
...(this.config.tools.workflowMode ? { workflowMode: this.config.tools.workflowMode } : {}),
|
|
892
|
+
...(this.config.agentProfile ? { agentProfile: this.config.agentProfile } : {}),
|
|
893
|
+
...(this.config.workflowToolProfile ? { workflowToolProfile: this.config.workflowToolProfile } : {}),
|
|
853
894
|
workNamespace: this.config.workNamespace ?? "main",
|
|
854
895
|
maxParallelTools: this.config.maxParallelTools ?? 3,
|
|
855
896
|
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
@@ -953,13 +994,31 @@ export class Session {
|
|
|
953
994
|
await result.accept?.();
|
|
954
995
|
await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
|
|
955
996
|
display: true, inContext: true });
|
|
997
|
+
// Evidence stays in the receipt; structured output validates the child's
|
|
998
|
+
// actual final message, never the human-readable receipt wrapper (D11).
|
|
999
|
+
const finalText = this.config.validateOutput ? result.finalText ?? "" : result.content;
|
|
1000
|
+
let outcome = result.outcome ?? executionOutcome("execution_error"), reason = "isolated skill finished";
|
|
1001
|
+
if (outcome.status === "completed") {
|
|
1002
|
+
const error = await this.validateFinalOutput(finalText);
|
|
1003
|
+
if (error !== undefined) { outcome = executionOutcome("execution_error"); reason = error; }
|
|
1004
|
+
}
|
|
1005
|
+
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason, outcome });
|
|
956
1006
|
await this.saveCursor();
|
|
957
|
-
yield { kind: "assistant_text", text:
|
|
958
|
-
yield { kind: "done", outcome
|
|
1007
|
+
yield { kind: "assistant_text", text: finalText };
|
|
1008
|
+
yield { kind: "done", outcome, reason };
|
|
959
1009
|
} finally { owned.finish(); }
|
|
960
1010
|
} finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
|
|
961
1011
|
}
|
|
962
1012
|
|
|
1013
|
+
/** Every final publication path records the same validator receipt (D11). */
|
|
1014
|
+
private async validateFinalOutput(text: string): Promise<string | undefined> {
|
|
1015
|
+
if (!this.config.validateOutput) return undefined;
|
|
1016
|
+
const validation = this.config.validateOutput(text);
|
|
1017
|
+
await this.appendExtension({ customType: "output_validation", content: validation.ok ? "Final JSON output passed the selected schema." : `Final JSON output failed: ${validation.error ?? "schema mismatch"}`,
|
|
1018
|
+
display: false, inContext: false, data: { valid: validation.ok } });
|
|
1019
|
+
return validation.ok ? undefined : `output schema validation failed: ${validation.error ?? "schema mismatch"}`;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
963
1022
|
/** O.8: dispatch and diagnostics share exact source hints, including forks. */
|
|
964
1023
|
contextInput(): ProjectInput {
|
|
965
1024
|
return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
|
|
@@ -1136,6 +1195,7 @@ export class Session {
|
|
|
1136
1195
|
): AsyncGenerator<LoopEvent> {
|
|
1137
1196
|
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
1138
1197
|
const owned = this.execution.begin(signal);
|
|
1198
|
+
this.processWake = new AbortController();
|
|
1139
1199
|
const savedTools = this.config.tools;
|
|
1140
1200
|
this.turnTools = savedTools;
|
|
1141
1201
|
this.config.tools = { ...savedTools };
|
|
@@ -1222,6 +1282,7 @@ export class Session {
|
|
|
1222
1282
|
let overflowRetried = false;
|
|
1223
1283
|
const requestStarted = this.clock.now();
|
|
1224
1284
|
for (let turn = 0; turn < this.config.maxTurns; turn++) {
|
|
1285
|
+
const phase = requestPhase(turn, this.config.maxTurns);
|
|
1225
1286
|
completedThisTurn.length = 0;
|
|
1226
1287
|
if (signal?.aborted) {
|
|
1227
1288
|
yield* this.closeInterrupted(completedThisTurn);
|
|
@@ -1229,12 +1290,18 @@ export class Session {
|
|
|
1229
1290
|
}
|
|
1230
1291
|
|
|
1231
1292
|
if (yield* this.checkStall()) {
|
|
1232
|
-
yield { kind: "done", reason: "
|
|
1293
|
+
yield { kind: "done", reason: await this.terminationReport("no-progress allowance exhausted", turn), outcome: executionOutcome("stalled") };
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
|
|
1297
|
+
yield { kind: "done", reason: await this.terminationReport("work budget exhausted; protocol cannot disable tools while preserving frozen definitions", turn), outcome: executionOutcome("max_turns") };
|
|
1233
1298
|
return;
|
|
1234
1299
|
}
|
|
1235
1300
|
yield { kind: "turn_start", node: this.state.node };
|
|
1236
1301
|
|
|
1237
|
-
if (
|
|
1302
|
+
if (phase === "finalize") {
|
|
1303
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "budget", sources: [], text: finalizationNotice() });
|
|
1304
|
+
} else if (this.config.guidance !== false) {
|
|
1238
1305
|
const names = this.adviceNames();
|
|
1239
1306
|
const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
|
|
1240
1307
|
continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
|
|
@@ -1276,7 +1343,7 @@ export class Session {
|
|
|
1276
1343
|
|
|
1277
1344
|
let result: Turn;
|
|
1278
1345
|
try {
|
|
1279
|
-
result = yield* this.awaitProvider(projected.messages, signal);
|
|
1346
|
+
result = yield* this.awaitProvider(projected.messages, signal, phase === "finalize" ? { toolUse: "disabled" } : undefined);
|
|
1280
1347
|
} catch (error) {
|
|
1281
1348
|
// D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
|
|
1282
1349
|
if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
|
|
@@ -1284,6 +1351,11 @@ export class Session {
|
|
|
1284
1351
|
yield* this.closeInterrupted(completedThisTurn);
|
|
1285
1352
|
return;
|
|
1286
1353
|
}
|
|
1354
|
+
if (phase === "finalize") {
|
|
1355
|
+
yield { kind: "discard" };
|
|
1356
|
+
yield { kind: "done", reason: await this.terminationReport("final response failed; no additional request was made", turn + 1), outcome: executionOutcome("provider_error") };
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1287
1359
|
if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
|
|
1288
1360
|
// The estimate said this fit and the provider says it does not: the
|
|
1289
1361
|
// estimate is wrong for this content. The only projection that can
|
|
@@ -1374,6 +1446,16 @@ export class Session {
|
|
|
1374
1446
|
(b): b is Extract<AssistantBlock, { type: "tool_use" }> => b.type === "tool_use",
|
|
1375
1447
|
);
|
|
1376
1448
|
|
|
1449
|
+
// Provider policy is a wire instruction, not authority. Pair every
|
|
1450
|
+
// refused call, but never admit execution in the reserved final request.
|
|
1451
|
+
if (phase === "finalize" && (calls.length > 0 || !isCompleteResponse(result.stopReason) ||
|
|
1452
|
+
!result.blocks.some(block => block.type === "text" && block.text.trim()))) {
|
|
1453
|
+
for (const item of await this.answerUnpairedTools("not executed: tools are disabled in the final request", "not_started"))
|
|
1454
|
+
yield { kind: "tool_result", text: item.content, ok: false };
|
|
1455
|
+
yield { kind: "done", reason: await this.terminationReport("request budget exhausted; final response did not provide a complete text answer", turn + 1), outcome: executionOutcome("max_turns") };
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1377
1459
|
// N.12: syntactically valid arguments in an unfinished response are not
|
|
1378
1460
|
// authorization to execute a partial batch. Pair without side effects.
|
|
1379
1461
|
if (!isCompleteResponse(result.stopReason)) {
|
|
@@ -1427,6 +1509,12 @@ export class Session {
|
|
|
1427
1509
|
continue;
|
|
1428
1510
|
}
|
|
1429
1511
|
const stopped = yield* this.maybeCut(counter, false);
|
|
1512
|
+
if (!stopped) {
|
|
1513
|
+
const error = await this.validateFinalOutput(result.blocks.filter(block => block.type === "text").map(block => block.text).join("\n"));
|
|
1514
|
+
if (error !== undefined) {
|
|
1515
|
+
yield { kind: "done", reason: error, outcome: executionOutcome("execution_error") }; return;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1430
1518
|
yield {
|
|
1431
1519
|
kind: "done",
|
|
1432
1520
|
reason: stopped ? "stopped: no verifiable progress" : result.stopReason ?? "end_turn",
|
|
@@ -1508,7 +1596,7 @@ export class Session {
|
|
|
1508
1596
|
}
|
|
1509
1597
|
}
|
|
1510
1598
|
|
|
1511
|
-
yield { kind: "done", reason:
|
|
1599
|
+
yield { kind: "done", reason: await this.terminationReport("request budget exhausted", this.config.maxTurns), outcome: executionOutcome("max_turns") };
|
|
1512
1600
|
} finally {
|
|
1513
1601
|
// If the consumer aborts and stops pulling, the generator is closed
|
|
1514
1602
|
// without running the yield* closeInterrupted path. Pairing still
|
|
@@ -1526,6 +1614,7 @@ export class Session {
|
|
|
1526
1614
|
*/
|
|
1527
1615
|
private async answerUnpairedTools(
|
|
1528
1616
|
content = "interrupted: the user cancelled before this tool completed",
|
|
1617
|
+
reason: ExecutionReason = "unknown",
|
|
1529
1618
|
): Promise<{ callId: string; content: string }[]> {
|
|
1530
1619
|
const added: { callId: string; content: string }[] = [];
|
|
1531
1620
|
for (const block of pendingToolCalls(this.state.events)) {
|
|
@@ -1540,7 +1629,7 @@ export class Session {
|
|
|
1540
1629
|
...(block.origin ? { origin: block.origin } : {}),
|
|
1541
1630
|
ok: false,
|
|
1542
1631
|
content: resultText,
|
|
1543
|
-
outcome: executionOutcome(
|
|
1632
|
+
outcome: executionOutcome(reason),
|
|
1544
1633
|
});
|
|
1545
1634
|
added.push({ callId: block.call_id, content: resultText });
|
|
1546
1635
|
}
|
|
@@ -1589,6 +1678,7 @@ export class Session {
|
|
|
1589
1678
|
private async *awaitProvider(
|
|
1590
1679
|
messages: readonly Message[],
|
|
1591
1680
|
signal?: AbortSignal,
|
|
1681
|
+
options?: ProviderSendOptions,
|
|
1592
1682
|
): AsyncGenerator<LoopEvent, Turn> {
|
|
1593
1683
|
const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1594
1684
|
this.provider.config.spec.input?.includes("image") ?? false);
|
|
@@ -1610,7 +1700,7 @@ export class Session {
|
|
|
1610
1700
|
default:
|
|
1611
1701
|
return assertNever(delta, "onDelta");
|
|
1612
1702
|
}
|
|
1613
|
-
}));
|
|
1703
|
+
}, options));
|
|
1614
1704
|
}
|
|
1615
1705
|
|
|
1616
1706
|
/**
|
|
@@ -1759,6 +1849,7 @@ export class Session {
|
|
|
1759
1849
|
if (native) context = nativeCommandContext(context);
|
|
1760
1850
|
const decoder = new TextDecoder();
|
|
1761
1851
|
context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
|
|
1852
|
+
context.onProcessWait = (session, active) => emit({ kind: "progress", progress: { kind: "waiting", session, active } });
|
|
1762
1853
|
const broker = new ToolBroker<DeferredToolResult>({
|
|
1763
1854
|
parallelLimit: this.config.maxParallelTools ?? 3,
|
|
1764
1855
|
callId: () => `nested-${randomUUID()}`,
|
|
@@ -2021,14 +2112,15 @@ export class Session {
|
|
|
2021
2112
|
this.children.set(node, branch);
|
|
2022
2113
|
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
2023
2114
|
|
|
2024
|
-
let
|
|
2115
|
+
let finalParts: string[] = [];
|
|
2025
2116
|
let outcome = executionOutcome("unknown");
|
|
2026
2117
|
try {
|
|
2027
2118
|
for await (const event of branch.snapshotTurn([], true)) {
|
|
2028
2119
|
if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
|
|
2029
2120
|
}
|
|
2030
2121
|
for await (const e of branch.send(prompt, signal)) {
|
|
2031
|
-
if (e.kind === "
|
|
2122
|
+
if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
|
|
2123
|
+
if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
|
|
2032
2124
|
if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
|
|
2033
2125
|
}
|
|
2034
2126
|
} catch {
|
|
@@ -2055,6 +2147,7 @@ export class Session {
|
|
|
2055
2147
|
? `\n\nIts changes are in ${worktree.path} on branch ${worktree.branch}, ` +
|
|
2056
2148
|
"not in your files. Adopting continues in that checkout without merging it into this one."
|
|
2057
2149
|
: "";
|
|
2150
|
+
const text = finalParts.join("\n");
|
|
2058
2151
|
const summary = text.trim() || "(the branch produced no closing message)";
|
|
2059
2152
|
const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2060
2153
|
if (!receiptHistory.ok) throw new Error(receiptHistory.error);
|
|
@@ -2081,6 +2174,7 @@ export class Session {
|
|
|
2081
2174
|
(this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
|
|
2082
2175
|
ok: outcome.status === "completed",
|
|
2083
2176
|
outcome,
|
|
2177
|
+
finalText: text,
|
|
2084
2178
|
accept: async () => {
|
|
2085
2179
|
if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
|
|
2086
2180
|
this.state.usage.push(...branch.state.usage);
|
|
@@ -2662,14 +2756,23 @@ export class Session {
|
|
|
2662
2756
|
}
|
|
2663
2757
|
const decision = stallDecision(this.stall, limit);
|
|
2664
2758
|
if (decision.stop) {
|
|
2665
|
-
yield { kind: "stop_and_ask", reason: `${decision.
|
|
2759
|
+
yield { kind: "stop_and_ask", reason: `${decision.unproductive} total tool exchanges without new evidence (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits); change the approach or provide new input` };
|
|
2666
2760
|
return true;
|
|
2667
2761
|
}
|
|
2668
2762
|
if (decision.warn) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2669
|
-
text: `Harness: ${decision.
|
|
2763
|
+
text: `Harness: ${decision.unproductive} total tool exchanges without new evidence or fresh verification (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits). Change the method, run a meaningful check, or give the final result with unresolved limits. The harness will stop at ${limit} total unproductive exchanges. Novel observations reset the streak, not the spent allowance. Notes and repeated observations do not count as progress.` });
|
|
2670
2764
|
return false;
|
|
2671
2765
|
}
|
|
2672
2766
|
|
|
2767
|
+
/** A steering signal releases only the wait; cancellation owns process termination. */
|
|
2768
|
+
wakeProcessWaits(): void { this.processWake.abort(); }
|
|
2769
|
+
|
|
2770
|
+
private async terminationReport(cause: string, requests: number): Promise<string> {
|
|
2771
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2772
|
+
if (!history.ok) throw new Error(history.error);
|
|
2773
|
+
return stopReport(history.nodes.flatMap(part => part.events), requests, this.config.maxTurns, cause);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2673
2776
|
/** N.8: build and check the replacement before committing a durable boundary. */
|
|
2674
2777
|
/**
|
|
2675
2778
|
* `overflowed` is the provider's own verdict that the current projection
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/** Explicit trusted servers, one lifecycle each; server edits never bypass checkpoint ownership. */
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { nextLspFrame, encodeLspFrame, diagnosticFreshness, MAX_LSP_FRAME, MAX_LSP_HEADER } from "../core/lsp-framing.ts";
|
|
5
|
+
import { readLspDocument } from "../store/lsp-document.ts";
|
|
6
|
+
import { presentObservation, type ToolDefinition, type ToolResult } from "./tools.ts";
|
|
7
|
+
|
|
8
|
+
export interface LspConfig { name: string; command: string[]; extensions: string[]; languageId: string }
|
|
9
|
+
export function parseLspConfig(value: unknown): LspConfig[] {
|
|
10
|
+
if (value === undefined) return [];
|
|
11
|
+
if (!Array.isArray(value) || value.length > 16) throw Error("lsp must be an array of at most 16 servers");
|
|
12
|
+
const names = new Set<string>();
|
|
13
|
+
return value.map(item => {
|
|
14
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw Error("invalid LSP config");
|
|
15
|
+
const v = item as Record<string, unknown>;
|
|
16
|
+
if (Object.keys(v).some(key => !["name", "command", "extensions", "languageId"].includes(key)) || typeof v["name"] !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(v["name"]) || names.has(v["name"]) || typeof v["languageId"] !== "string" || !v["languageId"] || !Array.isArray(v["command"]) || !v["command"].length || v["command"].some(part => typeof part !== "string" || !part || part.includes("\0")) || !Array.isArray(v["extensions"]) || !v["extensions"].length || v["extensions"].some(part => typeof part !== "string" || !/^\.[a-zA-Z0-9]+$/.test(part))) throw Error("invalid or duplicate LSP config");
|
|
17
|
+
names.add(v["name"]); return { name: v["name"], languageId: v["languageId"], command: v["command"].map(String), extensions: v["extensions"].map(String) };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
type Pending = { resolve(value: unknown): void; reject(error: Error): void; cancel(): void };
|
|
21
|
+
// Operational limits, not segment thresholds: bound unsolicited traffic and retained server state.
|
|
22
|
+
const MAX_LSP_STREAM_BYTES = 32 * 1024 * 1024;
|
|
23
|
+
const MAX_LSP_DIAGNOSTIC_BYTES = 512 * 1024;
|
|
24
|
+
const MAX_LSP_OPEN_DOCUMENTS = 32;
|
|
25
|
+
class LspConnection {
|
|
26
|
+
private readonly root: string;
|
|
27
|
+
private readonly config: LspConfig;
|
|
28
|
+
private process: ReturnType<typeof Bun.spawn> | undefined;
|
|
29
|
+
private buffer = new Uint8Array(0);
|
|
30
|
+
private next = 1;
|
|
31
|
+
private readonly pending = new Map<number, Pending>();
|
|
32
|
+
private readonly documents = new Map<string, { sha256: string; version: number }>();
|
|
33
|
+
private readonly diagnostics = new Map<string, { version?: number; items: unknown[] }>();
|
|
34
|
+
private ready: Promise<void> | undefined;
|
|
35
|
+
private closed = false;
|
|
36
|
+
private failure: string | undefined;
|
|
37
|
+
private streamBytes = 0;
|
|
38
|
+
private documentVersion = 0;
|
|
39
|
+
get usable(): boolean { return !this.closed && this.failure === undefined; }
|
|
40
|
+
constructor(root: string, config: LspConfig) { this.root = root; this.config = config; }
|
|
41
|
+
private send(message: unknown): void {
|
|
42
|
+
if (this.failure) throw Error(this.failure);
|
|
43
|
+
const stdin = this.process?.stdin;
|
|
44
|
+
if (!stdin || typeof stdin === "number") throw Error("LSP server is unavailable");
|
|
45
|
+
stdin.write(encodeLspFrame(message)); stdin.flush();
|
|
46
|
+
}
|
|
47
|
+
private notify(method: string, params: unknown): void { this.send({ jsonrpc: "2.0", method, params }); }
|
|
48
|
+
private request(method: string, params: unknown, signal?: AbortSignal, timeoutMs = 10_000): Promise<unknown> {
|
|
49
|
+
if (signal?.aborted) return Promise.reject(Error("LSP request cancelled"));
|
|
50
|
+
const id = this.next++;
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const stop = (reason: string) => { const pending = this.pending.get(id); if (!pending) return; this.pending.delete(id); pending.cancel(); try { this.notify("$/cancelRequest", { id }); } catch { /* process may be dead */ } reject(Error(reason)); };
|
|
53
|
+
const aborted = () => stop("LSP request cancelled");
|
|
54
|
+
const timer = setTimeout(() => stop("LSP request timed out"), timeoutMs);
|
|
55
|
+
this.pending.set(id, { resolve, reject, cancel: () => { clearTimeout(timer); signal?.removeEventListener("abort", aborted); } });
|
|
56
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
57
|
+
try { this.streamBytes = 0; this.send({ jsonrpc: "2.0", id, method, params }); } catch (error) { this.pending.delete(id); clearTimeout(timer); signal?.removeEventListener("abort", aborted); reject(error); }
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
private fail(reason: string): void {
|
|
61
|
+
this.failure ??= reason;
|
|
62
|
+
for (const item of this.pending.values()) { item.cancel(); item.reject(Error(reason)); } this.pending.clear();
|
|
63
|
+
}
|
|
64
|
+
private receive(value: unknown): void {
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("invalid LSP envelope");
|
|
66
|
+
const v = value as Record<string, unknown>;
|
|
67
|
+
if (v["jsonrpc"] !== "2.0") throw Error("invalid LSP version");
|
|
68
|
+
if (typeof v["method"] === "string") {
|
|
69
|
+
const params = v["params"] !== null && typeof v["params"] === "object" && !Array.isArray(v["params"]) ? v["params"] as Record<string, unknown> : undefined;
|
|
70
|
+
if (v["method"] === "textDocument/publishDiagnostics" && typeof params?.["uri"] === "string" && Array.isArray(params["diagnostics"])) {
|
|
71
|
+
const version = params["version"];
|
|
72
|
+
// A server may report a whole workspace; cache only documents opened by this client.
|
|
73
|
+
if (this.documents.has(params["uri"])) {
|
|
74
|
+
if (version !== undefined && (typeof version !== "number" || !Number.isInteger(version) || version < 0 || version > 2 ** 31 - 1)) throw Error("invalid LSP diagnostic version");
|
|
75
|
+
if (new TextEncoder().encode(JSON.stringify(params["diagnostics"])).length > MAX_LSP_DIAGNOSTIC_BYTES) throw Error("LSP diagnostics exceeded");
|
|
76
|
+
this.diagnostics.set(params["uri"], { ...(typeof version === "number" ? { version } : {}), items: params["diagnostics"] });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (v["id"] !== undefined) {
|
|
80
|
+
if (typeof v["id"] !== "string" && (typeof v["id"] !== "number" || !Number.isSafeInteger(v["id"]))) throw Error("invalid LSP request id");
|
|
81
|
+
const method = v["method"];
|
|
82
|
+
if (method === "workspace/configuration") this.send({ jsonrpc: "2.0", id: v["id"], result: Array.isArray(params?.["items"]) ? params["items"].map(() => null) : [] });
|
|
83
|
+
else if (method === "workspace/workspaceFolders") this.send({ jsonrpc: "2.0", id: v["id"], result: [{ uri: pathToFileURL(this.root).href, name: this.config.name }] });
|
|
84
|
+
else if (method === "workspace/applyEdit") this.send({ jsonrpc: "2.0", id: v["id"], result: { applied: false, failureReason: "use the owned Patch tool; LSP cannot write files" } });
|
|
85
|
+
else this.send({ jsonrpc: "2.0", id: v["id"], error: { code: -32601, message: "client capability not supported" } });
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (typeof v["id"] !== "number" || !Number.isSafeInteger(v["id"])) throw Error("invalid LSP response id");
|
|
90
|
+
const pending = this.pending.get(v["id"]); if (!pending) return;
|
|
91
|
+
if (Object.hasOwn(v, "result") === Object.hasOwn(v, "error")) throw Error("invalid LSP response result");
|
|
92
|
+
this.pending.delete(v["id"]); pending.cancel(); this.streamBytes = 0;
|
|
93
|
+
if (v["error"] !== undefined) pending.reject(Error(JSON.stringify(v["error"]).slice(0, 512))); else pending.resolve(v["result"]);
|
|
94
|
+
}
|
|
95
|
+
async start(signal?: AbortSignal): Promise<void> {
|
|
96
|
+
signal?.throwIfAborted();
|
|
97
|
+
if (this.closed) throw Error("LSP connection disposed");
|
|
98
|
+
if (this.failure) throw Error(this.failure);
|
|
99
|
+
if (this.ready) return this.ready;
|
|
100
|
+
this.ready = (async () => {
|
|
101
|
+
this.process = Bun.spawn(this.config.command, { cwd: this.root, stdin: "pipe", stdout: "pipe", stderr: "ignore" });
|
|
102
|
+
const process = this.process;
|
|
103
|
+
void (async () => {
|
|
104
|
+
try {
|
|
105
|
+
if (!process.stdout || typeof process.stdout === "number") throw Error("missing LSP stdout");
|
|
106
|
+
for await (const chunk of process.stdout) {
|
|
107
|
+
this.streamBytes += chunk.length;
|
|
108
|
+
if (this.streamBytes > MAX_LSP_STREAM_BYTES) throw Error("LSP stream budget exceeded");
|
|
109
|
+
// Drain complete frames before admitting more bytes; OS chunk boundaries are arbitrary.
|
|
110
|
+
for (let offset = 0; offset < chunk.length;) {
|
|
111
|
+
const capacity = MAX_LSP_FRAME + MAX_LSP_HEADER + 4 - this.buffer.length;
|
|
112
|
+
if (capacity <= 0) throw Error("LSP receive buffer exceeded");
|
|
113
|
+
const part = chunk.subarray(offset, offset + capacity); offset += part.length;
|
|
114
|
+
const merged = new Uint8Array(this.buffer.length + part.length); merged.set(this.buffer); merged.set(part, this.buffer.length); this.buffer = merged;
|
|
115
|
+
for (;;) { const frame = nextLspFrame(this.buffer); if (!frame) break; this.buffer = this.buffer.slice(frame.consumed); this.receive(JSON.parse(new TextDecoder("utf8", { fatal: true }).decode(frame.body))); }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.fail("LSP server exited");
|
|
119
|
+
} catch { this.fail("LSP protocol failed"); process.kill("SIGKILL"); }
|
|
120
|
+
})();
|
|
121
|
+
const result = await this.request("initialize", { processId: globalThis.process.pid, rootUri: pathToFileURL(this.root).href,
|
|
122
|
+
capabilities: { general: { positionEncodings: ["utf-16"] }, workspace: { applyEdit: false }, textDocument: { synchronization: { dynamicRegistration: false }, diagnostic: {} } },
|
|
123
|
+
workspaceFolders: [{ uri: pathToFileURL(this.root).href, name: this.config.name }] }, signal);
|
|
124
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || !("capabilities" in result) || !result.capabilities || typeof result.capabilities !== "object") throw Error("invalid LSP initialize result");
|
|
125
|
+
const encoding = (result.capabilities as Record<string, unknown>)["positionEncoding"];
|
|
126
|
+
if (encoding !== undefined && encoding !== "utf-16") throw Error("LSP server does not support UTF-16 positions");
|
|
127
|
+
signal?.throwIfAborted();
|
|
128
|
+
this.notify("initialized", {});
|
|
129
|
+
})();
|
|
130
|
+
try { await this.ready; } catch (error) { this.fail(error instanceof Error ? error.message : "LSP initialization failed"); this.process?.kill("SIGKILL"); throw error; }
|
|
131
|
+
}
|
|
132
|
+
async call(action: string, path: string, line: number, character: number, signal?: AbortSignal): Promise<unknown> {
|
|
133
|
+
const document = await readLspDocument(this.root, path, signal);
|
|
134
|
+
await this.start(signal); signal?.throwIfAborted();
|
|
135
|
+
const old = this.documents.get(document.uri);
|
|
136
|
+
const changed = old !== undefined && old.sha256 !== document.sha256;
|
|
137
|
+
// Monotonic versions also prevent late diagnostics from an evicted/reopened file matching it.
|
|
138
|
+
const version = !old || changed ? ++this.documentVersion : old.version;
|
|
139
|
+
if (!old && this.documents.size >= MAX_LSP_OPEN_DOCUMENTS) {
|
|
140
|
+
const oldest = this.documents.keys().next().value;
|
|
141
|
+
if (oldest !== undefined) { this.notify("textDocument/didClose", { textDocument: { uri: oldest } }); this.documents.delete(oldest); this.diagnostics.delete(oldest); }
|
|
142
|
+
}
|
|
143
|
+
if (!old) this.notify("textDocument/didOpen", { textDocument: { uri: document.uri, languageId: this.config.languageId, version, text: document.text } });
|
|
144
|
+
else if (changed) { this.diagnostics.delete(document.uri); this.notify("textDocument/didChange", { textDocument: { uri: document.uri, version }, contentChanges: [{ text: document.text }] }); }
|
|
145
|
+
const current = version;
|
|
146
|
+
this.documents.set(document.uri, { sha256: document.sha256, version: current });
|
|
147
|
+
if (action === "diagnostics") {
|
|
148
|
+
try {
|
|
149
|
+
const result = await this.request("textDocument/diagnostic", { textDocument: { uri: document.uri } }, signal);
|
|
150
|
+
const onDisk = await readLspDocument(this.root, path, signal);
|
|
151
|
+
return { sha256: document.sha256, version: current, kind: "pull", freshness: onDisk.sha256 === document.sha256 ? "current" : "stale", result };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
signal?.throwIfAborted(); if (this.failure) throw Error(this.failure);
|
|
155
|
+
const observed = this.diagnostics.get(document.uri), onDisk = await readLspDocument(this.root, path, signal);
|
|
156
|
+
const freshness = onDisk.sha256 !== document.sha256 ? "stale" : observed ? diagnosticFreshness(observed.version, current) : "unavailable";
|
|
157
|
+
return { sha256: document.sha256, version: current, kind: "push", freshness, observedVersion: observed?.version ?? null,
|
|
158
|
+
available: freshness === "current", versionConfirmed: freshness === "current", diagnostics: freshness === "current" ? observed?.items ?? [] : [],
|
|
159
|
+
unconfirmedCount: freshness === "current" ? 0 : observed?.items.length ?? 0, reason: error instanceof Error ? error.message : "pull diagnostics unavailable" };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const methods: Record<string, string> = { hover: "textDocument/hover", definition: "textDocument/definition", references: "textDocument/references" };
|
|
163
|
+
const method = methods[action]; if (!method) throw Error("unsupported LSP action");
|
|
164
|
+
const result = await this.request(method, { textDocument: { uri: document.uri }, position: { line, character }, ...(action === "references" ? { context: { includeDeclaration: true } } : {}) }, signal);
|
|
165
|
+
const onDisk = await readLspDocument(this.root, path, signal);
|
|
166
|
+
return { sha256: document.sha256, version: current, freshness: onDisk.sha256 === document.sha256 ? "current" : "stale", result };
|
|
167
|
+
}
|
|
168
|
+
async dispose(): Promise<void> {
|
|
169
|
+
if (this.closed) return; this.closed = true;
|
|
170
|
+
if (this.process) {
|
|
171
|
+
try { await this.request("shutdown", null, undefined, 500); this.notify("exit", null); } catch { /* bounded shutdown falls back to kill */ }
|
|
172
|
+
// This process is client-owned. A server ignoring shutdown cannot leave a resident leak.
|
|
173
|
+
this.process.kill("SIGKILL"); await this.process.exited;
|
|
174
|
+
}
|
|
175
|
+
this.fail("LSP disposed"); this.documents.clear(); this.diagnostics.clear();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export class LspManager {
|
|
179
|
+
private readonly root: string;
|
|
180
|
+
private readonly configs: LspConfig[];
|
|
181
|
+
private readonly connections = new Map<string, LspConnection>();
|
|
182
|
+
private closed = false;
|
|
183
|
+
constructor(root: string, configs: LspConfig[]) { this.root = root; this.configs = configs.map(config => ({ ...config, command: [...config.command], extensions: [...config.extensions] })); }
|
|
184
|
+
tool(): ToolDefinition {
|
|
185
|
+
return { name: "lsp", description: "Read language-server diagnostics, hover, definitions or references for a current workspace file. Positions use zero-based UTF-16 units. Servers come from trusted explicit configuration.",
|
|
186
|
+
access: "read", branch: "none", execution: "exclusive", input_schema: { type: "object", required: ["action", "path"],
|
|
187
|
+
properties: { action: { type: "string", enum: ["diagnostics", "hover", "definition", "references"] }, path: { type: "string" }, line: { type: "integer", minimum: 0 }, character: { type: "integer", minimum: 0 }, server: { type: "string" } } },
|
|
188
|
+
run: async (ctx, input): Promise<ToolResult> => {
|
|
189
|
+
if (this.closed) throw Error("LSP manager disposed");
|
|
190
|
+
const selected = this.configs.filter(config => config.extensions.includes(extname(String(input["path"]))) && (input["server"] === undefined || config.name === input["server"]));
|
|
191
|
+
if (selected.length !== 1) return { ok: false, content: "Select exactly one configured language server for this file." };
|
|
192
|
+
const config = selected[0]!; let connection = this.connections.get(config.name);
|
|
193
|
+
if (!connection) { connection = new LspConnection(this.root, config); this.connections.set(config.name, connection); }
|
|
194
|
+
try {
|
|
195
|
+
const result = await connection.call(String(input["action"]), String(input["path"]), Number(input["line"] ?? 0), Number(input["character"] ?? 0), ctx.signal);
|
|
196
|
+
return { ok: true, ...await presentObservation(ctx, JSON.stringify(result)) };
|
|
197
|
+
} catch (error) { if (!connection.usable) this.connections.delete(config.name); throw error; }
|
|
198
|
+
} };
|
|
199
|
+
}
|
|
200
|
+
async dispose(): Promise<void> { this.closed = true; await Promise.all([...this.connections.values()].map(connection => connection.dispose())); this.connections.clear(); }
|
|
201
|
+
}
|