@cjhyy/code-shell-core 0.8.12 → 0.8.20

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 (42) hide show
  1. package/dist/cli/agent-server-stdio.js +4 -3
  2. package/dist/engine/engine.js +24 -11
  3. package/dist/engine/run-session-open.js +1 -1
  4. package/dist/engine/run-types.d.ts +2 -0
  5. package/dist/engine/turn-loop.js +17 -0
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.internal.d.ts +1 -0
  8. package/dist/index.internal.js +1 -0
  9. package/dist/index.js +2 -2
  10. package/dist/llm/client-base.d.ts +2 -1
  11. package/dist/llm/client-base.js +3 -1
  12. package/dist/llm/providers/anthropic.js +24 -25
  13. package/dist/llm/providers/openai.d.ts +4 -1
  14. package/dist/llm/providers/openai.js +76 -13
  15. package/dist/panel-apps/index.d.ts +1 -1
  16. package/dist/panel-apps/index.js +1 -1
  17. package/dist/panel-apps/installer.d.ts +29 -0
  18. package/dist/panel-apps/installer.js +124 -15
  19. package/dist/plugins/pluginCatalog.d.ts +6 -0
  20. package/dist/plugins/pluginCatalog.js +7 -2
  21. package/dist/plugins/pluginContent.d.ts +1 -1
  22. package/dist/plugins/pluginContent.js +2 -10
  23. package/dist/protocol/chat-session.d.ts +2 -0
  24. package/dist/protocol/server.js +7 -0
  25. package/dist/protocol/types.d.ts +2 -0
  26. package/dist/session/session-manager.d.ts +7 -0
  27. package/dist/session/session-manager.js +19 -6
  28. package/dist/session/transcript.d.ts +9 -0
  29. package/dist/session/transcript.js +81 -0
  30. package/dist/settings/manager.d.ts +12 -0
  31. package/dist/settings/manager.js +31 -0
  32. package/dist/tool-system/builtin/configure-model-connection.d.ts +36 -0
  33. package/dist/tool-system/builtin/configure-model-connection.js +396 -0
  34. package/dist/tool-system/builtin/edit-model-catalog.d.ts +4 -6
  35. package/dist/tool-system/builtin/edit-model-catalog.js +5 -8
  36. package/dist/tool-system/builtin/index.js +14 -0
  37. package/dist/tool-system/builtin/install-capability.js +22 -8
  38. package/dist/tool-system/builtin/settings-changed.d.ts +9 -0
  39. package/dist/tool-system/builtin/settings-changed.js +18 -0
  40. package/dist/tool-system/context.d.ts +8 -2
  41. package/dist/types.d.ts +5 -0
  42. package/package.json +1 -1
