@cjhyy/code-shell-core 0.9.2 → 0.9.4

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 (38) hide show
  1. package/dist/cli/agent-server-stdio.js +26 -1
  2. package/dist/cli/agent-server-tcp.js +58 -7
  3. package/dist/engine/engine.d.ts +8 -0
  4. package/dist/engine/engine.js +16 -12
  5. package/dist/engine/model-facade.d.ts +3 -0
  6. package/dist/engine/model-facade.js +2 -0
  7. package/dist/engine/run-tooling.js +8 -8
  8. package/dist/engine/streaming-tool-queue.d.ts +4 -1
  9. package/dist/engine/streaming-tool-queue.js +17 -2
  10. package/dist/engine/turn-loop.d.ts +14 -5
  11. package/dist/engine/turn-loop.js +92 -37
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/llm/prompt-cache.d.ts +48 -0
  15. package/dist/llm/prompt-cache.js +100 -0
  16. package/dist/llm/providers/anthropic.d.ts +3 -0
  17. package/dist/llm/providers/anthropic.js +77 -53
  18. package/dist/llm/providers/openai.d.ts +7 -27
  19. package/dist/llm/providers/openai.js +120 -68
  20. package/dist/llm/types.d.ts +3 -0
  21. package/dist/onboarding.js +72 -49
  22. package/dist/panel-apps/manifest.d.ts +12 -12
  23. package/dist/profile/types.d.ts +26 -26
  24. package/dist/protocol/background-result-wakeup.d.ts +3 -1
  25. package/dist/protocol/background-result-wakeup.js +5 -5
  26. package/dist/protocol/server.d.ts +13 -0
  27. package/dist/protocol/server.js +22 -3
  28. package/dist/services/index.d.ts +0 -1
  29. package/dist/services/index.js +0 -1
  30. package/dist/session/memory.js +2 -2
  31. package/dist/session/session-manager.js +30 -1
  32. package/dist/tool-system/builtin/agent-notifications.d.ts +25 -1
  33. package/dist/tool-system/builtin/agent-notifications.js +334 -2
  34. package/dist/tool-system/context.d.ts +9 -4
  35. package/dist/tool-system/external-tool-exposure.js +11 -10
  36. package/package.json +2 -1
  37. package/dist/services/notifier.d.ts +0 -33
  38. package/dist/services/notifier.js +0 -83
@@ -16,7 +16,8 @@ import { capabilitiesFor } from "../capabilities/index.js";
16
16
  import { clampMaxTokens } from "../clamp-max-tokens.js";
17
17
  import { resolveApiKey, resolveHeaders } from "../provider-auth.js";
18
18
  import { stripVisionFromHistory } from "../strip-vision.js";
19
- import { STREAM_WATCHDOG_CONFIG, StreamIdleTimeoutError, } from "../stream-watchdog.js";
19
+ import { resolvePromptCachePolicy, uniquePromptCacheBreakpointIndexes, } from "../prompt-cache.js";
20
+ import { STREAM_WATCHDOG_CONFIG, StreamIdleTimeoutError } from "../stream-watchdog.js";
20
21
  /**
21
22
  * Extract prompt-cache counts from an OpenAI-compatible usage object.
22
23
  *
@@ -74,9 +75,7 @@ export async function runStreamWithWatchdog(stream, opts = {}) {
74
75
  // An explicit idleTimeoutMs always activates the watchdog. Otherwise follow
75
76
  // disableWatchdog (per-call override) if set, else the env default.
76
77
  const watchdogActive = opts.idleTimeoutMs !== undefined ||
77
- (opts.disableWatchdog === undefined
78
- ? STREAM_WATCHDOG_CONFIG.enabled
79
- : !opts.disableWatchdog);
78
+ (opts.disableWatchdog === undefined ? STREAM_WATCHDOG_CONFIG.enabled : !opts.disableWatchdog);
80
79
  const idleTimeoutMs = opts.idleTimeoutMs ?? STREAM_WATCHDOG_CONFIG.idleTimeoutMs;
81
80
  let text = "";
82
81
  // Fast path: watchdog disabled AND caller did not override → no overhead.
@@ -224,6 +223,9 @@ export class OpenAIClient extends LLMClientBase {
224
223
  // succeed. Omitting the field just means "model default reasoning", which is
225
224
  // fine for our background/aux calls.
226
225
  _dropReasoningEffort = false;
226
+ /** Compatibility fallbacks for OpenAI-compatible gateways that lag the API. */
227
+ _disableExplicitPromptCache = false;
228
+ _disablePromptCacheKey = false;
227
229
  constructor(config, defaults, runtimeOptions = {}) {
228
230
  super(config, defaults);
229
231
  this.dangerouslyAllowBrowser = runtimeOptions.dangerouslyAllowBrowser === true;
@@ -264,30 +266,26 @@ export class OpenAIClient extends LLMClientBase {
264
266
  }
265
267
  return this._capability;
266
268
  }
