@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -4
  3. package/docs/app-server.md +32 -3
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  7. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  8. package/docs/research/foundation-network-2026-10-09.md +37 -0
  9. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  10. package/package.json +10 -2
  11. package/src/apps/acp-server.ts +41 -0
  12. package/src/apps/agent-profiles.ts +19 -0
  13. package/src/apps/app-server.ts +4 -2
  14. package/src/apps/cli.ts +18 -2
  15. package/src/apps/commands.ts +20 -0
  16. package/src/apps/control-transport.ts +3 -2
  17. package/src/apps/model-catalog.ts +57 -31
  18. package/src/apps/question-ui.ts +27 -0
  19. package/src/apps/resources.ts +63 -25
  20. package/src/apps/review.ts +109 -0
  21. package/src/apps/session-hub.ts +95 -0
  22. package/src/apps/shell.ts +55 -1
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +89 -38
  25. package/src/apps/ui/image-cache.ts +71 -28
  26. package/src/apps/ui/surface.ts +1 -0
  27. package/src/apps/update-check.ts +71 -0
  28. package/src/apps/workflow.ts +58 -0
  29. package/src/core/activity-status.ts +28 -0
  30. package/src/core/agent-profile.ts +130 -0
  31. package/src/core/control-protocol.ts +8 -1
  32. package/src/core/file-precondition.ts +6 -0
  33. package/src/core/git-status.ts +29 -0
  34. package/src/core/image-limits.ts +72 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/patch.ts +130 -0
  38. package/src/core/permissions.ts +143 -4
  39. package/src/core/progress.ts +9 -0
  40. package/src/core/question.ts +94 -0
  41. package/src/core/request-budget.ts +13 -0
  42. package/src/core/review.ts +68 -0
  43. package/src/core/session-exit.ts +26 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/stall.ts +18 -6
  46. package/src/core/stop-report.ts +25 -0
  47. package/src/core/update-check.ts +24 -5
  48. package/src/core/web-content.ts +80 -0
  49. package/src/core/workflow-mode.ts +34 -0
  50. package/src/runtime/acp-agent.ts +289 -0
  51. package/src/runtime/agent-profiles.ts +21 -0
  52. package/src/runtime/catalog.ts +2 -2
  53. package/src/runtime/control-client.ts +149 -0
  54. package/src/runtime/control-connection.ts +39 -4
  55. package/src/runtime/exec-sessions.ts +40 -4
  56. package/src/runtime/extensions.ts +20 -2
  57. package/src/runtime/formatter-tool.ts +38 -0
  58. package/src/runtime/formatter.ts +50 -0
  59. package/src/runtime/foundation.ts +78 -0
  60. package/src/runtime/images.ts +6 -2
  61. package/src/runtime/integrations.ts +8 -2
  62. package/src/runtime/loop.ts +124 -21
  63. package/src/runtime/lsp-client.ts +201 -0
  64. package/src/runtime/mcp-client.ts +264 -0
  65. package/src/runtime/mcp-oauth.ts +168 -0
  66. package/src/runtime/mcp-schema.ts +51 -0
  67. package/src/runtime/oauth.ts +1 -1
  68. package/src/runtime/openai-responses.ts +6 -4
  69. package/src/runtime/openai.ts +6 -1
  70. package/src/runtime/output-schema.ts +26 -0
  71. package/src/runtime/patch-tool.ts +38 -0
  72. package/src/runtime/permission-resources.ts +11 -0
  73. package/src/runtime/pi-provider.ts +5 -4
  74. package/src/runtime/provider-tool-policy.ts +33 -0
  75. package/src/runtime/provider.ts +8 -1
  76. package/src/runtime/question-tool.ts +62 -0
  77. package/src/runtime/review-tool.ts +48 -0
  78. package/src/runtime/session-control.ts +61 -2
  79. package/src/runtime/session-hub.ts +85 -0
  80. package/src/runtime/tool-discovery.ts +4 -3
  81. package/src/runtime/tools.ts +44 -10
  82. package/src/runtime/update-check.ts +3 -2
  83. package/src/runtime/web-http.ts +56 -0
  84. package/src/runtime/web-tools.ts +88 -0
  85. package/src/runtime/workspace-lifecycle.ts +14 -1
  86. package/src/store/agent-profile-files.ts +13 -0
  87. package/src/store/blobs.ts +2 -1
  88. package/src/store/context-files.ts +77 -1
  89. package/src/store/file-checkpoints.ts +5 -4
  90. package/src/store/foundation-config.ts +24 -0
  91. package/src/store/git-status.ts +15 -0
  92. package/src/store/lsp-document.ts +30 -0
  93. package/src/store/mcp-credentials.ts +85 -0
  94. package/src/store/patch.ts +92 -0
  95. package/src/store/permission-paths.ts +34 -0
  96. package/src/store/review.ts +109 -0
  97. package/src/store/search.ts +48 -9
  98. package/src/tui/ui/layout.ts +7 -7
  99. package/src/tui/ui/messages.ts +1 -1
  100. package/src/tui/ui/pictures.ts +26 -9
  101. package/src/tui/ui/screen.ts +12 -8
  102. package/src/tui/ui/status.ts +32 -6
@@ -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
- async reloadResources(frozen: FrozenPrefix, skills = this.config.skills): Promise<boolean> {
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
- await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
768
- this.provider.config.spec.provider, this.provider.config.effort, contextRef);
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: result.content };
958
- yield { kind: "done", outcome: result.outcome ?? executionOutcome("execution_error"), reason: "isolated skill finished" };
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: "stopped: consecutive tool exchanges without new evidence", outcome: executionOutcome("stalled") };
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 (this.config.guidance !== false) {
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: `hit maxTurns (${this.config.maxTurns})`, outcome: executionOutcome("max_turns") };
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("unknown"),
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 text = "";
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 === "assistant_text" && e.text) text = e.text;
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.consecutive} consecutive tool exchanges without new evidence or fresh verification; change the approach or provide new input` };
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.consecutive} consecutive tool exchanges without new evidence or fresh verification. Change the method, run a meaningful check, or give the final result with unresolved limits. The harness will stop at ${limit} such exchanges. Notes and repeated observations do not count as progress.` });
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
+ }