@@ -0,0 +1,396 @@
1
+ /**
2
+ * ConfigureModelConnection — safely materialize one catalog model into the
3
+ * unified settings.modelConnections store without exposing or copying API
4
+ * keys. The write is schema-validated, lock-protected, atomic, and updates the
5
+ * selected tag default in the same transaction when requested.
6
+ */
7
+ import { SettingsManager } from "../../settings/manager.js";
8
+ import { getMergedCatalog } from "../../model-catalog/index.js";
9
+ import { modelEntriesFromConnections } from "../../engine/model-connections-pool.js";
10
+ import { ModelPool } from "../../llm/model-pool.js";
11
+ import { createLLMClient } from "../../llm/client-factory.js";
12
+ import { isCredentialCompatible, } from "../../model-catalog/resolve.js";
13
+ import { notifySettingsChanged } from "./settings-changed.js";
14
+ function redactCredentialSecrets(message, credentials) {
15
+ let redacted = message;
16
+ for (const credential of credentials) {
17
+ if (credential.apiKey)
18
+ redacted = redacted.replaceAll(credential.apiKey, "[REDACTED]");
19
+ }
20
+ return redacted;
21
+ }
22
+ export async function probeTextModelConnection(connection, credentials, catalog, options = {}) {
23
+ const entry = modelEntriesFromConnections([connection], credentials, catalog)[0];
24
+ if (!entry)
25
+ return { ok: false, error: "the saved connection could not be resolved" };
26
+ const pool = new ModelPool([entry]);
27
+ const config = pool.toLLMConfig(entry);
28
+ try {
29
+ const client = await (options.createClient ?? createLLMClient)(config, {
30
+ temperature: 0,
31
+ timeout: 30_000,
32
+ retryMaxAttempts: 1,
33
+ ...(options.fetch ? { fetch: options.fetch } : {}),
34
+ });
35
+ const response = await client.createMessage({
36
+ systemPrompt: "You are a connection health check. Follow the user's reply instruction.",
37
+ messages: [{ role: "user", content: "Reply with READY only." }],
38
+ tools: [],
39
+ maxTokens: 32,
40
+ stream: false,
41
+ signal: AbortSignal.timeout(30_000),
42
+ requestVisible: false,
43
+ });
44
+ return {
45
+ ok: true,
46
+ response: response.text.slice(0, 200),
47
+ stopReason: response.stopReason,
48
+ ...(response.usage
49
+ ? {
50
+ usage: {
51
+ promptTokens: response.usage.promptTokens,
52
+ completionTokens: response.usage.completionTokens,
53
+ totalTokens: response.usage.totalTokens,
54
+ },
55
+ }
56
+ : {}),
57
+ };
58
+ }
59
+ catch (error) {
60
+ const raw = error instanceof Error ? error.message : String(error);
61
+ const safeError = redactCredentialSecrets(raw, credentials);
62
+ return { ok: false, error: safeError.slice(0, 1000) };
63
+ }
64
+ }
65
+ const DEFAULT_DEPS = {
66
+ makeSettingsManager: (cwd, scope) => new SettingsManager(cwd, scope),
67
+ getCatalog: getMergedCatalog,
68
+ notifySettingsChanged,
69
+ testTextConnection: probeTextModelConnection,
70
+ };
71
+ export const configureModelConnectionToolDef = {
72
+ name: "ConfigureModelConnection",
73
+ description: "Create or update a configured model connection from an existing catalog model. " +
74
+ "Use this after EditModelCatalog when the user wants the model ready to use, not merely " +
75
+ "listed as a template. It validates the catalog model, reuses a compatible existing " +
76
+ "credential by id (never returns or copies its API key), seeds catalog parameter defaults, " +
77
+ "and atomically updates modelConnections plus defaults. User scope requires a full/trusted " +
78
+ "host context. If more than one compatible credential exists, pass credentialId explicitly. " +
79
+ "Set testConnection=true to make one small real request and report whether routing worked.",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: {
83
+ catalogId: {
84
+ type: "string",
85
+ description: "Existing merged catalog provider id, e.g. 'openrouter'.",
86
+ },
87
+ model: {
88
+ type: "string",
89
+ description: "Exact model preset value in that provider, e.g. 'openai/gpt-5.6-luna'.",
90
+ },
91
+ connectionId: {
92
+ type: "string",
93
+ description: "Optional stable instance id. Omit to update an existing connection for the same " +
94
+ "catalog model or generate a collision-free id.",
95
+ },
96
+ credentialId: {
97
+ type: "string",
98
+ description: "Existing compatible credential id. Omit to reuse the current connection credential " +
99
+ "or auto-select when exactly one compatible credential exists.",
100
+ },
101
+ paramValues: {
102
+ type: "object",
103
+ description: "Optional model parameter overrides keyed by catalog ParamSpec name. Catalog defaults " +
104
+ "are seeded first; unknown names or invalid enum/type/range values are rejected.",
105
+ },
106
+ setDefault: {
107
+ type: "boolean",
108
+ description: "Set this connection as the default for its catalog tag. The first configured " +
109
+ "connection for a tag becomes default automatically.",
110
+ },
111
+ testConnection: {
112
+ type: "boolean",
113
+ description: "After saving a text connection, send one small real request through it. This may " +
114
+ "incur a tiny provider charge. A failed test is reported without rolling back the " +
115
+ "validated connection.",
116
+ },
117
+ scope: {
118
+ type: "string",
119
+ enum: ["user", "project"],
120
+ description: "Settings layer to update. Defaults to user in a full desktop host, otherwise project.",
121
+ },
122
+ },
123
+ required: ["catalogId", "model"],
124
+ },
125
+ };
126
+ function isRecord(value) {
127
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
128
+ }
129
+ function connectionIdIsSafe(value) {
130
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
131
+ }
132
+ function slugPart(value) {
133
+ return (value
134
+ .toLowerCase()
135
+ .split("/")
136
+ .filter(Boolean)
137
+ .pop()
138
+ ?.replace(/[^a-z0-9._-]+/gu, "-")
139
+ .replace(/^-+|-+$/gu, "") || "model");
140
+ }
141
+ function uniqueConnectionId(entry, model, taken) {
142
+ const base = !taken.has(entry.id) ? entry.id : `${entry.id}-${slugPart(model)}`;
143
+ if (!taken.has(base))
144
+ return base;
145
+ let suffix = 2;
146
+ while (taken.has(`${base}-${suffix}`))
147
+ suffix += 1;
148
+ return `${base}-${suffix}`;
149
+ }
150
+ function paramDefaults(preset) {
151
+ const values = {};
152
+ for (const spec of preset.params ?? []) {
153
+ if (spec.default !== undefined)
154
+ values[spec.name] = spec.default;
155
+ }
156
+ return values;
157
+ }
158
+ function validateParamValue(spec, value) {
159
+ if (spec.control === "enum") {
160
+ if (typeof value !== "string")
161
+ return "must be a string enum value";
162
+ if (spec.options?.length && !spec.options.includes(value)) {
163
+ return `must be one of [${spec.options.join(", ")}]`;
164
+ }
165
+ return undefined;
166
+ }
167
+ if (spec.control === "number") {
168
+ if (typeof value !== "number" || !Number.isFinite(value))
169
+ return "must be a finite number";
170
+ if (spec.min !== undefined && value < spec.min)
171
+ return `must be >= ${spec.min}`;
172
+ if (spec.max !== undefined && value > spec.max)
173
+ return `must be <= ${spec.max}`;
174
+ return undefined;
175
+ }
176
+ if (spec.control === "toggle") {
177
+ return typeof value === "boolean" ? undefined : "must be a boolean";
178
+ }
179
+ return typeof value === "string" ? undefined : "must be a string";
180
+ }
181
+ function validateParamValues(values, specs) {
182
+ if (Object.keys(values).length > 64)
183
+ return "paramValues exceeds 64 entries";
184
+ const byName = new Map(specs.map((spec) => [spec.name, spec]));
185
+ for (const [name, value] of Object.entries(values)) {
186
+ const spec = byName.get(name);
187
+ if (!spec)
188
+ return `unknown parameter "${name}" for this model`;
189
+ const issue = validateParamValue(spec, value);
190
+ if (issue)
191
+ return `parameter "${name}" ${issue}`;
192
+ }
193
+ return undefined;
194
+ }
195
+ function connectionsFrom(settings) {
196
+ return Array.isArray(settings.modelConnections)
197
+ ? settings.modelConnections
198
+ : [];
199
+ }
200
+ function credentialsFrom(settings) {
201
+ return Array.isArray(settings.credentials) ? settings.credentials : [];
202
+ }
203
+ function findTargetConnection(connections, entry, model, explicitId) {
204
+ if (explicitId)
205
+ return connections.find((connection) => connection.id === explicitId);
206
+ return connections.find((connection) => connection.catalogId === entry.id &&
207
+ connection.tag === entry.tag &&
208
+ connection.model === model);
209
+ }
210
+ function selectCredential(entry, catalog, credentials, requestedId, existingId) {
211
+ const compatible = credentials.filter((credential) => isCredentialCompatible(entry, credential, catalog) &&
212
+ (entry.needsKey === false || Boolean(credential.apiKey?.trim())));
213
+ const selectedId = requestedId ?? existingId;
214
+ if (selectedId) {
215
+ const selected = credentials.find((credential) => credential.id === selectedId);
216
+ if (!selected)
217
+ return { error: `credential "${selectedId}" does not exist in this scope` };
218
+ if (!isCredentialCompatible(entry, selected, catalog)) {
219
+ return { error: `credential "${selectedId}" is not compatible with catalog "${entry.id}"` };
220
+ }
221
+ if (entry.needsKey !== false && !selected.apiKey?.trim()) {
222
+ return { error: `credential "${selectedId}" has no usable API key` };
223
+ }
224
+ return { credentialId: selected.id };
225
+ }
226
+ if (entry.needsKey === false)
227
+ return {};
228
+ if (compatible.length === 1)
229
+ return { credentialId: compatible[0].id };
230
+ if (compatible.length === 0) {
231
+ return { error: `no compatible credential is configured for catalog "${entry.id}"` };
232
+ }
233
+ return {
234
+ error: `multiple compatible credentials exist for catalog "${entry.id}": ` +
235
+ `${compatible.map((credential) => credential.id).join(", ")}. Pass credentialId explicitly.`,
236
+ };
237
+ }
238
+ export async function configureModelConnectionTool(args, ctx, deps = DEFAULT_DEPS) {
239
+ if (!ctx)
240
+ return "Error: ConfigureModelConnection requires a scoped tool context.";
241
+ if (ctx.settingsScope === "isolated") {
242
+ return "Error: isolated sessions cannot persist model connections.";
243
+ }
244
+ const requestedScope = args.scope;
245
+ const scope = requestedScope === "user" || requestedScope === "project"
246
+ ? requestedScope
247
+ : ctx.settingsScope === "full"
248
+ ? "user"
249
+ : "project";
250
+ if (scope === "user" && ctx.settingsScope !== "full") {
251
+ return "Error: user-scope model connections require settingsScope=full.";
252
+ }
253
+ const catalogId = typeof args.catalogId === "string" ? args.catalogId.trim() : "";
254
+ const model = typeof args.model === "string" ? args.model.trim() : "";
255
+ const connectionId = typeof args.connectionId === "string" ? args.connectionId.trim() : undefined;
256
+ const credentialId = typeof args.credentialId === "string" ? args.credentialId.trim() : undefined;
257
+ if (!catalogId)
258
+ return "Error: catalogId is required.";
259
+ if (!model)
260
+ return "Error: model is required.";
261
+ if (connectionId && !connectionIdIsSafe(connectionId)) {
262
+ return "Error: connectionId must be 1-128 safe id characters (letters, numbers, . _ : -).";
263
+ }
264
+ if (args.paramValues !== undefined && !isRecord(args.paramValues)) {
265
+ return "Error: paramValues must be an object.";
266
+ }
267
+ if (args.setDefault !== undefined && typeof args.setDefault !== "boolean") {
268
+ return "Error: setDefault must be a boolean.";
269
+ }
270
+ if (args.testConnection !== undefined && typeof args.testConnection !== "boolean") {
271
+ return "Error: testConnection must be a boolean.";
272
+ }
273
+ const catalog = deps.getCatalog();
274
+ const entry = catalog.find((candidate) => candidate.id === catalogId);
275
+ if (!entry)
276
+ return `Error: catalog "${catalogId}" does not exist.`;
277
+ const preset = entry.modelPresets?.find((candidate) => candidate.value === model);
278
+ if (!preset) {
279
+ return `Error: model "${model}" is not declared in catalog "${catalogId}".`;
280
+ }
281
+ if (args.testConnection === true && entry.tag !== "text") {
282
+ return "Error: testConnection currently supports text catalog entries only.";
283
+ }
284
+ const manager = deps.makeSettingsManager(ctx.cwd, ctx.settingsScope === "full" ? "full" : "project");
285
+ let effectiveSettings;
286
+ let targetSettings;
287
+ try {
288
+ effectiveSettings = manager.get();
289
+ targetSettings = manager.getForScope(scope, ctx.cwd);
290
+ }
291
+ catch (error) {
292
+ return `Error: settings validation failed: ${error instanceof Error ? error.message : String(error)}`;
293
+ }
294
+ const targetConnections = connectionsFrom(targetSettings);
295
+ const preflightExisting = findTargetConnection(targetConnections, entry, model, connectionId);
296
+ if (connectionId &&
297
+ preflightExisting &&
298
+ (preflightExisting.catalogId !== entry.id || preflightExisting.tag !== entry.tag)) {
299
+ return `Error: connectionId "${connectionId}" already belongs to another catalog or tag.`;
300
+ }
301
+ const effectiveCredentials = scope === "user" ? credentialsFrom(targetSettings) : credentialsFrom(effectiveSettings);
302
+ const selected = selectCredential(entry, catalog, effectiveCredentials, credentialId, preflightExisting?.credentialId);
303
+ if (selected.error)
304
+ return `Error: ${selected.error}`;
305
+ const providedParams = args.paramValues;
306
+ let outcome;
307
+ try {
308
+ manager.mutateSettingsForScope(scope, ctx.cwd, (current) => {
309
+ const connections = connectionsFrom(current);
310
+ const existing = findTargetConnection(connections, entry, model, connectionId);
311
+ if (connectionId &&
312
+ existing &&
313
+ (existing.catalogId !== entry.id || existing.tag !== entry.tag)) {
314
+ throw new Error(`connectionId "${connectionId}" was concurrently claimed`);
315
+ }
316
+ // Revalidate user-scope credential references inside the same locked
317
+ // file transaction so a concurrent credential deletion cannot leave a
318
+ // newly written dangling reference. Project connections may intentionally
319
+ // inherit a user credential from the effective settings layer.
320
+ if (scope === "user" && selected.credentialId) {
321
+ const lockedSelection = selectCredential(entry, catalog, credentialsFrom(current), selected.credentialId, undefined);
322
+ if (lockedSelection.error)
323
+ throw new Error(lockedSelection.error);
324
+ }
325
+ const id = existing?.id ??
326
+ connectionId ??
327
+ uniqueConnectionId(entry, model, new Set(connections.map((connection) => connection.id)));
328
+ const defaults = paramDefaults(preset);
329
+ const keepExistingParams = existing?.model === model && providedParams === undefined;
330
+ const paramValues = keepExistingParams
331
+ ? (existing?.paramValues ?? defaults)
332
+ : { ...defaults, ...(providedParams ?? {}) };
333
+ const paramIssue = validateParamValues(paramValues, preset.params ?? []);
334
+ if (paramIssue)
335
+ throw new Error(paramIssue);
336
+ const connection = {
337
+ ...(existing ?? {}),
338
+ id,
339
+ catalogId: entry.id,
340
+ tag: entry.tag,
341
+ model,
342
+ ...(selected.credentialId ? { credentialId: selected.credentialId } : {}),
343
+ ...(Object.keys(paramValues).length > 0 ? { paramValues } : {}),
344
+ };
345
+ if (!selected.credentialId)
346
+ delete connection.credentialId;
347
+ if (Object.keys(paramValues).length === 0)
348
+ delete connection.paramValues;
349
+ const nextConnections = existing
350
+ ? connections.map((candidate) => (candidate.id === existing.id ? connection : candidate))
351
+ : [...connections, connection];
352
+ current.modelConnections = nextConnections;
353
+ const rawDefaults = isRecord(current.defaults) ? current.defaults : {};
354
+ const becameDefault = args.setDefault === true || typeof rawDefaults[entry.tag] !== "string";
355
+ if (becameDefault)
356
+ current.defaults = { ...rawDefaults, [entry.tag]: id };
357
+ outcome = {
358
+ action: existing ? "updated" : "added",
359
+ connection,
360
+ becameDefault,
361
+ };
362
+ });
363
+ }
364
+ catch (error) {
365
+ return `Error: could not configure model connection: ${error instanceof Error ? error.message : String(error)}`;
366
+ }
367
+ if (!outcome)
368
+ return "Error: model connection write produced no result.";
369
+ deps.notifySettingsChanged();
370
+ let verification;
371
+ if (args.testConnection === true) {
372
+ try {
373
+ // Reload credential metadata after the settings transaction. This avoids
374
+ // probing with a stale key if another settings writer rotated it between
375
+ // preflight and persistence.
376
+ const refreshed = scope === "user" ? manager.getForScope("user", ctx.cwd) : manager.get();
377
+ verification = await deps.testTextConnection(outcome.connection, credentialsFrom(refreshed), catalog);
378
+ }
379
+ catch (error) {
380
+ const raw = error instanceof Error ? error.message : String(error);
381
+ verification = {
382
+ ok: false,
383
+ error: `could not start connection test: ${redactCredentialSecrets(raw, effectiveCredentials)}`.slice(0, 1000),
384
+ };
385
+ }
386
+ }
387
+ return JSON.stringify({
388
+ ok: true,
389
+ action: outcome.action,
390
+ scope,
391
+ connection: outcome.connection,
392
+ defaultForTag: outcome.becameDefault ? entry.tag : undefined,
393
+ hotReloadRequested: true,
394
+ ...(verification ? { verification } : {}),
395
+ }, null, 2);
396
+ }
@@ -1,9 +1,7 @@
1
1
  import type { ToolDefinition } from "../../types.js";
