@rynfar/meridian 1.70.0 → 1.71.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.
@@ -6870,6 +6870,13 @@ function ordinalSuffix(n) {
6870
6870
  function linkRequestAbort(signal) {
6871
6871
  const controller = new AbortController;
6872
6872
  let attached = false;
6873
+ const linkedAt = Date.now();
6874
+ let cause;
6875
+ const classify = () => {
6876
+ if (!controller.signal.aborted)
6877
+ return "none";
6878
+ return cause ?? "unknown_abort";
6879
+ };
6873
6880
  const abort = (reason) => {
6874
6881
  if (!controller.signal.aborted)
6875
6882
  controller.abort(reason);
@@ -6889,6 +6896,19 @@ function linkRequestAbort(signal) {
6889
6896
  return;
6890
6897
  signal.removeEventListener("abort", forwardAbort);
6891
6898
  attached = false;
6899
+ },
6900
+ setCause: (labeled) => {
6901
+ cause ??= labeled;
6902
+ },
6903
+ abortSnapshot: () => {
6904
+ const snapshot = {
6905
+ cause: classify(),
6906
+ aborted: controller.signal.aborted
6907
+ };
6908
+ if (controller.signal.aborted) {
6909
+ snapshot.elapsedMs = Math.max(0, Date.now() - linkedAt);
6910
+ }
6911
+ return snapshot;
6892
6912
  }
6893
6913
  };
6894
6914
  }
@@ -23873,6 +23893,45 @@ function canRecoverCapturedToolUses(input) {
23873
23893
  return false;
23874
23894
  }
23875
23895
  }
23896
+ function isStreamedToolBlockComplete(record2) {
23897
+ if (!record2.forwardedStart)
23898
+ return false;
23899
+ if (!record2.naturalStop)
23900
+ return false;
23901
+ if (record2.startedInputObject)
23902
+ return true;
23903
+ if (!record2.json.trim())
23904
+ return false;
23905
+ try {
23906
+ const parsed = JSON.parse(record2.json);
23907
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
23908
+ } catch {
23909
+ return false;
23910
+ }
23911
+ }
23912
+ function canRecoverUncapturedToolUses(input) {
23913
+ if (!input.uncapturedRecoveryEnabled)
23914
+ return false;
23915
+ if (!input.passthrough)
23916
+ return false;
23917
+ if (input.reason !== "max_turns")
23918
+ return false;
23919
+ if (input.attemptedMaxTurns !== 1)
23920
+ return false;
23921
+ if (input.capturedToolUses > 0)
23922
+ return false;
23923
+ if (input.streamedToolUses <= 0)
23924
+ return false;
23925
+ if (input.droppedToolUseIds > 0)
23926
+ return false;
23927
+ if (input.sawDuplicateToolUse)
23928
+ return false;
23929
+ if (input.forceSingleToolUse)
23930
+ return false;
23931
+ if (input.earlyStopFired)
23932
+ return false;
23933
+ return true;
23934
+ }
23876
23935
  function extractSdkTermination(errMsg) {
23877
23936
  const stderrTail = extractStderrTail(errMsg);
23878
23937
  const haystack = `${errMsg}
@@ -23937,6 +23996,9 @@ function formatSdkTermination(t, ctx) {
23937
23996
  parts.push(`deferred=${ctx.hasDeferredTools}`);
23938
23997
  if (ctx.sdkSessionId)
23939
23998
  parts.push(`session=${ctx.sdkSessionId.slice(0, 8)}`);
23999
+ if (ctx.abort) {
24000
+ parts.push(`abort=${ctx.abort.cause}`);
24001
+ }
23940
24002
  if (t.rawTail)
23941
24003
  parts.push(`raw=${JSON.stringify(t.rawTail)}`);
23942
24004
  if (t.stderrTail)
@@ -36925,6 +36987,7 @@ function createProxyServer(config2 = {}) {
36925
36987
  let durableWritesRevoked = false;
36926
36988
  let inFlightRequests = 0;
36927
36989
  const activeRequestAborts = new Set;
36990
+ const activeShutdownLabels = new Map;
36928
36991
  const internalHopToken = randomUUID6();
36929
36992
  const errorEnvelope = (shape, type, message) => shape === "anthropic" ? { type: "error", error: { type, message } } : { error: { type, message, code: null } };
36930
36993
  const DRAIN_MESSAGE = "Meridian is shutting down and is not accepting new requests. Retry against another instance.";
@@ -37216,6 +37279,7 @@ function createProxyServer(config2 = {}) {
37216
37279
  body: options.body,
37217
37280
  forcedProfileId: candidate,
37218
37281
  turnWatchdogSignal: options.turnWatchdogSignal,
37282
+ requestAbortLink: options.requestAbortLink,
37219
37283
  forceFreshPriorityReplay: priorityPublication !== undefined && (options.durableRoute?.forceFreshReplay === true || options.currentProfileId !== undefined && candidate !== options.currentProfileId),
37220
37284
  priorityPublication,
37221
37285
  priorityAttemptExposure: exposure
@@ -37293,7 +37357,7 @@ data: ${JSON.stringify(lastError)}
37293
37357
  const handleMessages = async (c, requestMeta, options) => {
37294
37358
  const requestStartAt = requestMeta.queueEnteredAt;
37295
37359
  const requestSignal = options.turnWatchdogSignal ? AbortSignal.any([c.req.raw.signal, options.turnWatchdogSignal]) : c.req.raw.signal;
37296
- const requestAbort = linkRequestAbort(requestSignal);
37360
+ const requestAbort = options.requestAbortLink ?? linkRequestAbort(requestSignal);
37297
37361
  let streamOwnsAbortLink = false;
37298
37362
  return withClaudeLogContext({ requestId: requestMeta.requestId, endpoint: requestMeta.endpoint }, async () => {
37299
37363
  const adapter = detectAdapter(c);
@@ -37628,6 +37692,7 @@ data: ${JSON.stringify(lastError)}
37628
37692
  wantsStream: body.stream === true,
37629
37693
  currentProfileId: assignedProfile,
37630
37694
  turnWatchdogSignal: options.turnWatchdogSignal,
37695
+ requestAbortLink: options.requestAbortLink,
37631
37696
  publicationTurn,
37632
37697
  claimTurn: trustedTurn,
37633
37698
  durableRoute
@@ -38310,6 +38375,7 @@ data: ${JSON.stringify(lastError)}
38310
38375
  name: toolName,
38311
38376
  reason: exceedsForcedSingle ? "forced_single" : "same_tool_repeat"
38312
38377
  });
38378
+ requestAbort.setCause("passthrough_single_step");
38313
38379
  requestAbort.abort("passthrough single-step complete");
38314
38380
  } else {
38315
38381
  capturedSignatures.add(signature);
@@ -38880,7 +38946,8 @@ Subprocess stderr: ${stderrOutput}`;
38880
38946
  requestSource,
38881
38947
  isResume,
38882
38948
  hasDeferredTools,
38883
- sdkSessionId: currentSessionId || resumeSessionId
38949
+ sdkSessionId: currentSessionId || resumeSessionId,
38950
+ abort: requestAbort.abortSnapshot()
38884
38951
  })} captured=${capturedToolUses.length}`, requestMeta.requestId);
38885
38952
  claudeLog("passthrough.max_turns_recovered", {
38886
38953
  mode: "non_stream",
@@ -39165,11 +39232,14 @@ Subprocess stderr: ${stderrOutput}`;
39165
39232
  let nextPassthroughToolCallAssistantUuid;
39166
39233
  let nextPassthroughToolCallIds;
39167
39234
  let sawCanonicalResult = false;
39235
+ const uncapturedToolRecoveryEnabled = env("PASSTHROUGH_UNCAPTURED_TOOL_RECOVERY") === "1";
39236
+ const streamedToolBlockRecords = new Map;
39168
39237
  const silentTurnRecoveryEnabled = env("SILENT_TURN_RECOVERY") !== "0";
39169
39238
  let silentTurnRecoveryAttempted = false;
39170
39239
  let silentTurnRecovered = false;
39171
39240
  const streamedToolUseIds = new Set;
39172
39241
  let pendingTerminalDelta = null;
39242
+ let lastAttemptMaxTurns;
39173
39243
  let pendingStructuredFrames = [];
39174
39244
  let pendingStructuredTextLength = 0;
39175
39245
  let terminalDeltaSent = false;
@@ -39313,6 +39383,7 @@ data: ${JSON.stringify({ type: "content_block_stop", index: idx })}
39313
39383
  advisorModel
39314
39384
  }, requestAbort.controller);
39315
39385
  attemptMaxTurns = attemptQuery.options.maxTurns;
