From b7beadde8e568e3968f4b0b8b708a218d37268e3 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 19 Jun 2026 13:53:47 +0800 Subject: [PATCH 01/11] Move UI history-related utility functions to a separate module --- .../codingcode/src/server/routes/sessions.ts | 142 +----------------- packages/codingcode/src/session/ui-history.ts | 140 +++++++++++++++++ .../codingcode/test/session/filter-ui.test.ts | 117 +-------------- .../test/session/store-diff-rebuild.test.ts | 87 +---------- .../test/session/ui-history-rollback.test.ts | 31 +--- 5 files changed, 146 insertions(+), 371 deletions(-) create mode 100644 packages/codingcode/src/session/ui-history.ts diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 2da7902..5424d4b 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -7,10 +7,9 @@ import { sessionJsonlPathFromCwd, getPermissionMode, setPermissionMode, - readHistory, deleteSession, } from '../../session/file-ops.js'; -import type { SessionEvent, SummaryEvent, CompactEvent } from '../../session/types.js'; +import { readUIHistory, findUserMessageForTurn } from '../../session/ui-history.js'; import { ContextService, estimatePromptTokens } from '../../context/service.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; import { WorkspaceService } from '../../core/workspace.js'; @@ -25,145 +24,6 @@ export const activeApprovalForks = new Map< { setPermissionMode: (mode: any) => Promise | void } >(); -// --- UI history functions (moved from messages.ts) --- - -function filterForUI(events: SessionEvent[]): SessionEvent[] { - const rollbackHiddenTurnIds = new Set(); - const rollbackHiddenOpUuids = new Set(); - - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - rollbackHiddenTurnIds.add(prior.turnId); - } - if (prior.type === 'summary' || prior.type === 'compact') { - if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { - rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); - } - } - } - } - - return events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; - if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; - if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; -} - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'compact' || event.type === 'rollback') continue; - - if (event.type === 'summary') { - let turn = turnsMap.get(event.endTurnId); - if (!turn) { - turn = { id: String(event.endTurnId), items: [], status: 'completed' }; - turnsMap.set(event.endTurnId, turn); - } - turn.items.push({ - id: `summary-${event.uuid}`, - type: 'summary', - content: event.summaryText, - startTurnId: event.startTurnId, - endTurnId: event.endTurnId, - }); - continue; - } - - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} - -function readUIHistory( - sessionId: string, - cwd: string -): Array<{ id: string; items: object[]; status: string }> { - const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); - if (!existsSync(jsonlPath)) return []; - const events = readHistory(jsonlPath); - const visibleEvents = filterForUI(events); - return sessionEventsToTurns(visibleEvents); -} - -function findUserMessageForTurn(sessionId: string, turnId: number, cwd: string): string { - const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); - if (!existsSync(jsonlPath)) return ''; - const rawEvents = readHistory(jsonlPath); - for (const ev of rawEvents) { - if (ev.type === 'user' && (ev as any).turnId === turnId) { - return (ev as any).content ?? ''; - } - } - return ''; -} - export function createSessionsRouter(rt: ManagedRt): Hono { const router = new Hono(); const runWithLayer = async (eff: Effect.Effect) => { diff --git a/packages/codingcode/src/session/ui-history.ts b/packages/codingcode/src/session/ui-history.ts new file mode 100644 index 0000000..d5825a8 --- /dev/null +++ b/packages/codingcode/src/session/ui-history.ts @@ -0,0 +1,140 @@ +import { existsSync } from 'fs'; +import { sessionJsonlPathFromCwd, readHistory } from './file-ops.js'; +import type { SessionEvent, SummaryEvent, CompactEvent } from './types.js'; + +export function filterForUI(events: SessionEvent[]): SessionEvent[] { + const rollbackHiddenTurnIds = new Set(); + const rollbackHiddenOpUuids = new Set(); + + for (const ev of events) { + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + rollbackHiddenTurnIds.add(prior.turnId); + } + if (prior.type === 'summary' || prior.type === 'compact') { + if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { + rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); + } + } + } + } + + return events.filter((ev) => { + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; + if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; + if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; + return true; + }) as SessionEvent[]; +} + +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + +export function sessionEventsToTurns( + events: SessionEvent[] +): Array<{ id: string; items: object[]; status: string }> { + const turnsMap = new Map(); + const nextId = createTurnScopedIdGenerator(); + + for (const event of events) { + if (event.type === 'session_meta') continue; + if (event.type === 'compact' || event.type === 'rollback') continue; + + if (event.type === 'summary') { + let turn = turnsMap.get(event.endTurnId); + if (!turn) { + turn = { id: String(event.endTurnId), items: [], status: 'completed' }; + turnsMap.set(event.endTurnId, turn); + } + turn.items.push({ + id: `summary-${event.uuid}`, + type: 'summary', + content: event.summaryText, + startTurnId: event.startTurnId, + endTurnId: event.endTurnId, + }); + continue; + } + + let turn = turnsMap.get(event.turnId); + if (!turn) { + turn = { id: String(event.turnId), items: [], status: 'completed' }; + turnsMap.set(event.turnId, turn); + } + switch (event.type) { + case 'user': + turn.items.push({ + id: nextId('user', event.turnId), + type: 'message', + role: 'user', + content: event.content, + }); + break; + case 'assistant': + if (event.content) { + turn.items.push({ + id: nextId('assistant', event.turnId), + type: 'message', + role: 'assistant', + content: event.content, + }); + } + for (const tc of event.toolCalls ?? []) { + const args = tc.arguments ?? {}; + turn.items.push({ + id: tc.id, + type: 'tool_call', + name: tc.name, + args, + status: 'approved', + }); + } + break; + case 'tool_result': { + const item: Record = { + id: `result-${event.toolCallId}`, + type: 'tool_result', + callId: event.toolCallId, + name: event.toolName, + output: event.output, + }; + turn.items.push(item); + break; + } + } + } + return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); +} + +export function readUIHistory( + sessionId: string, + cwd: string +): Array<{ id: string; items: object[]; status: string }> { + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(jsonlPath)) return []; + const events = readHistory(jsonlPath); + const visibleEvents = filterForUI(events); + return sessionEventsToTurns(visibleEvents); +} + +export function findUserMessageForTurn(sessionId: string, turnId: number, cwd: string): string { + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(jsonlPath)) return ''; + const rawEvents = readHistory(jsonlPath); + for (const ev of rawEvents) { + if (ev.type === 'user' && (ev as any).turnId === turnId) { + return (ev as any).content ?? ''; + } + } + return ''; +} diff --git a/packages/codingcode/test/session/filter-ui.test.ts b/packages/codingcode/test/session/filter-ui.test.ts index 21c2883..f58fc60 100644 --- a/packages/codingcode/test/session/filter-ui.test.ts +++ b/packages/codingcode/test/session/filter-ui.test.ts @@ -1,119 +1,6 @@ import { describe, it, expect } from 'vitest'; -import type { SessionEvent, SummaryEvent, CompactEvent } from '../../src/session/types.js'; - -function filterForUI(events: SessionEvent[]): SessionEvent[] { - const rollbackHiddenTurnIds = new Set(); - const rollbackHiddenOpUuids = new Set(); - - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - rollbackHiddenTurnIds.add(prior.turnId); - } - if (prior.type === 'summary' || prior.type === 'compact') { - if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { - rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); - } - } - } - } - - return events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; - if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; - if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; -} - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'compact' || event.type === 'rollback') continue; - - if (event.type === 'summary') { - let turn = turnsMap.get(event.endTurnId); - if (!turn) { - turn = { id: String(event.endTurnId), items: [], status: 'completed' }; - turnsMap.set(event.endTurnId, turn); - } - turn.items.push({ - id: `summary-${event.uuid}`, - type: 'summary', - content: event.summaryText, - startTurnId: event.startTurnId, - endTurnId: event.endTurnId, - }); - continue; - } - - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} +import type { SessionEvent } from '../../src/session/types.js'; +import { filterForUI, sessionEventsToTurns } from '../../src/session/ui-history.js'; function makeBaseEvents(extra: SessionEvent[] = []): SessionEvent[] { const base: SessionEvent[] = [ diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index 2a6d192..115b735 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -1,91 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { SessionEvent } from '../../src/session/types.js'; - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'compact' || event.type === 'rollback') continue; - - if (event.type === 'summary') { - let turn = turnsMap.get(event.endTurnId); - if (!turn) { - turn = { id: String(event.endTurnId), items: [], status: 'completed' }; - turnsMap.set(event.endTurnId, turn); - } - turn.items.push({ - id: `summary-${event.uuid}`, - type: 'summary', - content: event.summaryText, - startTurnId: event.startTurnId, - endTurnId: event.endTurnId, - }); - continue; - } - - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} +import { sessionEventsToTurns } from '../../src/session/ui-history.js'; describe('sessionEventsToTurns', () => { it('parses edit_file tool_result without diff (diff is computed on frontend)', () => { diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index 79b0da8..b168f97 100644 --- a/packages/codingcode/test/session/ui-history-rollback.test.ts +++ b/packages/codingcode/test/session/ui-history-rollback.test.ts @@ -5,35 +5,8 @@ import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { filterForContext, buildContextMessages } from '../../src/context/service.js'; import { readHistory } from '../../src/session/file-ops.js'; -import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; - -function filterForUI(events: SessionEvent[]): SessionEvent[] { - const rollbackHiddenTurnIds = new Set(); - const rollbackHiddenOpUuids = new Set(); - - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - rollbackHiddenTurnIds.add(prior.turnId); - } - if (prior.type === 'summary' || prior.type === 'compact') { - if ((prior as any).endTurnId >= ev.throughTurnId) { - rollbackHiddenOpUuids.add((prior as any).uuid); - } - } - } - } - - return events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as any).uuid)) return false; - if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as any).uuid)) return false; - if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; -} +import { filterForUI } from '../../src/session/ui-history.js'; +import type { SessionIndex } from '../../src/session/types.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); From 1ac4a41f0e875acb3de0ad6af07a695859ea0156 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 19 Jun 2026 16:11:55 +0800 Subject: [PATCH 02/11] Refactor skill module config files by splitting and unifying paths --- packages/codingcode/src/client/direct.ts | 73 ++-- .../codingcode/src/client/direct/settings.ts | 344 +++++++++++++++--- packages/codingcode/src/client/http.ts | 54 +-- .../codingcode/src/client/http/settings.ts | 6 +- packages/codingcode/src/client/types.ts | 24 +- packages/codingcode/src/core/workspace.ts | 5 + .../codingcode/src/server/routes/settings.ts | 36 +- packages/codingcode/src/skills/loader.ts | 2 +- packages/codingcode/src/skills/service.ts | 2 +- .../src/skills/{config.ts => source.ts} | 0 packages/codingcode/src/subagent/loader.ts | 6 +- .../test/client/agent-client-cwd.test.ts | 311 ++++++++++++++++ .../test/client/direct/settings.test.ts | 278 +++++++++++++- .../test/server/settings-routes.test.ts | 187 +++++++++- .../codingcode/test/skills/layout.test.ts | 46 +++ packages/desktop/src/lib/core-api.ts | 4 +- packages/desktop/src/settings/HooksPanel.tsx | 69 ++-- packages/desktop/src/settings/McpPanel.tsx | 69 ++-- .../desktop/src/settings/SubagentsPanel.tsx | 6 +- packages/desktop/test/settings-panels.test.ts | 178 +++++++++ 20 files changed, 1471 insertions(+), 229 deletions(-) rename packages/codingcode/src/skills/{config.ts => source.ts} (100%) create mode 100644 packages/codingcode/test/client/agent-client-cwd.test.ts create mode 100644 packages/codingcode/test/skills/layout.test.ts diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index 26caa15..d5fd029 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -379,8 +379,8 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.deleteMemoryExtraType(name); }, - async getSubagentEnabled() { - return clients.settings.getSubagentEnabled({ cwd: cwd() }); + async getSubagentEnabled({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.getSubagentEnabled({ cwd: targetCwd }); }, async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { @@ -391,8 +391,8 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.resetSubagentEnabled(body); }, - async getMcpStatus() { - return clients.settings.getMcpStatus(); + async getMcpStatus({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.getMcpStatus({ cwd: targetCwd }); }, async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { @@ -403,16 +403,23 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.resetMcpDisabled(body); }, - async createMcpServer(server: McpServerConfig): Promise { - await clients.settings.createMcpServer({ cwd: cwd(), server }); + async createMcpServer( + server: McpServerConfig, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.createMcpServer({ cwd: targetCwd, server }); }, - async updateMcpServer(name: string, server: McpServerConfig): Promise { - await clients.settings.updateMcpServer({ cwd: cwd(), name, server }); + async updateMcpServer( + name: string, + server: McpServerConfig, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.updateMcpServer({ cwd: targetCwd, name, server }); }, - async deleteMcpServer(name: string): Promise { - await clients.settings.deleteMcpServer({ cwd: cwd(), name }); + async deleteMcpServer(name: string, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.deleteMcpServer({ cwd: targetCwd, name }); }, async listSkills() { @@ -423,20 +430,24 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.toggleSkill(body); }, - async listAgents() { - return clients.settings.listAgents({ cwd: cwd() }); + async listAgents({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.listAgents({ cwd: targetCwd }); }, - async createAgent(profile: AgentProfile): Promise { - await clients.settings.createAgent({ cwd: cwd(), profile }); + async createAgent(profile: AgentProfile, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.createAgent({ cwd: targetCwd, profile }); }, - async updateAgent(name: string, profile: AgentProfile): Promise { - await clients.settings.updateAgent({ cwd: cwd(), name, profile }); + async updateAgent( + name: string, + profile: AgentProfile, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.updateAgent({ cwd: targetCwd, name, profile }); }, - async deleteAgent(name: string): Promise { - await clients.settings.deleteAgent({ cwd: cwd(), name }); + async deleteAgent(name: string, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.deleteAgent({ cwd: targetCwd, name }); }, async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { @@ -447,32 +458,32 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.resetAgentDisabled(body); }, - async listHooks() { - return clients.settings.listHooks({ cwd: cwd() }); + async listHooks({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.listHooks({ cwd: targetCwd }); }, async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { - await clients.settings.setHookDisabled({ - cwd: cwd(), - name: body.name, - disabled: body.disabled, - }); + await clients.settings.setHookDisabled(body); }, async resetHookDisabled(body: { name: string; cwd: string }): Promise { await clients.settings.resetHookDisabled(body); }, - async createHook(hook: UserHookConfig): Promise { - await clients.settings.createHook({ cwd: cwd(), hook }); + async createHook(hook: UserHookConfig, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.createHook({ cwd: targetCwd, hook }); }, - async updateHook(name: string, hook: UserHookConfig): Promise { - await clients.settings.updateHook({ cwd: cwd(), name, hook }); + async updateHook( + name: string, + hook: UserHookConfig, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.updateHook({ cwd: targetCwd, name, hook }); }, - async deleteHook(name: string): Promise { - await clients.settings.deleteHook({ cwd: cwd(), name }); + async deleteHook(name: string, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.deleteHook({ cwd: targetCwd, name }); }, async getPermissionMode(): Promise { diff --git a/packages/codingcode/src/client/direct/settings.ts b/packages/codingcode/src/client/direct/settings.ts index 2f67418..38a7de9 100644 --- a/packages/codingcode/src/client/direct/settings.ts +++ b/packages/codingcode/src/client/direct/settings.ts @@ -6,10 +6,14 @@ import { ApprovalService } from '../../approval/index.js'; import type { PermissionMode } from '../../approval/types.js'; import type { AgentProfile } from '../../subagent/types.js'; import type { UserHookConfig } from '../../hooks/types.js'; +import { isGlobalCwd } from '../../core/workspace.js'; import { loadMcpConfig, writeMcpConfig, + loadGlobalMcpConfig, + writeGlobalMcpConfig, resolveMcpDisabled, + getGlobalMcpDisabledState, setGlobalMcpDisabledState, setProjectMcpDisabledState, resetProjectMcpDisabledState, @@ -19,6 +23,10 @@ import { writeAgentProfile, updateAgentProfile, deleteAgentProfile, + loadGlobalAgentProfiles, + writeGlobalAgentProfile, + updateGlobalAgentProfile, + deleteGlobalAgentProfile, } from '../../subagent/loader.js'; import { EXPLORE_PROFILE, @@ -28,6 +36,7 @@ import { getProjectSubagentEnabledState, setProjectSubagentEnabledState, resetProjectSubagentEnabledState, + getGlobalAgentDisabledState, setGlobalAgentDisabledState, setProjectAgentDisabledState, resetProjectAgentDisabledState, @@ -37,6 +46,9 @@ import { import { loadHookConfigs, writeHookConfigs, + loadGlobalHookConfigs, + writeGlobalHookConfigs, + resolveHookConfigs, resolveHookDisabled, setGlobalHookDisabledState, setProjectHookDisabledState, @@ -69,7 +81,7 @@ export interface SettingsClient { getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; - getMcpStatus(): Promise; + getMcpStatus(input: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; @@ -95,31 +107,6 @@ export interface SettingsClient { // ---- Helpers with validation ---- -function mcpCreateServer(cwd: string, server: McpServerConfig): void { - const servers = loadMcpConfig(cwd); - if (servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers.push(server); - writeMcpConfig(cwd, servers); -} - -function mcpUpdateServer(cwd: string, name: string, server: McpServerConfig): void { - const servers = loadMcpConfig(cwd); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); - if (server.name !== name && servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers[idx] = server; - writeMcpConfig(cwd, servers); -} - -function mcpDeleteServer(cwd: string, name: string): void { - const servers = loadMcpConfig(cwd).filter((s) => s.name !== name); - writeMcpConfig(cwd, servers); -} - function agentsList(cwd: string): Array<{ name: string; description: string; @@ -133,10 +120,48 @@ function agentsList(cwd: string): Array<{ hasProjectOverride?: boolean; projectDisabled?: boolean; }> { - const custom = loadAgentProfiles(cwd); - return [EXPLORE_PROFILE, PLAN_PROFILE, ...custom].map((a) => { + if (isGlobalCwd(cwd)) { + const custom = loadGlobalAgentProfiles(); + return [EXPLORE_PROFILE, PLAN_PROFILE, ...custom].map((a) => { + const disabled = getGlobalAgentDisabledState(a.name); + return { + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled, + source: + a.name === EXPLORE_PROFILE.name || a.name === PLAN_PROFILE.name + ? ('builtin' as const) + : ('global' as const), + }; + }); + } + const globalCustom = loadGlobalAgentProfiles(); + const projectCustom = loadAgentProfiles(cwd); + const globalNames = new Set(globalCustom.map((a) => a.name)); + const projectNames = new Set(projectCustom.map((a) => a.name)); + + const result: Array<{ + name: string; + description: string; + tools?: string[]; + mcpServers?: string[]; + readonly?: boolean; + maxSteps?: number; + model?: string; + disabled: boolean; + source: 'builtin' | 'global' | 'project'; + hasProjectOverride?: boolean; + projectDisabled?: boolean; + }> = []; + + for (const a of [EXPLORE_PROFILE, PLAN_PROFILE]) { const projectVal = getProjectAgentDisabledState(cwd, a.name); - return { + result.push({ name: a.name, description: a.description, tools: a.tools, @@ -145,17 +170,59 @@ function agentsList(cwd: string): Array<{ maxSteps: a.maxSteps, model: a.model, disabled: resolveAgentDisabled(cwd, a.name), - source: - a.name === EXPLORE_PROFILE.name || a.name === PLAN_PROFILE.name - ? ('builtin' as const) - : ('project' as const), + source: 'builtin', hasProjectOverride: projectVal !== undefined, projectDisabled: projectVal, - }; - }); + }); + } + + for (const a of globalCustom) { + if (projectNames.has(a.name)) continue; + const projectVal = getProjectAgentDisabledState(cwd, a.name); + result.push({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: resolveAgentDisabled(cwd, a.name), + source: 'global', + hasProjectOverride: projectVal !== undefined, + projectDisabled: projectVal, + }); + } + + for (const a of projectCustom) { + const projectVal = getProjectAgentDisabledState(cwd, a.name); + result.push({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: resolveAgentDisabled(cwd, a.name), + source: 'project', + hasProjectOverride: globalNames.has(a.name), + projectDisabled: projectVal, + }); + } + + return result; } function agentsCreate(cwd: string, profile: AgentProfile): void { + if (isGlobalCwd(cwd)) { + const existing = loadGlobalAgentProfiles(); + if (existing.some((a) => a.name === profile.name)) { + throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); + } + writeGlobalAgentProfile(profile); + return; + } const existing = loadAgentProfiles(cwd); if (existing.some((a) => a.name === profile.name)) { throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); @@ -164,6 +231,17 @@ function agentsCreate(cwd: string, profile: AgentProfile): void { } function agentsUpdate(cwd: string, name: string, profile: AgentProfile): void { + if (isGlobalCwd(cwd)) { + const existing = loadGlobalAgentProfiles(); + if (!existing.some((a) => a.name === name)) { + throw new NotFoundError(`Agent '${name}' not found`); + } + if (profile.name !== name && existing.some((a) => a.name === profile.name)) { + throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); + } + updateGlobalAgentProfile(name, profile); + return; + } const existing = loadAgentProfiles(cwd); if (!existing.some((a) => a.name === name)) throw new NotFoundError(`Agent '${name}' not found`); if (profile.name !== name && existing.some((a) => a.name === profile.name)) { @@ -172,7 +250,101 @@ function agentsUpdate(cwd: string, name: string, profile: AgentProfile): void { updateAgentProfile(cwd, name, profile); } +function agentsDelete(cwd: string, name: string): void { + if (isGlobalCwd(cwd)) { + deleteGlobalAgentProfile(name); + return; + } + deleteAgentProfile(cwd, name); +} + +function mcpCreateServer(cwd: string, server: McpServerConfig): void { + if (isGlobalCwd(cwd)) { + const servers = loadGlobalMcpConfig(); + if (servers.some((s) => s.name === server.name)) { + throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); + } + writeGlobalMcpConfig([...servers, server]); + return; + } + const servers = loadMcpConfig(cwd); + if (servers.some((s) => s.name === server.name)) { + throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); + } + servers.push(server); + writeMcpConfig(cwd, servers); +} + +function mcpUpdateServer(cwd: string, name: string, server: McpServerConfig): void { + if (isGlobalCwd(cwd)) { + const servers = loadGlobalMcpConfig(); + const idx = servers.findIndex((s) => s.name === name); + if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); + if (server.name !== name && servers.some((s) => s.name === server.name)) { + throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); + } + servers[idx] = server; + writeGlobalMcpConfig(servers); + return; + } + const servers = loadMcpConfig(cwd); + const idx = servers.findIndex((s) => s.name === name); + if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); + if (server.name !== name && servers.some((s) => s.name === server.name)) { + throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); + } + servers[idx] = server; + writeMcpConfig(cwd, servers); +} + +function mcpDeleteServer(cwd: string, name: string): void { + if (isGlobalCwd(cwd)) { + const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); + writeGlobalMcpConfig(servers); + return; + } + const servers = loadMcpConfig(cwd); + if (!servers.some((s) => s.name === name)) { + throw new NotFoundError(`MCP server '${name}' not found in project config`); + } + writeMcpConfig( + cwd, + servers.filter((s) => s.name !== name) + ); +} + +function hooksList( + cwd: string +): Array { + if (isGlobalCwd(cwd)) { + return loadGlobalHookConfigs().map((h) => ({ ...h, source: 'global' as const })); + } + const globalHooks = loadGlobalHookConfigs(); + const projectHooks = loadHookConfigs(cwd); + const globalNames = new Set(globalHooks.map((h) => h.name)); + const projectNames = new Set(projectHooks.map((h) => h.name)); + const merged = resolveHookConfigs(cwd); + return merged.map((h) => { + const isFromProject = projectNames.has(h.name); + const isFromGlobal = globalNames.has(h.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...h, + source: (isFromProject ? 'project' : 'global') as 'global' | 'project', + hasProjectOverride, + }; + }); +} + function hooksCreate(cwd: string, hook: UserHookConfig): void { + if (isGlobalCwd(cwd)) { + const hooks = loadGlobalHookConfigs(); + if (hooks.some((h) => h.name === hook.name)) { + throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); + } + writeGlobalHookConfigs([...hooks, hook]); + return; + } const hooks = loadHookConfigs(cwd); if (hooks.some((h) => h.name === hook.name)) { throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); @@ -182,6 +354,17 @@ function hooksCreate(cwd: string, hook: UserHookConfig): void { } function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { + if (isGlobalCwd(cwd)) { + const hooks = loadGlobalHookConfigs(); + const idx = hooks.findIndex((h) => h.name === name); + if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); + if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { + throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); + } + hooks[idx] = hook; + writeGlobalHookConfigs(hooks); + return; + } const hooks = loadHookConfigs(cwd); const idx = hooks.findIndex((h) => h.name === name); if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); @@ -193,8 +376,19 @@ function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { } function hooksDelete(cwd: string, name: string): void { - const hooks = loadHookConfigs(cwd).filter((h) => h.name !== name); - writeHookConfigs(cwd, hooks); + if (isGlobalCwd(cwd)) { + const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); + writeGlobalHookConfigs(hooks); + return; + } + const hooks = loadHookConfigs(cwd); + if (!hooks.some((h) => h.name === name)) { + throw new NotFoundError(`Hook '${name}' not found in project config`); + } + writeHookConfigs( + cwd, + hooks.filter((h) => h.name !== name) + ); } function hooksSetDisabled(cwd: string, name: string, disabled: boolean): void { @@ -261,7 +455,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async setSubagentEnabled({ enabled, cwd }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setSubagentEnabledState(enabled); } else { setProjectSubagentEnabledState(cwd, enabled); @@ -272,17 +466,71 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { resetProjectSubagentEnabledState(cwd); }, - async getMcpStatus() { - return rt.runPromise( + async getMcpStatus({ cwd }) { + const projectCwd = isGlobalCwd(cwd) ? process.cwd() : cwd; + const runtime = await rt.runPromise( Effect.gen(function* () { const mcp = yield* McpService; - return yield* mcp.status(process.cwd()); + return yield* mcp.status(projectCwd); }) ); + const runtimeByName = new Map(runtime.map((r) => [r.name, r])); + if (isGlobalCwd(cwd)) { + return loadGlobalMcpConfig().map((s) => ({ + ...runtimeByName.get(s.name), + name: s.name, + disabled: getGlobalMcpDisabledState(s.name), + source: 'global' as const, + })) as McpStatus[]; + } + const globalServers = loadGlobalMcpConfig(); + const projectServers = loadMcpConfig(projectCwd); + const globalNames = new Set(globalServers.map((s) => s.name)); + const seen = new Set(); + const result: Array< + McpStatus & { source: 'global' | 'project'; hasProjectOverride?: boolean } + > = []; + for (const s of projectServers) { + seen.add(s.name); + const isFromGlobal = globalNames.has(s.name); + const r = runtimeByName.get(s.name); + result.push({ + ...(r ?? { + name: s.name, + connected: false, + transport: 'stdio' as const, + reconnectAttempts: 0, + leaseCount: 0, + toolCount: 0, + }), + name: s.name, + disabled: r?.disabled ?? false, + source: 'project', + hasProjectOverride: isFromGlobal, + }); + } + for (const s of globalServers) { + if (seen.has(s.name)) continue; + const r = runtimeByName.get(s.name); + result.push({ + ...(r ?? { + name: s.name, + connected: false, + transport: 'stdio' as const, + reconnectAttempts: 0, + leaseCount: 0, + toolCount: 0, + }), + name: s.name, + disabled: r?.disabled ?? false, + source: 'global', + }); + } + return result as McpStatus[]; }, async setMcpDisabled({ name, disabled, cwd }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setGlobalMcpDisabledState(name, disabled); } else { setProjectMcpDisabledState(cwd, name, disabled); @@ -291,8 +539,8 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { Effect.gen(function* () { const mcp = yield* McpService; return yield* disabled - ? mcp.disable(cwd || process.cwd(), name) - : mcp.enable(cwd || process.cwd(), name); + ? mcp.disable(isGlobalCwd(cwd) ? process.cwd() : cwd, name) + : mcp.enable(isGlobalCwd(cwd) ? process.cwd() : cwd, name); }) ); }, @@ -349,11 +597,11 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async deleteAgent({ cwd, name }) { - deleteAgentProfile(cwd, name); + agentsDelete(cwd, name); }, async setAgentDisabled({ name, disabled, cwd }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setGlobalAgentDisabledState(name, disabled); } else { setProjectAgentDisabledState(cwd, name, disabled); @@ -365,7 +613,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async listHooks({ cwd }) { - return loadHookConfigs(cwd); + return hooksList(cwd) as unknown as UserHookConfig[]; }, async createHook({ cwd, hook }) { @@ -381,7 +629,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async setHookDisabled({ cwd, name, disabled }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setGlobalHookDisabledState(name, disabled); } else { setProjectHookDisabledState(cwd, name, disabled); diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index 7579d60..ed05e0f 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -248,8 +248,8 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.deleteMemoryExtraType(name); }, - async getSubagentEnabled() { - return clients.settings.getSubagentEnabled({ cwd: '' }); + async getSubagentEnabled({ cwd }: { cwd: string }) { + return clients.settings.getSubagentEnabled({ cwd }); }, async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { @@ -260,8 +260,8 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.resetSubagentEnabled(body); }, - async getMcpStatus() { - return clients.settings.getMcpStatus(); + async getMcpStatus({ cwd }: { cwd: string }) { + return clients.settings.getMcpStatus({ cwd }); }, async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { @@ -280,32 +280,32 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.toggleSkill(body); }, - async createMcpServer(server: McpServerConfig) { - await clients.settings.createMcpServer({ cwd: '', server }); + async createMcpServer(server: McpServerConfig, { cwd }: { cwd: string }) { + await clients.settings.createMcpServer({ cwd, server }); }, - async updateMcpServer(name: string, server: McpServerConfig) { - await clients.settings.updateMcpServer({ cwd: '', name, server }); + async updateMcpServer(name: string, server: McpServerConfig, { cwd }: { cwd: string }) { + await clients.settings.updateMcpServer({ cwd, name, server }); }, - async deleteMcpServer(name: string) { - await clients.settings.deleteMcpServer({ cwd: '', name }); + async deleteMcpServer(name: string, { cwd }: { cwd: string }) { + await clients.settings.deleteMcpServer({ cwd, name }); }, - async listAgents() { - return clients.settings.listAgents({ cwd: '' }); + async listAgents({ cwd }: { cwd: string }) { + return clients.settings.listAgents({ cwd }); }, - async createAgent(profile: AgentProfile) { - await clients.settings.createAgent({ cwd: '', profile }); + async createAgent(profile: AgentProfile, { cwd }: { cwd: string }) { + await clients.settings.createAgent({ cwd, profile }); }, - async updateAgent(name: string, profile: AgentProfile) { - await clients.settings.updateAgent({ cwd: '', name, profile }); + async updateAgent(name: string, profile: AgentProfile, { cwd }: { cwd: string }) { + await clients.settings.updateAgent({ cwd, name, profile }); }, - async deleteAgent(name: string) { - await clients.settings.deleteAgent({ cwd: '', name }); + async deleteAgent(name: string, { cwd }: { cwd: string }) { + await clients.settings.deleteAgent({ cwd, name }); }, async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }) { @@ -316,28 +316,28 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.resetAgentDisabled(body); }, - async listHooks() { - return clients.settings.listHooks({ cwd: '' }); + async listHooks({ cwd }: { cwd: string }) { + return clients.settings.listHooks({ cwd }); }, async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }) { - await clients.settings.setHookDisabled({ cwd: '', name: body.name, disabled: body.disabled }); + await clients.settings.setHookDisabled(body); }, async resetHookDisabled(body: { name: string; cwd: string }) { await clients.settings.resetHookDisabled(body); }, - async createHook(hook: UserHookConfig) { - await clients.settings.createHook({ cwd: '', hook }); + async createHook(hook: UserHookConfig, { cwd }: { cwd: string }) { + await clients.settings.createHook({ cwd, hook }); }, - async updateHook(name: string, hook: UserHookConfig) { - await clients.settings.updateHook({ cwd: '', name, hook }); + async updateHook(name: string, hook: UserHookConfig, { cwd }: { cwd: string }) { + await clients.settings.updateHook({ cwd, name, hook }); }, - async deleteHook(name: string) { - await clients.settings.deleteHook({ cwd: '', name }); + async deleteHook(name: string, { cwd }: { cwd: string }) { + await clients.settings.deleteHook({ cwd, name }); }, async getPermissionMode() { diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index aac106b..b0761a1 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -18,7 +18,7 @@ export interface SettingsClient { getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; - getMcpStatus(): Promise; + getMcpStatus(input: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; @@ -95,8 +95,8 @@ export function createHttpSettingsClient( await apiPost(`/api/settings/subagent/enabled/reset${qsCwd(cwd)}`, {}); }, - async getMcpStatus() { - return apiGet('/api/settings/mcp'); + async getMcpStatus({ cwd }) { + return apiGet(`/api/settings/mcp${qsCwd(cwd)}`); }, async setMcpDisabled({ name, disabled, cwd }) { diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 6eda8e1..9d43ed7 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -65,15 +65,15 @@ export interface AgentClient { getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; - getMcpStatus(): Promise; - createMcpServer(server: McpServerConfig): Promise; - updateMcpServer(name: string, server: McpServerConfig): Promise; - deleteMcpServer(name: string): Promise; + getMcpStatus(query: { cwd: string }): Promise; + createMcpServer(server: McpServerConfig, query: { cwd: string }): Promise; + updateMcpServer(name: string, server: McpServerConfig, query: { cwd: string }): Promise; + deleteMcpServer(name: string, query: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; listSkills(): Promise>; toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; - listAgents(): Promise< + listAgents(query: { cwd: string }): Promise< Array<{ name: string; description: string; @@ -85,17 +85,17 @@ export interface AgentClient { disabled?: boolean; }> >; - createAgent(profile: AgentProfile): Promise; - updateAgent(name: string, profile: AgentProfile): Promise; - deleteAgent(name: string): Promise; + createAgent(profile: AgentProfile, query: { cwd: string }): Promise; + updateAgent(name: string, profile: AgentProfile, query: { cwd: string }): Promise; + deleteAgent(name: string, query: { cwd: string }): Promise; setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetAgentDisabled(body: { name: string; cwd: string }): Promise; - listHooks(): Promise; + listHooks(query: { cwd: string }): Promise; setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; - createHook(hook: UserHookConfig): Promise; - updateHook(name: string, hook: UserHookConfig): Promise; - deleteHook(name: string): Promise; + createHook(hook: UserHookConfig, query: { cwd: string }): Promise; + updateHook(name: string, hook: UserHookConfig, query: { cwd: string }): Promise; + deleteHook(name: string, query: { cwd: string }): Promise; getPermissionMode(): Promise; setPermissionMode(mode: PermissionMode): Promise; } diff --git a/packages/codingcode/src/core/workspace.ts b/packages/codingcode/src/core/workspace.ts index bbd2722..f66d3cd 100644 --- a/packages/codingcode/src/core/workspace.ts +++ b/packages/codingcode/src/core/workspace.ts @@ -32,6 +32,11 @@ export function parseWorkspaceArgs(argv: string[]): { workspaceCwd?: string; 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(); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 1812338..36da8c9 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SkillService } from '../../skills/service.js'; -import { WorkspaceService } from '../../core/workspace.js'; +import { WorkspaceService, isGlobalCwd } from '../../core/workspace.js'; import { AlreadyExistsError, NotFoundError } from '../../core/error.js'; import type { McpServerConfig } from '../../mcp/types.js'; import type { AgentProfile } from '../../subagent/types.js'; @@ -61,7 +61,7 @@ import { setProjectSkillDisabledState, discoverGlobalSkillDirs, discoverProjectSkillDirs, -} from '../../skills/config.js'; +} from '../../skills/source.js'; import { getMemoryConfig, getAllTypesWithStatus, @@ -92,12 +92,6 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { ); const resolveWorkspaceCwd = (override?: string) => ws.resolveWorkspaceCwd(override); - // ---- Helpers for global vs project ---- - - function isGlobalCwd(cwd: string | undefined): boolean { - return !cwd || cwd === '' || cwd === 'global'; - } - // ---- Helpers for CRUD with validation ---- function mcpCreateServer(cwd: string, server: McpServerConfig): void { @@ -121,8 +115,14 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { } function mcpDeleteServer(cwd: string, name: string): void { - const servers = loadMcpConfig(cwd).filter((s) => s.name !== name); - writeMcpConfig(cwd, servers); + const servers = loadMcpConfig(cwd); + if (!servers.some((s) => s.name === name)) { + throw new NotFoundError(`MCP server '${name}' not found in project config`); + } + writeMcpConfig( + cwd, + servers.filter((s) => s.name !== name) + ); } function agentsList(cwd: string): Array<{ @@ -268,8 +268,14 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { } function hooksDelete(cwd: string, name: string): void { - const hooks = loadHookConfigs(cwd).filter((h) => h.name !== name); - writeHookConfigs(cwd, hooks); + const hooks = loadHookConfigs(cwd); + if (!hooks.some((h) => h.name === name)) { + throw new NotFoundError(`Hook '${name}' not found in project config`); + } + writeHookConfigs( + cwd, + hooks.filter((h) => h.name !== name) + ); } // ---- Memory ---- @@ -488,7 +494,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { const hasProjectOverride = isFromProject && isFromGlobal; return { ...h, - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + source: isFromProject ? 'project' : 'global', hasProjectOverride, disabled: resolveHookDisabled(cwd, h.name), }; @@ -614,7 +620,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { return { ...s, disabled: resolveMcpDisabled(cwd, s.name), - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + source: isFromProject ? 'project' : 'global', hasProjectOverride, }; }) @@ -736,7 +742,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { const hasProjectOverride = isFromProject && isFromGlobal; return { ...s, - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + source: isFromProject ? 'project' : 'global', hasProjectOverride, }; }) diff --git a/packages/codingcode/src/skills/loader.ts b/packages/codingcode/src/skills/loader.ts index 66bbfd4..b38a9a7 100644 --- a/packages/codingcode/src/skills/loader.ts +++ b/packages/codingcode/src/skills/loader.ts @@ -1,7 +1,7 @@ import { statSync } from 'fs'; import { basename } from 'path'; import type { Skill } from './types.js'; -import { readSkillMd, readFileContent, getFilesInDir, getMimeType } from './config.js'; +import { readSkillMd, readFileContent, getFilesInDir, getMimeType } from './source.js'; export function loadSkill(dirPath: string): Skill | null { const parsed = readSkillMd(dirPath); diff --git a/packages/codingcode/src/skills/service.ts b/packages/codingcode/src/skills/service.ts index f1c2e97..14e9aac 100644 --- a/packages/codingcode/src/skills/service.ts +++ b/packages/codingcode/src/skills/service.ts @@ -1,5 +1,5 @@ import { Effect } from 'effect'; -import { discoverSkillDirs, resolveSkillDisabled, setProjectSkillDisabledState } from './config.js'; +import { discoverSkillDirs, resolveSkillDisabled, setProjectSkillDisabledState } from './source.js'; import { loadSkill } from './loader.js'; import type { Skill } from './types.js'; diff --git a/packages/codingcode/src/skills/config.ts b/packages/codingcode/src/skills/source.ts similarity index 100% rename from packages/codingcode/src/skills/config.ts rename to packages/codingcode/src/skills/source.ts diff --git a/packages/codingcode/src/subagent/loader.ts b/packages/codingcode/src/subagent/loader.ts index 3c2b8c4..90d9231 100644 --- a/packages/codingcode/src/subagent/loader.ts +++ b/packages/codingcode/src/subagent/loader.ts @@ -4,6 +4,7 @@ import { homedir } from 'os'; import { parse as parseYaml } from 'yaml'; import type { AgentProfile } from './types.js'; import { createLogger } from '@codingcode/infra/logger'; +import { NotFoundError } from '../core/error.js'; const logger = createLogger(); @@ -185,7 +186,10 @@ export function updateAgentProfile( export function deleteAgentProfile(projectCwd: string, name: string): void { const filePath = findAgentFile(projectCwd, name); - if (filePath) unlinkSync(filePath); + if (!filePath) { + throw new NotFoundError(`Agent '${name}' not found in project config`); + } + unlinkSync(filePath); } function loadAgentProfilesFromDir(dirPath: string): AgentProfile[] { diff --git a/packages/codingcode/test/client/agent-client-cwd.test.ts b/packages/codingcode/test/client/agent-client-cwd.test.ts new file mode 100644 index 0000000..a77d49d --- /dev/null +++ b/packages/codingcode/test/client/agent-client-cwd.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; + +import { WorkspaceService } from '../../src/core/workspace.js'; +import { LLMFactoryService } from '../../src/llm/factory.js'; +import { AgentError } from '../../src/core/error.js'; +import type { LLMClient } from '../../src/llm/client.js'; + +const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { + getWorkspaceCwd: () => '/workspace', +} as any); + +const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { + getLLMClient: () => Effect.succeed(null), + listModels: () => Effect.succeed([]), + switchModel: () => Effect.fail(new AgentError('CONFIG_INVALID', 'not found')), + findModel: () => Effect.succeed(null), + getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'No active model')), + createClient: () => Effect.succeed(null), +} as any); + +const TestLayer = Layer.mergeAll(MockWorkspaceLayer, MockLLMFactoryLayer); + +const noopLlm: LLMClient = { + completeStream: () => ({ + stream: (async function* () {})(), + response: Promise.resolve({ ok: true, value: { content: '', finishReason: 'stop' as const } }), + }), + complete: () => Effect.succeed({ content: '' } as any), + modelInfo: { id: 'test', provider: 'test', name: 'Test', contextWindow: 128000 } as any, +}; + +const calls: Record = { + getSubagentEnabled: [], + getMcpStatus: [], + createMcpServer: [], + updateMcpServer: [], + deleteMcpServer: [], + listAgents: [], + createAgent: [], + updateAgent: [], + deleteAgent: [], + listHooks: [], + createHook: [], + updateHook: [], + deleteHook: [], + toggleSkill: [], + setAgentDisabled: [], + setHookDisabled: [], +}; + +function makeMockSettings() { + return { + getMemoryEnabled: vi.fn().mockResolvedValue(true), + setMemoryEnabled: vi.fn().mockResolvedValue(undefined), + getMemoryConfig: vi.fn().mockResolvedValue({ enabled: true, types: [] }), + setMemoryTypeDisabled: vi.fn().mockResolvedValue(undefined), + addMemoryExtraType: vi.fn().mockResolvedValue(undefined), + updateMemoryExtraType: vi.fn().mockResolvedValue(undefined), + deleteMemoryExtraType: vi.fn().mockResolvedValue(undefined), + getSubagentEnabled: vi.fn().mockImplementation((...args: unknown[]) => { + calls.getSubagentEnabled.push(args); + return Promise.resolve({ enabled: true, source: 'global' }); + }), + setSubagentEnabled: vi.fn().mockResolvedValue(undefined), + resetSubagentEnabled: vi.fn().mockResolvedValue(undefined), + getMcpStatus: vi.fn().mockImplementation((...args: unknown[]) => { + calls.getMcpStatus.push(args); + return Promise.resolve([]); + }), + setMcpDisabled: vi.fn().mockResolvedValue(undefined), + resetMcpDisabled: vi.fn().mockResolvedValue(undefined), + createMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { + calls.createMcpServer.push(args); + return Promise.resolve(undefined); + }), + updateMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { + calls.updateMcpServer.push(args); + return Promise.resolve(undefined); + }), + deleteMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { + calls.deleteMcpServer.push(args); + return Promise.resolve(undefined); + }), + listSkills: vi.fn().mockResolvedValue([]), + toggleSkill: vi.fn().mockImplementation((...args: unknown[]) => { + calls.toggleSkill.push(args); + return Promise.resolve(undefined); + }), + listAgents: vi.fn().mockImplementation((...args: unknown[]) => { + calls.listAgents.push(args); + return Promise.resolve([]); + }), + createAgent: vi.fn().mockImplementation((...args: unknown[]) => { + calls.createAgent.push(args); + return Promise.resolve(undefined); + }), + updateAgent: vi.fn().mockImplementation((...args: unknown[]) => { + calls.updateAgent.push(args); + return Promise.resolve(undefined); + }), + deleteAgent: vi.fn().mockImplementation((...args: unknown[]) => { + calls.deleteAgent.push(args); + return Promise.resolve(undefined); + }), + setAgentDisabled: vi.fn().mockImplementation((...args: unknown[]) => { + calls.setAgentDisabled.push(args); + return Promise.resolve(undefined); + }), + resetAgentDisabled: vi.fn().mockResolvedValue(undefined), + listHooks: vi.fn().mockImplementation((...args: unknown[]) => { + calls.listHooks.push(args); + return Promise.resolve([]); + }), + setHookDisabled: vi.fn().mockImplementation((...args: unknown[]) => { + calls.setHookDisabled.push(args); + return Promise.resolve(undefined); + }), + resetHookDisabled: vi.fn().mockResolvedValue(undefined), + createHook: vi.fn().mockImplementation((...args: unknown[]) => { + calls.createHook.push(args); + return Promise.resolve(undefined); + }), + updateHook: vi.fn().mockImplementation((...args: unknown[]) => { + calls.updateHook.push(args); + return Promise.resolve(undefined); + }), + deleteHook: vi.fn().mockImplementation((...args: unknown[]) => { + calls.deleteHook.push(args); + return Promise.resolve(undefined); + }), + getGlobalPermissionMode: vi.fn().mockResolvedValue('default'), + setGlobalPermissionMode: vi.fn().mockResolvedValue(undefined), + }; +} + +vi.mock('../../src/client/direct/settings.js', () => ({ + createDirectSettingsClient: () => makeMockSettings(), +})); + +const { createDirectClient } = await import('../../src/client/direct.js'); + +describe('AgentClient SDK - unified cwd forwarding', () => { + let client: Awaited>; + + beforeEach(async () => { + for (const key of Object.keys(calls)) calls[key] = []; + const rt = ManagedRuntime.make(TestLayer); + client = await createDirectClient(noopLlm, rt); + }); + + describe('getSubagentEnabled - explicit cwd', () => { + it('forwards project cwd from query arg', async () => { + await client.getSubagentEnabled({ cwd: '/my-project' }); + expect(calls.getSubagentEnabled).toEqual([[{ cwd: '/my-project' }]]); + }); + + it('forwards empty cwd (= global) from query arg', async () => { + await client.getSubagentEnabled({ cwd: '' }); + expect(calls.getSubagentEnabled).toEqual([[{ cwd: '' }]]); + }); + }); + + describe('getMcpStatus - explicit cwd', () => { + it('forwards project cwd from query arg', async () => { + await client.getMcpStatus({ cwd: '/my-project' }); + expect(calls.getMcpStatus).toEqual([[{ cwd: '/my-project' }]]); + }); + + it('forwards empty cwd (= global) from query arg', async () => { + await client.getMcpStatus({ cwd: '' }); + expect(calls.getMcpStatus).toEqual([[{ cwd: '' }]]); + }); + }); + + describe('createMcpServer - explicit cwd', () => { + it('forwards cwd as second arg, not via closure', async () => { + await client.createMcpServer({ name: 'srv', command: 'npx' } as any, { + cwd: '/my-project', + }); + expect(calls.createMcpServer).toEqual([ + [{ cwd: '/my-project', server: { name: 'srv', command: 'npx' } }], + ]); + }); + }); + + describe('updateMcpServer - explicit cwd', () => { + it('forwards cwd as third arg', async () => { + await client.updateMcpServer('srv', { name: 'srv', command: 'npx' } as any, { + cwd: '/my-project', + }); + expect(calls.updateMcpServer).toEqual([ + [{ cwd: '/my-project', name: 'srv', server: { name: 'srv', command: 'npx' } }], + ]); + }); + }); + + describe('deleteMcpServer - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + await client.deleteMcpServer('srv', { cwd: '/my-project' }); + expect(calls.deleteMcpServer).toEqual([[{ cwd: '/my-project', name: 'srv' }]]); + }); + }); + + describe('listAgents - explicit cwd', () => { + it('forwards cwd from query', async () => { + await client.listAgents({ cwd: '/my-project' }); + expect(calls.listAgents).toEqual([[{ cwd: '/my-project' }]]); + }); + }); + + describe('createAgent - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; + await client.createAgent(profile as any, { cwd: '/my-project' }); + expect(calls.createAgent).toEqual([[{ cwd: '/my-project', profile }]]); + }); + + it('different cwds for the same agent name go to different settings calls', async () => { + const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; + await client.createAgent(profile as any, { cwd: '/project-a' }); + await client.createAgent(profile as any, { cwd: '/project-b' }); + expect(calls.createAgent).toEqual([ + [{ cwd: '/project-a', profile }], + [{ cwd: '/project-b', profile }], + ]); + }); + }); + + describe('updateAgent - explicit cwd', () => { + it('forwards cwd as third arg', async () => { + const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; + await client.updateAgent('a1', profile as any, { cwd: '/my-project' }); + expect(calls.updateAgent).toEqual([[{ cwd: '/my-project', name: 'a1', profile }]]); + }); + }); + + describe('deleteAgent - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + await client.deleteAgent('a1', { cwd: '/my-project' }); + expect(calls.deleteAgent).toEqual([[{ cwd: '/my-project', name: 'a1' }]]); + }); + }); + + describe('listHooks - explicit cwd', () => { + it('forwards cwd from query', async () => { + await client.listHooks({ cwd: '/my-project' }); + expect(calls.listHooks).toEqual([[{ cwd: '/my-project' }]]); + }); + }); + + describe('createHook - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + const hook = { + name: 'h1', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }; + await client.createHook(hook as any, { cwd: '/my-project' }); + expect(calls.createHook).toEqual([[{ cwd: '/my-project', hook }]]); + }); + }); + + describe('updateHook - explicit cwd', () => { + it('forwards cwd as third arg', async () => { + const hook = { + name: 'h1', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }; + await client.updateHook('h1', hook as any, { cwd: '/my-project' }); + expect(calls.updateHook).toEqual([[{ cwd: '/my-project', name: 'h1', hook }]]); + }); + }); + + describe('deleteHook - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + await client.deleteHook('h1', { cwd: '/my-project' }); + expect(calls.deleteHook).toEqual([[{ cwd: '/my-project', name: 'h1' }]]); + }); + }); +}); + +describe('AgentClient SDK - body-based methods still pass through', () => { + let client: Awaited>; + + beforeEach(async () => { + for (const key of Object.keys(calls)) calls[key] = []; + const rt = ManagedRuntime.make(TestLayer); + client = await createDirectClient(noopLlm, rt); + }); + + it('toggleSkill passes body with cwd unchanged', async () => { + await client.toggleSkill({ name: 's1', enabled: true, cwd: '/my-project' }); + expect(calls.toggleSkill).toEqual([[{ name: 's1', enabled: true, cwd: '/my-project' }]]); + }); + + it('setAgentDisabled passes body with cwd unchanged', async () => { + await client.setAgentDisabled({ name: 'a1', disabled: true, cwd: '/my-project' }); + expect(calls.setAgentDisabled).toEqual([[{ name: 'a1', disabled: true, cwd: '/my-project' }]]); + }); + + it('setHookDisabled passes body with cwd unchanged', async () => { + await client.setHookDisabled({ name: 'h1', disabled: true, cwd: '/my-project' }); + expect(calls.setHookDisabled).toEqual([[{ name: 'h1', disabled: true, cwd: '/my-project' }]]); + }); +}); diff --git a/packages/codingcode/test/client/direct/settings.test.ts b/packages/codingcode/test/client/direct/settings.test.ts index 6322c74..8d6c935 100644 --- a/packages/codingcode/test/client/direct/settings.test.ts +++ b/packages/codingcode/test/client/direct/settings.test.ts @@ -64,18 +64,29 @@ const rt = ManagedRuntime.make(TestLayer); vi.mock('../../../src/mcp/config.js', () => ({ loadMcpConfig: vi.fn().mockReturnValue([]), writeMcpConfig: vi.fn(), + loadGlobalMcpConfig: vi.fn().mockReturnValue([]), + writeGlobalMcpConfig: vi.fn(), resolveMcpDisabled: vi.fn().mockReturnValue(false), + resolveMcpConfig: vi.fn().mockReturnValue([]), + getGlobalMcpDisabledState: vi.fn().mockReturnValue(false), setGlobalMcpDisabledState: vi.fn(), setProjectMcpDisabledState: vi.fn(), resetProjectMcpDisabledState: vi.fn(), })); -vi.mock('../../../src/subagent/loader.js', () => ({ - loadAgentProfiles: vi.fn().mockReturnValue([]), - writeAgentProfile: vi.fn(), - updateAgentProfile: vi.fn(), - deleteAgentProfile: vi.fn(), -})); +vi.mock('../../../src/subagent/loader.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + loadAgentProfiles: vi.fn().mockReturnValue([]), + writeAgentProfile: vi.fn(), + updateAgentProfile: vi.fn(), + deleteAgentProfile: vi.fn().mockImplementation(actual.deleteAgentProfile), + loadGlobalAgentProfiles: vi.fn().mockReturnValue([]), + writeGlobalAgentProfile: vi.fn(), + updateGlobalAgentProfile: vi.fn(), + deleteGlobalAgentProfile: vi.fn(), + }; +}); vi.mock('../../../src/subagent/registry.js', () => ({ EXPLORE_PROFILE: { @@ -85,11 +96,19 @@ vi.mock('../../../src/subagent/registry.js', () => ({ readonly: true, maxSteps: 30, }, + PLAN_PROFILE: { + name: 'plan', + description: 'Plan', + tools: ['read_file'], + readonly: true, + maxSteps: 30, + }, setSubagentEnabledState: vi.fn(), resolveSubagentEnabled: vi.fn().mockReturnValue(true), getProjectSubagentEnabledState: vi.fn().mockReturnValue(undefined), setProjectSubagentEnabledState: vi.fn(), resetProjectSubagentEnabledState: vi.fn(), + getGlobalAgentDisabledState: vi.fn().mockReturnValue(false), setGlobalAgentDisabledState: vi.fn(), setProjectAgentDisabledState: vi.fn(), resetProjectAgentDisabledState: vi.fn(), @@ -100,6 +119,9 @@ vi.mock('../../../src/subagent/registry.js', () => ({ vi.mock('../../../src/hooks/config.js', () => ({ loadHookConfigs: vi.fn().mockReturnValue([]), writeHookConfigs: vi.fn(), + loadGlobalHookConfigs: vi.fn().mockReturnValue([]), + writeGlobalHookConfigs: vi.fn(), + resolveHookConfigs: vi.fn().mockReturnValue([]), resolveHookDisabled: vi.fn().mockReturnValue(false), setGlobalHookDisabledState: vi.fn(), setProjectHookDisabledState: vi.fn(), @@ -275,3 +297,247 @@ describe('createDirectSettingsClient - updated signatures with cwd', () => { }); }); }); + +// ---- Merged view: agents / hooks / MCP source labeling ---- + +describe('createDirectSettingsClient - merged views with source labeling', () => { + let client: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = createDirectSettingsClient(rt); + }); + + describe('listAgents', () => { + it('global cwd returns builtin (explore+plan) + global custom, all source labeled', async () => { + const { loadGlobalAgentProfiles } = await import('../../../src/subagent/loader.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'g1', description: 'G1', tools: ['read_file'] }, + { name: 'g2', description: 'G2', tools: ['read_file'] }, + ] as any); + const result = await client.listAgents({ cwd: 'global' }); + expect(result).toHaveLength(4); + expect(result.find((a: any) => a.name === 'explore')?.source).toBe('builtin'); + expect(result.find((a: any) => a.name === 'plan')?.source).toBe('builtin'); + expect(result.find((a: any) => a.name === 'g1')?.source).toBe('global'); + expect(result.find((a: any) => a.name === 'g2')?.source).toBe('global'); + }); + + it('project cwd returns builtin + global (deduped) + project, project override labeled source=project', async () => { + const { loadGlobalAgentProfiles, loadAgentProfiles } = + await import('../../../src/subagent/loader.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'shared', description: 'shared', tools: ['read_file'] }, + { name: 'global-only', description: 'G only', tools: ['read_file'] }, + ] as any); + vi.mocked(loadAgentProfiles).mockReturnValue([ + { name: 'shared', description: 'shared override', tools: ['read_file'] }, + { name: 'project-only', description: 'P only', tools: ['read_file'] }, + ] as any); + const result = await client.listAgents({ cwd: '/my-project' }); + const byName = new Map(result.map((a: any) => [a.name, a])); + expect(byName.get('explore')?.source).toBe('builtin'); + expect(byName.get('plan')?.source).toBe('builtin'); + expect(byName.get('global-only')?.source).toBe('global'); + // Override case: project's copy wins, labeled source=project + expect(byName.get('shared')?.source).toBe('project'); + expect(byName.get('shared')?.hasProjectOverride).toBe(true); + expect(byName.get('project-only')?.source).toBe('project'); + }); + }); + + describe('listHooks', () => { + it('global cwd returns global hooks with source=global', async () => { + const { loadGlobalHookConfigs } = await import('../../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'gh', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ] as any); + const result = (await client.listHooks({ cwd: 'global' })) as any[]; + expect(result).toHaveLength(1); + expect(result[0].source).toBe('global'); + }); + + it('project cwd returns merged hooks; project override labeled source=project', async () => { + const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs } = + await import('../../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + { + name: 'gh', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ] as any); + vi.mocked(loadHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + { name: 'ph', point: 'tool.execute.after', type: 'decision', command: 'sh', enabled: true }, + ] as any); + vi.mocked(resolveHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + { + name: 'gh', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + { name: 'ph', point: 'tool.execute.after', type: 'decision', command: 'sh', enabled: true }, + ] as any); + const result = (await client.listHooks({ cwd: '/my-project' })) as any[]; + const byName = new Map(result.map((h) => [h.name, h])); + expect(byName.get('shared')?.source).toBe('project'); + expect(byName.get('shared')?.hasProjectOverride).toBe(true); + expect(byName.get('gh')?.source).toBe('global'); + expect(byName.get('ph')?.source).toBe('project'); + }); + }); + + describe('getMcpStatus', () => { + it('global cwd returns global servers with source=global', async () => { + const { loadGlobalMcpConfig, getGlobalMcpDisabledState } = + await import('../../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'gs', command: 'npx' }] as any); + vi.mocked(getGlobalMcpDisabledState).mockReturnValue(false); + const result = (await client.getMcpStatus({ cwd: 'global' })) as any[]; + expect(result).toHaveLength(1); + expect(result[0].source).toBe('global'); + expect(result[0].name).toBe('gs'); + }); + + it('project cwd returns merged servers; project override labeled source=project', async () => { + const { loadGlobalMcpConfig, loadMcpConfig } = await import('../../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([ + { name: 'shared', command: 'global-cmd' }, + { name: 'gs', command: 'npx' }, + ] as any); + vi.mocked(loadMcpConfig).mockReturnValue([ + { name: 'shared', command: 'project-cmd' }, + { name: 'ps', command: 'node' }, + ] as any); + const result = (await client.getMcpStatus({ cwd: '/my-project' })) as any[]; + const byName = new Map(result.map((s) => [s.name, s])); + expect(byName.get('shared')?.source).toBe('project'); + expect(byName.get('shared')?.hasProjectOverride).toBe(true); + expect(byName.get('gs')?.source).toBe('global'); + expect(byName.get('ps')?.source).toBe('project'); + }); + }); +}); + +// ---- CRUD: global vs project branching ---- + +describe('createDirectSettingsClient - CRUD branches on global cwd', () => { + let client: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = createDirectSettingsClient(rt); + }); + + it('createAgent on global cwd calls writeGlobalAgentProfile', async () => { + const { writeGlobalAgentProfile } = await import('../../../src/subagent/loader.js'); + await client.createAgent({ + cwd: 'global', + profile: { name: 'new', description: 'New', systemPrompt: 'sp' } as any, + }); + expect(writeGlobalAgentProfile).toHaveBeenCalledWith({ + name: 'new', + description: 'New', + systemPrompt: 'sp', + }); + }); + + it('updateAgent on global cwd calls updateGlobalAgentProfile', async () => { + const { loadGlobalAgentProfiles, updateGlobalAgentProfile } = + await import('../../../src/subagent/loader.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'old', description: 'Old' }, + ] as any); + await client.updateAgent({ + cwd: 'global', + name: 'old', + profile: { name: 'old', description: 'Updated' } as any, + }); + expect(updateGlobalAgentProfile).toHaveBeenCalledWith('old', { + name: 'old', + description: 'Updated', + }); + }); + + it('deleteAgent on global cwd calls deleteGlobalAgentProfile', async () => { + const { deleteGlobalAgentProfile } = await import('../../../src/subagent/loader.js'); + await client.deleteAgent({ cwd: 'global', name: 'g1' }); + expect(deleteGlobalAgentProfile).toHaveBeenCalledWith('g1'); + }); + + it('createMcpServer on global cwd calls writeGlobalMcpConfig', async () => { + const { loadGlobalMcpConfig, writeGlobalMcpConfig } = + await import('../../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'existing', command: 'npx' }]); + await client.createMcpServer({ + cwd: 'global', + server: { name: 'new', command: 'node' } as any, + }); + expect(writeGlobalMcpConfig).toHaveBeenCalledWith([ + { name: 'existing', command: 'npx' }, + { name: 'new', command: 'node' }, + ]); + }); + + it('deleteHook on global cwd calls writeGlobalHookConfigs', async () => { + const { loadGlobalHookConfigs, writeGlobalHookConfigs } = + await import('../../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'g1', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + { + name: 'g2', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ] as any); + await client.deleteHook({ cwd: 'global', name: 'g1' }); + expect(writeGlobalHookConfigs).toHaveBeenCalledWith([ + { + name: 'g2', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ]); + }); +}); diff --git a/packages/codingcode/test/server/settings-routes.test.ts b/packages/codingcode/test/server/settings-routes.test.ts index b0272c9..6ae286e 100644 --- a/packages/codingcode/test/server/settings-routes.test.ts +++ b/packages/codingcode/test/server/settings-routes.test.ts @@ -140,16 +140,19 @@ vi.mock('../../src/mcp/config.js', () => ({ resetProjectMcpDisabledState: vi.fn(), })); -vi.mock('../../src/subagent/loader.js', () => ({ - loadAgentProfiles: vi.fn().mockReturnValue([]), - writeAgentProfile: vi.fn(), - updateAgentProfile: vi.fn(), - deleteAgentProfile: vi.fn(), - loadGlobalAgentProfiles: vi.fn().mockReturnValue([]), - writeGlobalAgentProfile: vi.fn(), - updateGlobalAgentProfile: vi.fn(), - deleteGlobalAgentProfile: vi.fn(), -})); +vi.mock('../../src/subagent/loader.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + loadAgentProfiles: vi.fn().mockReturnValue([]), + writeAgentProfile: vi.fn(), + updateAgentProfile: vi.fn(), + deleteAgentProfile: vi.fn().mockImplementation(actual.deleteAgentProfile), + loadGlobalAgentProfiles: vi.fn().mockReturnValue([]), + writeGlobalAgentProfile: vi.fn(), + updateGlobalAgentProfile: vi.fn(), + deleteGlobalAgentProfile: vi.fn(), + }; +}); vi.mock('../../src/hooks/config.js', () => ({ loadHookConfigs: vi.fn().mockReturnValue([]), @@ -167,20 +170,22 @@ vi.mock('../../src/hooks/executor.js', () => ({ setHookRuntimeEnabled: vi.fn(), })); -vi.mock('../../src/skills/config.js', () => ({ +vi.mock('../../src/skills/source.js', () => ({ setGlobalSkillDisabledState: vi.fn(), setProjectSkillDisabledState: vi.fn(), discoverGlobalSkillDirs: vi.fn().mockReturnValue([]), discoverProjectSkillDirs: vi.fn().mockReturnValue([]), })); -vi.mock('../../src/core/workspace.js', () => { +vi.mock('../../src/core/workspace.js', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { Context } = require('effect'); const tag = Context.GenericTag('Workspace') as any; + const actual = await importOriginal(); return { WorkspaceService: tag, resolveWorkspaceCwd: vi.fn((cwd?: string) => cwd ?? '/default'), + isGlobalCwd: actual.isGlobalCwd, }; }); @@ -620,7 +625,7 @@ describe('POST /skills', () => { }); it('calls setGlobalSkillDisabledState for global cwd', async () => { - const { setGlobalSkillDisabledState } = await import('../../src/skills/config.js'); + const { setGlobalSkillDisabledState } = await import('../../src/skills/source.js'); const res = await settingsRouter.request('/skills?cwd=global', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -631,7 +636,7 @@ describe('POST /skills', () => { }); it('calls setProjectSkillDisabledState for project cwd', async () => { - const { setProjectSkillDisabledState } = await import('../../src/skills/config.js'); + const { setProjectSkillDisabledState } = await import('../../src/skills/source.js'); const res = await settingsRouter.request('/skills?cwd=/my-project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -714,3 +719,157 @@ describe('POST /context/compaction-model', () => { expect(updateContextCompactionModel).toHaveBeenCalledWith('gpt-4o-mini'); }); }); + +// ---- Override source labeling (regression for L2) ---- + +describe('GET /mcp - override source labeling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('project override of global is labeled source=project + hasProjectOverride=true', async () => { + const { loadGlobalMcpConfig, loadMcpConfig, resolveMcpConfig, resolveMcpDisabled } = + await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'shared', command: 'global-cmd' }]); + vi.mocked(loadMcpConfig).mockReturnValue([{ name: 'shared', command: 'project-cmd' }]); + vi.mocked(resolveMcpConfig).mockReturnValue([{ name: 'shared', command: 'project-cmd' }]); + vi.mocked(resolveMcpDisabled).mockReturnValue(false); + const res = await settingsRouter.request('/mcp?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(1); + expect(body[0].source).toBe('project'); + expect(body[0].hasProjectOverride).toBe(true); + }); +}); + +describe('GET /hooks - override source labeling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('project override of global is labeled source=project + hasProjectOverride=true', async () => { + const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs, resolveHookDisabled } = + await import('../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ]); + vi.mocked(loadHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + ]); + vi.mocked(resolveHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + ]); + vi.mocked(resolveHookDisabled).mockReturnValue(false); + const res = await settingsRouter.request('/hooks?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(1); + expect(body[0].source).toBe('project'); + expect(body[0].hasProjectOverride).toBe(true); + }); +}); + +describe('GET /skills - override source labeling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('empty skill list returns empty array (override logic mirrors mcp/hooks)', async () => { + // The MockSkillLayer returns [] for listWithStatus, so we can only verify + // the endpoint shape with empty input. The source labeling change + // (isFromProject ? 'project' : 'global') is identical to mcp/hooks and + // is verified by the corresponding mcp/hooks tests above. + const res = await settingsRouter.request('/skills?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toEqual([]); + }); +}); + +// ---- Project-level delete rejects names not in project config (L5) ---- + +describe('DELETE /mcp/:name - project view rejects non-project items', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 500 (NotFoundError) when deleting a global-only MCP from project view', async () => { + const { loadMcpConfig } = await import('../../src/mcp/config.js'); + vi.mocked(loadMcpConfig).mockReturnValue([]); + const res = await settingsRouter.request('/mcp/global-only?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(500); + }); + + it('succeeds when deleting an MCP that exists in project config', async () => { + const { loadMcpConfig, writeMcpConfig } = await import('../../src/mcp/config.js'); + vi.mocked(loadMcpConfig).mockReturnValue([{ name: 'local', command: 'npx' }]); + const res = await settingsRouter.request('/mcp/local?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(200); + expect(writeMcpConfig).toHaveBeenCalled(); + }); +}); + +describe('DELETE /mcp/:name - global view remains idempotent', () => { + it('returns 200 even when name does not exist in global config', async () => { + const { loadGlobalMcpConfig, writeGlobalMcpConfig } = await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([]); + const res = await settingsRouter.request('/mcp/anything?cwd=global', { + method: 'DELETE', + }); + expect(res.status).toBe(200); + expect(writeGlobalMcpConfig).toHaveBeenCalled(); + }); +}); + +describe('DELETE /hooks/:name - project view rejects non-project items', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 500 (NotFoundError) when deleting a global-only hook from project view', async () => { + const { loadHookConfigs } = await import('../../src/hooks/config.js'); + vi.mocked(loadHookConfigs).mockReturnValue([]); + const res = await settingsRouter.request('/hooks/global-only?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(500); + }); +}); + +describe('DELETE /agents/:name - project view rejects non-project items', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 500 (NotFoundError) when deleting a global-only agent from project view', async () => { + const { loadAgentProfiles } = await import('../../src/subagent/loader.js'); + vi.mocked(loadAgentProfiles).mockReturnValue([]); + const res = await settingsRouter.request('/agents/global-only?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(500); + }); +}); diff --git a/packages/codingcode/test/skills/layout.test.ts b/packages/codingcode/test/skills/layout.test.ts new file mode 100644 index 0000000..5cd8ba1 --- /dev/null +++ b/packages/codingcode/test/skills/layout.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync, existsSync } from 'fs'; +import { join, relative } from 'path'; + +const REPO_ROOT = join(process.cwd(), 'packages', 'codingcode'); +const SKILLS_SRC_DIR = join(REPO_ROOT, 'src', 'skills'); +const SEARCH_ROOTS = [join(REPO_ROOT, 'src'), join(REPO_ROOT, 'test')]; + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) walk(full, out); + else if (/\.(ts|tsx)$/.test(entry)) out.push(full); + } + return out; +} + +function collectAllFiles(): string[] { + return SEARCH_ROOTS.flatMap((root) => walk(root)); +} + +describe('skills module file layout', () => { + it('exposes source.ts (not config.ts) as the on-disk layer', () => { + expect(existsSync(join(SKILLS_SRC_DIR, 'source.ts'))).toBe(true); + expect(existsSync(join(SKILLS_SRC_DIR, 'config.ts'))).toBe(false); + }); + + it('does not import the renamed-away "skills/config" path anywhere', () => { + const stale: Array<{ file: string; line: number; text: string }> = []; + for (const file of collectAllFiles()) { + if (file.endsWith('layout.test.ts')) continue; + const text = readFileSync(file, 'utf8'); + const lines = text.split(/\r?\n/); + lines.forEach((line, i) => { + if (/['"][^'"]*skills[\\/]+config(\.js)?['"]/.test(line)) { + stale.push({ file: relative(REPO_ROOT, file), line: i + 1, text: line.trim() }); + } + }); + } + expect( + stale, + `stale "skills/config" imports found:\n${JSON.stringify(stale, null, 2)}` + ).toEqual([]); + }); +}); diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 97ce6cc..d32ed30 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -139,8 +139,8 @@ export async function setCompactionModel( // ---- Settings: MCP ---- -export function listMcpServers(_cwd?: string): Promise { - return clients.settings.getMcpStatus(); +export function listMcpServers(cwd?: string): Promise { + return clients.settings.getMcpStatus({ cwd: cwd ?? '' }); } export function setMcpDisabled(name: string, disabled: boolean, cwd?: string): Promise { diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index e3cc4dc..0dfb7ef 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -293,6 +293,7 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { ); } + const canMutate = h.source === (isGlobal ? 'global' : 'project'); return (
- - + {canMutate && ( + <> + + + + )} { diff --git a/packages/desktop/src/settings/McpPanel.tsx b/packages/desktop/src/settings/McpPanel.tsx index ef32f93..55a4884 100644 --- a/packages/desktop/src/settings/McpPanel.tsx +++ b/packages/desktop/src/settings/McpPanel.tsx @@ -268,6 +268,7 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) {
); } + const canMutate = s.source === (isGlobal ? 'global' : 'project'); return (
- - + {canMutate && ( + <> + + + + )} toggle(s.name, !v)} /> ); diff --git a/packages/desktop/src/settings/SubagentsPanel.tsx b/packages/desktop/src/settings/SubagentsPanel.tsx index f0a0e6c..4cf704d 100644 --- a/packages/desktop/src/settings/SubagentsPanel.tsx +++ b/packages/desktop/src/settings/SubagentsPanel.tsx @@ -65,8 +65,6 @@ const EMPTY_FORM: AgentForm = { model: '', }; -const BUILT_IN = new Set(['explore', 'general']); - export default function SubagentsPanel({ global: isGlobal }: { global?: boolean }) { const [agents, setAgents] = useState([]); const [enabled, setEnabled] = useState(true); @@ -294,7 +292,7 @@ export default function SubagentsPanel({ global: isGlobal }: { global?: boolean ); } - const isBuiltIn = BUILT_IN.has(a.name); + const canMutate = a.source === (isGlobal ? 'global' : 'project'); return (
- {!isBuiltIn && ( + {canMutate && ( <>
+ {currentThreadId && ( + + )} {currentThreadId && }
@@ -356,6 +367,7 @@ export default function AgentWorkspace({ sendMessage, abort }: AgentWorkspacePro const currentThreadId = useAgentStore((s) => s.currentThreadId); const isCompressing = useAgentStore((s) => s.isCompressing); const workspace = useWorkspaceStore(); + const [planPanelOpen, setPlanPanelOpen] = useState(false); if (!currentThreadId) { return ( @@ -373,19 +385,32 @@ export default function AgentWorkspace({ sendMessage, abort }: AgentWorkspacePro } return ( -
- - - - {isCompressing && ( -
- - 正在压缩上下文... +
+
+ + + + {isCompressing && ( +
+ + 正在压缩上下文... +
+ )} +
+ setPlanPanelOpen(true)} + />
- )} -
-
+ {planPanelOpen && ( + setPlanPanelOpen(false)} + /> + )}
); } diff --git a/packages/desktop/src/agent/ApprovalPanel.tsx b/packages/desktop/src/agent/ApprovalPanel.tsx index dbc0f59..bec2fb6 100644 --- a/packages/desktop/src/agent/ApprovalPanel.tsx +++ b/packages/desktop/src/agent/ApprovalPanel.tsx @@ -1,16 +1,33 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import type { Item } from '@shared/types'; import { useAgentStore } from '../stores/agent.store'; -import { useAgentApproval } from '../hooks/useAgent'; +import { useAgentApproval, type PlanChoice } from '../hooks/useAgent'; import ToolCallCard from '../shared/ToolCallCard'; +import PlanApprovalModal from '../shared/PlanApprovalModal'; interface ApprovalPanelProps { threadId: string; } +type SubmitPlanItem = Item & { + type: 'tool_call'; + name: 'submit_plan'; + status: 'pending'; + args: { plan_content?: string; [k: string]: unknown }; + payload?: Record; +}; + +function isSubmitPlanItem(item: Item): item is SubmitPlanItem { + return ( + item.type === 'tool_call' && + item.name === 'submit_plan' && + item.status === 'pending' + ); +} + export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { const [collapsed, setCollapsed] = useState(false); - const { approveTool, rejectTool } = useAgentApproval(); + const { approveTool, rejectTool, submitPlanChoice } = useAgentApproval(); // Stable string key: only changes when pending item IDs change, not on every content update const pendingKey = useAgentStore((s) => { @@ -35,8 +52,52 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { ); }, [pendingKey, threadId]); + // Track the first submit_plan that needs approval; the modal will be shown + // for this single item at a time (subsequent plans queue behind). + const planItem = useMemo(() => { + for (const item of pendingItems) { + if (isSubmitPlanItem(item as Item)) return item as SubmitPlanItem; + } + return null; + }, [pendingItems]); + + const planContent = planItem?.args?.plan_content ?? ''; + const planPath = + typeof planItem?.payload?.path === 'string' + ? (planItem!.payload!.path as string) + : typeof planItem?.payload?.plan_path === 'string' + ? (planItem!.payload!.plan_path as string) + : undefined; + + const handlePlanChoice = useCallback( + async (callId: string, choice: PlanChoice) => { + await submitPlanChoice(threadId, callId, choice); + }, + [submitPlanChoice, threadId] + ); + if (pendingItems.length === 0) return null; + // If a submit_plan is pending, the modal takes over the whole screen — do + // not render the small approval card list to avoid double interaction. + if (planItem) { + return ( + void handlePlanChoice(planItem.id, { type: 'allow' })} + onModify={(newContent) => + void handlePlanChoice(planItem.id, { + type: 'modified', + input: { plan_content: newContent }, + }) + } + onCancel={() => void handlePlanChoice(planItem.id, { type: 'canceled' })} + /> + ); + } + if (collapsed) { return (
diff --git a/packages/desktop/src/agent/ModeIndicator.tsx b/packages/desktop/src/agent/ModeIndicator.tsx new file mode 100644 index 0000000..72c521d --- /dev/null +++ b/packages/desktop/src/agent/ModeIndicator.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Check, Eye, Hammer, Loader2 } from 'lucide-react'; +import { useAgentMode, type SessionModeSnapshot } from '../hooks/useAgent'; + +interface ModeIndicatorProps { + sessionId: string | null; + cwd: string; + onPlanPanelOpen?: () => void; +} + +type ModeValue = 'plan' | 'build'; + +const MODE_META: Record = { + plan: { + label: '计划模式', + color: 'text-[var(--accent-warning)] bg-[var(--tag-info-bg)]', + Icon: Eye, + }, + build: { + label: '构建模式', + color: 'text-[var(--accent-success)] bg-[var(--tag-action-bg)]', + Icon: Hammer, + }, +}; + +/** + * Compact status-bar pill that shows the current plan/build mode for the + * active session. Clicking it opens a popover with a switch button + a + * shortcut to open the PlanPanel. + */ +export default function ModeIndicator({ sessionId, cwd, onPlanPanelOpen }: ModeIndicatorProps) { + const { fetchMode, switchMode } = useAgentMode(); + const [mode, setMode] = useState(null); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const popRef = useRef(null); + const btnRef = useRef(null); + + // Fetch on sessionId / cwd change + useEffect(() => { + let cancelled = false; + if (!sessionId) { + setMode(null); + return; + } + setLoading(true); + fetchMode(sessionId, cwd) + .then((m) => { + if (cancelled) return; + setMode(m); + }) + .catch((e) => { + console.error('Failed to fetch session mode:', e); + if (cancelled) return; + setMode(null); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [sessionId, cwd, fetchMode]); + + // Close popover on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const t = e.target as Node | null; + if (!t) return; + if (popRef.current?.contains(t)) return; + if (btnRef.current?.contains(t)) return; + setOpen(false); + }; + window.addEventListener('mousedown', handler); + return () => window.removeEventListener('mousedown', handler); + }, [open]); + + const current: ModeValue = mode?.profileName === 'plan' ? 'plan' : 'build'; + + const doSwitch = useCallback( + async (target: ModeValue) => { + if (!sessionId || target === current) { + setOpen(false); + return; + } + setBusy(true); + try { + await switchMode(sessionId, target, cwd); + // Re-fetch to pick up the new mode from server + const m = await fetchMode(sessionId, cwd); + setMode(m); + } catch (e) { + console.error('Failed to switch mode:', e); + } finally { + setBusy(false); + setOpen(false); + } + }, + [sessionId, cwd, current, switchMode, fetchMode] + ); + + if (!sessionId) return null; + + const meta = MODE_META[current]; + const Icon = meta.Icon; + + return ( +
+ + {open && ( +
+
+
主代理模式
+
+ {meta.label} + {mode?.permissionMode && ( + + · {mode.permissionMode} + + )} +
+
+
+ {(['plan', 'build'] as ModeValue[]).map((m) => { + const mm = MODE_META[m]; + const MIcon = mm.Icon; + const active = m === current; + return ( + + ); + })} +
+ {onPlanPanelOpen && ( +
+ +
+ )} + {mode?.available && mode.available.length > 0 && ( +
+ {mode.available.length} 个可用配置 +
+ )} +
+ )} +
+ ); +} diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index dae383e..a60403c 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -12,6 +12,7 @@ import { createSession as createServerSession, deleteSession, sendApprovalResponse, + sendPlanApproval, getCheckpointDiff, revertCheckpointFiles, previewRollbackDiff, @@ -21,6 +22,9 @@ import { undoLastCodeRollback, getRollbackState, forkSession, + getSessionMode, + setSessionMode, + getSessionPlan, } from '../lib/core-api'; import type { CheckpointDiff, @@ -162,6 +166,10 @@ export function useAgentCore() { name: event.tool, args: event.args, status: 'pending', + // Forward the server-side payload (e.g. plan_content for submit_plan) + // so the UI can render a specialized approval modal without a second + // round-trip to fetch the plan file. + payload: event.payload, }; case 'tool_result': return { @@ -313,7 +321,12 @@ export function useAgentCore() { return { sendMessage, abort }; } -// ---- useAgentApproval: approveTool + rejectTool ---- +// ---- useAgentApproval: approveTool + rejectTool + submitPlanChoice ---- + +export type PlanChoice = + | { type: 'allow' } + | { type: 'modified'; input: Record } + | { type: 'canceled' }; export function useAgentApproval() { const updateToolCallStatus = useAgentStore((s) => s.updateToolCallStatus); @@ -342,7 +355,41 @@ export function useAgentApproval() { [updateToolCallStatus] ); - return { approveTool, rejectTool }; + /** + * Send a structured plan approval decision. The server interprets the JSON + * envelope via `parseApprovalResponse` and either: + * - 'allow' → writes the plan and switches to build mode + * - 'modified' → re-executes submit_plan with the supplied input + * - 'canceled' → denies the call + * + * The local UI status is updated so the pending card collapses immediately + * instead of waiting for the next stream event. + */ + const submitPlanChoice = useCallback( + async (threadId: string, callId: string, choice: PlanChoice) => { + const nextStatus = + choice.type === 'allow' + ? 'approved' + : choice.type === 'canceled' + ? 'rejected' + : 'running'; + updateToolCallStatus(threadId, callId, nextStatus as any); + try { + if (choice.type === 'allow') { + await sendPlanApproval(threadId, callId, { type: 'allow' }); + } else if (choice.type === 'canceled') { + await sendPlanApproval(threadId, callId, { type: 'canceled' }); + } else { + await sendPlanApproval(threadId, callId, { type: 'modified', input: choice.input }); + } + } catch (e) { + console.error('Failed to submit plan choice:', e); + } + }, + [updateToolCallStatus] + ); + + return { approveTool, rejectTool, submitPlanChoice }; } // ---- useAgentRollback: all rollback methods ---- @@ -591,3 +638,55 @@ export function useAgent() { const rollback = useAgentRollback(); return { ...core, ...approval, ...rollback }; } + +// ---- useAgentMode: plan/build mode switching + plan file access ---- + +export type SessionModeSnapshot = { + profileName: string; + permissionMode: 'default' | 'acceptEdits' | 'plan' | 'bypass'; + cwd: string; + available: Array<{ name: string; description: string }>; +}; + +export type PlanFileSnapshot = { + content: string; + path: string; + directory: string; + exists: boolean; +}; + +/** + * Hook for interacting with the plan/build mode of a single session, plus + * reading the persisted plan file. Each call returns a fresh API to the + * server — caching is done in the caller via useEffect / useState. + */ +export function useAgentMode() { + const workspace = useWorkspaceStore(); + + const fetchMode = useCallback( + async (sessionId: string, cwd?: string): Promise => { + return getSessionMode(sessionId, cwd ?? workspace.rootPath ?? ''); + }, + [workspace.rootPath] + ); + + const switchMode = useCallback( + async ( + sessionId: string, + profile: 'plan' | 'build', + cwd?: string + ): Promise<{ profileName: string; permissionMode: string }> => { + return setSessionMode(sessionId, cwd ?? workspace.rootPath ?? '', profile); + }, + [workspace.rootPath] + ); + + const fetchPlan = useCallback( + async (sessionId: string, cwd?: string): Promise => { + return getSessionPlan(sessionId, cwd ?? workspace.rootPath ?? ''); + }, + [workspace.rootPath] + ); + + return { fetchMode, switchMode, fetchPlan }; +} diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index d32ed30..a3b0b78 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -64,6 +64,66 @@ export function sendApprovalResponse( return clients.agent.sendApprovalResponse({ sessionId, approvalId: callId, response }); } +/** + * Send a structured plan approval decision to the server. The server-side + * `parseApprovalResponse` understands a JSON envelope with `type` set to one of + * 'allow' | 'deny' | 'modified' | 'canceled'. For 'modified' the call is + * re-executed server-side with the provided `input` (typically a revised + * `plan_content` for submit_plan). + */ +export function sendPlanApproval( + sessionId: string, + callId: string, + decision: { type: 'allow' } | { type: 'deny' } | { type: 'modified'; input: Record } | { type: 'canceled' } +): Promise { + return clients.agent.sendApprovalResponse({ + sessionId, + approvalId: callId, + response: JSON.stringify(decision), + }); +} + +// ---- Plan file ---- + +export function getSessionPlan( + sessionId: string, + cwd: string +): Promise<{ content: string; path: string; directory: string; exists: boolean }> { + return api<{ content: string; path: string; directory: string; exists: boolean }>( + `/api/sessions/${sessionId}/plan?cwd=${encodeURIComponent(cwd)}` + ); +} + +// ---- Plan/Build mode switching ---- + +export type SessionModeInfo = { + profileName: string; + permissionMode: 'default' | 'acceptEdits' | 'plan' | 'bypass'; + cwd: string; + available: Array<{ name: string; description: string }>; +}; + +export function getSessionMode(sessionId: string, cwd: string): Promise { + return api( + `/api/sessions/${sessionId}/mode?cwd=${encodeURIComponent(cwd)}` + ); +} + +export function setSessionMode( + sessionId: string, + cwd: string, + profile: 'plan' | 'build' +): Promise<{ profileName: string; permissionMode: string }> { + return api<{ profileName: string; permissionMode: string }>( + `/api/sessions/${sessionId}/mode`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd, profile }), + } + ); +} + // ---- Settings: Memory ---- export function getMemoryConfig(): Promise<{ diff --git a/packages/desktop/src/shared/PlanApprovalModal.tsx b/packages/desktop/src/shared/PlanApprovalModal.tsx new file mode 100644 index 0000000..7ceb41d --- /dev/null +++ b/packages/desktop/src/shared/PlanApprovalModal.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { X, Check, Pencil, Ban, ClipboardCopy } from 'lucide-react'; +import MarkdownRenderer from './MarkdownRenderer'; +import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; + +export interface PlanApprovalModalProps { + /** Plan content (Markdown) submitted by the agent. */ + planContent: string; + /** Path to the persisted plan file, displayed as a reference. */ + planPath?: string; + /** Optional session id (for showing in title only). */ + sessionId?: string; + /** + * Called when the user picks a final action. The parent is responsible for + * sending the corresponding JSON envelope to the server. + */ + onImplement: () => void; + onModify: (newContent: string) => void; + onCancel: () => void; +} + +type View = 'preview' | 'edit'; + +/** + * Three-option plan approval modal triggered when submit_plan asks for user + * confirmation. The plan content is rendered as Markdown by default; the user + * can switch to an edit view to revise it before sending the "modify" choice + * back to the model. + */ +export default function PlanApprovalModal({ + planContent, + planPath, + sessionId, + onImplement, + onModify, + onCancel, +}: PlanApprovalModalProps) { + const [view, setView] = useState('preview'); + const [draft, setDraft] = useState(planContent); + const [submitting, setSubmitting] = useState(null); + const textareaRef = useRef(null); + const { copiedId, copy } = useCopyToClipboard(); + + // Keep the draft in sync if a new plan arrives while the modal is open + useEffect(() => { + setDraft(planContent); + }, [planContent]); + + useEffect(() => { + if (view === 'edit') { + // Focus the editor on entry + setTimeout(() => textareaRef.current?.focus(), 0); + } + }, [view]); + + // ESC cancels (matches the "Cancel" button) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (submitting) return; + onCancel(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onCancel, submitting]); + + const handleImplement = useCallback(() => { + if (submitting) return; + setSubmitting('implement'); + onImplement(); + }, [onImplement, submitting]); + + const handleModify = useCallback(() => { + if (submitting) return; + if (draft.trim() === planContent.trim()) { + // No changes — treat as implement + setSubmitting('implement'); + onImplement(); + return; + } + setSubmitting('modify'); + onModify(draft); + }, [draft, planContent, onImplement, onModify, submitting]); + + const handleCancel = useCallback(() => { + if (submitting) return; + setSubmitting('cancel'); + onCancel(); + }, [onCancel, submitting]); + + const planLines = planContent.split('\n').length; + const planChars = planContent.length; + const planPathLabel = planPath ?? ''; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ 📋 计划审批 + {sessionId && ( + + 会话 {sessionId.slice(0, 8)} + + )} + + {planLines} 行 · {planChars} 字符 + + +
+ + {/* Plan meta */} + {planPathLabel && ( +
+ 计划文件:{planPathLabel} +
+ )} + + {/* Tabs */} +
+ + + +
+ + {/* Body */} +
+ {view === 'preview' ? ( +
+ {planContent ? ( + + ) : ( +
(计划内容为空)
+ )} +
+ ) : ( +
+