2
- /** Notifies process hosts after the user catalog has been persisted. Desktop
3
- * uses this to refresh mounted settings/connection views without waiting for
4
- * a parent turn_complete event (which may be absent for child/yielded runs). */
5
- type ModelCatalogChangedSink = () => void;
6
- export declare function setModelCatalogChangedSink(sink: ModelCatalogChangedSink | null): void;
2
+ import { type SettingsChangedSink } from "./settings-changed.js";
3
+ /** Backwards-compatible catalog-specific name for the shared settings/resource
4
+ * invalidation sink. */
5
+ export declare function setModelCatalogChangedSink(sink: SettingsChangedSink | null): void;
7
6
  export declare const editModelCatalogToolDef: ToolDefinition;
8
7
  export declare function editModelCatalogTool(args: Record<string, unknown>): Promise<string>;
9
- export {};
@@ -13,6 +13,7 @@ import { saveCatalogEntry } from "../../model-catalog/save-entry.js";
13
13
  import { catalogEntrySchema, modelPresetSchema, } from "../../model-catalog/types.js";
14
14
  import { upsertModelPreset } from "../../model-catalog/upsert.js";
15
15
  import { PROVIDER_KINDS } from "../../llm/provider-kinds.js";
16
+ import { notifySettingsChanged, setSettingsChangedSink, } from "./settings-changed.js";
16
17
  const CATALOG_ADAPTER_KINDS = [...Object.keys(PROVIDER_KINDS), "fal"].join("|");