39386
+ lastAttemptMaxTurns = attemptMaxTurns;
39316
39387
  for await (const event of runSdkQueryAttempt(attemptQuery, requestAbort.controller.signal, requestMeta, "stream", managedSdkAttemptLocators())) {
39317
39388
  if (event.type === "rate_limit_event") {
39318
39389
  rateLimitStore.record(profile.id, event.rate_limit_info);
@@ -39760,6 +39831,9 @@ data: ${JSON.stringify({ type: "content_block_stop", index: idx })}
39760
39831
  if (eventType === "content_block_stop") {
39761
39832
  flushToolArguments(clientIdx);
39762
39833
  passthroughToolBlockNames.delete(eventIndex);
39834
+ const record3 = streamedToolBlockRecords.get(clientIdx);
39835
+ if (record3)
39836
+ record3.naturalStop = true;
39763
39837
  }
39764
39838
  }
39765
39839
  if (eventType === "content_block_delta" && event.delta?.type === "text_delta" && shouldInjectSilentTurn({
@@ -39793,6 +39867,34 @@ data: ${JSON.stringify(event)}
39793
39867
  if (typeof idx === "number")
39794
39868
  openClientBlocks.delete(idx);
39795
39869
  }
39870
+ if (passthrough && uncapturedToolRecoveryEnabled) {
39871
+ const clientIdx = eventIndex !== undefined ? sdkToClientIndex.get(eventIndex) ?? eventIndex : undefined;
39872
+ if (clientIdx !== undefined) {
39873
+ if (eventType === "content_block_start") {
39874
+ const block = event.content_block;
39875
+ if (block?.type === "tool_use" && typeof block?.id === "string" && block.id) {
39876
+ streamedToolBlockRecords.set(clientIdx, {
39877
+ id: block.id,
39878
+ name: block.name,
39879
+ json: "",
39880
+ startedInputObject: block.input !== undefined && block.input !== null,
39881
+ forwardedStart: true,
39882
+ naturalStop: false
39883
+ });
39884
+ }
39885
+ } else if (eventType === "content_block_delta") {
39886
+ const delta = event.delta;
39887
+ const record3 = streamedToolBlockRecords.get(clientIdx);
39888
+ if (record3 && delta?.type === "input_json_delta" && typeof delta.partial_json === "string") {
39889
+ record3.json += delta.partial_json;
39890
+ }
39891
+ } else if (eventType === "content_block_stop") {
39892
+ const record3 = streamedToolBlockRecords.get(clientIdx);
39893
+ if (record3)
39894
+ record3.naturalStop = true;
39895
+ }
39896
+ }
39897
+ }
39796
39898
  if (passthrough && eventType === "message_delta" && event.delta?.stop_reason === "tool_use" && streamedToolUseIds.size > 0) {
39797
39899
  flushOpenClientBlocks("drain_close");
39798
39900
  if (earlyStopEnabled) {
@@ -40469,8 +40571,40 @@ Subprocess stderr: ${stderrOutput}`;
40469
40571
  capturedToolUses: capturedToolUses.length,
40470
40572
  abortIsOurs: sawDuplicateToolUse
40471
40573
  }) && messageStartEmitted;
40574
+ const uncapturedEligible = (() => {
40575
+ if (!canRecoverUncapturedToolUses({
40576
+ reason: sdkTerm.reason,
40577
+ passthrough,
40578
+ capturedToolUses: capturedToolUses.length,
40579
+ streamedToolUses: streamedToolUseIds.size,
40580
+ droppedToolUseIds: droppedToolUseIds.size,
40581
+ sawDuplicateToolUse,
40582
+ forceSingleToolUse,
40583
+ earlyStopFired,
40584
+ uncapturedRecoveryEnabled: uncapturedToolRecoveryEnabled,
40585
+ attemptedMaxTurns: lastAttemptMaxTurns
40586
+ }))
40587
+ return false;
40588
+ if (!messageStartEmitted || streamClosed || pendingTerminalDelta)
40589
+ return false;
40590
+ if (durableWritesRevoked)
40591
+ return false;
40592
+ if (requestAbort.abortSnapshot().aborted)
40593
+ return false;
40594
+ for (const record3 of streamedToolBlockRecords.values()) {
40595
+ if (!isStreamedToolBlockComplete(record3))
40596
+ return false;
40597
+ const declared = requestTools.some((t) => t.name === record3.name);
40598
+ if (!declared)
40599
+ return false;
40600
+ }
40601
+ if (streamedToolBlockRecords.size !== streamedToolUseIds.size)
40602
+ return false;
40603
+ return true;
40604
+ })();
40605
+ const uncapturedRecoveryActive = uncapturedEligible;
40472
40606
  const recoverableCheckpoint = canRecoverAsToolUse && sdkTerm.reason === "max_turns" && Boolean(currentSessionId) && Boolean(nextPassthroughToolCallAssistantUuid) && Boolean(nextPassthroughToolCallIds?.length) && earlyStopFired && !isIndependentSession && !sawDuplicateToolUse;
40473
- const mustEvictBeforeRecoveredTerminal = !isIndependentSession && canRecoverAsToolUse && !recoverableCheckpoint;
40607
+ const mustEvictBeforeRecoveredTerminal = !isIndependentSession && canRecoverAsToolUse && !recoverableCheckpoint || !isIndependentSession && uncapturedRecoveryActive && !recoverableCheckpoint;
40474
40608
  if (mustEvictBeforeRecoveredTerminal || !isIndependentSession && passthrough && streamedToolUseIds.size > 0 && !sawCanonicalResult && !recoverableCheckpoint) {
40475
40609
  const evicted = evictSession2(profileSessionId, profileScopedCwd, lineageMessages, mappingExpectedGeneration);
40476
40610
  if (mustEvictBeforeRecoveredTerminal && !evicted) {
@@ -40478,15 +40612,16 @@ Subprocess stderr: ${stderrOutput}`;
40478
40612
  }
40479
40613
  claudeLog("passthrough.noncanonical_session_evicted", { mode: "stream", reason: "drain_error" });
40480
40614
  }
40481
- if (canRecoverAsToolUse) {
40615
+ if (canRecoverAsToolUse || uncapturedRecoveryActive) {
40482
40616
  idleStalls.clear(idleStallSessionKey);
40483
- diagnosticLog2.session(`${requestMeta.requestId} sdk_termination_recovered ${formatSdkTermination(sdkTerm, {
40617
+ diagnosticLog2.session(`${requestMeta.requestId} sdk_termination_recovered${uncapturedRecoveryActive ? "_uncaptured" : ""} ${formatSdkTermination(sdkTerm, {
40484
40618
  model,
40485
40619
  requestSource,
40486
40620
  isResume,
40487
40621
  hasDeferredTools,
40488
- sdkSessionId: currentSessionId || resumeSessionId
40489
- })} captured=${capturedToolUses.length}`, requestMeta.requestId);
40622
+ sdkSessionId: currentSessionId || resumeSessionId,
40623
+ abort: requestAbort.abortSnapshot()
40624
+ })} captured=${capturedToolUses.length}${uncapturedRecoveryActive ? ` completed=${streamedToolBlockRecords.size}` : ""}`, requestMeta.requestId);
40490
40625
  flushOpenClientBlocks("recovery");
40491
40626
  const unseenToolUses = capturedToolUses.filter((tu) => !streamedToolUseIds.has(tu.id));
40492
40627
  for (let i = 0;i < unseenToolUses.length; i++) {
@@ -40627,7 +40762,8 @@ data: {"type":"message_stop"}
40627
40762
  requestSource,
40628
40763
  isResume,
40629
40764
  hasDeferredTools,
40630
- sdkSessionId: currentSessionId || resumeSessionId
40765
+ sdkSessionId: currentSessionId || resumeSessionId,
40766
+ abort: requestAbort.abortSnapshot()
40631
40767
  })} blocks=${nextClientBlockIndex}`, requestMeta.requestId);
40632
40768
  claudeLog("passthrough.capped_turn_truncated", {
40633
40769
  mode: "stream",
@@ -40700,7 +40836,8 @@ data: {"type":"message_stop"}
40700
40836
  requestSource,
40701
40837
  isResume,
40702
40838
  hasDeferredTools,
40703
- sdkSessionId: currentSessionId || resumeSessionId
40839
+ sdkSessionId: currentSessionId || resumeSessionId,
40840
+ abort: requestAbort.abortSnapshot()
40704
40841
  })} envelope=${messageStartEmitted ? "open" : "unopened"} blocks=${contentBlocksForwarded} ` + `text=${textEventsForwarded} tools=${capturedToolUses.length}/${streamedToolUseIds.size}`, requestMeta.requestId);
40705
40842
  const streamErrTotalMs = Date.now() - requestStartAt;
40706
40843
  const streamErrQueueWaitMs = totalQueueWaitMs(requestMeta);
@@ -40780,15 +40917,18 @@ data: ${JSON.stringify({
40780
40917
  await abandonManagedFork("stream_complete_without_commit");
40781
40918
  if (priorityRollbackRetirement)
40782
40919
  await priorityRollbackRetirement;
40783
- requestAbort.detach();
40920
+ if (!streamOwnsAbortLink)
40921
+ requestAbort.detach();
40784
40922
  }
40785
40923
  })().finally(() => {
40786
40924
  resolveStreamCompletion();
40787
40925
  });
40788
40926
  },
40789
40927
  cancel(reason) {
40928
+ requestAbort.setCause("stream_cancel");
40790
40929
  requestAbort.abort(reason);
40791
- requestAbort.detach();
40930
+ if (!streamOwnsAbortLink)
40931
+ requestAbort.detach();
40792
40932
  requestMeta.cascadeSubtreeCancel?.("stream_cancel");
40793
40933
  if (!isIndependentSession && (!managedForkTarget || managedForkPublished || clientAssistantContentExposed)) {
40794
40934
  evictSession2(profileSessionId, profileScopedCwd, lineageMessages, mappingExpectedGeneration);
@@ -40821,7 +40961,8 @@ data: ${JSON.stringify({
40821
40961
  claudeLog("proxy.error", { error: errMsg, classified: classified.type });
40822
40962
  const sdkTerm = extractSdkTermination(errMsg);
40823
40963
  diagnosticLog2.error(`${requestMeta.requestId} ${formatSdkTermination(sdkTerm, {
40824
- requestSource: c.req.header("x-meridian-source")?.slice(0, 64) || undefined
40964
+ requestSource: c.req.header("x-meridian-source")?.slice(0, 64) || undefined,
40965
+ abort: requestAbort.abortSnapshot()
40825
40966
  })}`, requestMeta.requestId);
40826
40967
  const errorQueueWaitMs = totalQueueWaitMs(requestMeta);
40827
40968
  const errorTotalMs = Date.now() - requestStartAt;
@@ -40908,6 +41049,14 @@ data: ${JSON.stringify({
40908
41049
  };
40909
41050
  const turnWatchdogAbort = new AbortController;
40910
41051
  activeRequestAborts.add(turnWatchdogAbort);
41052
+ const requestSignalForLink = AbortSignal.any([c.req.raw.signal, turnWatchdogAbort.signal]);
41053
+ const labelClientAbort = () => {
41054
+ if (!turnWatchdogAbort.signal.aborted)
41055
+ requestAbortLink?.setCause("client_abort");
41056
+ };
41057
+ c.req.raw.signal.addEventListener("abort", labelClientAbort, { once: true });
41058
+ const requestAbortLink = linkRequestAbort(requestSignalForLink);
41059
+ activeShutdownLabels.set(turnWatchdogAbort, () => requestAbortLink.setCause("process_shutdown"));
40911
41060
  let finished = false;
40912
41061
  let leaseReleased = false;
40913
41062
  let retainSessionTurnFence = false;
@@ -40936,6 +41085,8 @@ data: ${JSON.stringify({
40936
41085
  if (finished)
40937
41086
  return;
40938
41087
  finished = true;
41088
+ requestAbortLink.detach();
41089
+ c.req.raw.signal.removeEventListener("abort", labelClientAbort);
40939
41090
  if (retainSessionTurnFence && (sessionTurnLease || crossProcessTurnLease)) {
40940
41091
  leaseReleased = true;
40941
41092
  if (leaseWatchdog)
@@ -40950,6 +41101,7 @@ data: ${JSON.stringify({
40950
41101
  sessionTreeRegistration?.release();
40951
41102
  sessionTreeRegistration = undefined;
40952
41103
  activeRequestAborts.delete(turnWatchdogAbort);
41104
+ activeShutdownLabels.delete(turnWatchdogAbort);
40953
41105
  inFlightRequests--;
40954
41106
  };
40955
41107
  let body;
@@ -40981,7 +41133,10 @@ data: ${JSON.stringify({
40981
41133
  requestId,
40982
41134
  sessionKey: agentSessionId,
40983
41135
  parentKey: adapter.getParentSessionId?.(c, body),
40984
- abort: (reason) => turnWatchdogAbort.abort(reason)
41136
+ abort: (reason) => {
41137
+ requestAbortLink.setCause("subtree_cancel");
41138
+ turnWatchdogAbort.abort(reason);
41139
+ }
40985
41140
  });
40986
41141
  subtreeSessionKey = agentSessionId;
40987
41142
  const clientSignal = c.req.raw.signal;
@@ -41002,6 +41157,7 @@ data: ${JSON.stringify({
41002
41157
  leaseWatchdog = setTimeout(() => {
41003
41158
  claudeLog("session.turn_watchdog_abort", { requestId, heldMs: SESSION_TURN_MAX_HOLD_MS });
41004
41159
  plog(`[PROXY] ${requestId} session turn exceeded ${SESSION_TURN_MAX_HOLD_MS}ms — aborting without releasing its fencing lease`);
41160
+ requestAbortLink.setCause("session_watchdog");
41005
41161
  turnWatchdogAbort.abort(new Error("Session turn exceeded its maximum hold time"));
41006
41162
  }, SESSION_TURN_MAX_HOLD_MS);
41007
41163
  leaseWatchdog.unref?.();
@@ -41076,7 +41232,8 @@ data: ${JSON.stringify({
41076
41232
  };
41077
41233
  const response = await handleMessages(c, requestMeta, {
41078
41234
  body,
41079
- turnWatchdogSignal: turnWatchdogAbort.signal
41235
+ turnWatchdogSignal: turnWatchdogAbort.signal,
41236
+ requestAbortLink
41080
41237
  });
41081
41238
  const completion = responseCompletions.get(response);
41082
41239
  if (completion) {
@@ -41901,6 +42058,8 @@ data: ${JSON.stringify({ response: { id: responseId, status: "failed", error: {
41901
42058
  },
41902
42059
  forceAbortInFlight: () => {
41903
42060
  durableWritesRevoked = true;
42061
+ for (const label of activeShutdownLabels.values())
42062
+ label();
41904
42063
  for (const controller of activeRequestAborts) {
41905
42064
  controller.abort(new Error("Proxy shutdown grace period elapsed"));
41906
42065
  }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startProxyServer
4
- } from "./cli-198xnjcn.js";
4
+ } from "./cli-6c6dj69q.js";
5
5
  import"./cli-5jxyma6z.js";
6
6
  import"./cli-sry5aqdj.js";
7
7
  import"./cli-8yp89fan.js";
@@ -13,9 +13,12 @@ var MAX_HEADER_BYTES = 768;
13
13
  var MAX_PAYLOAD_BYTES = 384;
14
14
  var TURN_DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/;
15
15
  var SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
16
- function configDirectory() {
16
+ function meridianConfigDirectory() {
17
17
  return process.env.MERIDIAN_CONFIG_DIR ?? join(homedir(), ".config", "meridian");
18
18
  }
19
+ function configDirectory() {
20
+ return meridianConfigDirectory();
21
+ }
19
22
  function priorityAttestationKeyPath() {
20
23
  return join(configDirectory(), PRIORITY_ATTESTATION_KEY_FILE);
21
24
  }
@@ -20192,6 +20192,10 @@ var Info6 = exports_Schema.Struct({
20192
20192
  })
20193
20193
  })));
20194
20194
 
20195
+ // plugin/meridian-v2.ts
20196
+ import { readFileSync as readFileSync2, renameSync, rmSync, writeFileSync } from "node:fs";
20197
+ import { join as join3 } from "node:path";
20198
+
20195
20199
  // plugin/priority-attestation.ts
20196
20200
  import { createHash, createHmac } from "node:crypto";
20197
20201
  import { readFileSync } from "node:fs";
@@ -20207,9 +20211,12 @@ var MAX_HEADER_BYTES = 768;
20207
20211
  var MAX_PAYLOAD_BYTES = 384;
20208
20212
  var TURN_DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/;
20209
20213
  var SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
20210
- function configDirectory() {
20214
+ function meridianConfigDirectory() {
20211
20215
  return process.env.MERIDIAN_CONFIG_DIR ?? join2(homedir(), ".config", "meridian");
20212
20216
  }
20217
+ function configDirectory() {
20218
+ return meridianConfigDirectory();
20219
+ }
20213
20220
  function priorityAttestationKeyPath() {
20214
20221
  return join2(configDirectory(), PRIORITY_ATTESTATION_KEY_FILE);
20215
20222
  }
@@ -20307,6 +20314,9 @@ var PARENT_SESSION_ONE_SHOTS = new Set(["title", "summary"]);
20307
20314
  var ATTACHED_COMPACTION_AGENT = "compaction";
20308
20315
  var MODEL_DISCOVERY_TIMEOUT_MS = 3000;
20309
20316
  var PROVIDER_READY_POLL_MS = 25;
20317
+ var CATALOG_CACHE_FILE = "opencode-v2-catalog.json";
20318
+ var CATALOG_CACHE_VERSION = 1;
20319
+ var CATALOG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20310
20320
  var MERIDIAN_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
20311
20321
  var SESSION_AFFINITY_HEADERS = [
20312
20322
  "x-opencode-session",
@@ -20465,15 +20475,109 @@ async function loadMeridianModels(catalog, signal, fetcher = globalThis.fetch) {
20465
20475
  if (configured.length > 0) {
20466
20476
  const discovered = await Promise.all(configured.map(async ({ providerID, baseURL }) => {
20467
20477
  const models = await fetchMeridianModels(baseURL, signal, fetcher);
20468
- return models ? { providerID, models } : undefined;
20478
+ return models ? { providerID, baseURL: typeof baseURL === "string" ? baseURL : undefined, models } : undefined;
20469
20479
  }));
20470
- return discovered.flatMap((result3) => result3 ? [result3] : []);
20480
+ return {
20481
+ configured: configured.map((provider) => provider.providerID),
20482
+ discovered: discovered.flatMap((result3) => result3 ? [result3] : [])
20483
+ };
20471
20484
  }
20472
20485
  if (Date.now() >= deadline)
20473
- return [];
20486
+ return { configured: [], discovered: [] };
20474
20487
  await new Promise((resolve2) => setTimeout(resolve2, PROVIDER_READY_POLL_MS));
20475
20488
  }
20476
- return [];
20489
+ return { configured: [], discovered: [] };
20490
+ }
20491
+ function catalogCachePath() {
20492
+ return join3(meridianConfigDirectory(), CATALOG_CACHE_FILE);
20493
+ }
20494
+ function parseCatalogCache(value3, now3) {
20495
+ const entries3 = new Map;
20496
+ if (!isRecord(value3) || value3.version !== CATALOG_CACHE_VERSION)
20497
+ return entries3;
20498
+ if (!isRecord(value3.providers))
20499
+ return entries3;
20500
+ for (const [providerID, entry] of Object.entries(value3.providers)) {
20501
+ if (!MERIDIAN_PROVIDERS.has(providerID) || !isRecord(entry))
20502
+ continue;
20503
+ const { baseURL, fetchedAt } = entry;
20504
+ if (typeof baseURL !== "string" || meridianModelsURL(baseURL) === undefined)
20505
+ continue;
20506
+ if (typeof fetchedAt !== "number" || !Number.isSafeInteger(fetchedAt) || fetchedAt <= 0)
20507
+ continue;
20508
+ if (fetchedAt > now3 || now3 - fetchedAt > CATALOG_CACHE_TTL_MS)
20509
+ continue;
20510
+ const models = parseCachedModels(entry.models);
20511
+ if (!models || models.length === 0)
20512
+ continue;
20513
+ entries3.set(providerID, { baseURL, models });
20514
+ }
20515
+ return entries3;
20516
+ }
20517
+ function parseCachedModels(value3) {
20518
+ if (!Array.isArray(value3))
20519
+ return;
20520
+ const models = [];
20521
+ const ids = new Set;
20522
+ for (const item of value3) {
20523
+ if (!isRecord(item))
20524
+ return;
20525
+ const { id: id2, name, contextWindow, efforts } = item;
20526
+ if (typeof id2 !== "string" || id2.length === 0 || id2.length > 256 || /[^\x21-\x7E]/.test(id2) || ids.has(id2) || typeof name !== "string" || name.trim().length === 0 || name.length > 256 || typeof contextWindow !== "number" || !Number.isSafeInteger(contextWindow) || contextWindow <= 0 || !Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string" || !MERIDIAN_EFFORTS.includes(effort))) {
20527
+ return;
20528
+ }
20529
+ ids.add(id2);
20530
+ models.push({ id: id2, name, contextWindow, efforts });
20531
+ }
20532
+ return models;
20533
+ }
20534
+ function serializeCatalogCache(discovered, now3) {
20535
+ const providers = {};
20536
+ for (const entry of discovered) {
20537
+ if (typeof entry.baseURL !== "string" || entry.models.length === 0)
20538
+ continue;
20539
+ providers[entry.providerID] = { baseURL: entry.baseURL, fetchedAt: now3, models: entry.models };
20540
+ }
20541
+ return `${JSON.stringify({ version: CATALOG_CACHE_VERSION, providers }, null, 2)}
20542
+ `;
20543
+ }
20544
+ function removeCatalogCache(remove4 = (path) => rmSync(path, { force: true })) {
20545
+ try {
20546
+ remove4(catalogCachePath());
20547
+ } catch {}
20548
+ }
20549
+ function readCatalogCache(now3, read = (path) => readFileSync2(path, "utf-8")) {
20550
+ try {
20551
+ return parseCatalogCache(JSON.parse(read(catalogCachePath())), now3);
20552
+ } catch {
20553
+ return new Map;
20554
+ }
20555
+ }
20556
+ function writeCatalogCache(discovered, now3, write = (path, contents) => {
20557
+ const temporary = `${path}.${process.pid}.tmp`;
20558
+ writeFileSync(temporary, contents, { encoding: "utf-8", mode: 384 });
20559
+ renameSync(temporary, path);
20560
+ }) {
20561
+ try {
20562
+ const contents = serializeCatalogCache(discovered, now3);
20563
+ if (contents.includes('"providers": {}'))
20564
+ return;
20565
+ write(catalogCachePath(), contents);
20566
+ } catch {}
20567
+ }
20568
+ function resolveCatalogModels(catalog, discovered, cached3) {
20569
+ if (discovered.length > 0)
20570
+ return [...discovered];
20571
+ const seeded = [];
20572
+ for (const providerID of MERIDIAN_PROVIDERS) {
20573
+ const entry = cached3.get(providerID);
20574
+ if (!entry)
20575
+ continue;
20576
+ if (!catalog.provider.get(providerID))
20577
+ continue;
20578
+ seeded.push({ providerID, baseURL: entry.baseURL, models: [...entry.models] });
20579
+ }
20580
+ return seeded;
20477
20581
  }
20478
20582
  function applyMeridianModels(catalog, providerID, models) {
20479
20583
  for (const model of models) {
@@ -20533,19 +20637,31 @@ var MeridianV2Plugin = define({
20533
20637
  const modelDiscoveryController = new AbortController;
20534
20638
  let discoveredModels = [];
20535
20639
  let discoveryStarted = false;
20640
+ const cachedModels = readCatalogCache(Date.now());
20536
20641
  registered.push(await context3.catalog.transform((catalog) => {
20537
- for (const discovered of discoveredModels) {
20538
- applyMeridianModels(catalog, discovered.providerID, discovered.models);
20642
+ for (const entry of resolveCatalogModels(catalog, discoveredModels, cachedModels)) {
20643
+ applyMeridianModels(catalog, entry.providerID, entry.models);
20539
20644
  }
20540
20645
  }));
20541
20646
  const discoverModels = async () => {
20542
20647
  if (discoveryStarted || modelDiscoveryController.signal.aborted)
20543
20648
  return false;
20544
- const models = await loadMeridianModels(context3.catalog, modelDiscoveryController.signal).catch(() => []);
20545
- if (models.length === 0 || modelDiscoveryController.signal.aborted)
20649
+ const loaded = await loadMeridianModels(context3.catalog, modelDiscoveryController.signal).catch(() => ({ configured: [], discovered: [] }));
20650
+ if (modelDiscoveryController.signal.aborted)
20651
+ return false;
20652
+ if (loaded.configured.length === 0) {
20653
+ if (cachedModels.size > 0) {
20654
+ cachedModels.clear();
20655
+ removeCatalogCache();
20656
+ await context3.catalog.reload();
20657
+ }
20658
+ return false;
20659
+ }
20660
+ if (loaded.discovered.length === 0)
20546
20661
  return false;
20547
20662
  discoveryStarted = true;
20548
- discoveredModels = models;
20663
+ discoveredModels = loaded.discovered;
20664
+ writeCatalogCache(loaded.discovered, Date.now());
20549
20665
  await context3.catalog.reload();
20550
20666
  return true;
20551
20667
  };
@@ -20181,6 +20181,10 @@ var Info6 = exports_Schema.Struct({
20181
20181
  })
20182
20182
  })));
20183
20183
 
20184
+ // plugin/meridian-v2.ts
20185
+ import { readFileSync as readFileSync2, renameSync, rmSync, writeFileSync } from "node:fs";
20186
+ import { join as join3 } from "node:path";
20187
+
20184
20188
  // plugin/priority-attestation.ts
20185
20189
  import { createHash, createHmac } from "node:crypto";
20186
20190
  import { readFileSync } from "node:fs";
@@ -20196,9 +20200,12 @@ var MAX_HEADER_BYTES = 768;
20196
20200
  var MAX_PAYLOAD_BYTES = 384;
20197
20201
  var TURN_DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/;
20198
20202
  var SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
20199
- function configDirectory() {
20203
+ function meridianConfigDirectory() {
20200
20204
  return process.env.MERIDIAN_CONFIG_DIR ?? join2(homedir(), ".config", "meridian");
20201
20205
  }
20206
+ function configDirectory() {
20207
+ return meridianConfigDirectory();
20208
+ }
20202
20209
  function priorityAttestationKeyPath() {
20203
20210
  return join2(configDirectory(), PRIORITY_ATTESTATION_KEY_FILE);
20204
20211
  }
@@ -20297,6 +20304,9 @@ var PARENT_SESSION_ONE_SHOTS = new Set(["title", "summary"]);
20297
20304
  var ATTACHED_COMPACTION_AGENT = "compaction";
20298
20305
  var MODEL_DISCOVERY_TIMEOUT_MS = 3000;
20299
20306
  var PROVIDER_READY_POLL_MS = 25;
20307
+ var CATALOG_CACHE_FILE = "opencode-v2-catalog.json";
20308
+ var CATALOG_CACHE_VERSION = 1;
20309
+ var CATALOG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20300
20310
  var MERIDIAN_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
20301
20311
  var MERIDIAN_V2_EFFORTS = MERIDIAN_EFFORTS;
20302
20312
  var SESSION_AFFINITY_HEADERS = [
@@ -20456,15 +20466,109 @@ async function loadMeridianModels(catalog, signal, fetcher = globalThis.fetch) {
20456
20466
  if (configured.length > 0) {
20457
20467
  const discovered = await Promise.all(configured.map(async ({ providerID, baseURL }) => {
20458
20468
  const models = await fetchMeridianModels(baseURL, signal, fetcher);
20459
- return models ? { providerID, models } : undefined;
20469
+ return models ? { providerID, baseURL: typeof baseURL === "string" ? baseURL : undefined, models } : undefined;
20460
20470
  }));
20461
- return discovered.flatMap((result3) => result3 ? [result3] : []);
20471
+ return {
20472
+ configured: configured.map((provider) => provider.providerID),
20473
+ discovered: discovered.flatMap((result3) => result3 ? [result3] : [])
20474
+ };
20462
20475
  }
20463
20476
  if (Date.now() >= deadline)
20464
- return [];
20477
+ return { configured: [], discovered: [] };
20465
20478
  await new Promise((resolve2) => setTimeout(resolve2, PROVIDER_READY_POLL_MS));
20466
20479
  }
20467
- return [];
20480
+ return { configured: [], discovered: [] };
20481
+ }
20482
+ function catalogCachePath() {
20483
+ return join3(meridianConfigDirectory(), CATALOG_CACHE_FILE);
20484
+ }
20485
+ function parseCatalogCache(value3, now3) {
20486
+ const entries3 = new Map;
20487
+ if (!isRecord(value3) || value3.version !== CATALOG_CACHE_VERSION)
20488
+ return entries3;
20489
+ if (!isRecord(value3.providers))
20490
+ return entries3;
20491
+ for (const [providerID, entry] of Object.entries(value3.providers)) {
20492
+ if (!MERIDIAN_PROVIDERS.has(providerID) || !isRecord(entry))
20493
+ continue;
20494
+ const { baseURL, fetchedAt } = entry;
20495
+ if (typeof baseURL !== "string" || meridianModelsURL(baseURL) === undefined)
20496
+ continue;
20497
+ if (typeof fetchedAt !== "number" || !Number.isSafeInteger(fetchedAt) || fetchedAt <= 0)
20498
+ continue;
20499
+ if (fetchedAt > now3 || now3 - fetchedAt > CATALOG_CACHE_TTL_MS)
20500
+ continue;
20501
+ const models = parseCachedModels(entry.models);
20502
+ if (!models || models.length === 0)
20503
+ continue;
20504
+ entries3.set(providerID, { baseURL, models });
20505
+ }
20506
+ return entries3;
20507
+ }
20508
+ function parseCachedModels(value3) {
20509
+ if (!Array.isArray(value3))
20510
+ return;
20511
+ const models = [];
20512
+ const ids = new Set;
20513
+ for (const item of value3) {
20514
+ if (!isRecord(item))
20515
+ return;
20516
+ const { id: id2, name, contextWindow, efforts } = item;
20517
+ if (typeof id2 !== "string" || id2.length === 0 || id2.length > 256 || /[^\x21-\x7E]/.test(id2) || ids.has(id2) || typeof name !== "string" || name.trim().length === 0 || name.length > 256 || typeof contextWindow !== "number" || !Number.isSafeInteger(contextWindow) || contextWindow <= 0 || !Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string" || !MERIDIAN_EFFORTS.includes(effort))) {
20518
+ return;
20519
+ }
20520
+ ids.add(id2);
20521
+ models.push({ id: id2, name, contextWindow, efforts });
20522
+ }
20523
+ return models;
20524
+ }
20525
+ function serializeCatalogCache(discovered, now3) {
20526
+ const providers = {};
20527
+ for (const entry of discovered) {
20528
+ if (typeof entry.baseURL !== "string" || entry.models.length === 0)
20529
+ continue;
20530
+ providers[entry.providerID] = { baseURL: entry.baseURL, fetchedAt: now3, models: entry.models };
20531
+ }
20532
+ return `${JSON.stringify({ version: CATALOG_CACHE_VERSION, providers }, null, 2)}
20533
+ `;
20534
+ }
20535
+ function removeCatalogCache(remove4 = (path) => rmSync(path, { force: true })) {
20536
+ try {
20537
+ remove4(catalogCachePath());
20538
+ } catch {}
20539
+ }
20540
+ function readCatalogCache(now3, read = (path) => readFileSync2(path, "utf-8")) {
20541
+ try {
20542
+ return parseCatalogCache(JSON.parse(read(catalogCachePath())), now3);
20543
+ } catch {
20544
+ return new Map;
20545
+ }
20546
+ }
20547
+ function writeCatalogCache(discovered, now3, write = (path, contents) => {
20548
+ const temporary = `${path}.${process.pid}.tmp`;
20549
+ writeFileSync(temporary, contents, { encoding: "utf-8", mode: 384 });
20550
+ renameSync(temporary, path);
20551
+ }) {
20552
+ try {
20553
+ const contents = serializeCatalogCache(discovered, now3);
20554
+ if (contents.includes('"providers": {}'))
20555
+ return;
20556
+ write(catalogCachePath(), contents);
20557
+ } catch {}
20558
+ }
20559
+ function resolveCatalogModels(catalog, discovered, cached3) {
20560
+ if (discovered.length > 0)
20561
+ return [...discovered];
20562
+ const seeded = [];
20563
+ for (const providerID of MERIDIAN_PROVIDERS) {
20564
+ const entry = cached3.get(providerID);
20565
+ if (!entry)
20566
+ continue;
20567
+ if (!catalog.provider.get(providerID))
20568
+ continue;
20569
+ seeded.push({ providerID, baseURL: entry.baseURL, models: [...entry.models] });
20570
+ }
20571
+ return seeded;
20468
20572
  }
20469
20573
  function applyMeridianModels(catalog, providerID, models) {
20470
20574
  for (const model of models) {
@@ -20527,19 +20631,31 @@ var MeridianV2Plugin = define({
20527
20631
  const modelDiscoveryController = new AbortController;
20528
20632
  let discoveredModels = [];
20529
20633
  let discoveryStarted = false;
20634
+ const cachedModels = readCatalogCache(Date.now());
20530
20635
  registered.push(await context3.catalog.transform((catalog) => {
20531
- for (const discovered of discoveredModels) {
20532
- applyMeridianModels(catalog, discovered.providerID, discovered.models);
20636
+ for (const entry of resolveCatalogModels(catalog, discoveredModels, cachedModels)) {
20637
+ applyMeridianModels(catalog, entry.providerID, entry.models);
20533
20638
  }
20534
20639
  }));
20535
20640
  const discoverModels = async () => {
20536
20641
  if (discoveryStarted || modelDiscoveryController.signal.aborted)
20537
20642
  return false;
20538
- const models = await loadMeridianModels(context3.catalog, modelDiscoveryController.signal).catch(() => []);
20539
- if (models.length === 0 || modelDiscoveryController.signal.aborted)
20643
+ const loaded = await loadMeridianModels(context3.catalog, modelDiscoveryController.signal).catch(() => ({ configured: [], discovered: [] }));
20644
+ if (modelDiscoveryController.signal.aborted)
20645
+ return false;
20646
+ if (loaded.configured.length === 0) {
20647
+ if (cachedModels.size > 0) {
20648
+ cachedModels.clear();
20649
+ removeCatalogCache();
20650
+ await context3.catalog.reload();
20651
+ }
20652
+ return false;
20653
+ }
20654
+ if (loaded.discovered.length === 0)
20540
20655
  return false;
20541
20656
  discoveryStarted = true;
20542
- discoveredModels = models;
20657
+ discoveredModels = loaded.discovered;
20658
+ writeCatalogCache(loaded.discovered, Date.now());
20543
20659
  await context3.catalog.reload();
20544
20660
  return true;
20545
20661
  };
@@ -20667,8 +20783,14 @@ var MeridianV2Plugin = define({
20667
20783
  });
20668
20784
  var meridian_v2_default = MeridianV2Plugin;
20669
20785
  export {
20786
+ writeCatalogCache,
20670
20787
  shouldDetachFromParentSession,
20788
+ serializeCatalogCache,
20789
+ resolveCatalogModels,
20790
+ removeCatalogCache,
20791
+ readCatalogCache,
20671
20792
  parseMeridianModels,
20793
+ parseCatalogCache,
20672
20794
  meridianModelsURL,
20673
20795
  loadMeridianModels,
20674
20796
  isRootV2Session,
@@ -20677,6 +20799,7 @@ export {
20677
20799
  fetchMeridianModels,
20678
20800
  fallbackAgentTraits,
20679
20801
  meridian_v2_default as default,
20802
+ catalogCachePath,
20680
20803
  applyMeridianV2Headers,
20681
20804
  applyMeridianModels,
20682
20805
  SUPPORTED_OPENCODE_V2_VERSION,
@@ -2,6 +2,7 @@
2
2
  * Error classification for SDK errors.
3
3
  * Maps raw error messages to structured HTTP error responses.
4
4
  */
5
+ import type { AbortCauseSnapshot } from "./requestAbort";
5
6
  export interface ClassifiedError {
6
7
  status: number;
7
8
  type: string;
@@ -167,6 +168,58 @@ export declare function canRecoverCapturedToolUses(input: {
167
168
  capturedToolUses: number;
168
169
  abortIsOurs: boolean;
169
170
  }): boolean;
171
+ /**
172
+ * Per-block completeness record for a tool_use the client received on the
173
+ * wire (uncaptured-recovery tracker). Populated only by real forwarding;
174
+ * `naturalStop` is set exclusively when the block's own content_block_stop
175
+ * was enqueued — a synthetic flush closure never counts, because a dangling
176
+ * block's arguments may be incomplete.
177
+ */
178
+ export interface StreamedToolBlockRecord {
179
+ id: string;
180
+ name: string;
181
+ /** Accumulated input_json_delta partials (plus any inline start input). */
182
+ json: string;
183
+ /** True when the block carried an inline input object at start (no deltas). */
184
+ startedInputObject: boolean;
185
+ forwardedStart: boolean;
186
+ naturalStop: boolean;
187
+ }
188
+ /**
189
+ * Is a streamed-but-uncaptured tool call complete and executable?
190
+ * Pure function — no I/O.
191
+ */
192
+ export declare function isStreamedToolBlockComplete(record: StreamedToolBlockRecord): boolean;
193
+ /**
194
+ * Can a failed passthrough turn whose tool_use blocks fully streamed but
195
+ * were NEVER captured by the PreToolUse hook still be delivered as a
196
+ * tool-use response?
197
+ *
198
+ * This is the 2026-09-10 0a95wd-tusk incident shape: an abort landing
199
+ * between stream completion and tool dispatch makes the CLI yield
200
+ * `max_turns_reached` WITHOUT running the hook, so captures are empty even
201
+ * though every streamed block is complete and names a declared client tool.
202
+ * This is a materially different trust basis from
203
+ * `canRecoverCapturedToolUses` (which requires the hook to have seen the
204
+ * calls) and is therefore a separate predicate, not a relaxed count.
205
+ *
206
+ * Callers must further verify: the attempted maxTurns was 1, the kill switch
207
+ * is enabled, no cancellation of any kind fired, no forced-single/duplicate/
208
+ * early-stop state exists, and the envelope is still open. Every streamed
209
+ * block must pass `isStreamedToolBlockComplete`.
210
+ */
211
+ export declare function canRecoverUncapturedToolUses(input: {
212
+ reason: SdkTermination["reason"];
213
+ passthrough: boolean;
214
+ capturedToolUses: number;
215
+ streamedToolUses: number;
216
+ droppedToolUseIds: number;
217
+ sawDuplicateToolUse: boolean;
218
+ forceSingleToolUse: boolean;
219
+ earlyStopFired: boolean;
220
+ uncapturedRecoveryEnabled: boolean;
221
+ attemptedMaxTurns: number | undefined;
222
+ }): boolean;
170
223
  export declare function extractSdkTermination(errMsg: string): SdkTermination;
171
224
  /**
172
225
  * Render an SdkTermination plus request context as a single greppable log line.
@@ -182,5 +235,7 @@ export declare function formatSdkTermination(t: SdkTermination, ctx: {
182
235
  isResume?: boolean;
183
236
  hasDeferredTools?: boolean;
184
237
  sdkSessionId?: string;
238
+ /** Abort-cause snapshot: which Meridian-linked producer fired, if any. */
239
+ abort?: AbortCauseSnapshot;
185
240
  }): string;
186
241
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/proxy/errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAc1D;AAoKD,2EAA2E;AAC3E,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAEpF;AAqFD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,eAAe,CA0L7E;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAM3D;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,aAAa,GAAG,iBAAiB,CAAA;AAEtE;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAahG;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAI3E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGxD;AAuBD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,IAAI,MAAM,CAEhG;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5E;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGjE;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,WAAW,GAAG,cAAc,GAAG,SAAS,GAAG,eAAe,GAAG,kBAAkB,GAAG,SAAS,CAAA;IACnG,sDAAsD;IACtD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;wCAEoC;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAuBD;;;;;;GAMG;AACH;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE;IAChD,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAA;IAChC,WAAW,EAAE,OAAO,CAAA;IACpB,gBAAgB,EAAE,MAAM,CAAA;IACxB,WAAW,EAAE,OAAO,CAAA;CACrB,GAAG,OAAO,CAYV;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,CAiEpE;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,CAAC,EAAE,cAAc,EACjB,GAAG,EAAE;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,GACA,MAAM,CAYR"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/proxy/errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAExD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAc1D;AAoKD,2EAA2E;AAC3E,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAEpF;AAqFD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,eAAe,CA0L7E;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAM3D;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,aAAa,GAAG,iBAAiB,CAAA;AAEtE;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAahG;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAI3E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGxD;AAuBD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,IAAI,MAAM,CAEhG;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5E;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGjE;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,WAAW,GAAG,cAAc,GAAG,SAAS,GAAG,eAAe,GAAG,kBAAkB,GAAG,SAAS,CAAA;IACnG,sDAAsD;IACtD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;wCAEoC;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAuBD;;;;;;GAMG;AACH;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE;IAChD,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAA;IAChC,WAAW,EAAE,OAAO,CAAA;IACpB,gBAAgB,EAAE,MAAM,CAAA;IACxB,WAAW,EAAE,OAAO,CAAA;CACrB,GAAG,OAAO,CAYV;AAED;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAA;IACZ,+EAA+E;IAC/E,kBAAkB,EAAE,OAAO,CAAA;IAC3B,cAAc,EAAE,OAAO,CAAA;IACvB,WAAW,EAAE,OAAO,CAAA;CACrB;AAED;;;GAGG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAaT;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE;IAClD,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAA;IAChC,WAAW,EAAE,OAAO,CAAA;IACpB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;IACxB,iBAAiB,EAAE,MAAM,CAAA;IACzB,mBAAmB,EAAE,OAAO,CAAA;IAC5B,kBAAkB,EAAE,OAAO,CAAA;IAC3B,cAAc,EAAE,OAAO,CAAA;IACvB,yBAAyB,EAAE,OAAO,CAAA;IAClC,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAA;CACtC,GAAG,OAAO,CAeV;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,CAiEpE;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,CAAC,EAAE,cAAc,EACjB,GAAG,EAAE;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,0EAA0E;IAC1E,KAAK,CAAC,EAAE,kBAAkB,CAAA;CAC3B,GACA,MAAM,CAkBR"}
@@ -1,7 +1,43 @@
1
+ /**
2
+ * Request-scoped abort link with an abort-cause registry.
3
+ *
4
+ * Meridian aborts a request's SDK query from several producers (client
5
+ * disconnect, cancelled response body, session turn watchdog, subtree
6
+ * cancellation, process shutdown, the proxy's own passthrough single-step
7
+ * abort). When a capped turn then dies with an opaque SDK termination, the
8
+ * operator needs to know WHICH producer fired — or that none did, which is
9
+ * the discriminating marker for the uncaptured-tool-turn incident
10
+ * (2026-09-10 0a95wd-tusk): an externally-initiated CLI abort masqueraded as
11
+ * "Reached maximum number of turns (1)".
12
+ *
13
+ * The registry latches the FIRST classified cause. `abortSnapshot` is the
14
+ * single read side used by diagnostics. `cause: "none"` means no
15
+ * Meridian-linked abort was observed — it is NOT proof that no abort occurred
16
+ * inside the CLI subprocess; only that nothing Meridian controls aborted.
17
+ */
18
+ export type RequestAbortCause = "client_abort" | "stream_cancel" | "session_watchdog" | "process_shutdown" | "subtree_cancel" | "passthrough_single_step" | "unknown_abort";
19
+ export declare const REQUEST_ABORT_CAUSES: readonly RequestAbortCause[];
20
+ export interface AbortCauseSnapshot {
21
+ /** First classified cause; "none" when the observed signal never aborted. */
22
+ cause: RequestAbortCause | "none";
23
+ /** Whether the linked controller's signal is aborted. */
24
+ aborted: boolean;
25
+ /** Monotonic ms from link creation to abort; undefined when not aborted. */
26
+ elapsedMs?: number;
27
+ }
1
28
  export interface RequestAbortLink {
2
29
  controller: AbortController;
3
30
  abort: (reason?: unknown) => void;
4
31
  detach: () => void;
32
+ /**
33
+ * Latch the classified cause of an impending abort. First call wins; later
34
+ * labels are ignored so the earliest producer owns the diagnosis. Callers
35
+ * should label BEFORE invoking abort, and may label a cause that arrives
36
+ * with an already-aborted signal.
37
+ */
38
+ setCause: (cause: RequestAbortCause) => void;
39
+ /** Read-side snapshot for diagnostics and telemetry. */
40
+ abortSnapshot: () => AbortCauseSnapshot;
5
41
  }
6
42
  /** Forward an HTTP request abort into the SDK query lifecycle. */
7
43
  export declare function linkRequestAbort(signal: AbortSignal): RequestAbortLink;
@@ -1 +1 @@
1
- {"version":3,"file":"requestAbort.d.ts","sourceRoot":"","sources":["../../src/proxy/requestAbort.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,eAAe,CAAA;IAC3B,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;IACjC,MAAM,EAAE,MAAM,IAAI,CAAA;CACnB;AAED,kEAAkE;AAClE,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,gBAAgB,CAyBtE"}
1
+ {"version":3,"file":"requestAbort.d.ts","sourceRoot":"","sources":["../../src/proxy/requestAbort.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,iBAAiB,GACzB,cAAc,GACd,eAAe,GACf,kBAAkB,GAClB,kBAAkB,GAClB,gBAAgB,GAChB,yBAAyB,GACzB,eAAe,CAAA;AAEnB,eAAO,MAAM,oBAAoB,EAAE,SAAS,iBAAiB,EAQnD,CAAA;AAEV,MAAM,WAAW,kBAAkB;IACjC,6EAA6E;IAC7E,KAAK,EAAE,iBAAiB,GAAG,MAAM,CAAA;IACjC,yDAAyD;IACzD,OAAO,EAAE,OAAO,CAAA;IAChB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,eAAe,CAAA;IAC3B,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;IACjC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAA;IAC5C,wDAAwD;IACxD,aAAa,EAAE,MAAM,kBAAkB,CAAA;CACxC;AAED,kEAAkE;AAClE,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,gBAAgB,CAgDtE"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/proxy/server.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AACtE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,WAAW,EAAE,CAAA;AAGvD,YAAY,EACV,SAAS,EACT,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,WAAW,GACZ,MAAM,aAAa,CAAA;AAKpB,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AA4EnG,OAAO,EACL,kBAAkB,EAElB,WAAW,EACX,oBAAoB,EAMpB,KAAK,aAAa,EAGnB,MAAM,mBAAmB,CAAA;AAI1B,OAAO,EAKL,iBAAiB,EACjB,mBAAmB,EAKpB,MAAM,iBAAiB,CAAA;AA0CxB,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,oBAAoB,EAAE,CAAA;AAChE,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,CAAA;AACjD,YAAY,EAAE,aAAa,EAAE,CAAA;AAsY7B,wBAAgB,iBAAiB,CAAC,MAAM,GAAE,OAAO,CAAC,WAAW,CAAM,GAAG,WAAW,CAs8OhF;AAWD,wBAAgB,gCAAgC,IAAI,IAAI,CAavD;AAED,wBAAsB,gBAAgB,CAAC,MAAM,GAAE,OAAO,CAAC,WAAW,CAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA8NhG"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/proxy/server.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AACtE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,WAAW,EAAE,CAAA;AAGvD,YAAY,EACV,SAAS,EACT,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,WAAW,GACZ,MAAM,aAAa,CAAA;AAKpB,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AA4EnG,OAAO,EACL,kBAAkB,EAElB,WAAW,EACX,oBAAoB,EAMpB,KAAK,aAAa,EAGnB,MAAM,mBAAmB,CAAA;AAI1B,OAAO,EAKL,iBAAiB,EACjB,mBAAmB,EAKpB,MAAM,iBAAiB,CAAA;AA0CxB,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,oBAAoB,EAAE,CAAA;AAChE,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,CAAA;AACjD,YAAY,EAAE,aAAa,EAAE,CAAA;AA+Y7B,wBAAgB,iBAAiB,CAAC,MAAM,GAAE,OAAO,CAAC,WAAW,CAAM,GAAG,WAAW,CAsmPhF;AAWD,wBAAgB,gCAAgC,IAAI,IAAI,CAavD;AAED,wBAAsB,gBAAgB,CAAC,MAAM,GAAE,OAAO,CAAC,WAAW,CAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA8NhG"}
package/dist/server.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  runObserveHook,
12
12
  runTransformHook,
13
13
  startProxyServer
14
- } from "./cli-198xnjcn.js";
14
+ } from "./cli-6c6dj69q.js";
15
15
  import"./cli-5jxyma6z.js";
16
16
  import"./cli-sry5aqdj.js";
17
17
  import"./cli-8yp89fan.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynfar/meridian",
3
- "version": "1.70.0",
3
+ "version": "1.71.0",
4
4
  "description": "Local Anthropic API powered by your Claude Max subscription. One subscription, every agent.",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -18,11 +18,14 @@
18
18
  import * as Plugin from "@opencode-ai/plugin/promise/plugin"
19
19
  import { Model } from "@opencode-ai/schema/model"
20
20
  import type { CatalogDraft } from "@opencode-ai/plugin/promise/catalog"
21
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
22
+ import { join } from "node:path"
21
23
  import {
22
24
  PRIORITY_ATTESTATION_HEADER,
23
25
  createPriorityAttestation,
24
26
  deleteHeader,
25
27
  getHeader,
28
+ meridianConfigDirectory,
26
29
  setHeader,
27
30
  type MutableHeaders,
28
31
  } from "./priority-attestation"
@@ -36,6 +39,25 @@ const PARENT_SESSION_ONE_SHOTS = new Set(["title", "summary"])
36
39
  const ATTACHED_COMPACTION_AGENT = "compaction"
37
40
  const MODEL_DISCOVERY_TIMEOUT_MS = 3_000
38
41
  const PROVIDER_READY_POLL_MS = 25
42
+
43
+ /**
44
+ * Catalog cache, for the cold-start gap (#1008).
45
+ *
46
+ * Discovery cannot begin until OpenCode has finished assembling the catalog, so
47
+ * the first request against a freshly started server used to see only
48
+ * OpenCode's built-in models.dev entries and rejected a Meridian-only variant
49
+ * with `provider.no-route`. Awaiting the catalog inside `setup` deadlocks the
50
+ * server, so the seed has to come from somewhere that is not the catalog: a
51
+ * plain file, read synchronously before the first transform runs.
52
+ *
53
+ * The entry records the base URL it was discovered from and is only applied to a
54
+ * provider still pointed at that URL, so repointing OpenCode at a different
55
+ * Meridian cannot apply another one's models.
56
+ */
57
+ const CATALOG_CACHE_FILE = "opencode-v2-catalog.json"
58
+ const CATALOG_CACHE_VERSION = 1
59
+ /** Bounds how stale a seed can be if discovery never succeeds again. */
60
+ const CATALOG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
39
61
  // The plugin tree does not import from src/, so this mirrors VALID_EFFORTS in
40
62
  // src/proxy/effort.ts. A test asserts the two stay identical. Filtering in this
41
63
  // order also gives every model its variants low -> max regardless of the order
@@ -152,6 +174,8 @@ export interface MeridianModel {
152
174
 
153
175
  export interface MeridianProviderModels {
154
176
  providerID: string
177
+ /** The URL these models came from, so a cached seed is never applied to another. */
178
+ baseURL?: string
155
179
  models: MeridianModel[]
156
180
  }
157
181
 
@@ -260,11 +284,17 @@ function isLegacyMeridianBaseURL(baseURL: unknown): boolean {
260
284
  }
261
285
  }
262
286
 
287
+ export interface MeridianCatalogLoad {
288
+ /** Providers whose base URL still looks like Meridian, reachable or not. */
289
+ readonly configured: string[]
290
+ readonly discovered: MeridianProviderModels[]
291
+ }
292
+
263
293
  export async function loadMeridianModels(
264
294
  catalog: MeridianCatalogClient,
265
295
  signal: AbortSignal,
266
296
  fetcher: ModelFetcher = globalThis.fetch,
267
- ): Promise<MeridianProviderModels[]> {
297
+ ): Promise<MeridianCatalogLoad> {
268
298
  const deadline = Date.now() + MODEL_DISCOVERY_TIMEOUT_MS
269
299
  while (!signal.aborted) {
270
300
  const providers = await Promise.all([...MERIDIAN_PROVIDERS].map(async (providerID) => {
@@ -281,14 +311,176 @@ export async function loadMeridianModels(
281
311
  if (configured.length > 0) {
282
312
  const discovered = await Promise.all(configured.map(async ({ providerID, baseURL }) => {
283
313
  const models = await fetchMeridianModels(baseURL, signal, fetcher)
284
- return models ? { providerID, models } : undefined
314
+ return models ? { providerID, baseURL: typeof baseURL === "string" ? baseURL : undefined, models } : undefined
285
315
  }))
286
- return discovered.flatMap(result => result ? [result] : [])
316
+ return {
317
+ configured: configured.map(provider => provider.providerID),
318
+ discovered: discovered.flatMap(result => result ? [result] : []),
319
+ }
287
320
  }
288
- if (Date.now() >= deadline) return []
321
+ if (Date.now() >= deadline) return { configured: [], discovered: [] }
289
322
  await new Promise<void>((resolve) => setTimeout(resolve, PROVIDER_READY_POLL_MS))
290
323
  }
291
- return []
324
+ return { configured: [], discovered: [] }
325
+ }
326
+
327
+ export interface CachedProviderCatalog {
328
+ readonly baseURL: string
329
+ readonly models: readonly MeridianModel[]
330
+ }
331
+
332
+ export function catalogCachePath(): string {
333
+ return join(meridianConfigDirectory(), CATALOG_CACHE_FILE)
334
+ }
335
+
336
+ /**
337
+ * Validate a cache document as strictly as a discovery response.
338
+ *
339
+ * A corrupt or hand-edited file must seed nothing rather than write junk into
340
+ * the catalog, so every failure returns an empty map.
341
+ */
342
+ export function parseCatalogCache(value: unknown, now: number): Map<string, CachedProviderCatalog> {
343
+ const entries = new Map<string, CachedProviderCatalog>()
344
+ if (!isRecord(value) || value.version !== CATALOG_CACHE_VERSION) return entries
345
+ if (!isRecord(value.providers)) return entries
346
+ for (const [providerID, entry] of Object.entries(value.providers)) {
347
+ if (!MERIDIAN_PROVIDERS.has(providerID) || !isRecord(entry)) continue
348
+ const { baseURL, fetchedAt } = entry
349
+ if (typeof baseURL !== "string" || meridianModelsURL(baseURL) === undefined) continue
350
+ if (typeof fetchedAt !== "number" || !Number.isSafeInteger(fetchedAt) || fetchedAt <= 0) continue
351
+ if (fetchedAt > now || now - fetchedAt > CATALOG_CACHE_TTL_MS) continue
352
+ const models = parseCachedModels(entry.models)
353
+ if (!models || models.length === 0) continue
354
+ entries.set(providerID, { baseURL, models })
355
+ }
356
+ return entries
357
+ }
358
+
359
+ function parseCachedModels(value: unknown): MeridianModel[] | undefined {
360
+ if (!Array.isArray(value)) return undefined
361
+ const models: MeridianModel[] = []
362
+ const ids = new Set<string>()
363
+ for (const item of value) {
364
+ if (!isRecord(item)) return undefined
365
+ const { id, name, contextWindow, efforts } = item
366
+ if (
367
+ typeof id !== "string"
368
+ || id.length === 0
369
+ || id.length > 256
370
+ || /[^\x21-\x7E]/.test(id)
371
+ || ids.has(id)
372
+ || typeof name !== "string"
373
+ || name.trim().length === 0
374
+ || name.length > 256
375
+ || typeof contextWindow !== "number"
376
+ || !Number.isSafeInteger(contextWindow)
377
+ || contextWindow <= 0
378
+ || !Array.isArray(efforts)
379
+ || efforts.some(effort => typeof effort !== "string" || !MERIDIAN_EFFORTS.includes(effort as never))
380
+ ) {
381
+ return undefined
382
+ }
383
+ ids.add(id)
384
+ models.push({ id, name, contextWindow, efforts: efforts as string[] })
385
+ }
386
+ return models
387
+ }
388
+
389
+ export function serializeCatalogCache(
390
+ discovered: readonly MeridianProviderModels[],
391
+ now: number,
392
+ ): string {
393
+ const providers: Record<string, unknown> = {}
394
+ for (const entry of discovered) {
395
+ if (typeof entry.baseURL !== "string" || entry.models.length === 0) continue
396
+ providers[entry.providerID] = { baseURL: entry.baseURL, fetchedAt: now, models: entry.models }
397
+ }
398
+ return `${JSON.stringify({ version: CATALOG_CACHE_VERSION, providers }, null, 2)}\n`
399
+ }
400
+
401
+ /**
402
+ * Drop the seed. Called when no Meridian-shaped provider is configured any
403
+ * more, so a later cold start cannot describe a provider this catalog no longer
404
+ * points at. Best effort, like the write.
405
+ */
406
+ export function removeCatalogCache(remove: (path: string) => void = path => rmSync(path, { force: true })): void {
407
+ try {
408
+ remove(catalogCachePath())
409
+ } catch {
410
+ // Ignored on purpose: a stale seed is corrected by the next discovery.
411
+ }
412
+ }
413
+
414
+ /** Read the seed. Any failure — missing, unreadable, corrupt — seeds nothing. */
415
+ export function readCatalogCache(
416
+ now: number,
417
+ read: (path: string) => string = path => readFileSync(path, "utf-8"),
418
+ ): Map<string, CachedProviderCatalog> {
419
+ try {
420
+ return parseCatalogCache(JSON.parse(read(catalogCachePath())), now)
421
+ } catch {
422
+ return new Map()
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Persist the seed for the next cold start. Best effort: a cache we cannot write
428
+ * costs a deferred catalog on the next start, which is the old behaviour, so it
429
+ * must never interrupt a working session.
430
+ */
431
+ export function writeCatalogCache(
432
+ discovered: readonly MeridianProviderModels[],
433
+ now: number,
434
+ write: (path: string, contents: string) => void = (path, contents) => {
435
+ // Rename onto the target so a concurrent reader never sees a partial file.
436
+ const temporary = `${path}.${process.pid}.tmp`
437
+ writeFileSync(temporary, contents, { encoding: "utf-8", mode: 0o600 })
438
+ renameSync(temporary, path)
439
+ },
440
+ ): void {
441
+ try {
442
+ const contents = serializeCatalogCache(discovered, now)
443
+ if (contents.includes('"providers": {}')) return
444
+ write(catalogCachePath(), contents)
445
+ } catch {
446
+ // Ignored on purpose: see above.
447
+ }
448
+ }
449
+
450
+ /**
451
+ * The only thing choosing a seed needs from the draft: whether a provider is
452
+ * still in the catalog. Narrower than `CatalogDraft` on purpose, so the decision
453
+ * stays unit-testable without standing up a whole branded provider record.
454
+ */
455
+ export interface CatalogProviderProbe {
456
+ readonly provider: { get(providerID: string): unknown }
457
+ }
458
+
459
+ /**
460
+ * Choose what the transform should write: live discovery when it has landed,
461
+ * otherwise the cached seed.
462
+ *
463
+ * The seed cannot be validated against the provider's configured URL here. A
464
+ * draft `Provider.Info` exposes only `id`, `name`, `activation`, `package`,
465
+ * `integrationID` and `headers` — verified on beta-18866, where the whole
466
+ * record contains no URL at all. So the seed is applied optimistically to any
467
+ * Meridian provider that still exists in the catalog, and `discoverModels`
468
+ * corrects or drops it once it can read the real URL.
469
+ */
470
+ export function resolveCatalogModels(
471
+ catalog: CatalogProviderProbe,
472
+ discovered: readonly MeridianProviderModels[],
473
+ cached: ReadonlyMap<string, CachedProviderCatalog>,
474
+ ): MeridianProviderModels[] {
475
+ if (discovered.length > 0) return [...discovered]
476
+ const seeded: MeridianProviderModels[] = []
477
+ for (const providerID of MERIDIAN_PROVIDERS) {
478
+ const entry = cached.get(providerID)
479
+ if (!entry) continue
480
+ if (!catalog.provider.get(providerID)) continue
481
+ seeded.push({ providerID, baseURL: entry.baseURL, models: [...entry.models] })
482
+ }
483
+ return seeded
292
484
  }
293
485
 
294
486
  /**
@@ -391,19 +583,41 @@ const MeridianV2Plugin = Plugin.define({
391
583
  const modelDiscoveryController = new AbortController()
392
584
  let discoveredModels: MeridianProviderModels[] = []
393
585
  let discoveryStarted = false
586
+ // Read synchronously, before the first transform can run. Awaiting the
587
+ // catalog here would deadlock the server, which is why the seed is a file
588
+ // and not a catalog read (#1008).
589
+ const cachedModels = readCatalogCache(Date.now())
394
590
 
395
591
  registered.push(await context.catalog.transform((catalog) => {
396
- for (const discovered of discoveredModels) {
397
- applyMeridianModels(catalog, discovered.providerID, discovered.models)
592
+ for (const entry of resolveCatalogModels(catalog, discoveredModels, cachedModels)) {
593
+ applyMeridianModels(catalog, entry.providerID, entry.models)
398
594
  }
399
595
  }))
400
596
 
401
597
  const discoverModels = async () => {
402
598
  if (discoveryStarted || modelDiscoveryController.signal.aborted) return false
403
- const models = await loadMeridianModels(context.catalog, modelDiscoveryController.signal).catch(() => [])
404
- if (models.length === 0 || modelDiscoveryController.signal.aborted) return false
599
+ const loaded = await loadMeridianModels(context.catalog, modelDiscoveryController.signal)
600
+ .catch((): MeridianCatalogLoad => ({ configured: [], discovered: [] }))
601
+ if (modelDiscoveryController.signal.aborted) return false
602
+ if (loaded.configured.length === 0) {
603
+ // Nothing here points at Meridian any more — the user repointed the
604
+ // provider. A seed from a previous run would describe a different
605
+ // endpoint, so drop it and rebuild the catalog without it.
606
+ if (cachedModels.size > 0) {
607
+ cachedModels.clear()
608
+ removeCatalogCache()
609
+ await context.catalog.reload()
610
+ }
611
+ return false
612
+ }
613
+ // Configured but unreachable: keep the seed. It is the last thing Meridian
614
+ // actually served, which beats OpenCode's models.dev entries.
615
+ if (loaded.discovered.length === 0) return false
405
616
  discoveryStarted = true
406
- discoveredModels = models
617
+ discoveredModels = loaded.discovered
618
+ // Seed the next cold start before the reload, so a crash mid-reload still
619
+ // leaves the catalog available to the following process.
620
+ writeCatalogCache(loaded.discovered, Date.now())
407
621
  await context.catalog.reload()
408
622
  return true
409
623
  }
@@ -32,10 +32,20 @@ export interface PriorityAttestationSignInput {
32
32
  readonly issuedAt: number
33
33
  }
34
34
 
35
- function configDirectory(): string {
35
+ /**
36
+ * Meridian's own configuration directory, as the plugin sees it.
37
+ *
38
+ * Exported because the V2 plugin also keeps its catalog cache here: the plugin
39
+ * tree must not import from src/, so this is the one place the resolution lives.
40
+ */
41
+ export function meridianConfigDirectory(): string {
36
42
  return process.env.MERIDIAN_CONFIG_DIR ?? join(homedir(), ".config", "meridian")
37
43
  }
38
44
 
45
+ function configDirectory(): string {
46
+ return meridianConfigDirectory()
47
+ }
48
+
39
49
  export function priorityAttestationKeyPath(): string {
40
50
  return join(configDirectory(), PRIORITY_ATTESTATION_KEY_FILE)
41
51
  }