267
- /**
268
- * True when this client routes an Anthropic-family model through OpenRouter's
269
- * OpenAI-compatible endpoint. Anthropic caching is EXPLICIT — nothing is
270
- * cached unless the request carries `cache_control` breakpoints (verified live
271
- * 2026-07-02: plain requests to anthropic/claude-opus-4.7-fast via OpenRouter
272
- * report cached_tokens 0 on every repeat; a single system-block breakpoint
273
- * turns the whole stable prefix — tools + system — into a cache hit, ~89%
274
- * cheaper on the follow-up). OpenAI and other OpenRouter models cache
275
- * automatically, so they must NOT get breakpoints. The slug arrives resolved
276
- * (e.g. "anthropic/claude-opus-4.7-fast") or as the router alias
277
- * ("~anthropic/claude-opus-latest") — both start with an optional "~" then
278
- * "anthropic/".
279
- */
280
- get isOpenRouterAnthropic() {
281
- return this.config.providerKind === "openrouter" && /^~?anthropic\//.test(this.model);
269
+ promptCachePolicy(request) {
270
+ const policy = resolvePromptCachePolicy({
271
+ provider: this.provider,
272
+ providerKind: this.config.providerKind,
273
+ model: this.model,
274
+ request,
275
+ explicitDisabled: this._disableExplicitPromptCache,
276
+ });
277
+ return this._disablePromptCacheKey && policy.cacheKey
278
+ ? { ...policy, cacheKey: undefined }
279
+ : policy;
282
280
  }
283
281
  getPromptCacheConfigIdentity() {
284
282
  const capability = this.capability;
283
+ const cachePolicy = this.promptCachePolicy();
285
284
  return {
286
285
  ...super.getPromptCacheConfigIdentity(),
287
- cacheStrategy: this.isOpenRouterAnthropic
288
- ? "openrouter-anthropic-explicit"
289
- : "provider-automatic",
290
- cacheLayoutVersion: this.isOpenRouterAnthropic ? "system-history-v1" : "automatic-v1",
286
+ cacheStrategy: cachePolicy.strategy,
287
+ cacheLayoutVersion: cachePolicy.layoutVersion,
288
+ cacheBreakpoints: cachePolicy.breakpoints,
291
289
  tokenLimitField: this._forceMaxCompletionTokens
292
290
  ? "max_completion_tokens"
293
291
  : capability.tokenLimitField,
@@ -295,6 +293,8 @@ export class OpenAIClient extends LLMClientBase {
295
293
  rejectedParams: [...capability.rejectedParams].sort(),
296
294
  forceMaxCompletionTokens: this._forceMaxCompletionTokens,
297
295
  dropReasoningEffort: this._dropReasoningEffort,
296
+ disableExplicitPromptCache: this._disableExplicitPromptCache,
297
+ disablePromptCacheKey: this._disablePromptCacheKey,
298
298
  };
299
299
  }
300
300
  async createMessage(options) {
@@ -305,7 +305,7 @@ export class OpenAIClient extends LLMClientBase {
305
305
  // Per-call reasoning wins; otherwise fall back to provider default
306
306
  // (settings.providers[].reasoning, threaded through LLMConfig).
307
307
  const reasoning = options.reasoning ?? this.config.reasoning;
308
- const messages = this.buildMessages(options.systemPrompt, options.messages, reasoning);
308
+ const messages = this.buildMessages(options.systemPrompt, options.messages, reasoning, options.promptCache);
309
309
  const tools = options.tools?.length ? this.convertTools(options.tools) : undefined;
310
310
  const span = logger.span("llm.request", {
311
311
  cat: "llm",
@@ -314,6 +314,7 @@ export class OpenAIClient extends LLMClientBase {
314
314
  stream: !!(options.stream && options.onChunk),
315
315
  messageCount: messages.length,
316
316
  toolCount: tools?.length ?? 0,
317
+ cacheStrategy: this.promptCachePolicy(options.promptCache).strategy,
317
318
  });
318
319
  try {
319
320
  const response = options.stream && options.onChunk
@@ -341,6 +342,7 @@ export class OpenAIClient extends LLMClientBase {
341
342
  */
342
343
  buildRequestBody(options, messages, tools, reasoning, stream) {
343
344
  const cap = this.capability;
345
+ const cachePolicy = this.promptCachePolicy(options.promptCache);
344
346
  // Clamp to the model's known output ceiling so a stale catalog value
345
347
  // (e.g. 384000 inherited after a hot model switch) can't 400 a
346
348
  // smaller-cap model. No known cap → send the value as-is.
@@ -449,10 +451,10 @@ export class OpenAIClient extends LLMClientBase {
449
451
  // requested summary level to that object. For the bare `reasoning_effort`
450
452
  // shape there's no summary field on chat-completions, so we skip it rather
451
453
  // than send an unknown top-level param.
452
- if (this.config.reasoningSummary && reasoningBody.reasoning &&
454
+ if (this.config.reasoningSummary &&
455
+ reasoningBody.reasoning &&
453
456
  typeof reasoningBody.reasoning === "object") {
454
- reasoningBody.reasoning.summary =
455
- this.config.reasoningSummary;
457
+ reasoningBody.reasoning.summary = this.config.reasoningSummary;
456
458
  }
457
459
  // Catalog-driven passthrough params (temperature/top_p/thinking etc, already
458
460
  // wire-mapped from the connection's paramValues by applyParams). Filter each
@@ -489,6 +491,12 @@ export class OpenAIClient extends LLMClientBase {
489
491
  ...paramBody,
490
492
  // service_tier (TODO 7.2): passed through verbatim when configured.
491
493
  ...(this.config.serviceTier ? { service_tier: this.config.serviceTier } : {}),
494
+ ...(options.promptCache && cachePolicy.cacheKey
495
+ ? { prompt_cache_key: cachePolicy.cacheKey }
496
+ : {}),
497
+ ...(options.promptCache && cachePolicy.promptCacheOptions
498
+ ? { prompt_cache_options: cachePolicy.promptCacheOptions }
499
+ : {}),
492
500
  ...(tools ? { tools } : {}),
493
501
  ...(stream ? { stream: true, stream_options: { include_usage: true } } : {}),
494
502
  };
@@ -740,7 +748,7 @@ export class OpenAIClient extends LLMClientBase {
740
748
  ...(reasoningContent ? { reasoningContent } : {}),
741
749
  };
742
750
  }
743
- buildMessages(systemPrompt, messages, reasoning) {
751
+ buildMessages(systemPrompt, messages, reasoning, promptCache) {
744
752
  const result = [{ role: "system", content: systemPrompt }];
745
753
  // Drop historical image blocks when the active model can't accept vision.
746
754
  // Engine.run only gates *new* attachments; an image left in history from
@@ -748,6 +756,8 @@ export class OpenAIClient extends LLMClientBase {
748
756
  // below and 400s ("unknown variant `image_url`") after a model switch.
749
757
  // Identity-preserving on the common path (vision models / no images).
750
758
  messages = stripVisionFromHistory(messages, this.capability.supportsVision);
759
+ const stablePrefixMessageCount = Math.max(0, Math.min(messages.length, promptCache?.stablePrefixMessageCount ?? messages.length));
760
+ let stablePrefixEndMessage = stablePrefixMessageCount === 0 ? result[0] : undefined;
751
761
  // Reasoning-content echo-back contract — driven by capability:
752
762
  // "when-tools" : backfill an empty placeholder if the prior assistant
753
763
  // turn doesn't carry one (DeepSeek V4 + tools 400s
@@ -760,11 +770,13 @@ export class OpenAIClient extends LLMClientBase {
760
770
  const cap = this.capability;
761
771
  const hasTools = messages.some((m) => Array.isArray(m.content) &&
762
772
  m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
763
- const needsReasoningBackfill = reasoning?.mode !== "off" &&
764
- cap.echoReasoning === "when-tools" &&
765
- hasTools;
773
+ const needsReasoningBackfill = reasoning?.mode !== "off" && cap.echoReasoning === "when-tools" && hasTools;
766
774
  const stripReasoning = cap.echoReasoning === "never";
767
- for (const msg of messages) {
775
+ for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
776
+ if (sourceIndex === stablePrefixMessageCount) {
777
+ stablePrefixEndMessage = result[result.length - 1];
778
+ }
779
+ const msg = messages[sourceIndex];
768
780
  if (msg.role === "system")
769
781
  continue;
770
782
  if (msg.role === "assistant") {
@@ -948,55 +960,64 @@ export class OpenAIClient extends LLMClientBase {
948
960
  }
949
961
  }
950
962
  }
963
+ stablePrefixEndMessage ??= result[result.length - 1];
951
964
  const normalized = normalizeOpenAIToolMessagePairs(result);
952
- if (this.isOpenRouterAnthropic) {
953
- this.applyAnthropicCacheBreakpoints(normalized);
965
+ const stablePrefixEndIndex = stablePrefixEndMessage
966
+ ? normalized.indexOf(stablePrefixEndMessage)
967
+ : undefined;
968
+ const cachePolicy = this.promptCachePolicy(promptCache);
969
+ if (promptCache || cachePolicy.strategy === "anthropic-explicit") {
970
+ this.applyPromptCacheBreakpoints(normalized, cachePolicy, stablePrefixEndIndex !== undefined && stablePrefixEndIndex >= 0
971
+ ? stablePrefixEndIndex
972
+ : undefined);
954
973
  }
955
974
  return normalized;
956
975
  }
957
976
  /**
958
- * In-place: add prompt-cache breakpoints for Anthropic-over-OpenRouter.
959
- * Mirrors the native anthropic provider (≤4 breakpoints):
960
- * 1. System block — the stable prefix. Anthropic sees tools BEFORE the
961
- * system prompt, so one marker on the system block caches tools too
962
- * (verified live: system-only marker cached 3511/3952 prompt tokens
963
- * including tool defs).
964
- * 2. Last message — one rolling breakpoint so the growing conversation
965
- * history becomes a cached prefix. Not scrolled: as history grows the
966
- * "last message" naturally advances and its tail is the next write.
967
- * A string `content` is lifted to a single-element `[{type:"text",...}]`
968
- * array so it can carry `cache_control`; OpenRouter accepts this OpenAI
969
- * multimodal wire form for text.
977
+ * Translate semantic prefix boundaries to the active wire format. Both
978
+ * formats annotate content blocks without reordering messages.
970
979
  */
971
- applyAnthropicCacheBreakpoints(messages) {
972
- const mark = (m) => {
973
- if (!m)
980
+ applyPromptCacheBreakpoints(messages, policy, stablePrefixEndIndex) {
981
+ if (policy.strategy !== "anthropic-explicit" && policy.strategy !== "openai-explicit") {
982
+ return;
983
+ }
984
+ const markedMessages = new Set();
985
+ const mark = (index) => {
986
+ let cursor = Math.min(index, messages.length - 1);
987
+ let m;
988
+ while (cursor >= 0) {
989
+ const candidate = messages[cursor];
990
+ if ((typeof candidate.content === "string" && candidate.content.length > 0) ||
991
+ (Array.isArray(candidate.content) && candidate.content.length > 0)) {
992
+ m = candidate;
993
+ break;
994
+ }
995
+ cursor--;
996
+ }
997
+ if (!m || markedMessages.has(m))
974
998
  return;
999
+ markedMessages.add(m);
1000
+ const marker = policy.strategy === "anthropic-explicit"
1001
+ ? { cache_control: { type: "ephemeral" } }
1002
+ : { prompt_cache_breakpoint: { mode: "explicit" } };
975
1003
  // Lift a plain-string content to a text-block array so it can carry the
976
- // marker. Non-text content (tool messages, image arrays) already uses an
977
- // array of parts — mark the last part instead.
1004
+ // marker. Non-text content already uses an array of parts.
978
1005
  if (typeof m.content === "string") {
979
- m.content = [
980
- { type: "text", text: m.content, cache_control: { type: "ephemeral" } },
981
- ];
1006
+ m.content = [{ type: "text", text: m.content, ...marker }];
982
1007
  return;
983
1008
  }
984
1009
  if (Array.isArray(m.content) && m.content.length > 0) {
985
- // cache_control is an Anthropic-via-OpenRouter extension field, not in
986
- // the OpenAI content-part union — attach through `unknown`.
987
1010
  const last = m.content[m.content.length - 1];
988
- last.cache_control = { type: "ephemeral" };
1011
+ Object.assign(last, marker);
989
1012
  }
990
1013
  };
991
- // 1. System block (always index 0 — buildMessages seeds it first).
992
- const sys = messages[0];
993
- if (sys && sys.role === "system")
994
- mark(sys);
995
- // 2. Rolling history breakpoint on the very last message. Skip if it IS the
996
- // system message (no conversation yet) — one breakpoint already covers it.
997
- const last = messages[messages.length - 1];
998
- if (last && last !== sys)
999
- mark(last);
1014
+ const requested = uniquePromptCacheBreakpointIndexes([
1015
+ policy.breakpoints.includes("system") ? 0 : undefined,
1016
+ policy.breakpoints.includes("stable-history") ? stablePrefixEndIndex : undefined,
1017
+ policy.breakpoints.includes("rolling-history") ? messages.length - 1 : undefined,
1018
+ ]);
1019
+ for (const index of requested)
1020
+ mark(index);
1000
1021
  }
1001
1022
  convertTools(tools) {
1002
1023
  return tools.map((t) => ({
@@ -1031,6 +1052,33 @@ export class OpenAIClient extends LLMClientBase {
1031
1052
  // the corrected body — fixing the call that triggered it, not just the
1032
1053
  // next one.
1033
1054
  let selfCorrected = false;
1055
+ // New GPT-5.6 cache fields may reach an OpenAI-compatible gateway before
1056
+ // that gateway supports them. Downgrade sticky-per-client and retry once:
1057
+ // first explicit -> implicit, then omit the affinity key only if that is
1058
+ // also rejected. Native OpenAI keeps the optimized path.
1059
+ if (err.status === 400 && msg.includes("prompt_cache")) {
1060
+ if ((msg.includes("prompt_cache_options") || msg.includes("prompt_cache_breakpoint")) &&
1061
+ !this._disableExplicitPromptCache) {
1062
+ this._disableExplicitPromptCache = true;
1063
+ selfCorrected = true;
1064
+ logger.warn("llm.prompt_cache_explicit_unsupported", {
1065
+ cat: "llm",
1066
+ provider: this.provider,
1067
+ providerKind: this.config.providerKind,
1068
+ model: this.model,
1069
+ });
1070
+ }
1071
+ if (msg.includes("prompt_cache_key") && !this._disablePromptCacheKey) {
1072
+ this._disablePromptCacheKey = true;
1073
+ selfCorrected = true;
1074
+ logger.warn("llm.prompt_cache_key_unsupported", {
1075
+ cat: "llm",
1076
+ provider: this.provider,
1077
+ providerKind: this.config.providerKind,
1078
+ model: this.model,
1079
+ });
1080
+ }
1081
+ }
1034
1082
  // o-series / gpt-5+ reject `max_tokens` and demand
1035
1083
  // `max_completion_tokens`. The id-based regex catches the common
1036
1084
  // cases; this is the belt-and-suspenders path for ids that ship
@@ -1095,8 +1143,12 @@ export class OpenAIClient extends LLMClientBase {
1095
1143
  function deepMergeInto(dst, src) {
1096
1144
  for (const [k, v] of Object.entries(src)) {
1097
1145
  const cur = dst[k];
1098
- if (v && typeof v === "object" && !Array.isArray(v) &&
1099
- cur && typeof cur === "object" && !Array.isArray(cur)) {
1146
+ if (v &&
1147
+ typeof v === "object" &&
1148
+ !Array.isArray(v) &&
1149
+ cur &&
1150
+ typeof cur === "object" &&
1151
+ !Array.isArray(cur)) {
1100
1152
  deepMergeInto(cur, v);
1101
1153
  }
1102
1154
  else {
@@ -2,6 +2,7 @@
2
2
  * LLM-specific internal types.
3
3
  */
4
4
  import type { Message, ToolDefinition, LLMStreamChunk, TokenUsage } from "../types.js";
5
+ import type { PromptCacheRequestContext } from "./prompt-cache.js";
5
6
  export interface CreateMessageOptions {
6
7
  systemPrompt: string;
7
8
  messages: Message[];
@@ -33,6 +34,8 @@ export interface CreateMessageOptions {
33
34
  * the client's capability layer.
34
35
  */
35
36
  reasoning?: import("./reasoning-setting.js").ReasoningSetting;
37
+ /** Provider-neutral prompt-cache context supplied by the model facade. */
38
+ promptCache?: PromptCacheRequestContext;
36
39
  }
37
40
  export interface LLMUsageTracker {
38
41
  records: TokenUsage[];
@@ -4,7 +4,7 @@
4
4
  * (see src/ui/components/OnboardingPrompt.tsx); this module only exposes
5
5
  * pure(-ish) helpers it consumes, so there's a single input stack.
6
6
  */
7
- import { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, rmSync } from "node:fs";
7
+ import { readFileSync, existsSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { userHome } from "./settings/manager.js";
10
10
  import { getOpenRouterModels } from "./data/openrouter-models.js";
@@ -14,6 +14,7 @@ import { KNOWN_MAX_OUTPUT, KNOWN_CONTEXT_WINDOWS, OPENROUTER_VENDORS, PROVIDERS,
14
14
  export { PROVIDERS };
15
15
  import { sanitizeApiKey } from "./llm/api-key-sanitize.js";
16
16
  import { getMergedCatalog } from "./model-catalog/index.js";
17
+ import { mutateJsonFile } from "./utils/file-mutex.js";
17
18
  // ProviderDef 类型 + PROVIDERS 目录已外移到 data/model-metadata.json
18
19
  // (loader: data/model-metadata.ts),并在文件顶部 re-export。core 只读目录数据。
19
20
  // ─── Dynamic model list (OpenRouter snapshot) ────────────────────
@@ -115,7 +116,7 @@ export async function validateApiKey(baseUrl, apiKey) {
115
116
  headers: { Authorization: `Bearer ${apiKey}` },
116
117
  signal: AbortSignal.timeout(10_000),
117
118
  });
118
- const data = await res.json();
119
+ const data = (await res.json());
119
120
  return !data.error;
120
121
  }
121
122
  const url = baseUrl.replace(/\/$/, "") + "/models";
@@ -162,6 +163,8 @@ export function hasApiKey() {
162
163
  // Reads ~/.code-shell/ only — ~/.claude/ compat was dropped because Claude
163
164
  // Code's settings schema diverges and merging broke boot.
164
165
  const p = join(userHome(), ".code-shell", "settings.json");
166
+ // This is only a boot-time hint. A slightly stale read can show onboarding
167
+ // once; all mutations below still re-read under the shared file lock.
165
168
  if (existsSync(p)) {
166
169
  try {
167
170
  const data = JSON.parse(readFileSync(p, "utf-8"));
@@ -170,7 +173,9 @@ export function hasApiKey() {
170
173
  if (Array.isArray(data?.modelConnections) && data.modelConnections.length > 0)
171
174
  return true;
172
175
  }
173
- catch { /* ignore */ }
176
+ catch {
177
+ /* ignore */
178
+ }
174
179
  }
175
180
  return false;
176
181
  }
@@ -297,50 +302,68 @@ export function appendOnboardingResult(opts) {
297
302
  const tag = opts.tag ?? "text";
298
303
  const dir = join(userHome(), ".code-shell");
299
304
  const file = join(dir, "settings.json");
300
- mkdirSync(dir, { recursive: true });
301
- let existing = {};
302
- if (existsSync(file)) {
303
- try {
304
- existing = JSON.parse(readFileSync(file, "utf-8"));
305
- }
306
- catch { /* corrupt → replace */ }
307
- }
308
- const creds = Array.isArray(existing.credentials)
309
- ? [...existing.credentials] : [];
310
- const conns = Array.isArray(existing.modelConnections)
311
- ? [...existing.modelConnections] : [];
312
- for (const m of opts.models) {
313
- const catalogId = catalogIdForKind(m.kind);
314
- const credId = `${m.instanceId}-key`;
315
- if (!creds.some((c) => c?.id === credId)) {
316
- creds.push({ id: credId, catalogId, apiKey: m.apiKey, baseUrl: m.baseUrl });
317
- }
318
- if (!conns.some((c) => c?.id === m.instanceId)) {
319
- conns.push({
320
- id: m.instanceId, catalogId, tag, model: m.model, credentialId: credId,
321
- ...(m.baseUrl ? { baseUrl: m.baseUrl } : {}),
322
- });
323
- }
324
- }
325
- const existingDefaults = (typeof existing.defaults === "object" && existing.defaults)
326
- ? existing.defaults : {};
327
- const updated = {
328
- ...existing,
329
- credentials: creds,
330
- modelConnections: conns,
331
- defaults: { ...existingDefaults, [tag]: opts.activeId },
332
- };
333
- // Atomic write: tmp file in the same dir, then rename (atomic on POSIX).
334
- // mode 0o600 — settings.json holds plaintext API keys, must be owner-only.
335
- const tmp = `${file}.${process.pid}.tmp`;
336
- writeFileSync(tmp, JSON.stringify(updated, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
337
- try {
338
- renameSync(tmp, file);
339
- }
340
- catch {
341
- // Fallback: best-effort direct write if rename fails (e.g. cross-device),
342
- // then remove the orphaned temp file the failed rename left behind.
343
- writeFileSync(file, JSON.stringify(updated, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
344
- rmSync(tmp, { force: true });
345
- }
305
+ mutateJsonFile(file, {
306
+ parse: (raw) => {
307
+ if (raw === undefined)
308
+ return {};
309
+ let parsed;
310
+ try {
311
+ parsed = JSON.parse(raw);
312
+ }
313
+ catch (error) {
314
+ throw new Error("settings.json is unreadable; onboarding did not overwrite it", {
315
+ cause: error,
316
+ });
317
+ }
318
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
319
+ throw new Error("settings.json must contain a JSON object");
320
+ }
321
+ return parsed;
322
+ },
323
+ serialize: (value) => `${JSON.stringify(value, null, 2)}\n`,
324
+ mutation: (existing) => {
325
+ const creds = Array.isArray(existing.credentials)
326
+ ? [...existing.credentials]
327
+ : [];
328
+ const conns = Array.isArray(existing.modelConnections)
329
+ ? [...existing.modelConnections]
330
+ : [];
331
+ for (const model of opts.models) {
332
+ const catalogId = catalogIdForKind(model.kind);
333
+ const credentialId = `${model.instanceId}-key`;
334
+ if (!creds.some((credential) => credential?.id === credentialId)) {
335
+ creds.push({
336
+ id: credentialId,
337
+ catalogId,
338
+ apiKey: model.apiKey,
339
+ baseUrl: model.baseUrl,
340
+ });
341
+ }
342
+ if (!conns.some((connection) => connection?.id === model.instanceId)) {
343
+ conns.push({
344
+ id: model.instanceId,
345
+ catalogId,
346
+ tag,
347
+ model: model.model,
348
+ credentialId,
349
+ ...(model.baseUrl ? { baseUrl: model.baseUrl } : {}),
350
+ });
351
+ }
352
+ }
353
+ const existingDefaults = existing.defaults &&
354
+ typeof existing.defaults === "object" &&
355
+ !Array.isArray(existing.defaults)
356
+ ? existing.defaults
357
+ : {};
358
+ return {
359
+ value: {
360
+ ...existing,
361
+ credentials: creds,
362
+ modelConnections: conns,
363
+ defaults: { ...existingDefaults, [tag]: opts.activeId },
364
+ },
365
+ };
366
+ },
367
+ mode: 0o600,
368
+ });
346
369
  }
@@ -57,37 +57,37 @@ export declare const PanelAppAgentContribution: z.ZodEffects<z.ZodObject<{
57
57
  }>, "many">>;
58
58
  skills: z.ZodDefault<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
59
59
  }, "strict", z.ZodTypeAny, {
60
- skills: string[];
61
60
  tools: {
62
61
  name: string;
63
62
  description: string;
64
63
  inputSchema: Record<string, unknown>;
65
64
  readOnly: boolean;
66
65
  }[];
66
+ skills: string[];
67
67
  }, {
68
- skills?: string[] | undefined;
69
68
  tools?: {
70
69
  name: string;
71
70
  description: string;
72
71
  inputSchema: Record<string, unknown>;
73
72
  readOnly?: boolean | undefined;
74
73
  }[] | undefined;
74
+ skills?: string[] | undefined;
75
75
  }>, {
76
- skills: string[];
77
76
  tools: {
78
77
  name: string;
79
78
  description: string;
80
79
  inputSchema: Record<string, unknown>;
81
80
  readOnly: boolean;
82
81
  }[];
82
+ skills: string[];
83
83
  }, {
84
- skills?: string[] | undefined;
85
84
  tools?: {
86
85
  name: string;
87
86
  description: string;
88
87
  inputSchema: Record<string, unknown>;
89
88
  readOnly?: boolean | undefined;
90
89
  }[] | undefined;
90
+ skills?: string[] | undefined;
91
91
  }>;
92
92
  /**
93
93
  * Schema v2 keeps one installable Panel App identity while allowing it to
@@ -178,37 +178,37 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
178
178
  }>, "many">>;
179
179
  skills: z.ZodDefault<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
180
180
  }, "strict", z.ZodTypeAny, {
181
- skills: string[];
182
181
  tools: {
183
182
  name: string;
184
183
  description: string;
185
184
  inputSchema: Record<string, unknown>;
186
185
  readOnly: boolean;
187
186
  }[];
187
+ skills: string[];
188
188
  }, {
189
- skills?: string[] | undefined;
190
189
  tools?: {
191
190
  name: string;
192
191
  description: string;
193
192
  inputSchema: Record<string, unknown>;
194
193
  readOnly?: boolean | undefined;
195
194
  }[] | undefined;
195
+ skills?: string[] | undefined;
196
196
  }>, {
197
- skills: string[];
198
197
  tools: {
199
198
  name: string;
200
199
  description: string;
201
200
  inputSchema: Record<string, unknown>;
202
201
  readOnly: boolean;
203
202
  }[];
203
+ skills: string[];
204
204
  }, {
205
- skills?: string[] | undefined;
206
205
  tools?: {
207
206
  name: string;
208
207
  description: string;
209
208
  inputSchema: Record<string, unknown>;
210
209
  readOnly?: boolean | undefined;
211
210
  }[] | undefined;
211
+ skills?: string[] | undefined;
212
212
  }>>;
213
213
  id: z.ZodString;
214
214
  version: z.ZodString;
@@ -247,13 +247,13 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
247
247
  placement: "right-dock";
248
248
  singleton: boolean;
249
249
  agent?: {
250
- skills: string[];
251
250
  tools: {
252
251
  name: string;
253
252
  description: string;
254
253
  inputSchema: Record<string, unknown>;
255
254
  readOnly: boolean;
256
255
  }[];
256
+ skills: string[];
257
257
  } | undefined;
258
258
  description?: string | undefined;
259
259
  }, {
@@ -267,13 +267,13 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
267
267
  };
268
268
  schemaVersion: 2;
269
269
  agent?: {
270
- skills?: string[] | undefined;
271
270
  tools?: {
272
271
  name: string;
273
272
  description: string;
274
273
  inputSchema: Record<string, unknown>;
275
274
  readOnly?: boolean | undefined;
276
275
  }[] | undefined;
276
+ skills?: string[] | undefined;
277
277
  } | undefined;
278
278
  description?: string | undefined;
279
279
  permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[] | undefined;
@@ -310,13 +310,13 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
310
310
  placement: "right-dock";
311
311
  singleton: boolean;
312
312
  agent?: {
313
- skills: string[];
314
313
  tools: {
315
314
  name: string;
316
315
  description: string;
317
316
  inputSchema: Record<string, unknown>;
318
317
  readOnly: boolean;
319
318
  }[];
319
+ skills: string[];
320
320
  } | undefined;
321
321
  description?: string | undefined;
322
322
  }, {
@@ -345,13 +345,13 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
345
345
  };
346
346
  schemaVersion: 2;
347
347
  agent?: {
348
- skills?: string[] | undefined;
349
348
  tools?: {
350
349
  name: string;
351
350
  description: string;
352
351
  inputSchema: Record<string, unknown>;
353
352
  readOnly?: boolean | undefined;
354
353
  }[] | undefined;
354
+ skills?: string[] | undefined;
355
355
  } | undefined;
356
356
  description?: string | undefined;
357
357
  permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[] | undefined;