@cjhyy/code-shell-core 0.8.13 → 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
@@ -16,6 +16,9 @@ const MAX_FILE_BYTES = 16 * 1024 * 1024;
16
16
  const MAX_DEPTH = 16;
17
17
  const MAX_MANIFEST_BYTES = 1024 * 1024;
18
18
  const MAX_AGENT_SKILL_BYTES = 256 * 1024;
19
+ const MAX_PANEL_DISCOVERY_DEPTH = 4;
20
+ const MAX_PANEL_DISCOVERY_DIRECTORIES = 512;
21
+ const MAX_PANEL_DISCOVERY_RESULTS = 16;
19
22
  const REVIEWED_GIT_SNAPSHOT_TTL_MS = 10 * 60 * 1000;
20
23
  const MAX_REVIEWED_GIT_SNAPSHOTS = 8;
21
24
  const ALLOWED_ASSET_EXTENSIONS = new Set([
@@ -61,9 +64,14 @@ function normalizeGitPanelAppSource(input) {
61
64
  if (!raw || raw.length > MAX_SOURCE_PATH || raw.includes("\0")) {
62
65
  throw new PanelAppInstallError("GitHub repository URL is invalid");
63
66
  }
67
+ const urlText = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}(?:\.git)?\/?$/.test(raw)
68
+ ? `https://github.com/${raw}`
69
+ : /^github\.com\//i.test(raw)
70
+ ? `https://${raw}`
71
+ : raw;
64
72
  let parsed;
65
73
  try {
66
- parsed = new URL(raw);
74
+ parsed = new URL(urlText);
67
75
  }
68
76
  catch {
69
77
  throw new PanelAppInstallError("GitHub repository URL is invalid");
@@ -135,19 +143,52 @@ function normalizedGitSourceKey(input) {
135
143
  function looksLikePanelAppRoot(directory) {
136
144
  return existsSync(join(directory, PANEL_APP_MANIFEST_FILE));
137
145
  }
146
+ async function discoverPanelAppRoots(directory) {
147
+ const found = [];
148
+ const pending = [{ directory, depth: 0 }];
149
+ let visited = 0;
150
+ while (pending.length > 0 &&
151
+ visited < MAX_PANEL_DISCOVERY_DIRECTORIES &&
152
+ found.length < MAX_PANEL_DISCOVERY_RESULTS) {
153
+ const current = pending.shift();
154
+ visited += 1;
155
+ if (looksLikePanelAppRoot(current.directory)) {
156
+ found.push(current.directory);
157
+ continue;
158
+ }
159
+ if (current.depth >= MAX_PANEL_DISCOVERY_DEPTH)
160
+ continue;
161
+ const entries = await readdir(current.directory, { withFileTypes: true });
162
+ for (const entry of entries) {
163
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") {
164
+ continue;
165
+ }
166
+ pending.push({ directory: join(current.directory, entry.name), depth: current.depth + 1 });
167
+ }
168
+ }
169
+ return found;
170
+ }
138
171
  async function findPanelAppRoot(directory) {
139
172
  if (looksLikePanelAppRoot(directory))
140
173
  return directory;
141
- const entries = await readdir(directory, { withFileTypes: true });
142
- const children = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
143
- if (children.length === 1) {
144
- const nested = join(directory, children[0].name);
145
- if (looksLikePanelAppRoot(nested))
146
- return nested;
147
- }
148
- throw new PanelAppInstallError(`no Panel App found (expected ${PANEL_APP_MANIFEST_FILE})`);
174
+ const found = await discoverPanelAppRoots(directory);
175
+ if (found.length === 1)
176
+ return found[0];
177
+ if (found.length > 1) {
178
+ const candidates = found
179
+ .map((root) => relative(directory, root).split(sep).join(posix.sep))
180
+ .sort()
181
+ .join(", ");
182
+ throw new PanelAppInstallError(`multiple Panel Apps found; choose an app subdirectory: ${candidates}`);
183
+ }
184
+ throw new PanelAppInstallError(`no Panel App found (expected ${PANEL_APP_MANIFEST_FILE}); ` +
185
+ "for a monorepo, provide the app subdirectory or a GitHub /tree/<ref>/<path> URL");
149
186
  }
150
- async function openGitPanelAppSource(input) {
187
+ function joinedPanelAppSubdirectory(base, nested) {
188
+ const parts = [base, nested].filter((part) => Boolean(part));
189
+ return parts.length > 0 ? parts.join("/") : undefined;
190
+ }
191
+ async function openGitPanelAppArchive(input) {
151
192
  const source = normalizeGitPanelAppSource(input);
152
193
  const temporaryRoot = await mkdtemp(join(tmpdir(), "cs-panel-app-git-"));
153
194
  try {
@@ -157,18 +198,86 @@ async function openGitPanelAppSource(input) {
157
198
  await downloadGitHubPanelAppArchive(source, archivePath);
158
199
  await extractZipSubdirectory(archivePath, extractedRoot, source.subdir);
159
200
  await rm(archivePath, { force: true });
201
+ return { source, extractedRoot, temporaryRoot };
202
+ }
203
+ catch (error) {
204
+ await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined);
205
+ throw error;
206
+ }
207
+ }
208
+ async function openGitPanelAppSource(input) {
209
+ const opened = await openGitPanelAppArchive(input);
210
+ try {
160
211
  return {
161
- source,
162
- sourceKey: JSON.stringify(source),
163
- sourceRoot: await findPanelAppRoot(extractedRoot),
164
- temporaryRoot,
212
+ source: opened.source,
213
+ sourceKey: JSON.stringify(opened.source),
214
+ sourceRoot: await findPanelAppRoot(opened.extractedRoot),
215
+ temporaryRoot: opened.temporaryRoot,
165
216
  };
166
217
  }
167
218
  catch (error) {
168
- await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined);
219
+ await rm(opened.temporaryRoot, { recursive: true, force: true }).catch(() => undefined);
169
220
  throw error;
170
221
  }
171
222
  }
223
+ /**
224
+ * Download a public GitHub repository once and enumerate every independently
225
+ * installable Panel App beneath it. Discovery is read-only: selecting a result
226
+ * still runs the normal full review and digest-bound install flow.
227
+ */
228
+ export async function discoverGitPanelApps(input) {
229
+ const opened = await openGitPanelAppArchive(input);
230
+ try {
231
+ const roots = await discoverPanelAppRoots(opened.extractedRoot);
232
+ if (roots.length === 0) {
233
+ throw new PanelAppInstallError(`no Panel App found (expected ${PANEL_APP_MANIFEST_FILE}); ` +
234
+ "check the repository, branch, or optional search subdirectory");
235
+ }
236
+ const panels = [];
237
+ const issues = [];
238
+ for (const root of roots) {
239
+ const nested = relative(opened.extractedRoot, root).split(sep).join(posix.sep);
240
+ const subdir = joinedPanelAppSubdirectory(opened.source.subdir, nested);
241
+ const label = subdir ?? ".";
242
+ try {
243
+ const inspected = await inspectPanelAppSource(root);
244
+ panels.push({
245
+ subdir: label,
246
+ source: {
247
+ kind: "git",
248
+ url: opened.source.url,
249
+ ...(opened.source.ref ? { ref: opened.source.ref } : {}),
250
+ ...(subdir ? { subdir } : {}),
251
+ },
252
+ id: inspected.manifest.id,
253
+ version: inspected.manifest.version,
254
+ title: inspected.manifest.title,
255
+ ...(inspected.manifest.description
256
+ ? { description: inspected.manifest.description }
257
+ : {}),
258
+ icon: inspected.manifest.icon,
259
+ });
260
+ }
261
+ catch (error) {
262
+ issues.push({
263
+ subdir: label,
264
+ error: error instanceof Error ? error.message : String(error),
265
+ });
266
+ }
267
+ }
268
+ panels.sort((left, right) => left.subdir.localeCompare(right.subdir));
269
+ issues.sort((left, right) => left.subdir.localeCompare(right.subdir));
270
+ if (panels.length === 0) {
271
+ throw new PanelAppInstallError(`found ${issues.length} Panel App manifest(s), but none passed validation: ${issues
272
+ .map((issue) => `${issue.subdir}: ${issue.error}`)
273
+ .join("; ")}`);
274
+ }
275
+ return { source: opened.source, panels, issues };
276
+ }
277
+ finally {
278
+ await rm(opened.temporaryRoot, { recursive: true, force: true }).catch(() => undefined);
279
+ }
280
+ }
172
281
  async function disposeReviewedGitSnapshot(snapshot) {
173
282
  clearTimeout(snapshot.expiryTimer);
174
283
  await rm(snapshot.temporaryRoot, { recursive: true, force: true }).catch(() => undefined);
@@ -33,6 +33,12 @@ export interface PluginAutomationTemplateContribution {
33
33
  * instantiated later. Install paths and timestamps are intentionally excluded.
34
34
  */
35
35
  export declare function pluginAutomationTemplateRevision(installKey: string, template: PluginAutomationTemplate): string;
36
+ /**
37
+ * Read an installed plugin manifest without following links or accepting an
38
+ * unbounded/non-regular file. Shared by the runtime catalog and UI inventory so
39
+ * those views cannot disagree about which manifest bytes are trusted.
40
+ */
41
+ export declare function readCatalogPluginManifest(installPath: string): CanonicalPluginManifestData | null;
36
42
  /**
37
43
  * Load the installed plugin catalog at the core boundary.
38
44
  *
@@ -19,7 +19,12 @@ export function pluginAutomationTemplateRevision(installKey, template) {
19
19
  .update(JSON.stringify(template))
20
20
  .digest("hex");
21
21
  }
22
- function readCanonicalManifest(installPath) {
22
+ /**
23
+ * Read an installed plugin manifest without following links or accepting an
24
+ * unbounded/non-regular file. Shared by the runtime catalog and UI inventory so
25
+ * those views cannot disagree about which manifest bytes are trusted.
26
+ */
27
+ export function readCatalogPluginManifest(installPath) {
23
28
  const file = join(installPath, CANONICAL_PLUGIN_MANIFEST_FILE);
24
29
  let descriptor;
25
30
  try {
@@ -69,7 +74,7 @@ export function loadPluginCatalog(options = {}) {
69
74
  const installPath = resolveSafePluginPath(entry.installPath, root);
70
75
  if (!installPath)
71
76
  continue;
72
- const manifest = readCanonicalManifest(installPath);
77
+ const manifest = readCatalogPluginManifest(installPath);
73
78
  const identity = identityFromInstallKey(installKey, manifest);
74
79
  catalog.push({
75
80
  installKey,
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { type PluginHookEntry } from "./loadPluginHooks.js";
9
9
  import { type PluginHookReview } from "./pluginHookApproval.js";
10
- import { type PluginAutomationTemplate } from "./installer/types.js";
10
+ import type { PluginAutomationTemplate } from "./installer/types.js";
11
11
  export type PluginAutomationTemplateDescriptor = PluginAutomationTemplate & {
12
12
  revision: string;
13
13
  };
@@ -11,8 +11,7 @@ import { parseFrontmatter } from "../skills/frontmatter.js";
11
11
  import { listPluginHooks } from "./loadPluginHooks.js";
12
12
  import { reviewPluginHooks } from "./pluginHookApproval.js";
13
13
  import { readPluginMcp } from "./installer/loadPluginMcp.js";
14
- import { CANONICAL_PLUGIN_MANIFEST_FILE, CanonicalPluginManifest, } from "./installer/types.js";
15
- import { pluginAutomationTemplateRevision } from "./pluginCatalog.js";
14
+ import { pluginAutomationTemplateRevision, readCatalogPluginManifest } from "./pluginCatalog.js";
16
15
  function resolveContainedPath(root, candidate) {
17
16
  try {
18
17
  const realRoot = realpathSync(root);
@@ -117,14 +116,7 @@ export function describePluginContent(pluginName, installPath, installKey) {
117
116
  return undefined;
118
117
  }
119
118
  })();
120
- const manifest = (() => {
121
- try {
122
- return CanonicalPluginManifest.parse(JSON.parse(readFileSync(join(installPath, CANONICAL_PLUGIN_MANIFEST_FILE), "utf-8")));
123
- }
124
- catch {
125
- return null;
126
- }
127
- })();
119
+ const manifest = readCatalogPluginManifest(installPath);
128
120
  return {
129
121
  skills: listSkills(installPath),
130
122
  commands: listMdNames(installPath, "commands"),
@@ -37,6 +37,8 @@ export interface TurnOpts {
37
37
  archiveBeforeCurrentTurn?: {
38
38
  fromClientMessageId?: string;
39
39
  segmentId?: string;
40
+ /** Host-authored replacement for the archived span; skips the summarizer. */
41
+ summary?: string;
40
42
  };
41
43
  /** Structured input attachments for this turn. */
42
44
  attachments?: InputAttachmentMeta[];
@@ -85,6 +85,13 @@ function runInputError(params) {
85
85
  return `archiveBeforeCurrentTurn.${field} must be a bounded non-empty string`;
86
86
  }
87
87
  }
88
+ if (archive.summary !== undefined &&
89
+ (typeof archive.summary !== "string" ||
90
+ archive.summary.trim().length === 0 ||
91
+ archive.summary.length > 4_000 ||
92
+ /[\u0000]/u.test(archive.summary))) {
93
+ return "archiveBeforeCurrentTurn.summary must be a non-empty string up to 4000 characters";
94
+ }
88
95
  }
89
96
  if (params.behaviorMode !== undefined &&
90
97
  (typeof params.behaviorMode !== "string" || params.behaviorMode.length === 0)) {
@@ -87,6 +87,8 @@ export interface RunParams {
87
87
  archiveBeforeCurrentTurn?: {
88
88
  fromClientMessageId?: string;
89
89
  segmentId?: string;
90
+ /** Host-authored replacement for the archived span; skips the summarizer. */
91
+ summary?: string;
90
92
  };
91
93
  /** Desktop host ownership generation for process-local Quick Chat runs. */
92
94
  quickChatClaimId?: string;
@@ -221,6 +221,13 @@ export declare class SessionManager {
221
221
  state: SessionState;
222
222
  } | undefined;
223
223
  resume(sessionId: string): SessionBundle;
224
+ /**
225
+ * Resume a model run with a bounded active replay for large Pet transcripts
226
+ * that already contain a full-history archive boundary. Audit/detail callers
227
+ * continue to use resume() and receive every persisted event.
228
+ */
229
+ resumeForRun(sessionId: string): SessionBundle;
230
+ private resumeWithTranscriptMode;
224
231
  /**
225
232
  * Return the exact persistence directory owned by this manager.
226
233
  *
@@ -1001,6 +1001,17 @@ export class SessionManager {
1001
1001
  return undefined;
1002
1002
  }
1003
1003
  resume(sessionId) {
1004
+ return this.resumeWithTranscriptMode(sessionId, false);
1005
+ }
1006
+ /**
1007
+ * Resume a model run with a bounded active replay for large Pet transcripts
1008
+ * that already contain a full-history archive boundary. Audit/detail callers
1009
+ * continue to use resume() and receive every persisted event.
1010
+ */
1011
+ resumeForRun(sessionId) {
1012
+ return this.resumeWithTranscriptMode(sessionId, true);
1013
+ }
1014
+ resumeWithTranscriptMode(sessionId, contextOnly) {
1004
1015
  assertSafeSessionId(sessionId);
1005
1016
  const processLocal = this.processLocalBundle(sessionId);
1006
1017
  if (processLocal) {
@@ -1037,8 +1048,10 @@ export class SessionManager {
1037
1048
  chmodSync(sessionDir, 0o700);
1038
1049
  chmodSync(stateFile, 0o600);
1039
1050
  }
1040
- const transcript = Transcript.loadFromFile(transcriptFile);
1041
1051
  state.kind = normalizedSessionKind(state.kind);
1052
+ const transcript = contextOnly && state.kind === "pet"
1053
+ ? Transcript.loadContextFromFile(transcriptFile)
1054
+ : Transcript.loadFromFile(transcriptFile);
1042
1055
  state.status = "active";
1043
1056
  delete state.lastCompletionKind;
1044
1057
  Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
@@ -1697,11 +1710,11 @@ export class SessionManager {
1697
1710
  list(limit = 20, opts) {
1698
1711
  if (!existsSync(this.sessionsDir))
1699
1712
  return [];
1700
- // Extension-owned session kinds stay out of generic lists. Defaults to
1701
- // hiding "pet" so default-arg callers keep today's behavior.
1702
- // TODO(pet-out-of-core): drop the default once every caller passes the
1703
- // host's hidden-kind union explicitly.
1704
- const excludeKinds = opts?.excludeKinds ?? ["pet"];
1713
+ // Core is domain-agnostic: only the composition root knows which
1714
+ // extension-owned kinds belong outside its generic list. AgentServer passes
1715
+ // the resolved hidden-kind union explicitly; direct SDK callers see every
1716
+ // kind unless they choose exclusions themselves.
1717
+ const excludeKinds = opts?.excludeKinds ?? [];
1705
1718
  const dirs = readdirSync(this.sessionsDir, { withFileTypes: true })
1706
1719
  .filter((d) => d.isDirectory() && !d.name.startsWith(".pending-") && !d.name.startsWith("qchat-"))
1707
1720
  .map((d) => d.name);
@@ -184,6 +184,15 @@ export declare class Transcript {
184
184
  */
185
185
  static selectContextRange(events: readonly TranscriptEvent[], range: ContextEventRange): SelectedContextRange;
186
186
  static loadFromFile(filePath: string): Transcript;
187
+ /**
188
+ * Load the active replay for a model run while leaving the append-only audit
189
+ * transcript untouched on disk. A host-authored from-less range archive
190
+ * replaces everything before its `to` anchor, so a large Mimi transcript can
191
+ * retain only that boundary and the live tail in memory. If the boundary is
192
+ * absent, too old, malformed, or not self-contained in the bounded tail, we
193
+ * fail open to the full loader.
194
+ */
195
+ static loadContextFromFile(filePath: string, maxTailBytes?: number): Transcript;
187
196
  private loadEvents;
188
197
  }
189
198
  export {};
@@ -6,6 +6,7 @@ import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fstatSync
6
6
  import { dirname } from "node:path";
7
7
  import { nanoid } from "nanoid";
8
8
  import { logger } from "../logging/logger.js";
9
+ const DEFAULT_CONTEXT_TAIL_SCAN_BYTES = 32 * 1024 * 1024;
9
10
  function appendTranscriptLine(filePath, data) {
10
11
  // Use one append-mode descriptor so concurrent OS writers cannot overwrite
11
12
  // one another. Also repair the record boundary after a crash-torn final line;
@@ -678,6 +679,86 @@ export class Transcript {
678
679
  transcript.repairToolResultPairs();
679
680
  return transcript;
680
681
  }
682
+ /**
683
+ * Load the active replay for a model run while leaving the append-only audit
684
+ * transcript untouched on disk. A host-authored from-less range archive
685
+ * replaces everything before its `to` anchor, so a large Mimi transcript can
686
+ * retain only that boundary and the live tail in memory. If the boundary is
687
+ * absent, too old, malformed, or not self-contained in the bounded tail, we
688
+ * fail open to the full loader.
689
+ */
690
+ static loadContextFromFile(filePath, maxTailBytes = DEFAULT_CONTEXT_TAIL_SCAN_BYTES) {
691
+ if (!existsSync(filePath))
692
+ return new Transcript(filePath);
693
+ let tail;
694
+ try {
695
+ const fd = openSync(filePath, "r");
696
+ try {
697
+ const fileSize = fstatSync(fd).size;
698
+ if (fileSize <= maxTailBytes)
699
+ return Transcript.loadFromFile(filePath);
700
+ const length = Math.min(fileSize, Math.max(1, maxTailBytes));
701
+ const buffer = Buffer.allocUnsafe(length);
702
+ const bytesRead = readSync(fd, buffer, 0, length, fileSize - length);
703
+ let window = buffer.subarray(0, bytesRead);
704
+ const newline = window.indexOf(0x0a);
705
+ if (newline < 0)
706
+ return Transcript.loadFromFile(filePath);
707
+ window = window.subarray(newline + 1);
708
+ tail = window.toString("utf8");
709
+ }
710
+ finally {
711
+ closeSync(fd);
712
+ }
713
+ }
714
+ catch {
715
+ return Transcript.loadFromFile(filePath);
716
+ }
717
+ const events = [];
718
+ for (const line of tail.split("\n")) {
719
+ if (!line.trim())
720
+ continue;
721
+ try {
722
+ events.push(JSON.parse(line));
723
+ }
724
+ catch {
725
+ // A malformed tail must not turn a partial window into authoritative
726
+ // context. The normal loader keeps its established skip behavior.
727
+ return Transcript.loadFromFile(filePath);
728
+ }
729
+ }
730
+ let markerIndex = -1;
731
+ let toClientMessageId;
732
+ for (const [index, event] of events.entries()) {
733
+ if (event.type !== "range_archive")
734
+ continue;
735
+ const data = event.data;
736
+ if (data.fromClientMessageId === undefined &&
737
+ typeof data.summary === "string" &&
738
+ typeof data.toClientMessageId === "string") {
739
+ markerIndex = index;
740
+ toClientMessageId = data.toClientMessageId;
741
+ }
742
+ }
743
+ if (markerIndex < 0 || !toClientMessageId)
744
+ return Transcript.loadFromFile(filePath);
745
+ const anchorIndex = events.findIndex((event, index) => index < markerIndex &&
746
+ event.type === "message" &&
747
+ event.data.clientMessageId === toClientMessageId);
748
+ if (anchorIndex < 0)
749
+ return Transcript.loadFromFile(filePath);
750
+ let startIndex = anchorIndex;
751
+ for (let index = anchorIndex - 1; index >= 0; index -= 1) {
752
+ if (events[index]?.type !== "turn_boundary")
753
+ continue;
754
+ startIndex = index;
755
+ break;
756
+ }
757
+ const transcript = new Transcript(filePath);
758
+ transcript.loadEvents(events.slice(startIndex));
759
+ transcript.repairToolResultPairs();
760
+ return transcript;
761
+ }
681
762
  loadEvents(events) {
682
763
  this.events = structuredClone([...events]);
683
764
  this.currentTurn = 0;
@@ -153,6 +153,18 @@ export declare class SettingsManager {
153
153
  * be shared with collaborators.
154
154
  */
155
155
  saveLocalSetting(key: string, value: unknown, cwd: string): void;
156
+ /**
157
+ * Mutate one writable settings layer under the same cross-process lock used
158
+ * by the desktop settings service. Domain tools use this when two related
159
+ * fields must change atomically (for example modelConnections + defaults):
160
+ * composing multiple save*Setting calls would expose an intermediate state
161
+ * and could interleave with another process between writes.
162
+ *
163
+ * The callback receives only the selected layer's raw object, not the merged
164
+ * settings view. The result is schema-validated before it replaces the file.
165
+ * Returning false makes the operation a no-op.
166
+ */
167
+ mutateSettingsForScope(scope: "user" | "project", cwd: string, mutate: (current: Record<string, unknown>) => boolean | void): void;
156
168
  /**
157
169
  * Delete a single dotted key from the PROJECT-level config file. Used to
158
170
  * express "inherit" — we don't persist the literal "inherit"; we remove the
@@ -417,6 +417,37 @@ export class SettingsManager {
417
417
  });
418
418
  this.invalidate();
419
419
  }
420
+ /**
421
+ * Mutate one writable settings layer under the same cross-process lock used
422
+ * by the desktop settings service. Domain tools use this when two related
423
+ * fields must change atomically (for example modelConnections + defaults):
424
+ * composing multiple save*Setting calls would expose an intermediate state
425
+ * and could interleave with another process between writes.
426
+ *
427
+ * The callback receives only the selected layer's raw object, not the merged
428
+ * settings view. The result is schema-validated before it replaces the file.
429
+ * Returning false makes the operation a no-op.
430
+ */
431
+ mutateSettingsForScope(scope, cwd, mutate) {
432
+ const path = scope === "user"
433
+ ? join(this.userConfigDir(), "settings.json")
434
+ : this.projectSettingsPath(cwd);
435
+ if (scope === "project") {
436
+ this.validateProjectCwd(cwd, "project");
437
+ if (!existsSync(cwd))
438
+ throw new Error(`project directory does not exist: ${cwd}`);
439
+ }
440
+ this.mutateSettingsFile(path, (current) => {
441
+ if (mutate(current) === false)
442
+ return false;
443
+ // Validate the complete resulting layer before persistence. We keep the
444
+ // original object for serialization so forward-compatible unknown keys
445
+ // are preserved instead of being stripped by Zod's parsed result.
446
+ validateSettings(current);
447
+ return true;
448
+ });
449
+ this.invalidate();
450
+ }
420
451
  /**
421
452
  * Delete a single dotted key from the PROJECT-level config file. Used to
422
453
  * express "inherit" — we don't persist the literal "inherit"; we remove the
@@ -0,0 +1,36 @@
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 type { ToolDefinition } from "../../types.js";
8
+ import type { ToolContext } from "../context.js";
9
+ import { SettingsManager } from "../../settings/manager.js";
10
+ import { type CatalogEntry } from "../../model-catalog/index.js";
11
+ import { createLLMClient } from "../../llm/client-factory.js";
12
+ import { type Credential, type ModelInstance } from "../../model-catalog/resolve.js";
13
+ interface ConfigureModelConnectionDeps {
14
+ makeSettingsManager(cwd: string, scope: "full" | "project"): SettingsManager;
15
+ getCatalog(): CatalogEntry[];
16
+ notifySettingsChanged(): void;
17
+ testTextConnection(connection: ModelInstance, credentials: Credential[], catalog: CatalogEntry[]): Promise<ConnectionTestResult>;
18
+ }
19
+ interface ConnectionTestResult {
20
+ ok: boolean;
21
+ response?: string;
22
+ stopReason?: string;
23
+ usage?: {
24
+ promptTokens: number;
25
+ completionTokens: number;
26
+ totalTokens: number;
27
+ };
28
+ error?: string;
29
+ }
30
+ export declare function probeTextModelConnection(connection: ModelInstance, credentials: Credential[], catalog: CatalogEntry[], options?: {
31
+ fetch?: typeof globalThis.fetch;
32
+ createClient?: typeof createLLMClient;
33
+ }): Promise<ConnectionTestResult>;
34
+ export declare const configureModelConnectionToolDef: ToolDefinition;
35
+ export declare function configureModelConnectionTool(args: Record<string, unknown>, ctx?: ToolContext, deps?: ConfigureModelConnectionDeps): Promise<string>;
36
+ export {};