codeep 2.12.0 → 2.13.1

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.
package/README.md CHANGED
@@ -1340,7 +1340,15 @@ Apache 2.0
1340
1340
 
1341
1341
  ## Contributing
1342
1342
 
1343
- Contributions are welcome! Please open an issue or submit a pull request on [GitHub](https://github.com/VladoIvankovic/Codeep).
1343
+ Contributions are welcome! Before opening a PR, please read the
1344
+ [**Contributing guide**](./CONTRIBUTING.md) — it covers the development setup
1345
+ (npm is the canonical package manager), the test/build loop, code style, and
1346
+ how to propose a change. For provider integrations specifically, the guide
1347
+ walks through the files you need to touch.
1348
+
1349
+ Quick version: `npm install` → `npm test` → `npx tsc --noEmit` clean → open a
1350
+ PR against `main`. To report a security issue, see
1351
+ [**Security policy**](./SECURITY.md).
1344
1352
 
1345
1353
  ## Support
1346
1354
 
@@ -1 +1,42 @@
1
+ import { SessionModeState, SessionConfigOption } from './protocol.js';
2
+ export declare const AGENT_MODES: SessionModeState;
3
+ /**
4
+ * Format a tool call's parameters into a human-readable object for the
5
+ * permission dialog. Truncates long content fields so the dialog stays readable.
6
+ *
7
+ * Exported for unit testing (see server.test.ts).
8
+ */
9
+ export declare function formatToolInputForPermission(tool: string, params: Record<string, unknown>): Record<string, string>;
10
+ /**
11
+ * Resolve a `file://` (or absolute path) URI to a local filesystem path.
12
+ * Returns null for unsupported schemes (http/https/git/etc.) — we only embed
13
+ * local content for safety/privacy.
14
+ *
15
+ * Exported for unit testing (see server.test.ts).
16
+ */
17
+ export declare function resolveLocalPath(uri: string): string | null;
18
+ /**
19
+ * Walk the prompt's ContentBlock[] looking for ResourceLink and Resource
20
+ * blocks. ResourceLink → read file from disk. Resource → use embedded text.
21
+ * Returns a markdown-fenced snippet block ready to prepend to the prompt,
22
+ * or empty string if there are no embedded contexts.
23
+ *
24
+ * Exported for unit testing (see server.test.ts).
25
+ */
26
+ export declare function collectEmbeddedContext(blocks: import('./protocol.js').ContentBlock[]): Promise<string>;
27
+ /** Check if a provider has an API key stored (synchronous; relies on the cache
28
+ * loaded at startup via loadAllApiKeys and the non-secret configuredProviderIds
29
+ * index — never reads plaintext key material).
30
+ *
31
+ * Exported for unit testing (see server.test.ts).
32
+ */
33
+ export declare function providerHasKey(providerId: string): boolean;
34
+ /**
35
+ * Build the list of session-level config options advertised to the ACP client
36
+ * (provider/model picker, language, per-tool confirmation toggles).
37
+ *
38
+ * Reads the global config + provider table; pure of transport/session state.
39
+ * Exported for unit testing (see server.test.ts).
40
+ */
41
+ export declare function buildConfigOptions(): SessionConfigOption[];
1
42
  export declare function startAcpServer(): Promise<void>;
@@ -7,12 +7,13 @@ import { fileURLToPath } from 'url';
7
7
  import { StdioTransport } from './transport.js';
8
8
  import { runAgentSession } from './session.js';
9
9
  import { loadCustomCommands } from '../utils/customCommands.js';
10
- import { registerSessionServers, disposeSession as disposeMcpSession, disposeAllSessions as disposeAllMcpSessions } from '../utils/mcpRegistry.js';
10
+ import { registerSessionServers, disposeAllSessions as disposeAllMcpSessions } from '../utils/mcpRegistry.js';
11
11
  import { loadMcpServerConfig, mergeMcpServers } from '../utils/mcpConfig.js';
12
12
  import { handleMcpSamplingRequest } from '../utils/mcpSamplingBridge.js';
13
13
  import { executeCommandAsync } from '../utils/shell.js';
14
14
  import { initWorkspace, loadWorkspace, handleCommand } from './commands.js';
15
- import { autoSaveSession, config, setProvider, setApiKey, getApiKey, getConfiguredProviders, listSessionsWithInfo, deleteSession as deleteSessionFile } from '../config/index.js';
15
+ import { handleSetMode as handleSetModeExternal, handleSetConfigOption as handleSetConfigOptionExternal, handleSessionList as handleSessionListExternal, handleSessionDelete as handleSessionDeleteExternal, handleListProviders as handleListProvidersExternal, } from './serverHandlers.js';
16
+ import { autoSaveSession, config, getApiKey, getConfiguredProviders } from '../config/index.js';
16
17
  import { ApiError } from '../api/index.js';
17
18
  import { PROVIDERS } from '../config/providers.js';
18
19
  import { getCurrentVersion } from '../utils/update.js';
@@ -87,7 +88,8 @@ const AVAILABLE_COMMANDS = [
87
88
  { name: 'deploy', description: 'Deploy the project' },
88
89
  ];
89
90
  // ─── Mode definitions ─────────────────────────────────────────────────────────
90
- const AGENT_MODES = {
91
+ // Exported for unit testing + reference from serverHandlers modules.
92
+ export const AGENT_MODES = {
91
93
  currentModeId: 'auto',
92
94
  availableModes: [
93
95
  { id: 'auto', name: 'Auto', description: 'Agent runs automatically without confirmation' },
@@ -112,8 +114,10 @@ const LANGUAGE_OPTIONS = [
112
114
  /**
113
115
  * Format a tool call's parameters into a human-readable object for the
114
116
  * permission dialog. Truncates long content fields so the dialog stays readable.
117
+ *
118
+ * Exported for unit testing (see server.test.ts).
115
119
  */
116
- function formatToolInputForPermission(tool, params) {
120
+ export function formatToolInputForPermission(tool, params) {
117
121
  const MAX_LEN = 120;
118
122
  // Bigger budget for diff/content fields so clients can render an actual preview
119
123
  // before the user clicks Allow. Truncated with a visible marker so users know
@@ -167,8 +171,10 @@ function formatToolInputForPermission(tool, params) {
167
171
  * Resolve a `file://` (or absolute path) URI to a local filesystem path.
168
172
  * Returns null for unsupported schemes (http/https/git/etc.) — we only embed
169
173
  * local content for safety/privacy.
174
+ *
175
+ * Exported for unit testing (see server.test.ts).
170
176
  */
171
- function resolveLocalPath(uri) {
177
+ export function resolveLocalPath(uri) {
172
178
  if (!uri)
173
179
  return null;
174
180
  if (uri.startsWith('file://')) {
@@ -189,8 +195,10 @@ function resolveLocalPath(uri) {
189
195
  * blocks. ResourceLink → read file from disk. Resource → use embedded text.
190
196
  * Returns a markdown-fenced snippet block ready to prepend to the prompt,
191
197
  * or empty string if there are no embedded contexts.
198
+ *
199
+ * Exported for unit testing (see server.test.ts).
192
200
  */
193
- async function collectEmbeddedContext(blocks) {
201
+ export async function collectEmbeddedContext(blocks) {
194
202
  const MAX_BYTES_PER_FILE = 200_000; // ~200 KB cap per resource
195
203
  const snippets = [];
196
204
  for (const block of blocks) {
@@ -243,8 +251,11 @@ async function collectEmbeddedContext(blocks) {
243
251
  }
244
252
  /** Check if a provider has an API key stored (synchronous; relies on the cache
245
253
  * loaded at startup via loadAllApiKeys and the non-secret configuredProviderIds
246
- * index — never reads plaintext key material). */
247
- function providerHasKey(providerId) {
254
+ * index — never reads plaintext key material).
255
+ *
256
+ * Exported for unit testing (see server.test.ts).
257
+ */
258
+ export function providerHasKey(providerId) {
248
259
  // Check environment variable first
249
260
  const envKey = PROVIDERS[providerId]?.envKey;
250
261
  if (envKey && process.env[envKey])
@@ -255,7 +266,14 @@ function providerHasKey(providerId) {
255
266
  // Non-secret index of providers that have a key in secure storage
256
267
  return getConfiguredProviders().some(p => p.id === providerId);
257
268
  }
258
- function buildConfigOptions() {
269
+ /**
270
+ * Build the list of session-level config options advertised to the ACP client
271
+ * (provider/model picker, language, per-tool confirmation toggles).
272
+ *
273
+ * Reads the global config + provider table; pure of transport/session state.
274
+ * Exported for unit testing (see server.test.ts).
275
+ */
276
+ export function buildConfigOptions() {
259
277
  const currentModel = config.get('model') ?? '';
260
278
  const currentProviderId = config.get('provider') ?? '';
261
279
  // Only show providers that have an API key configured
@@ -345,6 +363,10 @@ export function startAcpServer() {
345
363
  const transport = new StdioTransport();
346
364
  // ACP sessionId → full AcpSession (includes history + codeep session tracking)
347
365
  const sessions = new Map();
366
+ // Shared deps object for the extracted handlers in serverHandlers.ts.
367
+ // Handlers read transport + sessions off this; stubbing both in a test
368
+ // (see serverHandlers.test.ts) is what makes them unit-testable.
369
+ const handlerDeps = { transport, sessions };
348
370
  // Tear down all MCP child processes when the CLI dies. Without this,
349
371
  // killing `codeep acp` with Ctrl+C orphans any servers we spawned —
350
372
  // they keep running until the user hunts them down with `ps`.
@@ -720,143 +742,19 @@ export function startAcpServer() {
720
742
  }
721
743
  // ── session/set_mode ────────────────────────────────────────────────────────
722
744
  function handleSetMode(msg) {
723
- const { sessionId, modeId } = msg.params;
724
- const session = sessions.get(sessionId);
725
- if (!session) {
726
- transport.error(msg.id, -32602, `Unknown sessionId: ${sessionId}`);
727
- return;
728
- }
729
- const validMode = AGENT_MODES.availableModes.find(m => m.id === modeId);
730
- if (!validMode) {
731
- transport.error(msg.id, -32602, `Unknown modeId: ${modeId}`);
732
- return;
733
- }
734
- session.currentModeId = modeId;
735
- // Do NOT persist the global `agentConfirmation` config here. The ACP
736
- // permission gate is driven per-session by `session.currentModeId` (see the
737
- // onRequestPermission wiring in handleSessionPrompt). Writing it globally
738
- // would leak this session's mode into other processes — e.g. switching ACP
739
- // to 'auto' would silently disarm the TUI's confirmation gate. Mode stays
740
- // on the in-memory session object only.
741
- transport.respond(msg.id, {});
742
- // Notify Zed of the mode change
743
- transport.notify('session/update', {
744
- sessionId,
745
- update: {
746
- sessionUpdate: 'current_mode_update',
747
- currentModeId: modeId,
748
- },
749
- });
745
+ handleSetModeExternal(msg, handlerDeps);
750
746
  }
751
747
  // ── session/set_config_option ───────────────────────────────────────────────
752
748
  function handleSetConfigOption(msg) {
753
- const { sessionId, configId, value } = msg.params;
754
- const session = sessions.get(sessionId);
755
- if (!session) {
756
- transport.error(msg.id, -32602, `Unknown sessionId: ${sessionId}`);
757
- return;
758
- }
759
- if (configId === 'model' && typeof value === 'string') {
760
- // value is "providerId/modelId" — split and switch both
761
- const slashIdx = value.indexOf('/');
762
- if (slashIdx !== -1) {
763
- const providerId = value.slice(0, slashIdx);
764
- const modelId = value.slice(slashIdx + 1);
765
- setProvider(providerId); // sets provider + defaultModel + protocol
766
- config.set('model', modelId);
767
- }
768
- else {
769
- config.set('model', value);
770
- }
771
- }
772
- else if (configId === 'provider' && typeof value === 'string') {
773
- // Switch provider without specifying a model — picks the provider's
774
- // default model + protocol. Used by editor clients that pin a provider
775
- // in their settings (e.g. the VS Code `codeep.provider` setting).
776
- setProvider(value);
777
- }
778
- else if (configId === 'customBaseUrl' && typeof value === 'string') {
779
- // Base URL for the `custom` (OpenAI-compatible) provider — lets editor
780
- // clients point Codeep at a self-hosted endpoint (vLLM/LiteLLM/LM Studio)
781
- // without hand-editing ~/.codeep/config.json.
782
- config.set('customBaseUrl', value);
783
- }
784
- else if (configId === 'language' && typeof value === 'string') {
785
- config.set('language', value);
786
- }
787
- else if (configId === 'agentConfirmDeleteFile' ||
788
- configId === 'agentConfirmExecuteCommand' ||
789
- configId === 'agentConfirmWriteFile' ||
790
- configId === 'userProfile' ||
791
- configId === 'autoLearnProfile') {
792
- // Accept boolean or "true"/"false" string from Zed/VSCode
793
- const bool = value === true || value === 'true';
794
- config.set(configId, bool);
795
- }
796
- else if (configId === 'login' && typeof value === 'string') {
797
- // value is "providerId:apiKey"
798
- const colonIdx = value.indexOf(':');
799
- if (colonIdx !== -1) {
800
- const providerId = value.slice(0, colonIdx);
801
- const apiKey = value.slice(colonIdx + 1);
802
- if (providerId && apiKey) {
803
- setProvider(providerId);
804
- // Cache is updated synchronously inside setApiKey; the keychain write
805
- // resolves while the long-lived server runs. Swallow a persistence
806
- // failure here (secondary path) so it can't become an unhandled
807
- // rejection — the key still works for this session via the cache.
808
- setApiKey(apiKey, providerId).catch(() => { });
809
- }
810
- }
811
- }
812
- transport.respond(msg.id, {});
813
- // Confirm the new value back to Zed so its UI state stays in sync
814
- transport.notify('session/update', {
815
- sessionId,
816
- update: {
817
- sessionUpdate: 'config_option_update',
818
- configOptions: buildConfigOptions(),
819
- },
820
- });
749
+ handleSetConfigOptionExternal(msg, handlerDeps);
821
750
  }
822
751
  // ── session/list ─────────────────────────────────────────────────────────────
823
752
  function handleSessionList(msg) {
824
- const params = (msg.params ?? {});
825
- // Collect local (project-scoped) sessions and global sessions, deduplicated by name
826
- const seen = new Set();
827
- const merged = [
828
- ...listSessionsWithInfo(params.cwd), // project-local first
829
- ...listSessionsWithInfo(), // global fallback
830
- ].filter(s => {
831
- if (seen.has(s.name))
832
- return false;
833
- seen.add(s.name);
834
- return true;
835
- });
836
- // Sort newest first
837
- merged.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
838
- const acpSessions = merged.map(s => ({
839
- sessionId: s.name,
840
- cwd: params.cwd ?? '',
841
- title: s.title,
842
- updatedAt: s.createdAt,
843
- }));
844
- const result = { sessions: acpSessions };
845
- transport.respond(msg.id, result);
753
+ handleSessionListExternal(msg, handlerDeps);
846
754
  }
847
755
  // ── session/delete ───────────────────────────────────────────────────────────
848
756
  function handleSessionDelete(msg) {
849
- const { sessionId, cwd } = (msg.params ?? {});
850
- // Remove from in-memory sessions map if present
851
- sessions.delete(sessionId);
852
- // Tear down any MCP server processes attached to this session — leaks
853
- // children otherwise. Fire-and-forget; client doesn't wait on stop().
854
- disposeMcpSession(sessionId).catch(() => { });
855
- // Try project dir first, then global
856
- const deleted = deleteSessionFile(sessionId, cwd || undefined);
857
- if (!deleted && cwd)
858
- deleteSessionFile(sessionId);
859
- transport.respond(msg.id, {});
757
+ handleSessionDeleteExternal(msg, handlerDeps);
860
758
  }
861
759
  // ── session/list_providers ──────────────────────────────────────────────────
862
760
  // Canonical provider list for ACP clients (Codeep VS Code extension etc.).
@@ -864,24 +762,7 @@ export function startAcpServer() {
864
762
  // source instead of carrying its own hardcoded copy. Keep this stable —
865
763
  // adding new fields is fine, removing/renaming would break existing clients.
866
764
  function handleListProviders(msg) {
867
- const providers = Object.entries(PROVIDERS).map(([id, p]) => ({
868
- id,
869
- name: p.name,
870
- description: p.description,
871
- groupLabel: p.groupLabel ?? p.name,
872
- hint: p.hint ?? p.description,
873
- requiresKey: !p.noApiKey,
874
- subscribeUrl: p.subscribeUrl,
875
- // Model metadata so ACP clients (e.g. the VS Code model picker) can
876
- // offer a provider → model selector without hardcoding a catalog.
877
- // `dynamicModels` flags providers whose model list is open-ended
878
- // (OpenRouter, Ollama, custom endpoints) — clients should let the
879
- // user type a model id rather than only pick from `models`.
880
- models: p.models.map((m) => ({ id: m.id, name: m.name })),
881
- defaultModel: p.defaultModel,
882
- dynamicModels: p.dynamicModels ?? false,
883
- }));
884
- transport.respond(msg.id, { providers });
765
+ handleListProvidersExternal(msg, handlerDeps);
885
766
  }
886
767
  // ── session/prompt ──────────────────────────────────────────────────────────
887
768
  async function handleSessionPrompt(msg) {
@@ -0,0 +1,88 @@
1
+ import type { JsonRpcRequest } from './protocol.js';
2
+ import type { AcpSession } from './commands.js';
3
+ /** Session record held by the running server. Lives in server.ts; re-exposed
4
+ * here so handler signatures can name it without re-declaring the shape. */
5
+ export interface AcpServerSession extends AcpSession {
6
+ abortController: AbortController | null;
7
+ currentModeId: string;
8
+ titleSent: boolean;
9
+ hadHistory: boolean;
10
+ }
11
+ /** Sessions map the server owns. */
12
+ export type SessionMap = Map<string, AcpServerSession>;
13
+ /** The transport surface handlers actually use. Mirrors `StdioTransport`'s
14
+ * public methods — stub this in tests. */
15
+ export interface AcpTransport {
16
+ respond(id: string | number, result: unknown): void;
17
+ error(id: string | number, code: number, message: string): void;
18
+ notify(method: string, params: unknown): void;
19
+ }
20
+ /** Everything a handler needs. Constructed once in `startAcpServer()` and
21
+ * passed to every handler call. */
22
+ export interface AcpHandlerDeps {
23
+ transport: AcpTransport;
24
+ sessions: SessionMap;
25
+ }
26
+ /**
27
+ * Switch the agent confirmation mode for a session (auto = no prompts,
28
+ * manual = prompt before destructive tools). Per-session only — the global
29
+ * `agentConfirmation` config is deliberately NOT mutated, see the comment
30
+ * in the body.
31
+ */
32
+ export declare function handleSetMode(msg: JsonRpcRequest, deps: AcpHandlerDeps): void;
33
+ /**
34
+ * Apply a session-level config change coming from the ACP client (model picker,
35
+ * language dropdown, confirmation toggles, login field). Mutates the global
36
+ * config (these ARE meant to persist across sessions, unlike mode) and pushes
37
+ * the refreshed config-option list back to the client.
38
+ */
39
+ export declare function handleSetConfigOption(msg: JsonRpcRequest, deps: AcpHandlerDeps): void;
40
+ /**
41
+ * Pure-ish: apply a single config-option update to the global config.
42
+ * Split out from the handler so it can be tested without a transport.
43
+ *
44
+ * - `model` / `provider`: switch the active provider + model.
45
+ * - `customBaseUrl`: point the `custom` OpenAI-compatible provider at a
46
+ * self-hosted endpoint.
47
+ * - `language`: response language code.
48
+ * - `agentConfirm*` / `userProfile` / `autoLearnProfile`: boolean toggles,
49
+ * accept `true`/`false` or the strings `"true"`/`"false"` (Zed sends strings).
50
+ * - `login`: special-case `"providerId:apiKey"` — sets the provider and writes
51
+ * the key to the keychain. The keychain write is async and fire-and-forget;
52
+ * a persistence failure only means the key isn't durable, it's still usable
53
+ * in-process via the cache.
54
+ */
55
+ export declare function applyConfigOption(configId: string, value: unknown): void;
56
+ /**
57
+ * List saved sessions (project-scoped first, then global fallback), deduped
58
+ * by name and sorted newest-first.
59
+ */
60
+ export declare function handleSessionList(msg: JsonRpcRequest, deps: AcpHandlerDeps): void;
61
+ /**
62
+ * Remove a session: drop it from the in-memory map, tear down any MCP child
63
+ * processes attached to it, then delete the on-disk session file (project dir
64
+ * first, global fallback).
65
+ */
66
+ export declare function handleSessionDelete(msg: JsonRpcRequest, deps: AcpHandlerDeps): void;
67
+ /**
68
+ * Pure shape: map the PROVIDERS table into the serialisable form ACP clients
69
+ * expect (id/name/groupLabel/models/dynamicModels/…). No transport side-effect.
70
+ * Exported so the handler is a one-liner and the shape can be unit-tested.
71
+ */
72
+ export declare function buildProviderList(): {
73
+ id: string;
74
+ name: string;
75
+ description: string;
76
+ groupLabel: string;
77
+ hint: string;
78
+ requiresKey: boolean;
79
+ subscribeUrl: string | undefined;
80
+ models: {
81
+ id: string;
82
+ name: string;
83
+ }[];
84
+ defaultModel: string;
85
+ dynamicModels: boolean;
86
+ }[];
87
+ /** session/list_providers handler — delegate to the pure builder + respond. */
88
+ export declare function handleListProviders(msg: JsonRpcRequest, deps: AcpHandlerDeps): void;
@@ -0,0 +1,237 @@
1
+ // src/acp/serverHandlers.ts
2
+ //
3
+ // Session-lifecycle handlers for the ACP server, extracted from the
4
+ // `startAcpServer()` closure in `server.ts` into a factory so they can be
5
+ // unit-tested with a stub transport + sessions map.
6
+ //
7
+ // Each handler is a pure function of (msg, deps) → side-effect on deps.
8
+ // `deps` carries the transport (for respond/error/notify), the sessions map,
9
+ // and a handful of module-scope helpers (buildConfigOptions, AGENT_MODES).
10
+ // No handler reads process-level singletons directly — everything comes in
11
+ // through deps, which is what makes them testable.
12
+ //
13
+ // Not all handlers from server.ts live here yet. The big async ones
14
+ // (`handleSessionPrompt`) reach into MCP spawning, vision APIs, and the agent
15
+ // loop — those stay inside the closure until their dependencies are also
16
+ // lifted. The handlers in this file are the synchronous ones whose contract
17
+ // is "look up the session, mutate config or session state, acknowledge".
18
+ import { AGENT_MODES, buildConfigOptions } from './server.js';
19
+ import { config, setProvider, setApiKey, listSessionsWithInfo, deleteSession as deleteSessionFile, } from '../config/index.js';
20
+ import { disposeSession as disposeMcpSession } from '../utils/mcpRegistry.js';
21
+ // ─── session/set_mode ─────────────────────────────────────────────────────────
22
+ /**
23
+ * Switch the agent confirmation mode for a session (auto = no prompts,
24
+ * manual = prompt before destructive tools). Per-session only — the global
25
+ * `agentConfirmation` config is deliberately NOT mutated, see the comment
26
+ * in the body.
27
+ */
28
+ export function handleSetMode(msg, deps) {
29
+ const { transport, sessions } = deps;
30
+ const { sessionId, modeId } = msg.params;
31
+ const session = sessions.get(sessionId);
32
+ if (!session) {
33
+ transport.error(msg.id, -32602, `Unknown sessionId: ${sessionId}`);
34
+ return;
35
+ }
36
+ const validMode = AGENT_MODES.availableModes.find(m => m.id === modeId);
37
+ if (!validMode) {
38
+ transport.error(msg.id, -32602, `Unknown modeId: ${modeId}`);
39
+ return;
40
+ }
41
+ session.currentModeId = modeId;
42
+ // Do NOT persist the global `agentConfirmation` config here. The ACP
43
+ // permission gate is driven per-session by `session.currentModeId` (see the
44
+ // onRequestPermission wiring in handleSessionPrompt). Writing it globally
45
+ // would leak this session's mode into other processes — e.g. switching ACP
46
+ // to 'auto' would silently disarm the TUI's confirmation gate. Mode stays
47
+ // on the in-memory session object only.
48
+ transport.respond(msg.id, {});
49
+ // Notify Zed of the mode change
50
+ transport.notify('session/update', {
51
+ sessionId,
52
+ update: {
53
+ sessionUpdate: 'current_mode_update',
54
+ currentModeId: modeId,
55
+ },
56
+ });
57
+ }
58
+ // ─── session/set_config_option ────────────────────────────────────────────────
59
+ /**
60
+ * Apply a session-level config change coming from the ACP client (model picker,
61
+ * language dropdown, confirmation toggles, login field). Mutates the global
62
+ * config (these ARE meant to persist across sessions, unlike mode) and pushes
63
+ * the refreshed config-option list back to the client.
64
+ */
65
+ export function handleSetConfigOption(msg, deps) {
66
+ const { transport, sessions } = deps;
67
+ const { sessionId, configId, value } = msg.params;
68
+ const session = sessions.get(sessionId);
69
+ if (!session) {
70
+ transport.error(msg.id, -32602, `Unknown sessionId: ${sessionId}`);
71
+ return;
72
+ }
73
+ applyConfigOption(configId, value);
74
+ transport.respond(msg.id, {});
75
+ // Confirm the new value back to Zed so its UI state stays in sync
76
+ transport.notify('session/update', {
77
+ sessionId,
78
+ update: {
79
+ sessionUpdate: 'config_option_update',
80
+ configOptions: buildConfigOptions(),
81
+ },
82
+ });
83
+ }
84
+ /**
85
+ * Pure-ish: apply a single config-option update to the global config.
86
+ * Split out from the handler so it can be tested without a transport.
87
+ *
88
+ * - `model` / `provider`: switch the active provider + model.
89
+ * - `customBaseUrl`: point the `custom` OpenAI-compatible provider at a
90
+ * self-hosted endpoint.
91
+ * - `language`: response language code.
92
+ * - `agentConfirm*` / `userProfile` / `autoLearnProfile`: boolean toggles,
93
+ * accept `true`/`false` or the strings `"true"`/`"false"` (Zed sends strings).
94
+ * - `login`: special-case `"providerId:apiKey"` — sets the provider and writes
95
+ * the key to the keychain. The keychain write is async and fire-and-forget;
96
+ * a persistence failure only means the key isn't durable, it's still usable
97
+ * in-process via the cache.
98
+ */
99
+ export function applyConfigOption(configId, value) {
100
+ if (configId === 'model' && typeof value === 'string') {
101
+ // value is "providerId/modelId" — split and switch both
102
+ const slashIdx = value.indexOf('/');
103
+ if (slashIdx !== -1) {
104
+ const providerId = value.slice(0, slashIdx);
105
+ const modelId = value.slice(slashIdx + 1);
106
+ setProvider(providerId); // sets provider + defaultModel + protocol
107
+ config.set('model', modelId);
108
+ }
109
+ else {
110
+ config.set('model', value);
111
+ }
112
+ }
113
+ else if (configId === 'provider' && typeof value === 'string') {
114
+ // Switch provider without specifying a model — picks the provider's
115
+ // default model + protocol. Used by editor clients that pin a provider
116
+ // in their settings (e.g. the VS Code `codeep.provider` setting).
117
+ setProvider(value);
118
+ }
119
+ else if (configId === 'customBaseUrl' && typeof value === 'string') {
120
+ // Base URL for the `custom` (OpenAI-compatible) provider — lets editor
121
+ // clients point Codeep at a self-hosted endpoint (vLLM/LiteLLM/LM Studio)
122
+ // without hand-editing ~/.codeep/config.json.
123
+ config.set('customBaseUrl', value);
124
+ }
125
+ else if (configId === 'language' && typeof value === 'string') {
126
+ config.set('language', value);
127
+ }
128
+ else if (configId === 'agentConfirmDeleteFile' ||
129
+ configId === 'agentConfirmExecuteCommand' ||
130
+ configId === 'agentConfirmWriteFile' ||
131
+ configId === 'userProfile' ||
132
+ configId === 'autoLearnProfile') {
133
+ // Accept boolean or "true"/"false" string from Zed/VSCode
134
+ const bool = value === true || value === 'true';
135
+ config.set(configId, bool);
136
+ }
137
+ else if (configId === 'login' && typeof value === 'string') {
138
+ // value is "providerId:apiKey"
139
+ const colonIdx = value.indexOf(':');
140
+ if (colonIdx !== -1) {
141
+ const providerId = value.slice(0, colonIdx);
142
+ const apiKey = value.slice(colonIdx + 1);
143
+ if (providerId && apiKey) {
144
+ setProvider(providerId);
145
+ // Cache is updated synchronously inside setApiKey; the keychain write
146
+ // resolves while the long-lived server runs. Swallow a persistence
147
+ // failure here (secondary path) so it can't become an unhandled
148
+ // rejection — the key still works for this session via the cache.
149
+ setApiKey(apiKey, providerId).catch(() => { });
150
+ }
151
+ }
152
+ }
153
+ }
154
+ // ─── session/list ─────────────────────────────────────────────────────────────
155
+ /**
156
+ * List saved sessions (project-scoped first, then global fallback), deduped
157
+ * by name and sorted newest-first.
158
+ */
159
+ export function handleSessionList(msg, deps) {
160
+ const { transport } = deps;
161
+ const params = (msg.params ?? {});
162
+ // Collect local (project-scoped) sessions and global sessions, deduplicated by name
163
+ const seen = new Set();
164
+ const merged = [
165
+ ...listSessionsWithInfo(params.cwd), // project-local first
166
+ ...listSessionsWithInfo(), // global fallback
167
+ ].filter(s => {
168
+ if (seen.has(s.name))
169
+ return false;
170
+ seen.add(s.name);
171
+ return true;
172
+ });
173
+ // Sort newest first
174
+ merged.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
175
+ const acpSessions = merged.map(s => ({
176
+ sessionId: s.name,
177
+ cwd: params.cwd ?? '',
178
+ title: s.title,
179
+ updatedAt: s.createdAt,
180
+ }));
181
+ const result = { sessions: acpSessions };
182
+ transport.respond(msg.id, result);
183
+ }
184
+ // ─── session/delete ───────────────────────────────────────────────────────────
185
+ /**
186
+ * Remove a session: drop it from the in-memory map, tear down any MCP child
187
+ * processes attached to it, then delete the on-disk session file (project dir
188
+ * first, global fallback).
189
+ */
190
+ export function handleSessionDelete(msg, deps) {
191
+ const { transport, sessions } = deps;
192
+ const { sessionId, cwd } = (msg.params ?? {});
193
+ // Remove from in-memory sessions map if present
194
+ sessions.delete(sessionId);
195
+ // Tear down any MCP server processes attached to this session — leaks
196
+ // children otherwise. Fire-and-forget; client doesn't wait on stop().
197
+ disposeMcpSession(sessionId).catch(() => { });
198
+ // Try project dir first, then global
199
+ const deleted = deleteSessionFile(sessionId, cwd || undefined);
200
+ if (!deleted && cwd)
201
+ deleteSessionFile(sessionId);
202
+ transport.respond(msg.id, {});
203
+ }
204
+ // ─── session/list_providers ───────────────────────────────────────────────────
205
+ // Canonical provider list for ACP clients (Codeep VS Code extension etc.).
206
+ // Lets the client populate its API-key dropdowns and group labels from one
207
+ // source instead of carrying its own hardcoded copy. Keep this stable —
208
+ // adding new fields is fine, removing/renaming would break existing clients.
209
+ import { PROVIDERS } from '../config/providers.js';
210
+ /**
211
+ * Pure shape: map the PROVIDERS table into the serialisable form ACP clients
212
+ * expect (id/name/groupLabel/models/dynamicModels/…). No transport side-effect.
213
+ * Exported so the handler is a one-liner and the shape can be unit-tested.
214
+ */
215
+ export function buildProviderList() {
216
+ return Object.entries(PROVIDERS).map(([id, p]) => ({
217
+ id,
218
+ name: p.name,
219
+ description: p.description,
220
+ groupLabel: p.groupLabel ?? p.name,
221
+ hint: p.hint ?? p.description,
222
+ requiresKey: !p.noApiKey,
223
+ subscribeUrl: p.subscribeUrl,
224
+ // Model metadata so ACP clients (e.g. the VS Code model picker) can
225
+ // offer a provider → model selector without hardcoding a catalog.
226
+ // `dynamicModels` flags providers whose model list is open-ended
227
+ // (OpenRouter, Ollama, custom endpoints) — clients should let the
228
+ // user type a model id rather than only pick from `models`.
229
+ models: p.models.map((m) => ({ id: m.id, name: m.name })),
230
+ defaultModel: p.defaultModel,
231
+ dynamicModels: p.dynamicModels ?? false,
232
+ }));
233
+ }
234
+ /** session/list_providers handler — delegate to the pure builder + respond. */
235
+ export function handleListProviders(msg, deps) {
236
+ deps.transport.respond(msg.id, { providers: buildProviderList() });
237
+ }