17
18
  /**
18
19
  * Recognise first-party provider hosts so the catalog tool can reject the
@@ -52,17 +53,13 @@ function providerIdentityError(entry) {
52
53
  `protocol="${protocolHint}". adapterKind identifies the provider/gateway account; ` +
53
54
  `protocol only identifies the HTTP wire format.`);
54
55
  }
55
- let modelCatalogChangedSink = null;
56
+ /** Backwards-compatible catalog-specific name for the shared settings/resource
57
+ * invalidation sink. */
56
58
  export function setModelCatalogChangedSink(sink) {
57
- modelCatalogChangedSink = sink;
59
+ setSettingsChangedSink(sink);
58
60
  }
59
61
  function fireModelCatalogChanged() {
60
- try {
61
- modelCatalogChangedSink?.();
62
- }
63
- catch {
64
- // Host notification is best-effort; the catalog write itself succeeded.
65
- }
62
+ notifySettingsChanged();
66
63
  }
67
64
  export const editModelCatalogToolDef = {
68
65
  name: "EditModelCatalog",
@@ -5,6 +5,7 @@ import { readToolDef, readTool } from "./read.js";
5
5
  import { writeToolDef, writeTool } from "./write.js";
6
6
  import { generateImageToolDef, generateImageTool, isGenerateImageAvailable, } from "./generate-image.js";
7
7
  import { editModelCatalogToolDef, editModelCatalogTool } from "./edit-model-catalog.js";
8
+ import { configureModelConnectionToolDef, configureModelConnectionTool, } from "./configure-model-connection.js";
8
9
  import { generateVideoToolDef, generateVideoTool, isGenerateVideoAvailable, } from "./generate-video.js";
9
10
  import { viewImageToolDef, viewImageTool } from "./view-image.js";
10
11
  import { editToolDef, editTool } from "./edit.js";
@@ -123,6 +124,19 @@ const BUILTIN_CONTRIBUTIONS = [
123
124
  execute: editModelCatalogTool,
124
125
  exposure: expose(GENERAL_TAGS),
125
126
  },
127
+ {
128
+ definition: {
129
+ ...configureModelConnectionToolDef,
130
+ source: "builtin",
131
+ permissionDefault: "ask",
132
+ isReadOnly: false,
133
+ // One lock-protected settings transaction; concurrent model connection
134
+ // mutations must serialize to preserve unique ids and defaults.
135
+ isConcurrencySafe: false,
136
+ },
137
+ execute: configureModelConnectionTool,
138
+ exposure: expose(GENERAL_TAGS),
139
+ },
126
140
  {
127
141
  definition: {
128
142
  ...generateImageToolDef,
@@ -28,6 +28,7 @@ import { resolveExecutable } from "../../utils/exec.js";
28
28
  const SAFE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
29
29
  const SAFE_PLUGIN_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
30
30
  const SAFE_SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
31
+ const SAFE_OVERRIDE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
31
32
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
32
33
  const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/;
33
34
  const MCP_TOOL_NAME_RE = /^[^\s\u0000-\u001F\u007F]{1,256}$/;
@@ -338,10 +339,13 @@ function isSafeRemoteUrl(raw) {
338
339
  return false;
339
340
  return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname.toLowerCase());
340
341
  }
341
- async function installMarketplacePlugin(args, deps) {
342
+ async function installMarketplacePlugin(args, ctx, deps) {
342
343
  if (args.scope !== undefined && args.scope !== "user") {
343
344
  return "Error: marketplace plugins currently install at user scope; omit scope or use scope='user'.";
344
345
  }
346
+ if (ctx?.settingsScope !== "full") {
347
+ return "Error: this host isolates user settings; marketplace plugins cannot be installed.";
348
+ }
345
349
  const plugin = safePluginSegment(args.plugin);
346
350
  const marketplace = safePluginSegment(args.marketplace);
347
351
  if (!plugin)
@@ -440,6 +444,9 @@ async function mutateMarketplacePlugin(args, ctx, deps) {
440
444
  if (!deps.listInstalled().some((candidate) => candidate.key === installKey)) {
441
445
  return `Error: plugin ${installKey} is not installed.`;
442
446
  }
447
+ if ((action === "update" || action === "uninstall") && ctx?.settingsScope !== "full") {
448
+ return "Error: this host isolates user settings; marketplace plugins cannot be changed.";
449
+ }
443
450
  if (action === "update") {
444
451
  const refreshed = await deps.refreshMarketplace(marketplace);
445
452
  if (!refreshed.ok) {
@@ -474,12 +481,15 @@ async function mutateMarketplacePlugin(args, ctx, deps) {
474
481
  if (args.scope !== undefined && args.scope !== "project" && args.scope !== "user") {
475
482
  return "Error: plugin enable/disable scope must be `project` or `user`.";
476
483
  }
477
- if (scope === "user" && ctx?.settingsScope && ctx.settingsScope !== "full") {
484
+ if (scope === "user" && ctx?.settingsScope !== "full") {
478
485
  return "Error: this host isolates user settings; use project scope.";
479
486
  }
480
487
  const manager = deps.makeSettingsManager(cwd, scope === "user" ? "full" : "project");
481
488
  const enabled = action === "enable";
482
489
  if (scope === "project") {
490
+ if (!SAFE_OVERRIDE_NAME_RE.test(plugin)) {
491
+ return "Error: project-scoped plugin overrides do not support dots in plugin names; use user scope.";
492
+ }
483
493
  manager.saveProjectSetting(`capabilityOverrides.plugins.${plugin}`, enabled ? "on" : "off", cwd);
484
494
  }
485
495
  else {
@@ -670,12 +680,16 @@ async function mutateProjectSkills(args, ctx, deps) {
670
680
  if (args.scope !== undefined && args.scope !== "project" && args.scope !== "user") {
671
681
  return "Error: Skill enable/disable scope must be `project` or `user`.";
672
682
  }
673
- if (scope === "user" && ctx?.settingsScope && ctx.settingsScope !== "full") {
683
+ if (scope === "user" && ctx?.settingsScope !== "full") {
674
684
  return "Error: this host isolates user settings; use project scope.";
675
685
  }
676
686
  const manager = deps.makeSettingsManager(cwd, scope === "user" ? "full" : "project");
677
687
  const enabled = action === "enable";
678
688
  if (scope === "project") {
689
+ const unsafe = skills.filter((skill) => !SAFE_OVERRIDE_NAME_RE.test(skill));
690
+ if (unsafe.length > 0) {
691
+ return `Error: project-scoped Skill overrides do not support dots in names: ${unsafe.join(", ")}.`;
692
+ }
679
693
  for (const skill of skills) {
680
694
  manager.saveProjectSetting(`capabilityOverrides.skills.${skill}`, enabled ? "on" : "off", cwd);
681
695
  }
@@ -806,7 +820,7 @@ async function installMcpServer(args, ctx, deps) {
806
820
  if (!resolved.ok)
807
821
  return `Error: ${resolved.error}`;
808
822
  const scope = resolved.scope;
809
- if (scope === "user" && ctx?.settingsScope && ctx.settingsScope !== "full") {
823
+ if (scope === "user" && ctx?.settingsScope !== "full") {
810
824
  return "Error: this host isolates user settings; install the MCP server at local or project scope.";
811
825
  }
812
826
  const built = buildMcpConfig(args);
@@ -927,7 +941,7 @@ function mcpDetailLines(name, scope, config) {
927
941
  }
928
942
  function listMcpServers(ctx, deps) {
929
943
  const cwd = ctx?.cwd ?? process.cwd();
930
- const full = !ctx?.settingsScope || ctx.settingsScope === "full";
944
+ const full = ctx?.settingsScope === "full";
931
945
  const manager = deps.makeSettingsManager(cwd, full ? "full" : "project");
932
946
  const scopes = full
933
947
  ? ["local", "project", "user"]
@@ -953,7 +967,7 @@ function inspectMcpServer(args, ctx, deps) {
953
967
  const resolved = mcpScope(args);
954
968
  if (!resolved.ok)
955
969
  return `Error: ${resolved.error}`;
956
- if (resolved.scope === "user" && ctx?.settingsScope && ctx.settingsScope !== "full") {
970
+ if (resolved.scope === "user" && ctx?.settingsScope !== "full") {
957
971
  return "Error: this host isolates user settings; user MCP configuration is unavailable.";
958
972
  }
959
973
  const manager = deps.makeSettingsManager(cwd, resolved.scope === "user" ? "full" : "project");
@@ -980,7 +994,7 @@ async function mutateMcpServer(args, ctx, deps) {
980
994
  const resolved = mcpScope(args);
981
995
  if (!resolved.ok)
982
996
  return `Error: ${resolved.error}`;
983
- if (resolved.scope === "user" && ctx?.settingsScope && ctx.settingsScope !== "full") {
997
+ if (resolved.scope === "user" && ctx?.settingsScope !== "full") {
984
998
  return "Error: this host isolates user settings; use local or project scope.";
985
999
  }
986
1000
  const manager = deps.makeSettingsManager(cwd, resolved.scope === "user" ? "full" : "project");
@@ -1044,7 +1058,7 @@ export async function installCapabilityWithDeps(args, ctx, deps) {
1044
1058
  }
1045
1059
  if (action === "install") {
1046
1060
  if (args.kind === "plugin")
1047
- return installMarketplacePlugin(args, deps);
1061
+ return installMarketplacePlugin(args, ctx, deps);
1048
1062
  if (args.kind === "skill")
1049
1063
  return installGithubSkills(args, ctx, deps);
1050
1064
  return installMcpServer(args, ctx, deps);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Process-host invalidation bridge for built-ins that persist settings-like
3
+ * resources outside the renderer. Desktop wires one sink that forwards the
4
+ * existing agent/settingsChanged notification; headless hosts may leave it
5
+ * unset and pick the change up on their next settings load.
6
+ */
7
+ export type SettingsChangedSink = () => void;
8
+ export declare function setSettingsChangedSink(sink: SettingsChangedSink | null): void;
9
+ export declare function notifySettingsChanged(): void;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Process-host invalidation bridge for built-ins that persist settings-like
3
+ * resources outside the renderer. Desktop wires one sink that forwards the
4
+ * existing agent/settingsChanged notification; headless hosts may leave it
5
+ * unset and pick the change up on their next settings load.
6
+ */
7
+ let settingsChangedSink = null;
8
+ export function setSettingsChangedSink(sink) {
9
+ settingsChangedSink = sink;
10
+ }
11
+ export function notifySettingsChanged() {
12
+ try {
13
+ settingsChangedSink?.();
14
+ }
15
+ catch {
16
+ // Persistence already succeeded. Host invalidation is best-effort.
17
+ }
18
+ }
@@ -227,10 +227,16 @@ export interface ToolContext {
227
227
  cwd: string;
228
228
  /**
229
229
  * True when this call belongs to an external Agent Runtime rather than the
230
- * native Engine loop. Async handoff tools use this to keep their result on
231
- * the current turn instead of queueing a wake-up only the Engine can consume.
230
+ * native Engine loop.
232
231
  */
233
232
  externalRuntime?: boolean;
233
+ /**
234
+ * The external-runtime host can route notificationQueue completions back into
235
+ * this business Session as an injected continuation turn. Async tools must
236
+ * require this capability before detaching work: `externalRuntime` alone says
237
+ * who owns the turn, not whether anybody can deliver a later result.
238
+ */
239
+ externalRuntimeBackgroundDelivery?: boolean;
234
240
  /**
235
241
  * Active digital-human profile's portable memory root. Present only when
236
242
  * the resolved WorkspaceProfile enables portableMemory for this run.
package/dist/types.d.ts CHANGED
@@ -747,6 +747,11 @@ export interface ClientDefaults {
747
747
  timeout?: number;
748
748
  /** Max retry attempts for transient errors. Default 3. */
749
749
  retryMaxAttempts?: number;
750
+ /**
751
+ * Optional HTTP transport override. Primarily used by embedders and tests
752
+ * that need an isolated transport without mutating process-global fetch.
753
+ */
754
+ fetch?: typeof globalThis.fetch;
750
755
  /**
751
756
  * Provider-agnostic image clarity level. Drives the renderer-side
752
757
  * downscale (long-edge cap: low→~1024 / standard→~1568 / high→~2576)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.8.12",
3
+ "version": "0.8.20",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",