diff --git a/AGENTS.md b/AGENTS.md index c48964d4..9ee02fb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,6 @@ Coding Code 是 AI 编程助手。 - `scheduler`:定时调度 - `todo`:任务清单 - `rules`:全局 / 项目级规则装载 -- `workspace`:工作区信息 - `server`:HTTP / SSE 入口 - `client`:HTTP 客户端(`AgentClient` 的实现) - `direct`:进程内直连端口,免 HTTP 的 runtime / sessions / settings / models 接口 @@ -51,7 +50,7 @@ Coding Code 是 AI 编程助手。 | L0 通用件 | `core/` | node 内置 + 同目录 | | L1 共享契约 | `contracts/` | `core/` + 同目录 + 第三方(type-only) | | L1' 端口契约 | 各 `xxx/port.ts`(含 `agent/port.ts` 的装配端口) | `core/` + `contracts/` | -| L2 实现 | `tools/`、`hooks/`、`session/`、`approval/`、`llm/`、`mcp/`、`context/`、`workspace/` … | L0 + L1 | +| L2 实现 | `tools/`、`hooks/`、`session/`、`approval/`、`llm/`、`mcp/`、`context/` … | L0 + L1 | | L3 组合根 | `layer.ts`、`agent/tool-env.ts` | 全部 | **架构边界硬规则**(由 `packages/codingcode/test/architecture/boundaries.test.ts` 静态断言,共 29 项): diff --git a/CLAUDE.md b/CLAUDE.md index b6af698d..9ee02fb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,6 @@ Coding Code 是 AI 编程助手。 - `scheduler`:定时调度 - `todo`:任务清单 - `rules`:全局 / 项目级规则装载 -- `workspace`:工作区信息 - `server`:HTTP / SSE 入口 - `client`:HTTP 客户端(`AgentClient` 的实现) - `direct`:进程内直连端口,免 HTTP 的 runtime / sessions / settings / models 接口 @@ -42,7 +41,7 @@ Coding Code 是 AI 编程助手。 ## 架构要求 -**依赖倒置**:所有非叶子模块利用 `deps.ts`(消费者侧窄端口)或 `port.ts`(叶子侧宽契约)声明自己需要的接口和类型定义,使调用者不需要依赖实现方;只允许依赖下层模块。 +**依赖倒置**:所有非叶子模块利用 `port.ts`(宽契约;agent 自持的装配端口也在 `agent/port.ts`)声明自己需要的接口和类型定义,使调用者不需要依赖实现方;只允许依赖下层模块。 **分层与允许依赖**: @@ -50,14 +49,14 @@ Coding Code 是 AI 编程助手。 |---|---|---| | L0 通用件 | `core/` | node 内置 + 同目录 | | L1 共享契约 | `contracts/` | `core/` + 同目录 + 第三方(type-only) | -| L1' 端口契约 | `agent/deps.ts`、各 `xxx/port.ts` | `core/` + `contracts/` | -| L2 实现 | `tools/`、`hooks/`、`session/`、`approval/`、`llm/`、`mcp/`、`context/`、`workspace/` … | L0 + L1 | -| L3 组合根 | `layer.ts`、`agent/tool-catalog.ts`、`agent/tool-env.ts` | 全部 | +| L1' 端口契约 | 各 `xxx/port.ts`(含 `agent/port.ts` 的装配端口) | `core/` + `contracts/` | +| L2 实现 | `tools/`、`hooks/`、`session/`、`approval/`、`llm/`、`mcp/`、`context/` … | L0 + L1 | +| L3 组合根 | `layer.ts`、`agent/tool-env.ts` | 全部 | -**架构边界硬规则**(由 `packages/codingcode/test/architecture/boundaries.test.ts` 静态断言,共 28 项): +**架构边界硬规则**(由 `packages/codingcode/test/architecture/boundaries.test.ts` 静态断言,共 29 项): - **R1** 契约不得 import 实现:`contracts/` 与 `**/port.ts` 的相对 import 只能落在 `core/`、`contracts/` 或同目录 -- **R2** 实现不得依赖消费者模块:窄端口 `agent/deps.ts` 只对 `agent/agent.ts`、`agent/tool-catalog.ts`、`agent/tool-env.ts`、`layer.ts` 可见 +- **R2** 实现不得依赖消费者模块:agent 自持的装配端口 `ToolEnvPort` 只在 `agent/` 内部出现 - **R3** `core/` 零内部依赖:不引用 `core/` 之外的任何 src 模块 - **R4** 一个概念只允许一处类型定义,canonical 落点为 `contracts/` - **准入**:`core/` 的 import 只能是 node 内置与同目录;`contracts/` 只引用 `core/`、同目录与第三方 diff --git a/docs/skills.md b/docs/skills.md index e5600202..2ec90cd5 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -74,10 +74,10 @@ Agent 判断技能相关后,使用 `read_file` 读取 `skillPath`,再按需 通过 `AgentClient` SDK 读取技能元数据: ```typescript -const client = await createHttpClient('http://localhost:8080'); +const clients = createHttpClients('http://localhost:8080'); -// 列出所有技能 -const skills = await client.listSkills(); +// 列出技能:cwd 为项目路径,空串走全局分支 +const skills = await clients.settings.listSkills({ cwd: '/path/to/project' }); // 返回:Array<{ name: string, description: string, skillPath: string }> ``` @@ -85,4 +85,4 @@ const skills = await client.listSkills(); | 路由 | 方法 | 说明 | |------|------|------| -| `/api/settings/skills` | GET | 列出所有技能元数据 | +| `/api/settings/skills` | GET | 列出技能元数据;`?cwd=` 指定项目,缺省只返回全局技能 | diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 050471a1..03d8e802 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -62,9 +62,9 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const normalizedCwd = normalizePath(opts.cwd); rules.evictProjectRules(normalizedCwd); - yield* hooks.reloadUserHooks(normalizedCwd).pipe(Effect.catchAll(() => Effect.void)); - yield* hooks.emit('agent.turn.start', { sessionId: '' }).pipe(Effect.catchAll(() => Effect.void)); - yield* mcp.syncConnections(normalizedCwd).pipe(Effect.catchAll(() => Effect.void)); + yield* hooks.reloadUserHooks(normalizedCwd); + yield* hooks.emit('agent.turn.start', { sessionId: '' }); + yield* mcp.syncConnections(normalizedCwd); let sessionId = opts.sessionId; const llm = yield* llmFactory.getLLMClient(); diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index 8cf03c28..e37fbaf5 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -4,15 +4,15 @@ import { LLMFactoryService } from './llm/port.js'; import { createServer } from './server/index.js'; import { createAppRuntime } from './layer.js'; import { loadConfig, ensureUserConfig } from '@codingcode/infra/config'; -import { WorkspaceService, parseWorkspaceArgs } from './workspace/workspace.js'; +import { ensureTempCwd } from './core/path.js'; import { findAvailablePort } from './server/port-discovery.js'; import { AgentError } from './core/error.js'; import { SchedulerService } from './scheduler/port.js'; async function main() { - const installRoot = process.cwd(); - const { workspaceCwd, args } = parseWorkspaceArgs(process.argv.slice(2)); + const args = process.argv.slice(2); ensureUserConfig(); + ensureTempCwd(); const config = loadConfig(); const serveOnly = args.includes('serve'); @@ -22,12 +22,6 @@ async function main() { const rt = createAppRuntime(); const program = Effect.gen(function* () { - const ws = yield* WorkspaceService; - ws.init({ processRoot: installRoot, workspaceCwd }); - if (workspaceCwd) { - console.log(`Workspace: ${ws.getWorkspaceCwd()}`); - } - const port = yield* Effect.tryPromise(() => findAvailablePort(basePort)); const llmFactory = yield* LLMFactoryService; diff --git a/packages/codingcode/src/client/contracts.ts b/packages/codingcode/src/client/contracts.ts index 5492dcf1..7cc51242 100644 --- a/packages/codingcode/src/client/contracts.ts +++ b/packages/codingcode/src/client/contracts.ts @@ -138,7 +138,7 @@ export interface SettingsClient { createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; deleteMcpServer(input: { cwd: string; name: string }): Promise; - listSkills(): Promise>; + listSkills(input: { cwd: string }): Promise>; listHooks(input: { cwd: string }): Promise; createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index 526b8791..b6c6512c 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -67,8 +67,8 @@ export function createHttpSettingsClient( await apiDelete(`/api/settings/mcp/${encodeURIComponent(name)}${qsCwd(cwd)}`); }, - async listSkills() { - return apiGet('/api/settings/skills'); + async listSkills({ cwd }) { + return apiGet(`/api/settings/skills${qsCwd(cwd)}`); }, async listHooks({ cwd }) { diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index a2253983..2a00bb6a 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -1,5 +1,11 @@ +import { mkdirSync } from 'fs'; import { homedir } from 'os'; -import { join } from 'path'; +import { join, resolve } from 'path'; + +/** 空值 / `'global'` 表示操作全局(home)配置,而非某个项目。 */ +export function isGlobalCwd(cwd: string | undefined): boolean { + return !cwd || cwd === '' || cwd === 'global'; +} export function normalizePath(p: string): string { let s = p.replaceAll('\\', '/'); @@ -26,6 +32,20 @@ export function getProjectBaseDir(): string { return _projectBaseOverride ?? join(homedir(), '.codingcode', 'project'); } +/** 无 cwd 的请求共用的工作目录。 */ +export function getTempCwd(): string { + return join(homedir(), '.codingcode', 'temp'); +} + +export function ensureTempCwd(): void { + mkdirSync(getTempCwd(), { recursive: true }); +} + +/** 请求级 cwd:请求没带 cwd 时落到共用的临时工作目录。 */ +export function resolveCwd(cwd?: string): string { + return cwd ? resolve(cwd) : getTempCwd(); +} + export interface SessionPaths { sessionId: string; cwd: string; diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts index dd915829..0c6d4b0f 100644 --- a/packages/codingcode/src/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -4,7 +4,7 @@ import type { McpServerConfig, McpStatus } from '../contracts/mcp.js'; import { SkillService } from '../skills/port.js'; import type { PermissionMode } from '../contracts/permission.js'; import type { UserHookConfig } from '../contracts/hooks.js'; -import { isGlobalCwd } from '../workspace/workspace.js'; +import { isGlobalCwd, resolveCwd } from '../core/path.js'; import { loadMcpConfig, writeMcpConfig, @@ -224,7 +224,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async getMcpStatus({ cwd }) { - const projectCwd = isGlobalCwd(cwd) ? process.cwd() : cwd; + const projectCwd = resolveCwd(cwd); const runtime = await rt.runPromise( Effect.gen(function* () { const mcp = yield* McpService; @@ -287,17 +287,18 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async setMcpDisabled({ name, disabled, cwd }) { + const projectCwd = resolveCwd(cwd); if (isGlobalCwd(cwd)) { setGlobalMcpDisabledState(name, disabled); } else { - setProjectMcpDisabledState(cwd, name, disabled); + setProjectMcpDisabledState(projectCwd, name, disabled); } await rt.runPromise( Effect.gen(function* () { const mcp = yield* McpService; return yield* disabled - ? mcp.disable(isGlobalCwd(cwd) ? process.cwd() : cwd, name) - : mcp.enable(isGlobalCwd(cwd) ? process.cwd() : cwd, name); + ? mcp.disable(projectCwd, name) + : mcp.enable(projectCwd, name); }) ); }, @@ -318,11 +319,11 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { mcpDeleteServer(cwd, name); }, - async listSkills() { + async listSkills({ cwd }) { return rt.runPromise( Effect.gen(function* () { const skill = yield* SkillService; - return yield* skill.getAll(process.cwd()); + return yield* skill.getAll(resolveCwd(cwd)); }) ); }, diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 60b62a6b..eea42d81 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -16,25 +16,23 @@ import { AgentLayer } from './agent/agent.js'; import { ToolEnvLayer } from './agent/tool-env.js'; import { SubagentRunnerLayer } from './subagent/subagent.js'; import { SchedulerLayer } from './scheduler/scheduler.js'; -import { WorkspaceService } from './workspace/workspace.js'; // base layers const InfraLayer = Layer.mergeAll( - WorkspaceService.Default, HookLayer, RulesLayer, SkillLayer, McpLayer, ApprovalWaitLayer, TodoLayer, + HookLayer, RulesLayer, SkillLayer, McpLayer, ApprovalWaitLayer, TodoLayer, ); -const LlmWithDeps = LlmLayer.pipe(Layer.provide(WorkspaceService.Default)); const ApprovalWithDeps = ApprovalLayer.pipe(Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer))); const ToolExecutorWithDeps = ToolExecutorLayer.pipe( Layer.provide(Layer.mergeAll(HookLayer, ApprovalWithDeps)) ); -const ContextWithDeps = ContextLayer.pipe(Layer.provide(Layer.mergeAll(SessionLayer, LlmWithDeps))); -const MemoryWithDeps = MemoryLayer.pipe(Layer.provide(LlmWithDeps)); +const ContextWithDeps = ContextLayer.pipe(Layer.provide(Layer.mergeAll(SessionLayer, LlmLayer))); +const MemoryWithDeps = MemoryLayer.pipe(Layer.provide(LlmLayer)); // agent 直接消费的宽服务集合 const AgentServiceLayers = Layer.mergeAll( InfraLayer, SessionLayer, ToolExecutorWithDeps, ApprovalWithDeps, - ContextWithDeps, MemoryWithDeps, CheckpointLayer, LlmWithDeps, + ContextWithDeps, MemoryWithDeps, CheckpointLayer, LlmLayer, ); // agent with deps @@ -47,7 +45,7 @@ const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps)); export const AppLayer = Layer.mergeAll( InfraLayer, - LlmWithDeps, + LlmLayer, ApprovalWithDeps, SessionLayer, ToolExecutorWithDeps, diff --git a/packages/codingcode/src/llm/llm.ts b/packages/codingcode/src/llm/llm.ts index 31a3d521..52dcbb1f 100644 --- a/packages/codingcode/src/llm/llm.ts +++ b/packages/codingcode/src/llm/llm.ts @@ -2,11 +2,10 @@ import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { Layer, Effect } from 'effect'; import { AgentError } from '../core/error.js'; -import { WorkspaceService } from '../workspace/workspace.js'; import type { LLMClient, SelectableModel } from '../contracts/provider.js'; import { OpenAIProvider } from './providers/openai.js'; import { DeepSeekProvider } from './providers/deepseek.js'; -import { updateActiveModel } from '@codingcode/infra/config'; +import { loadConfig, updateActiveModel } from '@codingcode/infra/config'; import { LLMFactoryService } from './port.js'; export interface ModelDescriptor { @@ -52,13 +51,12 @@ function flattenModels(cat: ProviderCatalog): SelectableModel[] { } export const LlmLayer = Layer.effect(LLMFactoryService, Effect.gen(function* () { - const workspace = yield* WorkspaceService; let catalog: ProviderCatalog | null = null; let currentEntry: SelectableModel | null = null; let currentClient: LLMClient | null = null; function modelsFile(): string { - return resolve(workspace.getProcessRoot(), 'config/models.json'); + return resolve(process.cwd(), 'config/models.json'); } const loadCatalog = (): Effect.Effect => @@ -105,7 +103,7 @@ export const LlmLayer = Layer.effect(LLMFactoryService, Effect.gen(function* () getActiveEntry: (): Effect.Effect => Effect.gen(function* () { if (currentEntry) return currentEntry; - const cfg = workspace.getConfig().activeModel; + const cfg = loadConfig().activeModel; if (!cfg) { return yield* Effect.fail( new AgentError( @@ -198,7 +196,7 @@ export const LlmLayer = Layer.effect(LLMFactoryService, Effect.gen(function* () getLLMClient: (): Effect.Effect => Effect.gen(function* () { if (currentClient) return currentClient; - const cfg = workspace.getConfig().activeModel; + const cfg = loadConfig().activeModel; if (!cfg) { return yield* Effect.fail( new AgentError( diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index d022d78a..b3f7d383 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { AgentService } from '../../agent/port.js'; -import { WorkspaceService } from '../../workspace/workspace.js'; +import { resolveCwd } from '../../core/path.js'; import { errorResponse } from '../util.js'; import { createSseHandler } from '../handler.js'; @@ -13,12 +13,7 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/messages', async (c) => { let sessionId = c.req.param('id'); const { input, cwd } = await c.req.json<{ input: string; cwd: string }>(); - const normalizedCwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(cwd); - }) - ); + const normalizedCwd = resolveCwd(cwd); const isNew = sessionId === '_' || !sessionId; const runOpts: any = { diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 76865716..ac43e37d 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -5,11 +5,10 @@ import { join } from 'path'; import type { SessionStoreState } from '../../contracts/session.js'; import type { ProfileName } from '../../contracts/types.js'; import { SessionService } from '../../session/port.js'; -import { computePaths } from '../../core/path.js'; +import { computePaths, resolveCwd } from '../../core/path.js'; import { ContextService } from '../../context/port.js'; import { estimatePromptTokensFrom } from '../../context/context.js'; import { CheckpointService } from '../../checkpoint/port.js'; -import { WorkspaceService } from '../../workspace/workspace.js'; import { LLMFactoryService } from '../../llm/port.js'; import type { LLMClient } from '../../contracts/provider.js'; import { errorResponse } from '../util.js'; @@ -36,12 +35,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { }; router.get('/api/sessions', async (c) => { - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(c.req.query('cwd')); - }) - ); + const cwd = resolveCwd(c.req.query('cwd')); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -71,12 +65,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { if (!body.model) { return c.json({ error: 'model required' }, 400); } - const normalizedCwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const normalizedCwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -98,12 +87,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/resume', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string }; - const normalizedCwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const normalizedCwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -121,12 +105,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/compact', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string }; - const normalizedCwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const normalizedCwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const context = yield* ContextService; @@ -190,12 +169,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { // "current" plan is whichever .md has the most recent mtime in the // project's plan directory. router.get('/api/sessions/:id/plan', async (c) => { - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(c.req.query('cwd')); - }) - ); + const cwd = resolveCwd(c.req.query('cwd')); const planDir = join(getProjectBaseDir(), encodeProjectPath(cwd)); if (!existsSync(planDir)) { return c.json({ @@ -238,12 +212,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { // ---- Agent profile switching ---- router.get('/api/sessions/:id/profile', async (c) => { const sessionId = c.req.param('id'); - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(c.req.query('cwd')); - }) - ); + const cwd = resolveCwd(c.req.query('cwd')); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -268,12 +237,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/profile', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd?: string; activeProfile: ProfileName }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const cwd = resolveCwd(body.cwd); const activeProfile = body.activeProfile; if (!isAgentProfileName(activeProfile)) { return c.json({ error: `Invalid activeProfile: ${activeProfile}` }, 400); @@ -337,12 +301,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.get('/api/sessions/:id/checkpoints/latest/diff', async (c) => { const sessionId = c.req.param('id'); - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(c.req.query('cwd')); - }) - ); + const cwd = resolveCwd(c.req.query('cwd')); const result = await runWithLayer( Effect.gen(function* () { const checkpoint = yield* CheckpointService; @@ -359,12 +318,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.get('/api/sessions/:id/checkpoints/:turnId/diff', async (c) => { const sessionId = c.req.param('id'); const turnId = parseInt(c.req.param('turnId'), 10); - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(c.req.query('cwd')); - }) - ); + const cwd = resolveCwd(c.req.query('cwd')); const result = await runWithLayer( Effect.gen(function* () { const checkpoint = yield* CheckpointService; @@ -385,12 +339,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/checkpoints/latest/revert-file', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; file: string }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const cwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const checkpoint = yield* CheckpointService; @@ -407,12 +356,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/checkpoints/latest/revert-files', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; files: string[] }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const cwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const checkpoint = yield* CheckpointService; @@ -428,12 +372,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.get('/api/sessions/:id/rollback-preview', async (c) => { const sessionId = c.req.param('id'); - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(c.req.query('cwd')); - }) - ); + const cwd = resolveCwd(c.req.query('cwd')); const throughTurnId = parseInt(c.req.query('throughTurnId') ?? '0', 10); const result = await runWithLayer( Effect.gen(function* () { @@ -451,12 +390,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/rollback-code-to-turn', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const cwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const checkpoint = yield* CheckpointService; @@ -473,12 +407,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/rollback-context', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const cwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -500,12 +429,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/rollback-both-to-turn', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const cwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -535,12 +459,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/fork', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; atTurnId?: number }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); + const cwd = resolveCwd(body.cwd); const atTurnId = body.atTurnId ?? 0; const result = await runWithLayer( Effect.gen(function* () { diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index e8ab3dda..f012b535 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SkillService } from '../../skills/port.js'; -import { WorkspaceService, isGlobalCwd } from '../../workspace/workspace.js'; +import { isGlobalCwd, resolveCwd } from '../../core/path.js'; import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; import type { McpServerConfig } from '../../contracts/mcp.js'; import type { UserHookConfig } from '../../contracts/hooks.js'; @@ -45,12 +45,6 @@ type ManagedRt = ManagedRuntime.ManagedRuntime; export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promise { const runWithLayer = createRunWithLayer(rt); - const ws = await rt.runPromise( - Effect.gen(function* () { - return yield* WorkspaceService; - }) - ); - const resolveWorkspaceCwd = (override?: string) => ws.resolveWorkspaceCwd(override); // ---- Helpers for CRUD with validation ---- @@ -181,7 +175,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi })) ); } - const cwd = resolveWorkspaceCwd(rawCwd); + const cwd = resolveCwd(rawCwd); const globalHooks = loadGlobalHookConfigs(); const projectHooks = loadHookConfigs(cwd); const globalNames = new Set(globalHooks.map((h) => h.name)); @@ -214,7 +208,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi hooks.push(body); writeGlobalHookConfigs(hooks); } else { - hooksCreate(resolveWorkspaceCwd(rawCwd), body); + hooksCreate(resolveCwd(rawCwd), body); } return c.json({ ok: true }); } catch (e) { @@ -238,7 +232,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi hooks[idx] = body; writeGlobalHookConfigs(hooks); } else { - hooksUpdate(resolveWorkspaceCwd(rawCwd), name, body); + hooksUpdate(resolveCwd(rawCwd), name, body); } return c.json({ ok: true }); } catch (e) { @@ -255,7 +249,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); writeGlobalHookConfigs(hooks); } else { - hooksDelete(resolveWorkspaceCwd(rawCwd), name); + hooksDelete(resolveCwd(rawCwd), name); } return c.json({ ok: true }); }); @@ -274,7 +268,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi writeGlobalHookConfigs(hooks); } } else { - const cwd = resolveWorkspaceCwd(rawCwd); + const cwd = resolveCwd(rawCwd); setProjectHookDisabledState(cwd, name, body.disabled); setHookRuntimeEnabled(name, !body.disabled); const hooks = loadHookConfigs(cwd); @@ -290,7 +284,10 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi router.post('/api/settings/hooks/:name/disabled/reset', async (c) => { const name = c.req.param('name'); const rawCwd = c.req.query('cwd'); - resetProjectHookDisabledState(resolveWorkspaceCwd(rawCwd), name); + // 全局态只有全局开关,没有项目覆盖可重置 + if (!isGlobalCwd(rawCwd)) { + resetProjectHookDisabledState(resolveCwd(rawCwd), name); + } return c.json({ ok: true }); }); @@ -306,7 +303,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi })) ); } - const cwd = resolveWorkspaceCwd(rawCwd); + const cwd = resolveCwd(rawCwd); const globalServers = loadGlobalMcpConfig(); const projectServers = loadMcpConfig(cwd); const globalNames = new Set(globalServers.map((s) => s.name)); @@ -339,7 +336,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi servers.push(body); writeGlobalMcpConfig(servers); } else { - mcpCreateServer(resolveWorkspaceCwd(rawCwd), body); + mcpCreateServer(resolveCwd(rawCwd), body); } return c.json({ ok: true }); } catch (e) { @@ -363,7 +360,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi servers[idx] = body; writeGlobalMcpConfig(servers); } else { - mcpUpdateServer(resolveWorkspaceCwd(rawCwd), name, body); + mcpUpdateServer(resolveCwd(rawCwd), name, body); } return c.json({ ok: true }); } catch (e) { @@ -380,7 +377,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); writeGlobalMcpConfig(servers); } else { - mcpDeleteServer(resolveWorkspaceCwd(rawCwd), name); + mcpDeleteServer(resolveCwd(rawCwd), name); } return c.json({ ok: true }); }); @@ -392,7 +389,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi if (isGlobalCwd(rawCwd)) { setGlobalMcpDisabledState(name, body.disabled); } else { - setProjectMcpDisabledState(resolveWorkspaceCwd(rawCwd), name, body.disabled); + setProjectMcpDisabledState(resolveCwd(rawCwd), name, body.disabled); } return c.json({ ok: true }); }); @@ -400,7 +397,10 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi router.post('/api/settings/mcp/:name/disabled/reset', async (c) => { const name = c.req.param('name'); const rawCwd = c.req.query('cwd'); - resetProjectMcpDisabledState(resolveWorkspaceCwd(rawCwd), name); + // 全局态只有全局开关,没有项目覆盖可重置 + if (!isGlobalCwd(rawCwd)) { + resetProjectMcpDisabledState(resolveCwd(rawCwd), name); + } return c.json({ ok: true }); }); @@ -408,7 +408,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi router.get('/api/settings/skills', async (c) => { const rawCwd = c.req.query('cwd'); if (isGlobalCwd(rawCwd)) { - const cwd = resolveWorkspaceCwd(rawCwd); + const cwd = resolveCwd(rawCwd); const result = await runWithLayer( Effect.gen(function* () { const skill = yield* SkillService; @@ -423,7 +423,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi })) ); } - const cwd = resolveWorkspaceCwd(rawCwd); + const cwd = resolveCwd(rawCwd); const globalDirs = discoverGlobalSkillDirs(); const projectDirs = discoverProjectSkillDirs(cwd); const globalNames = new Set(globalDirs.map((d) => d.name)); diff --git a/packages/codingcode/src/workspace/workspace.ts b/packages/codingcode/src/workspace/workspace.ts deleted file mode 100644 index 2025fec4..00000000 --- a/packages/codingcode/src/workspace/workspace.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Effect } from 'effect'; -import { existsSync, statSync } from 'fs'; -import { resolve } from 'path'; -import { AgentError } from '../core/error.js'; -import { encodeProjectPath } from '../core/path.js'; -import { loadConfig, type AppConfig } from '@codingcode/infra/config'; - -export interface WorkspaceInit { - processRoot?: string; - workspaceCwd?: string; -} - -/** Parse `--cwd ` / `--cwd=` from CLI args; returns remaining flags. */ -export function parseWorkspaceArgs(argv: string[]): { workspaceCwd?: string; args: string[] } { - const args: string[] = []; - let workspaceCwd: string | undefined; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === '--cwd') { - const next = argv[++i]; - if (!next) throw new AgentError('CONFIG_INVALID', '--cwd requires a directory path'); - workspaceCwd = next; - continue; - } - if (arg!.startsWith('--cwd=')) { - workspaceCwd = arg!.slice('--cwd='.length); - if (!workspaceCwd) throw new AgentError('CONFIG_INVALID', '--cwd requires a directory path'); - continue; - } - args.push(arg!); - } - return { workspaceCwd, args }; -} - -/** Returns true when the given cwd refers to the global (home) config rather than a project. */ -export function isGlobalCwd(cwd: string | undefined): boolean { - return !cwd || cwd === '' || cwd === 'global'; -} - -export class WorkspaceService extends Effect.Service()('Workspace', { - sync: () => { - let processRoot = process.cwd(); - let workspaceCwd = process.cwd(); - - return { - init(opts: WorkspaceInit = {}): void { - processRoot = resolve(opts.processRoot ?? process.cwd()); - const raw = opts.workspaceCwd ?? processRoot; - workspaceCwd = resolve(raw); - if (!existsSync(workspaceCwd)) { - throw new AgentError( - 'CONFIG_INVALID', - `Workspace directory does not exist: ${workspaceCwd}` - ); - } - if (!statSync(workspaceCwd).isDirectory()) { - throw new AgentError( - 'CONFIG_INVALID', - `Workspace path is not a directory: ${workspaceCwd}` - ); - } - }, - - getProcessRoot(): string { - return processRoot; - }, - - getWorkspaceCwd(): string { - return workspaceCwd; - }, - - resolveWorkspaceCwd(override?: string): string { - if (override) return resolve(override); - return workspaceCwd; - }, - - getWorkspacePath(): string { - return encodeProjectPath(workspaceCwd); - }, - - resolveInWorkspace(path: string): string { - return resolve(workspaceCwd, path); - }, - - getConfig(): AppConfig { - return loadConfig(); - }, - }; - }, -}) {} diff --git a/packages/codingcode/test/client/direct-types.test.ts b/packages/codingcode/test/client/direct-types.test.ts index 2cdb4757..a9636cfd 100644 --- a/packages/codingcode/test/client/direct-types.test.ts +++ b/packages/codingcode/test/client/direct-types.test.ts @@ -8,7 +8,6 @@ import { createDirectSettingsClient } from '../../src/direct/settings.js'; import type { AppRuntime } from '../../src/layer.js'; import type { LLMClient } from '../../src/contracts/provider.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; -import { WorkspaceService } from '../../src/workspace/workspace.js'; import { LLMFactoryService } from '../../src/llm/port.js'; import { AgentError } from '../../src/core/error.js'; import { ApprovalWaitLayer } from '../../src/approval/wait.js'; @@ -25,10 +24,6 @@ type _RtParamNotAny = AssertNotAny<_AgentParams[1]>; type _SessionParams = Parameters; type _SessionRtNotAny = AssertNotAny<_SessionParams[0]>; -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/tmp/test', -} as any); - const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { getLLMClient: () => Effect.succeed(null), listModels: () => Effect.succeed([]), @@ -40,7 +35,6 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { const TestLayer = Layer.mergeAll( ApprovalWaitLayer, - MockWorkspaceLayer, MockLLMFactoryLayer ); diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index e9a2f462..275025e5 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -4,14 +4,9 @@ import { Effect, Layer, ManagedRuntime } from 'effect'; import { createDirectModelClient } from '../../src/direct/models.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { AgentError } from '../../src/core/error.js'; -import { WorkspaceService } from '../../src/workspace/workspace.js'; import { LLMFactoryService } from '../../src/llm/port.js'; import { ApprovalWaitLayer } from '../../src/approval/wait.js'; -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/tmp/test', -} as any); - const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { getLLMClient: () => Effect.succeed(null), listModels: () => @@ -36,7 +31,6 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { const TestLayer = Layer.mergeAll( ApprovalWaitLayer, - MockWorkspaceLayer, MockLLMFactoryLayer ); diff --git a/packages/codingcode/test/core/path.test.ts b/packages/codingcode/test/core/path.test.ts index 92c507d8..8316e664 100644 --- a/packages/codingcode/test/core/path.test.ts +++ b/packages/codingcode/test/core/path.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; +import { + normalizePath, + encodeProjectPath, + resolveCwd, + getTempCwd, + isGlobalCwd, +} from '../../src/core/path.js'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; describe('core/path', () => { @@ -31,4 +39,22 @@ describe('core/path', () => { const sg = new ShadowGit(path); expect(sg.gitDir).toContain(encodeProjectPath(path)); }); + + it('resolveCwd uses the request cwd when present', () => { + const otherDir = join(tmpdir(), 'cc-other'); + expect(resolveCwd(otherDir)).toBe(resolve(otherDir)); + }); + + it('resolveCwd falls back to the shared temp workspace', () => { + expect(resolveCwd()).toBe(getTempCwd()); + expect(resolveCwd('')).toBe(getTempCwd()); + expect(getTempCwd()).not.toBe(process.cwd()); + }); + + it('isGlobalCwd treats missing, empty and "global" as global', () => { + expect(isGlobalCwd(undefined)).toBe(true); + expect(isGlobalCwd('')).toBe(true); + expect(isGlobalCwd('global')).toBe(true); + expect(isGlobalCwd('/some/project')).toBe(false); + }); }); diff --git a/packages/codingcode/test/core/workspace.test.ts b/packages/codingcode/test/core/workspace.test.ts deleted file mode 100644 index 6092fa94..00000000 --- a/packages/codingcode/test/core/workspace.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Effect } from 'effect'; -import { mkdirSync, rmSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { randomUUID } from 'crypto'; -import { WorkspaceService, parseWorkspaceArgs } from '../../src/workspace/workspace.js'; -import { encodeProjectPath } from '../../src/core/path.js'; - -describe('core/workspace', () => { - let installRoot: string; - let otherDir: string; - - beforeEach(() => { - installRoot = join(tmpdir(), `install-${randomUUID().slice(0, 8)}`); - otherDir = join(tmpdir(), `ws-${randomUUID().slice(0, 8)}`); - mkdirSync(installRoot, { recursive: true }); - mkdirSync(otherDir, { recursive: true }); - mkdirSync(join(installRoot, 'config'), { recursive: true }); - writeFileSync( - join(installRoot, 'config', 'models.json'), - '{"active":"p","providers":[]}', - 'utf8' - ); - }); - - afterEach(() => { - try { - rmSync(installRoot, { recursive: true, force: true }); - } catch { - /* ignore */ - } - try { - rmSync(otherDir, { recursive: true, force: true }); - } catch { - /* ignore */ - } - }); - - it('parseWorkspaceArgs extracts --cwd and leaves other flags', () => { - expect(parseWorkspaceArgs(['serve', '--cwd', otherDir])).toEqual({ - workspaceCwd: otherDir, - args: ['serve'], - }); - expect(parseWorkspaceArgs(['--cwd=' + otherDir, 'tui'])).toEqual({ - workspaceCwd: otherDir, - args: ['tui'], - }); - }); - - it('init separates install root and workspace cwd', async () => { - await Effect.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - ws.init({ processRoot: installRoot, workspaceCwd: otherDir }); - expect(ws.getProcessRoot()).toBe(installRoot); - expect(ws.getWorkspaceCwd()).toBe(otherDir); - expect(encodeProjectPath(ws.getWorkspaceCwd())).toBe(encodeProjectPath(otherDir)); - }).pipe(Effect.provide(WorkspaceService.Default)) - ); - }); - - it('resolveInWorkspace resolves relative paths against workspace', async () => { - await Effect.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - ws.init({ processRoot: installRoot, workspaceCwd: otherDir }); - expect(ws.resolveInWorkspace('src/a.ts')).toBe(join(otherDir, 'src/a.ts')); - }).pipe(Effect.provide(WorkspaceService.Default)) - ); - }); - - it('throws when --cwd path does not exist', async () => { - await Effect.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - expect(() => - ws.init({ - processRoot: installRoot, - workspaceCwd: join(tmpdir(), 'missing-' + randomUUID()), - }) - ).toThrow(/does not exist/); - }).pipe(Effect.provide(WorkspaceService.Default)) - ); - }); -}); diff --git a/packages/codingcode/test/llm/factory.test.ts b/packages/codingcode/test/llm/factory.test.ts index 1d279e51..79f0e117 100644 --- a/packages/codingcode/test/llm/factory.test.ts +++ b/packages/codingcode/test/llm/factory.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Effect, Layer } from 'effect'; -import { tmpdir } from 'os'; +import { Effect } from 'effect'; const mockCatalog = { providers: [ @@ -30,19 +29,15 @@ function mockFs() { }); } -function makeWorkspaceLayer( - WorkspaceService: any, - activeModel: { model: string; apiKeyEnv: string } | undefined +/** llm.ts 的 activeModel 来自 loadConfig(),与 updateActiveModel 同模块,一并桩掉。 */ +function mockActiveModel( + activeModel: { model: string; apiKeyEnv: string } | undefined, + extra: Record = {} ) { - return Layer.succeed(WorkspaceService, { - init: () => {}, - getProcessRoot: () => tmpdir(), - getWorkspaceCwd: () => tmpdir(), - resolveWorkspaceCwd: (override?: string) => override ?? tmpdir(), - getWorkspacePath: () => 'test', - resolveInWorkspace: (path: string) => path, - getConfig: () => ({ activeModel }) as any, - } as any); + vi.doMock('@codingcode/infra/config', async (importOriginal: any) => { + const orig = await importOriginal(); + return { ...orig, loadConfig: () => ({ activeModel }), ...extra }; + }); } describe('switchModel - persists to config', () => { @@ -52,26 +47,17 @@ describe('switchModel - persists to config', () => { it('calls updateActiveModel with model and api_key_env after switching', async () => { const updateActiveModel = vi.fn(); - vi.doMock('@codingcode/infra/config', async (importOriginal: any) => { - const orig = await importOriginal(); - return { ...orig, updateActiveModel }; - }); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); mockFs(); const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { WorkspaceService } = await import('../../src/workspace/workspace.js'); - const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { - model: 'model-x', - apiKeyEnv: 'API_KEY_A', - }); const { LlmLayer } = await import('../../src/llm/llm.js'); - const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.switchModel('model-y@API_KEY_A'); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(result._tag).toBe('Right'); if (result._tag === 'Right') { @@ -82,26 +68,17 @@ describe('switchModel - persists to config', () => { it('does not call updateActiveModel when model id is not found', async () => { const updateActiveModel = vi.fn(); - vi.doMock('@codingcode/infra/config', async (importOriginal: any) => { - const orig = await importOriginal(); - return { ...orig, updateActiveModel }; - }); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); mockFs(); const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { WorkspaceService } = await import('../../src/workspace/workspace.js'); - const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { - model: 'model-x', - apiKeyEnv: 'API_KEY_A', - }); const { LlmLayer } = await import('../../src/llm/llm.js'); - const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.switchModel('nonexistent@API_KEY_A'); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(result._tag).toBe('Left'); if (result._tag === 'Left') { @@ -119,20 +96,16 @@ describe('getActiveEntry - activeModel priority', () => { it('uses activeModel from config when it matches a catalog entry', async () => { mockFs(); + mockActiveModel({ model: 'model-y', apiKeyEnv: 'API_KEY_A' }); + const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { WorkspaceService } = await import('../../src/workspace/workspace.js'); - const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { - model: 'model-y', - apiKeyEnv: 'API_KEY_A', - }); const { LlmLayer } = await import('../../src/llm/llm.js'); - const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(result._tag).toBe('Right'); if (result._tag === 'Right') { @@ -141,17 +114,16 @@ describe('getActiveEntry - activeModel priority', () => { }); it('returns error when activeModel is not set in config', async () => { + mockActiveModel(undefined); + const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { WorkspaceService } = await import('../../src/workspace/workspace.js'); - const workspaceLayer = makeWorkspaceLayer(WorkspaceService, undefined); const { LlmLayer } = await import('../../src/llm/llm.js'); - const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(result._tag).toBe('Left'); if (result._tag === 'Left') { @@ -163,20 +135,16 @@ describe('getActiveEntry - activeModel priority', () => { it('returns error when activeModel does not match any catalog entry', async () => { mockFs(); + mockActiveModel({ model: 'nonexistent', apiKeyEnv: 'UNKNOWN_KEY' }); + const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { WorkspaceService } = await import('../../src/workspace/workspace.js'); - const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { - model: 'nonexistent', - apiKeyEnv: 'UNKNOWN_KEY', - }); const { LlmLayer } = await import('../../src/llm/llm.js'); - const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(result._tag).toBe('Left'); if (result._tag === 'Left') { @@ -194,20 +162,16 @@ describe('createClient - API key validation', () => { it('returns CONFIG_MISSING when API key env is not set', async () => { mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); + const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { WorkspaceService } = await import('../../src/workspace/workspace.js'); - const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { - model: 'model-x', - apiKeyEnv: 'API_KEY_A', - }); const { LlmLayer } = await import('../../src/llm/llm.js'); - const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const entryResult = await Effect.runPromise( Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(entryResult._tag).toBe('Right'); if (entryResult._tag === 'Left') return; @@ -219,7 +183,7 @@ describe('createClient - API key validation', () => { Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.createClient(entryResult.right); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(result._tag).toBe('Left'); if (result._tag === 'Left') { @@ -231,20 +195,16 @@ describe('createClient - API key validation', () => { it('succeeds when OPENAI_API_KEY fallback is set', async () => { mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); + const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { WorkspaceService } = await import('../../src/workspace/workspace.js'); - const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { - model: 'model-x', - apiKeyEnv: 'API_KEY_A', - }); const { LlmLayer } = await import('../../src/llm/llm.js'); - const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const entryResult = await Effect.runPromise( Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(entryResult._tag).toBe('Right'); if (entryResult._tag === 'Left') return; @@ -256,7 +216,7 @@ describe('createClient - API key validation', () => { Effect.gen(function* () { const factory = yield* LLMFactoryService; return yield* factory.createClient(entryResult.right); - }).pipe(Effect.provide(factoryLayer), Effect.either) + }).pipe(Effect.provide(LlmLayer), Effect.either) ); expect(result._tag).toBe('Right'); }); diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index fbf6570e..4ae1238f 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { createServer } from '../../src/server/index.js'; -import { WorkspaceService } from '../../src/workspace/workspace.js'; import { SessionService } from '../../src/session/port.js'; import { LLMFactoryService } from '../../src/llm/port.js'; import { ApprovalService } from '../../src/approval/port.js'; @@ -19,11 +18,6 @@ import { ApprovalLayer } from '../../src/approval/approval.js'; const mockCompactWithLLM = vi.fn(); -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/tmp/test', - resolveWorkspaceCwd: (override?: string) => override ?? '/tmp/test', -} as any); - const MockSessionLayer = Layer.succeed(SessionService, { create: () => Effect.succeed({ @@ -162,7 +156,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { } as any); const TestLayer = Layer.mergeAll( - MockWorkspaceLayer, MockSessionLayer, MockLLMFactoryLayer, MockApprovalLayer, @@ -239,7 +232,6 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { } as any); const FailLayer = Layer.mergeAll( - MockWorkspaceLayer, MockSessionLayer, FailingFactoryLayer, MockApprovalLayer, diff --git a/packages/codingcode/test/server/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts index a6a8051d..2a9a2671 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -1,21 +1,16 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; +import { Effect, ManagedRuntime } from 'effect'; import { Hono } from 'hono'; import { readFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import { WorkspaceService } from '../../src/workspace/workspace.js'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); -function makeLayer() { - return Layer.mergeAll(SessionLayer, WorkspaceService.Default); -} - describe('POST /api/sessions — atomic mode + permissionMode + model', () => { let cwd: string; let rt: ManagedRuntime.ManagedRuntime; @@ -24,7 +19,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { beforeEach(async () => { cwd = join(base.dir, 'create-session-active-profile'); mkdirSync(cwd, { recursive: true }); - rt = ManagedRuntime.make(makeLayer() as any); + rt = ManagedRuntime.make(SessionLayer as any); app = new Hono(); registerSessionsRoutes(app, rt); }); diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index 02aea953..1b60f8d8 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { createServer } from '../../src/server/index.js'; -import { WorkspaceService } from '../../src/workspace/workspace.js'; import { SessionService } from '../../src/session/port.js'; import { LLMFactoryService } from '../../src/llm/port.js'; import { ApprovalService } from '../../src/approval/port.js'; @@ -17,11 +16,6 @@ import { HookLayer } from '../../src/hooks/hooks.js'; import { ApprovalWaitLayer } from '../../src/approval/wait.js'; import { ApprovalLayer } from '../../src/approval/approval.js'; -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/tmp/test', - resolveWorkspaceCwd: (override?: string) => override ?? '/tmp/test', -} as any); - const MockSessionLayer = Layer.succeed(SessionService, { create: () => Effect.succeed({ sessionId: 'test', cwd: '/tmp/test' }), recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), @@ -106,7 +100,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { } as any); const TestLayer = Layer.mergeAll( - MockWorkspaceLayer, MockSessionLayer, MockLLMFactoryLayer, MockApprovalLayer, diff --git a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts index 28b24d77..4bc4cc91 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -11,7 +11,6 @@ import { computePaths } from '../../src/core/path.js'; import { HookService } from '../../src/hooks/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { AgentService } from '../../src/agent/port.js'; -import { WorkspaceService } from '../../src/workspace/workspace.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); @@ -39,10 +38,6 @@ const mockApprovalWaitService = { hasEmitter: () => Effect.succeed(false), }; -const mockWorkspace = { - resolveWorkspaceCwd: (cwd: string | undefined) => cwd || '/tmp', -} as any; - // The message-send path now lives in AgentService.runTurn. A real runTurn loads // the persisted session (which reads permissionMode from the on-disk index) // before streaming. We mirror that seam here so the test keeps validating that @@ -66,7 +61,6 @@ function makeLayer() { return Layer.mergeAll( Layer.succeed(HookService, mockHookService), Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any), - Layer.succeed(WorkspaceService, mockWorkspace), Layer.succeed(AgentService, mockAgentService), SessionLayer ); diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index cd0f0131..61d37d30 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -4,10 +4,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { mkdirSync, writeFileSync, utimesSync } from 'fs'; -import { join } from 'path'; +import { join, resolve } from 'path'; import { Hono } from 'hono'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; -import { WorkspaceService } from '../../src/workspace/workspace.js'; import { SessionService } from '../../src/session/port.js'; import { LLMFactoryService } from '../../src/llm/port.js'; import { ApprovalService } from '../../src/approval/port.js'; @@ -19,18 +18,13 @@ import { MemoryService } from '../../src/memory/port.js'; import { SchedulerService } from '../../src/scheduler/port.js'; import { ContextService } from '../../src/context/port.js'; import { CheckpointService } from '../../src/checkpoint/port.js'; -import { setProjectBaseDir } from '../../src/core/path.js'; +import { encodeProjectPath, setProjectBaseDir } from '../../src/core/path.js'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { HookLayer } from '../../src/hooks/hooks.js'; import { ApprovalWaitLayer } from '../../src/approval/wait.js'; import { ApprovalLayer } from '../../src/approval/approval.js'; -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/tmp/test', - resolveWorkspaceCwd: (override?: string) => override ?? '/tmp/test', -} as any); - const MockSessionLayer = Layer.succeed(SessionService, { create: () => Effect.succeed({ @@ -164,7 +158,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { } as any); const TestLayer = Layer.mergeAll( - MockWorkspaceLayer, MockSessionLayer, MockLLMFactoryLayer, MockApprovalLayer, @@ -183,9 +176,9 @@ let plansDir = ''; beforeEach(() => { tempBase = mkdtempSync(join(tmpdir(), 'codingcode-plan-route-')); - // The route reads getProjectBaseDir() + encodeProjectPath(cwd). - // encodeProjectPath('/tmp/test') -> 'tmp-test'. - plansDir = join(tempBase, 'tmp-test'); + // The route resolves the query cwd to an absolute path first + // (core/path.resolveCwd -> path.resolve), then encodes it. + plansDir = join(tempBase, encodeProjectPath(resolve('/tmp/test'))); mkdirSync(plansDir, { recursive: true }); setProjectBaseDir(tempBase); }); diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index dc46d9d9..401a14d2 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -26,7 +26,6 @@ export default defineConfig({ resolve: { alias: { '@shared': resolve('shared'), - '@codingcode/core/core/workspace': resolve(codingcodeRoot, 'core/workspace.ts'), '@codingcode/core/layer': resolve(codingcodeRoot, 'layer.ts'), '@codingcode/core/server/create': resolve(codingcodeRoot, 'server/index.ts'), '@codingcode/core/server/port-discovery': resolve( diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 474cc1c5..d5ef6b43 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -178,7 +178,7 @@ export function deleteMcpServer(cwd: string | undefined, name: string): Promise< // ---- Settings: Skills ---- -export function listSkills(_cwd?: string): Promise< +export function listSkills(cwd?: string): Promise< Array<{ name: string; description: string; @@ -187,7 +187,7 @@ export function listSkills(_cwd?: string): Promise< hasProjectOverride?: boolean; }> > { - return clients.settings.listSkills(); + return clients.settings.listSkills({ cwd: cwd ?? '' }); } // ---- Settings: Hooks ---- diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index 0df6e201..fe2bbe82 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -64,7 +64,7 @@ export function createTuiClientFromFacades(llm: LLMClient, rt: AppRuntime): TuiC listSessions: () => sessions.listSessions({ cwd: '' }), getMcpStatus: (query) => settings.getMcpStatus(query), setMcpDisabled: (body) => settings.setMcpDisabled(body), - listSkills: () => settings.listSkills(), + listSkills: () => settings.listSkills({ cwd: '' }), getPermissionMode: (input) => settings.getGlobalPermissionMode(input), setPermissionMode: (input) => settings.setGlobalPermissionMode(input), resumeSession: (sid) => sessions.resumeSession({ sessionId: sid, cwd: '' }),