diff --git a/.gitignore b/.gitignore
index 491a887c..3b632596 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,10 @@ client-tests.txt
desktop-test.txt
h-test.txt
test-fallback.txt
+test2.txt
+tsc-out.txt
+arch-out.txt
+vitest-result.json
# Compiled JS artifacts leaked into src directories
packages/infra/src/*.js
@@ -51,3 +55,4 @@ packages/desktop/pnpm-workspace.yaml
Thumbs.db
.idea/
.vscode/
+.workbuddy/memory/
diff --git a/.prettierignore b/.prettierignore
index b9d26309..330c03fc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,5 @@ node_modules/
package-lock.json
pnpm-lock.yaml
*.md
+vitest-result.json
+test-output*.txt
diff --git a/.prettierrc b/.prettierrc
index 1f4c4bbc..85a8e67e 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -3,5 +3,6 @@
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
- "printWidth": 100
+ "printWidth": 100,
+ "endOfLine": "auto"
}
diff --git a/docs/configuration.md b/docs/configuration.md
index f81dc40a..561d1faf 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -40,10 +40,7 @@ context:
memory:
enabled: false # 启用长期记忆
model: "" # 记忆提取模型,空字符串回退主模型
- maxBytes: 16384 # 记忆文件最大字节数
- promptMaxBytes: 8192 # 注入提示的最大字节数
- extraTypes: [] # 自定义记忆类型
- disabledTypes: [] # 禁用的记忆类型名
+ promptMaxBytes: 8192 # 注入提示的记忆内容最大字节数
```
### 字段详细说明
@@ -57,26 +54,7 @@ memory:
| `context.compactionModel` | `''` | 上下文压缩使用的模型,空字符串回退到主会话 LLM |
| `memory.enabled` | `false` | 是否启用长期记忆系统 |
| `memory.model` | `''` | 记忆提取使用的模型,空字符串回退到主模型 |
-| `memory.maxBytes` | `16384` | 单个记忆文件的最大字节数 |
| `memory.promptMaxBytes` | `8192` | 注入 system prompt 的记忆内容最大字节数 |
-| `memory.extraTypes` | `[]` | 自定义记忆类型列表 |
-| `memory.disabledTypes` | `[]` | 禁用的内置记忆类型名列表 |
-
-### 自定义记忆类型示例
-
-```yaml
-memory:
- enabled: true
- extraTypes:
- - name: feedback
- description: 工作流程中的教训和已验证的方法
- enabled: true
- - name: decision
- description: 重要的架构和设计决策
- enabled: true
- disabledTypes:
- - reference
-```
---
diff --git a/docs/memory.md b/docs/memory.md
index 15b72890..b8b91aad 100644
--- a/docs/memory.md
+++ b/docs/memory.md
@@ -1,116 +1,74 @@
# 长期记忆系统
-Coding Code 支持跨会话的长期记忆,自动从对话中提取和存储关键信息。本文档介绍记忆类型、内容分类、自动提取机制和手动编辑方法。
+Coding Code 支持跨会话的长期记忆:自动从对话中提取关键信息,并在下一次会话开始时重新注入。本文档介绍记忆文件、自动提取机制和手动编辑方法。
---
-## 内存类型
+## 记忆文件
-记忆文件存储在项目的 `.codingcode/memory.md` 中。
+记忆存储为单个 Markdown 文件:
----
-
-## 记忆内容
+```
+.codingcode/memory.md
+```
-内置三种记忆类型:
+**整个文件就是长期记忆**,没有分区、没有标记块。文件的全部内容会作为记忆注入,也会作为"已有记忆"参与下一次提取。
-| 类型 | 提取来源 | 内容 |
-|------|---------|------|
-| `user` | `[user]` 标签的消息 | 用户角色、技能栈、工作偏好及对 Agent 的纠正 |
-| `project` | `[user]` + `[assistant]` 消息 | 架构决策、技术选型、部署信息 |
-| `reference` | `[user]` + `[tool:*]` 消息 | 外部资源、文档、Dashboard 链接 |
+```markdown
+### 项目
+- 采用 monorepo 架构,使用 pnpm workspaces
+- 入口文件:packages/codingcode/src/cli.ts
-可通过 `memory.extraTypes` 添加自定义记忆类型,通过 `memory.disabledTypes` 禁用内置类型。
+### 用户偏好
+- 偏好结构化 Markdown 输出
+```
---
## 自动提取
-Agent 在每次会话后自动执行记忆提取:
+记忆模式开启后,Agent 在会话结束时自动执行记忆更新:
-1. 构建 system prompt,包含各记忆类型的提取指引
-2. 发送已有记忆 + 会话记录给 LLM
-3. LLM 输出 `...` 块
-4. 提取块内容,返回新记忆文本(null 表示无新内容)
-5. 矛盾时新信息替换旧条目,同一会话以最新为准
+1. 读取记忆文件全文作为"已有记忆"
+2. 将会话记录(按 `[user]` / `[assistant]` / `[tool:名称]` 标注)与已有记忆一起发送给 LLM
+3. LLM 输出整份**最新版记忆**,放在 `...` 块中
+4. 直接用输出内容整体替换记忆文件(受字节上限约束)
-提取使用的模型可通过 `memory.model` 配置,留空则回退到主会话模型。
-
----
+模型自行决定更新哪些内容:可以新增条目、修改过时信息、删除不再相关的内容,代码不做"模型只改动哪部分"的任何假设。若模型没有输出有效内容、或输出与当前文件一致,则不写入。
-## 记忆文件格式
+### 提取提示词
-记忆文件使用 Markdown 格式,自动提取内容包裹在标记块中:
+提取行为的规范全部写在提示词中,代码不感知记忆内容结构:
-```markdown
-
-### user
-- 偏好使用函数式编程风格
-- 常用技术栈:React + TypeScript
+- 只保留值得跨会话记住的信息:用户偏好与纠正、项目架构决策、技术选型、外部资源与链接等
+- 忽略一次性任务、调试过程、报错堆栈、闲聊
+- 输出必须是一份完整、自洽的最新记忆,而不是只输出变动部分
+- 新旧信息矛盾时以最新为准
+- 记忆用 `### 主题` 小节组织,小节下用 `- ` 列要点
-### project
-- 采用 monorepo 架构,使用 pnpm workspaces
-- 入口文件:packages/codingcode/src/cli.ts
+提取使用的模型可通过 `memory.model` 配置,留空则回退到主会话模型。
-### reference
-- [API 文档](https://example.com/api)
-
+---
-手动添加的内容可以写在标记块之外,不会被自动提取覆盖。
-```
+## 手动编辑
-### 标记块机制
+记忆文件就是普通 Markdown,用户可以直接编辑:
-- `replaceAutoBlock()`:原子替换 `` 和 `` 之间的内容
-- `stripMarkersForPrompt()`:去掉标记后注入系统提示
-- `enforceMaxBytes()`:按 `### ` 小节逐个裁剪到字节上限(默认 16384 字节)
-- `mergeAutoBlocks()`:以 `### ` 小节名为 key 合并,incoming 覆盖 base
+- 手动写下的内容会在下次会话时作为记忆注入 Agent
+- 手动编辑也会被下一次自动提取作为"已有记忆"读到;保留、修改还是删除由模型根据后续对话自行决定
+- 自动提取在写入前会重新检查文件:若提取期间文件被手动改动,则放弃本次写入,避免覆盖用户编辑
---
## 配置
-在 `codingcode.yaml` 中配置记忆系统:
-
-```yaml
-memory:
- enabled: true # 启用长期记忆(默认 false)
- model: "" # 记忆提取模型,空字符串回退到主模型
- maxBytes: 16384 # 记忆文件最大字节数
- promptMaxBytes: 8192 # 注入提示的最大字节数
- extraTypes: [] # 自定义记忆类型
- disabledTypes: [] # 禁用的记忆类型名
-```
-
-### 自定义记忆类型
+在 `~/.codingcode/config.yaml` 中配置记忆系统:
```yaml
memory:
- enabled: true
- extraTypes:
- - name: feedback
- description: 工作流程中的教训和已验证的方法
- enabled: true
- - name: decision
- description: 重要的架构和设计决策
- enabled: true
- disabledTypes:
- - reference # 禁用内置的 reference 类型
+ enabled: true # 启用长期记忆(默认 false)
+ model: "" # 记忆提取模型,空字符串回退到主模型
+ promptMaxBytes: 8192 # 注入提示词的记忆内容最大字节数
```
----
-
-## 手动编辑
-
-记忆文件采用 Markdown 格式,支持手动编辑。手动内容可写在 `` 标记之后,不会被自动提取覆盖:
-
-```markdown
-
-### user
-- 偏好使用函数式编程风格
-
-
-### 手动备注
-- 项目部署流程:npm run build -> scp dist/ -> pm2 restart
-- 数据库连接字符串在 Vault 中
-```
+记忆文件本身有 16KB 的硬上限,超限时按 `### ` 小节从后往前裁掉超出部分。
diff --git a/docs/tools.md b/docs/tools.md
index bfcce750..190c53c2 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -97,16 +97,15 @@ interface ToolVisibilityPolicy {
### 审批流水线(始终生效)
-六层决策链,按顺序执行,任一层返回 deny/allow 即终止:
+五层决策链,按顺序执行,任一层返回 deny/allow 即终止:
| 层级 | 名称 | 逻辑 |
|------|------|------|
| 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 |
-| 2 | **ReadonlyWhitelist** | 只读工具自动放行(read_file, search_code, search_files, fetch_url, web_search, dispatch_agent, todo_write) |
-| 3 | **PermissionMode** | 权限模式判断:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层)。`plan` Profile 由独立的 `agent/profile.ts` 中的 `planProfileGateHook` 在 Layer 4 强制,不在此层处理 |
-| 4 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 |
-| 5 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 |
-| 6 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 |
+| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行,涵盖只读与编辑工具)、`default`(不自动放行,继续下一层)。只读工具不再有无条件的独立白名单层;`plan` Profile 由 `agent/profile.ts` 中的 `planProfileGateHook` 在下一层强制,不在此层处理 |
+| 3 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 |
+| 4 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 |
+| 5 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 |
### 预设安全规则
@@ -130,11 +129,13 @@ interface ToolVisibilityPolicy {
type PermissionMode = 'default' | 'acceptEdits' | 'bypass';
```
-- `default`:逐层审批,危险操作需用户确认
-- `acceptEdits`:非破坏性工具自动放行,减少确认弹窗
+- `default`:不自动放行任何工具(含只读工具),全部逐层审批
+- `acceptEdits`:非破坏性工具自动放行(涵盖只读工具与编辑类工具),破坏性工具仍需确认
- `bypass`:全部放行,跳过所有审批(慎用)
-> `plan` 不再是 `PermissionMode` 的成员。plan Profile 通过 `AgentProfile.name === 'plan'` 结构化识别,由 `agent/profile.ts` 的 `planProfileGateHook` 和 `PLAN_PROFILE_ALLOWED_TOOLS` 共同限制工具。
+> 原独立的 `ReadonlyWhitelist` 层(在 `default` 下也无条件放行只读工具)已废弃,其语义并入 `PermissionMode` 的自动放行判定:`acceptEdits` 视只读工具为非破坏性工具自动放行,`default` 不再自动放行。
+
+> `plan` 不再是 `PermissionMode` 的成员。plan Profile 通过 `AgentProfile.name === 'plan'` 结构化识别,由 `agent/profile.ts` 的 `planProfileGateHook` 和 `PLAN_PROFILE_ALLOWED_TOOLS` 共同限制工具。因只读白名单层已删除,`dispatch_agent` 等在 plan 下不再被流水线上层提前放行,统一由 plan gate 拦截。
### OS 级沙箱(预留)
diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json
index 6ad3221d..a715ea62 100644
--- a/packages/codingcode/package.json
+++ b/packages/codingcode/package.json
@@ -8,44 +8,18 @@
},
"exports": {
".": "./src/layer.ts",
- "./agent/agent": "./src/agent/agent.ts",
- "./agent/todo": "./src/agent/todo.ts",
- "./agent/prompt": "./src/agent/prompt.ts",
- "./session/store": "./src/session/store.ts",
- "./session/io": "./src/session/io.ts",
- "./session/types": "./src/session/types.ts",
- "./session/messages": "./src/session/messages.ts",
- "./core/path": "./src/core/path.ts",
- "./core/workspace": "./src/core/workspace.ts",
- "./core/error": "./src/core/error.ts",
- "./core/result": "./src/core/result.ts",
- "./core/types": "./src/core/types.ts",
- "./context/context": "./src/context/context.ts",
- "./hooks/registry": "./src/hooks/registry.ts",
- "./tools/executor": "./src/tools/executor.ts",
- "./mcp/client": "./src/mcp/client.ts",
- "./mcp/types": "./src/mcp/types.ts",
- "./skills/types": "./src/skills/types.ts",
- "./approval/types": "./src/approval/types.ts",
- "./approval/async-confirm": "./src/approval/async-confirm.ts",
- "./server/create": "./src/server/index.ts",
- "./server/adapter": "./src/server/adapter.ts",
- "./server/port-discovery": "./src/server/port-discovery.ts",
+ "./client": "./src/client/http/index.ts",
"./client/types": "./src/client/types.ts",
- "./client/http": "./src/client/http.ts",
- "./client/http-clients": "./src/client/http/index.ts",
+ "./server": "./src/server/index.ts",
"./direct/agent-runtime": "./src/direct/agent-runtime.ts",
"./direct/sessions": "./src/direct/sessions.ts",
"./direct/settings": "./src/direct/settings.ts",
"./direct/models": "./src/direct/models.ts",
- "./agent/stream-adapter": "./src/agent/stream-adapter.ts",
- "./checkpoint/checkpoint-service": "./src/checkpoint/checkpoint-service.ts",
- "./checkpoint/shadow-git": "./src/checkpoint/shadow-git.ts",
- "./checkpoint/bootstrap": "./src/checkpoint/bootstrap.ts",
- "./llm/factory": "./src/llm/factory.ts",
- "./llm/client": "./src/llm/client.ts",
- "./layer": "./src/layer.ts",
- "./subagent/types": "./src/subagent/types.ts"
+ "./approval/types": "./src/approval/types.ts",
+ "./agent/profile": "./src/agent/profile.ts",
+ "./core/error": "./src/core/error.ts",
+ "./core/types": "./src/core/types.ts",
+ "./llm/client": "./src/llm/client.ts"
},
"dependencies": {
"@ai-sdk/deepseek": "^2.0.35",
diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts
index a5b2efb1..bc47e0b9 100644
--- a/packages/codingcode/src/agent/agent.ts
+++ b/packages/codingcode/src/agent/agent.ts
@@ -1,246 +1,172 @@
-import { Effect, Queue, Stream, Fiber } from 'effect';
-import type { Message } from '../core/types.js';
+import { Effect, Queue, Stream, Fiber, Layer } from 'effect';
import { AgentError } from '../core/error.js';
import { Result } from '../core/result.js';
-import type { LLMClient } from '../llm/client.js';
-import { ToolExecutorService, type ToolLookup } from '../tools/executor.js';
-import { SessionService } from '../session/store.js';
-import { CheckpointService } from '../checkpoint/checkpoint-service.js';
-import { ApprovalService } from '../approval/index.js';
-import { ApprovalWaitService } from '../approval/async-confirm.js';
+import { AgentService } from './port.js';
+import type { RunTurnOptions } from './port.js';
+import {
+ SessionPort, ToolExecutorPort, CheckpointPort, HookPort,
+ ApprovalPort, SkillPort, McpPort, ContextPort, MemoryPort,
+ LlmPort, RulesPort, TodoPort, ToolEnvPort, ToolCatalogPort,
+} from './deps.js';
+import type { ToolEnv, ToolCatalog } from './deps.js';
import { buildSystemPrompt } from './prompt.js';
-import type { AgentEvent, RunStreamOptions } from './types.js';
-import { resolveConfig } from './config.js';
-import { TodoService } from './todo.js';
-import { HookService } from '../hooks/registry.js';
-import { SkillService } from '../skills/service.js';
-import { McpService } from '../mcp/index.js';
-import { ContextService } from '../context/service.js';
-import { MemoryService } from '../memory/index.js';
+import type { AgentEvent } from './types.js';
+import { loadConfig } from '@codingcode/infra/config';
import { createLogger } from '@codingcode/infra/logger';
-import { ProjectRuntimeService } from '../runtime/project-runtime.js';
-import { registerBuiltinTools } from '../tools/builtin-tools.js';
-import { ToolRegistry } from '../tools/registry.js';
-import { submitPlanTool } from '../tools/domains/subagent/submit-plan.js';
-import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js';
-import { normalizePath } from '../core/path.js';
-import { isPlanProfile } from './profile.js';
-import type { AgentProfileName } from '../subagent/types.js';
+import { normalizePath, computePaths } from '../core/path.js';
+import { resolveProfile, getToolNames } from './profile.js';
+import type { AgentProfile } from './profile.js';
import type { PermissionMode } from '../approval/types.js';
-const REACTIVE_COMPACT_MAX_RETRIES = 3;
-import { RulesService } from '../rules/index.js';
-
const logger = createLogger();
-export class AgentService extends Effect.Service()('Agent', {
- effect: Effect.gen(function* () {
- const executor = yield* ToolExecutorService;
- const hooks = yield* HookService;
- const approval = yield* ApprovalService;
- const approvalWait = yield* ApprovalWaitService;
- const session = yield* SessionService;
- const checkpoint = yield* CheckpointService;
- const runtime = yield* ProjectRuntimeService;
- const todo = yield* TodoService;
- const context = yield* ContextService;
- const memory = yield* MemoryService;
- const { maxSteps, maxStopContinuations } = resolveConfig();
-
- const runStream = (
- opts: RunStreamOptions
- ): AsyncGenerator, unknown> => {
- const q = Effect.runSync(Queue.unbounded());
-
- const program = Effect.scoped(
- Effect.gen(function* () {
- yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
- hooks.disposeSession(opts.state.sessionId);
- })
- );
- return yield* agentLoop(executor, hooks, maxSteps, maxStopContinuations, opts, q);
- }).pipe(
- Effect.provideService(HookService, hooks),
- Effect.provideService(ToolExecutorService, executor),
- Effect.provideService(ApprovalService, approval),
- Effect.provideService(ApprovalWaitService, approvalWait),
- Effect.provideService(SessionService, session),
- Effect.provideService(CheckpointService, checkpoint),
- Effect.provideService(ProjectRuntimeService, runtime),
- Effect.provideService(TodoService, todo),
- Effect.provideService(ContextService, context),
- Effect.provideService(MemoryService, memory)
- )
- );
-
- return (async function* () {
- const fiber = Effect.runFork(program);
-
- if (opts.abortSignal) {
- opts.abortSignal.addEventListener(
- 'abort',
- () => {
- Effect.runFork(Fiber.interrupt(fiber));
- },
- { once: true }
+export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () {
+ const session = yield* SessionPort;
+ const executor = yield* ToolExecutorPort;
+ const checkpoint = yield* CheckpointPort;
+ const hooks = yield* HookPort;
+ const approval = yield* ApprovalPort;
+ const skills = yield* SkillPort;
+ const mcp = yield* McpPort;
+ const context = yield* ContextPort;
+ const memory = yield* MemoryPort;
+ const llmFactory = yield* LlmPort;
+ const rules = yield* RulesPort;
+ const todo = yield* TodoPort;
+ const toolEnvPort = yield* ToolEnvPort;
+ const toolCatalog = yield* ToolCatalogPort;
+ const cfg = loadConfig();
+ const maxSteps = cfg.maxSteps ?? 250;
+ const maxStopContinuations = cfg.maxStopContinuations ?? 3;
+
+ const runTurn = (input: string, opts: RunTurnOptions) =>
+ Effect.gen(function* () {
+ const normalizedCwd = normalizePath(opts.cwd);
+
+ rules.evictProjectRules(normalizedCwd);
+ yield* hooks.emit('agent.turn.start', { sessionId: '' }).pipe(Effect.catchAll(() => Effect.void));
+ yield* mcp.syncConnections(normalizedCwd).pipe(Effect.catchAll(() => Effect.void));
+
+ let sessionId = opts.sessionId;
+ const llm = yield* llmFactory.getLLMClient();
+ if (!sessionId) {
+ if (!opts.activeProfile || !opts.permissionMode) {
+ return yield* Effect.fail(
+ new AgentError('CONFIG_MISSING', 'new session requires activeProfile and permissionMode')
);
- if (opts.abortSignal.aborted) {
- Effect.runFork(Fiber.interrupt(fiber));
- }
}
+ const model = opts.model ?? llm.modelInfo.model;
+ const created = yield* session.create(normalizedCwd, {
+ model,
+ activeProfile: opts.activeProfile,
+ permissionMode: opts.permissionMode,
+ });
+ sessionId = created.sessionId;
+ }
- const stream = Stream.fromQueue(q).pipe(Stream.interruptWhen(Fiber.await(fiber)));
-
- for await (const event of Stream.toAsyncIterable(stream) as AsyncIterable) {
- yield event;
- }
+ const state = yield* session.load(normalizedCwd, sessionId);
- try {
- const result = await Effect.runPromise(Fiber.join(fiber));
- return result;
- } catch (e) {
- return Result.err(
- e instanceof AgentError ? e : new AgentError('AGENT_ABORTED' as any, String(e))
- );
- }
- })();
- };
-
- return { runStream };
- }),
-}) {}
-
-export const sendMessage = (
- sessionId: string | undefined,
- input: string,
- cwd: string,
- llm: LLMClient,
- options: {
- signal?: AbortSignal;
- approvalOverride?: import('../approval/index.js').ApprovalService;
- activeProfile?: AgentProfileName;
- permissionMode?: PermissionMode;
- model?: string;
- }
-) =>
- Effect.gen(function* () {
- const session = yield* SessionService;
- const agent = yield* AgentService;
- const hooks = yield* HookService;
- const mcp = yield* McpService;
- const checkpoint = yield* CheckpointService;
- const approval = yield* ApprovalService;
- const skills = yield* SkillService;
- const runtime = yield* ProjectRuntimeService;
- const todo = yield* TodoService;
- const rules = yield* RulesService;
- const context = yield* ContextService;
- const memory = yield* MemoryService;
-
- const normalizedCwd = normalizePath(cwd);
- yield* runtime.prepareProject(normalizedCwd);
- yield* skills.evictProject(normalizedCwd);
-
- if (!sessionId) {
- if (!options.activeProfile || !options.permissionMode || !options.model) {
- return yield* Effect.fail(
- new AgentError(
- 'CONFIG_MISSING',
- 'new session requires activeProfile, permissionMode, and model'
- )
- );
+ // restore session profile/permission from the frontend request, falling back to persisted values
+ const effectivePerm = opts.permissionMode ?? state.permissionMode;
+ const profileName = opts.activeProfile ?? state.activeProfile;
+ if (opts.permissionMode) {
+ yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode);
+ }
+ if (opts.activeProfile) {
+ yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile);
}
- const created = yield* session.create(normalizedCwd, {
- model: options.model,
- activeProfile: options.activeProfile,
- permissionMode: options.permissionMode,
- });
- sessionId = created.sessionId;
- }
- const state = yield* session.load(normalizedCwd, sessionId);
- yield* runtime.restoreSessionProfile(
- normalizedCwd,
- state.sessionId,
- state.activeProfile,
- state.permissionMode
- );
- state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd);
- const sid = state.sessionId;
- const profile = runtime.resolveMainAgentProfile(normalizedCwd, state.sessionId);
- const policy = runtime.getToolPolicy(profile);
+ state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd);
- const dispatchTool = yield* createDispatchAgentTool();
+ const profile: AgentProfile | undefined = profileName ? resolveProfile(profileName) : undefined;
- const activeLlm = llm;
- const effectiveMaxSteps = profile?.maxSteps;
- const effectiveApproval: any = options?.approvalOverride;
+ // get MCP tools
+ const mcpTools = mcp.listProjectMcpTools(normalizedCwd);
- const mcpTools = mcp.listProjectMcpTools(normalizedCwd);
+ const catalog = toolCatalog.register(getToolNames(profile), mcpTools);
- const turnId = session.incrementTurn(state);
- const [, actualInput] = yield* skills.extractSkill(state.cwd, input);
+ const toolEnv = yield* toolEnvPort.getToolEnv();
- yield* session.recordUser(state, actualInput);
+ // record user (increments turn) + extract skill
+ const [, actualInput] = yield* skills.extractSkill(state.cwd, input);
+ const userEvent = yield* session.recordUser(state, actualInput);
- yield* checkpoint.snapshotBaseline(state.cwd, sid, turnId);
+ // checkpoint baseline
+ yield* checkpoint.snapshotBaseline(state.cwd, sessionId, userEvent.turnId);
- const rulesText = rules.getAllRules(state.cwd);
+ // get rules text
+ const rulesText = rules.getAllRules(state.cwd);
+
+ // run agent loop
+ const stream = runAgentLoop({
+ state, llm, profile, catalog,
+ toolEnv,
+ abortSignal: opts.signal, rulesText,
+ sid: sessionId, projectPath: state.cwd, permissionMode: effectivePerm,
+ });
- const stream = agent.runStream({
- state,
- llm: activeLlm,
- profile,
- toolPolicy: policy,
- maxStepsOverride: effectiveMaxSteps,
- approvalOverride: effectiveApproval,
- mcpTools,
- abortSignal: options?.signal,
- rulesText,
- dispatchTool,
+ return { stream, sessionId };
});
- return { stream, sessionId: sid };
- });
-
-export function agentLoop(
- executor: ToolExecutorService,
- hooks: HookService,
- maxSteps: number,
- maxStopContinuations: number,
- opts: RunStreamOptions,
- q: Queue.Queue
-): Effect.Effect<
- Result,
- AgentError,
- | HookService
- | ToolExecutorService
- | CheckpointService
- | SessionService
- | ProjectRuntimeService
- | TodoService
- | ContextService
- | MemoryService
-> {
- const state = opts.state;
- const llm = opts.llm;
- const profile = opts.profile;
- const sessionId = state.sessionId;
- const projectPath = state.cwd;
-
- return Effect.gen(function* () {
- const checkpoint = yield* CheckpointService;
- const session = yield* SessionService;
- const runtime = yield* ProjectRuntimeService;
- const todo = yield* TodoService;
- const context = yield* ContextService;
- const memory = yield* MemoryService;
- const { rulesText } = opts;
-
- const basePrompt =
- opts.systemOverride ??
- buildSystemPrompt({
+ function runAgentLoop(opts: {
+ state: any; llm: any; profile: AgentProfile | undefined;
+ abortSignal: AbortSignal | undefined;
+ catalog: ToolCatalog;
+ toolEnv: ToolEnv;
+ rulesText: string;
+ sid: string; projectPath: string; permissionMode: PermissionMode;
+ }): AsyncGenerator {
+ const q = Effect.runSync(Queue.unbounded());
+
+ const program: any = Effect.scoped(
+ Effect.gen(function* () {
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => { hooks.disposeSession(opts.sid); })
+ );
+ return yield* agentLoopInternal(opts, q);
+ }).pipe(
+ Effect.provideService(SessionPort, session),
+ Effect.provideService(ToolExecutorPort, executor),
+ Effect.provideService(CheckpointPort, checkpoint),
+ Effect.provideService(HookPort, hooks),
+ Effect.provideService(ApprovalPort, approval),
+ Effect.provideService(SkillPort, skills),
+ Effect.provideService(McpPort, mcp),
+ Effect.provideService(ContextPort, context),
+ Effect.provideService(MemoryPort, memory),
+ Effect.provideService(LlmPort, llmFactory),
+ Effect.provideService(RulesPort, rules),
+ Effect.provideService(TodoPort, todo),
+ )
+ );
+
+ return (async function* () {
+ const fiber = Effect.runFork(opts.toolEnv.provide(program));
+ if (opts.abortSignal) {
+ opts.abortSignal.addEventListener('abort', () => {
+ Effect.runFork(Fiber.interrupt(fiber));
+ }, { once: true });
+ if (opts.abortSignal.aborted) Effect.runFork(Fiber.interrupt(fiber));
+ }
+ const stream = Stream.fromQueue(q).pipe(Stream.interruptWhen(Fiber.await(fiber)));
+ for await (const event of Stream.toAsyncIterable(stream) as AsyncIterable) {
+ yield event;
+ }
+ })();
+ }
+
+ function agentLoopInternal(opts: {
+ state: any; llm: any; profile: AgentProfile | undefined;
+ abortSignal: AbortSignal | undefined;
+ catalog: ToolCatalog;
+ rulesText: string;
+ sid: string; projectPath: string; permissionMode: PermissionMode;
+ }, q: Queue.Queue): any {
+ const { state, llm, profile, abortSignal, catalog, rulesText, sid, projectPath, permissionMode } = opts;
+ const { tools, lookup: toolLookup } = catalog;
+
+ return Effect.gen(function* () {
+ const basePrompt = buildSystemPrompt({
cwd: projectPath,
platform: process.platform,
shell: process.env.SHELL || process.env.ComSpec || 'bash',
@@ -248,222 +174,98 @@ export function agentLoop(
profileSystemPrompt: profile?.systemPrompt,
});
- const memoryBlock = state.memorySnapshot;
- const memorySection = memoryBlock ? `## Session Memory\n\n${memoryBlock}` : '';
- const system = [basePrompt, memorySection].filter(Boolean).join('\n\n');
-
- const maxOverflowRetries = REACTIVE_COMPACT_MAX_RETRIES;
- const effectiveMaxSteps = opts.maxStepsOverride ?? maxSteps;
+ const memoryBlock = state.memorySnapshot;
+ const memorySection = memoryBlock ? `## Session Memory\n\n${memoryBlock}` : '';
+ const system = [basePrompt, memorySection].filter(Boolean).join('\n\n');
- let stopContinuations = 0;
- const effectiveMaxStopContinuations = opts.maxStopContinuations ?? maxStopContinuations;
-
- const registry = new ToolRegistry();
- yield* registerBuiltinTools(registry);
- registry.register(...(opts.mcpTools ?? []));
- if (opts.dispatchTool) registry.register(opts.dispatchTool);
- if (isPlanProfile(profile)) registry.register(submitPlanTool);
-
- let messages: Message[] = [];
- let submittedPlanTitle: string | null = null;
-
- for (let attempt = 0; attempt <= maxOverflowRetries; attempt++) {
- const payload = yield* Effect.sync(() =>
- context.assemblePayload(session.getTranscriptPath(state), llm.modelInfo.maxTokens)
- );
- messages = payload.messages;
+ let stopContinuations = 0;
+ const effectiveMaxStopContinuations = maxStopContinuations;
let lastResult: Result | null = null;
- let overflow = false;
-
- yield* hooks.emit('agent.turn.start', { sessionId });
+ yield* hooks.emit('agent.turn.start', { sessionId: sid });
yield* q.offer({ _tag: 'TurnId', turnId: state.currentTurnId });
- for (let step = 0; step < effectiveMaxSteps; step++) {
- yield* q.offer({ _tag: 'Step', step: step + 1, max: effectiveMaxSteps });
-
- const allowedByPolicy = opts.toolPolicy?.allowedTools;
- const tools = registry.describe(allowedByPolicy);
- const toolLookup: ToolLookup = (name: string) => registry.get(name, allowedByPolicy);
- const systemWithCatalog = system;
-
- const stepBeforePayload = { sessionId, step: step + 1 };
- yield* hooks.emitDecision('agent.step.before', stepBeforePayload);
-
- const compressResult = yield* Effect.tryPromise({
- try: () =>
- context.compactIfNeeded(
- session.getTranscriptPath(state),
- messages,
- llm.modelInfo.maxTokens,
- llm
- ),
+ for (let step = 0; step < maxSteps; step++) {
+ yield* q.offer({ _tag: 'Step', step: step + 1, max: maxSteps });
+
+ yield* hooks.emitDecision('agent.step.before', { sessionId: sid, step: step + 1 });
+
+ const payload = yield* Effect.tryPromise({
+ try: () => context.assemblePayload(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, llm.modelInfo.maxTokens, llm),
catch: (e) => new AgentError('LLM_FAILED', String(e)),
});
- if (compressResult.didCompress && compressResult.messages) {
+ if (payload.compressed) {
yield* q.offer({
- _tag: 'ReactiveCompact',
- attempt: 1,
- released: compressResult.released,
- promptEstimate: compressResult.promptEstimate,
+ _tag: 'ContextCompressed',
+ released: payload.released,
+ promptEstimate: payload.promptEstimate,
});
-
- messages = compressResult.messages;
- state.usage = undefined;
}
-
- const llmMessages = [...messages];
-
+ const llmMessages = [...payload.messages];
const { stream: rawStream, response: respPromise } = llm.completeStream(
- {
- messages: llmMessages,
- system: systemWithCatalog,
- tools,
- maxSteps: 1,
- },
- opts.abortSignal
+ { messages: llmMessages, system, tools, maxSteps: 1 },
+ abortSignal
);
yield* Effect.tryPromise({
try: async () => {
for await (const chunk of rawStream) {
- if (opts.abortSignal?.aborted) break;
+ if (abortSignal?.aborted) break;
Effect.runSync(q.offer({ _tag: 'LlmChunk', text: chunk }));
}
},
catch: (e) => new AgentError('LLM_FAILED', String(e)),
});
- const llmResult = yield* Effect.tryPromise({
+ const llmResult: any = yield* Effect.tryPromise({
try: () => respPromise,
catch: (e) => new AgentError('LLM_FAILED', String(e)),
});
if (!llmResult.ok) {
- if (llmResult.error.code === 'CONTEXT_OVERFLOW' && attempt < maxOverflowRetries) {
- const compressResult = yield* Effect.tryPromise({
- try: () =>
- context.compactWithLLM(
- session.getTranscriptPath(state),
- llm.modelInfo.maxTokens,
- llm,
- undefined
- ),
- catch: (e) => new AgentError('LLM_FAILED', String(e)),
- });
- if (compressResult.didCompress && compressResult.messages) {
- messages = compressResult.messages;
- }
- yield* q.offer({
- _tag: 'ReactiveCompact',
- attempt: attempt + 1,
- released: compressResult.released,
- promptEstimate: compressResult.promptEstimate,
- });
- overflow = true;
- break;
- }
yield* q.offer({ _tag: 'Error', error: llmResult.error });
lastResult = Result.err(llmResult.error);
- yield* hooks.emit('agent.turn.end', {
- sessionId,
- turnId: state.currentTurnId,
- status: 'error',
- });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' });
break;
}
const resp = llmResult.value;
const toolCalls = resp.toolCalls;
- const assistantMsg: Message = { role: 'assistant', content: resp.content };
- if (toolCalls && toolCalls.length > 0) {
- assistantMsg.tool_calls = toolCalls;
- }
- messages.push(assistantMsg);
yield* q.offer({ _tag: 'Assistant', content: resp.content, toolCalls });
if (resp.usage) {
- yield* q.offer({
- _tag: 'Usage',
- prompt: resp.usage.prompt,
- completion: resp.usage.completion,
- total: resp.usage.total,
- });
+ yield* q.offer({ _tag: 'Usage', prompt: resp.usage.prompt, completion: resp.usage.completion, total: resp.usage.total });
}
if (!toolCalls || toolCalls.length === 0) {
- if (session) {
- yield* session.recordAssistant(state, resp.content, toolCalls || [], resp.usage);
- }
- const stopDecision = yield* hooks.emitDecision('agent.turn.stop', {
- sessionId,
- content: resp.content,
- turnId: state.currentTurnId,
- });
+ yield* session.recordAssistant(state, resp.content, toolCalls || [], resp.usage);
+ const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId: sid, content: resp.content, turnId: state.currentTurnId });
if (stopDecision && stopDecision.decision === 'continue') {
if (stopContinuations >= effectiveMaxStopContinuations) {
- yield* q.offer({
- _tag: 'Error',
- error: new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded'),
- });
- yield* hooks.emit('agent.turn.end', {
- sessionId,
- turnId: state.currentTurnId,
- status: 'error',
- });
- memory
- .flushSessionToMemory(state.sessionId, llm, state.cwd)
- .catch((e) => logger.error('memory flush failed:', e));
- return Result.err(
- new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded')
- );
+ yield* q.offer({ _tag: 'Error', error: new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded') });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' });
+ memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e));
+ return Result.err(new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded'));
}
stopContinuations++;
const injection = stopDecision.injection ?? '(continue)';
- if (session) {
- yield* session.recordUser(state, injection);
- }
- messages.push({ role: 'user', content: injection });
+ yield* session.recordSystem(state, injection);
continue;
}
- if (submittedPlanTitle !== null) {
- yield* hooks.emit('plan.ready', {
- sessionId,
- projectPath,
- title: submittedPlanTitle,
- });
- submittedPlanTitle = null;
- }
-
yield* q.offer({ _tag: 'Done', content: resp.content });
lastResult = Result.ok(resp.content);
- yield* hooks.emit('agent.turn.end', {
- sessionId,
- turnId: state.currentTurnId,
- status: 'done',
- });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'done' });
break;
}
- if (toolCalls) {
- for (const tc of toolCalls) {
- yield* q.offer({
- _tag: 'ToolStart',
- id: tc.id,
- name: tc.name,
- args: tc.arguments ?? {},
- });
- }
+ for (const tc of toolCalls as any[]) {
+ yield* q.offer({ _tag: 'ToolStart', id: tc.id, name: tc.name, args: tc.arguments ?? {} });
}
- const record = yield* session.recordAssistant(state, resp.content, toolCalls!, resp.usage);
+ yield* session.recordAssistant(state, resp.content, toolCalls!, resp.usage);
const allResults = yield* executor.executeBatch(toolCalls, state.sessionId, {
- turnId: state.currentTurnId,
- projectPath,
- signal: opts.abortSignal,
- approval: opts.approvalOverride,
- toolLookup,
+ turnId: state.currentTurnId, projectPath, signal: abortSignal, toolLookup, permissionMode,
});
let todoPrinted = false;
@@ -473,93 +275,41 @@ export function agentLoop(
if (r.type === 'denied') {
yield* q.offer({ _tag: 'ToolDenied', id: r.id, name: r.name, reason: r.reason });
} else {
- const isOk = r.type === 'ok';
- yield* q.offer({
- _tag: 'ToolResult',
- id: r.id,
- name: r.name,
- output: resultOut,
- ok: isOk,
- });
- }
- if (!messages.find((m) => m.tool_call_id === r.id)) {
- const content =
- r.type === 'denied'
- ? `[Denied] Tool "${r.name}" was denied: ${r.reason}`
- : (r.output ?? '');
- messages.push({ role: 'tool', content, tool_call_id: r.id, tool_name: r.name });
+ yield* q.offer({ _tag: 'ToolResult', id: r.id, name: r.name, output: resultOut, ok: r.type === 'ok' });
}
if (!todoPrinted && r.name === 'todo_write') {
- yield* q.offer({ _tag: 'TodoUpdate', items: todo.read(sessionId) });
+ yield* q.offer({ _tag: 'TodoUpdate', items: todo.read(sid) as any });
todoPrinted = true;
}
}
-
- const submitPlanCall = toolCalls?.find((tc) => tc.name === 'submit_plan');
- const submitPlanResult = allResults.find(
- (r) => r.name === 'submit_plan' && r.type === 'ok'
- );
- if (submitPlanCall && submitPlanResult && submittedPlanTitle === null) {
- submittedPlanTitle = String(submitPlanCall.arguments?.title ?? '');
- }
}
- if (overflow) continue;
-
yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId);
-
- memory
- .flushSessionToMemory(state.sessionId, llm, state.cwd)
- .catch((e) => logger.error('memory flush failed:', e));
+ memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e));
if (lastResult) return lastResult;
- yield* q.offer({ _tag: 'Error', error: AgentError.maxStepsReached(effectiveMaxSteps) });
- yield* hooks.emit('agent.turn.end', {
- sessionId,
- turnId: state.currentTurnId,
- status: 'maxSteps',
- });
- return Result.err(AgentError.maxStepsReached(effectiveMaxSteps));
- }
-
- yield* q.offer({ _tag: 'Error', error: AgentError.maxStepsReached(effectiveMaxSteps) });
- yield* hooks.emit('agent.turn.end', {
- sessionId,
- turnId: state.currentTurnId,
- status: 'maxSteps',
- });
- memory
- .flushSessionToMemory(state.sessionId, llm, state.cwd)
- .catch((e) => logger.error('memory flush failed:', e));
- return Result.err(AgentError.maxStepsReached(effectiveMaxSteps));
- }).pipe(
- Effect.interruptible,
- Effect.onInterrupt(() =>
- Effect.gen(function* () {
- yield* Effect.sync(() => {
- Effect.runSync(
- q.offer({ _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') })
- );
- });
- yield* hooks
- .emit('agent.turn.end', {
- sessionId,
- turnId: state.currentTurnId,
- status: 'aborted',
- })
- .pipe(Effect.ignore);
- })
- ),
- Effect.ensuring(
- Effect.gen(function* () {
- const cp = yield* CheckpointService;
- yield* cp.snapshotFinal(projectPath, sessionId, state.currentTurnId).pipe(Effect.ignore);
- const mem = yield* MemoryService;
- mem
- .flushSessionToMemory(state.sessionId, llm, state.cwd)
- .catch((e) => logger.error('memory flush failed:', e));
- })
- )
- );
-}
+ yield* q.offer({ _tag: 'Error', error: AgentError.maxStepsReached(maxSteps) });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'maxSteps' });
+ return Result.err(AgentError.maxStepsReached(maxSteps));
+ }).pipe(
+ Effect.interruptible,
+ Effect.onInterrupt(() =>
+ Effect.gen(function* () {
+ yield* Effect.sync(() => {
+ Effect.runSync(q.offer({ _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') }));
+ });
+ yield* hooks.emit('agent.turn.end', { sessionId: opts.sid, turnId: opts.state.currentTurnId, status: 'aborted' }).pipe(Effect.ignore);
+ })
+ ),
+ Effect.ensuring(
+ Effect.gen(function* () {
+ yield* checkpoint.snapshotFinal(opts.projectPath, opts.sid, opts.state.currentTurnId).pipe(Effect.ignore);
+ memory.flushSessionToMemory(opts.state.sessionId, opts.llm, opts.projectPath).catch((e) => logger.error('memory flush failed:', e));
+ })
+ )
+ );
+ }
+
+ return { runTurn };
+} as any));
diff --git a/packages/codingcode/src/agent/config.ts b/packages/codingcode/src/agent/config.ts
deleted file mode 100644
index 7ac00240..00000000
--- a/packages/codingcode/src/agent/config.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { loadConfig } from '@codingcode/infra/config';
-import type { ResolvedConfig } from './types.js';
-
-export function resolveConfig(): ResolvedConfig {
- const cfg = loadConfig();
- return {
- maxSteps: cfg.maxSteps ?? 250,
- maxStopContinuations: cfg.maxStopContinuations ?? 3,
- };
-}
diff --git a/packages/codingcode/src/agent/deps.ts b/packages/codingcode/src/agent/deps.ts
new file mode 100644
index 00000000..f2d50c9a
--- /dev/null
+++ b/packages/codingcode/src/agent/deps.ts
@@ -0,0 +1,121 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { AgentError } from '../core/error.js';
+import type { ToolCall, Message, ToolDescription } from '../core/types.js';
+import type { SessionStoreState, SessionEvent, TokenUsage, UserEvent } from '../session/types.js';
+import type { ToolResultUnion, ToolLookup } from '../tools/port.js';
+import type { ToolDefinition } from '../tools/types.js';
+import type { LLMClient } from '../llm/client.js';
+import type { AgentProfileName } from './profile.js';
+import type { PermissionMode } from '../approval/types.js';
+import type { ApprovalDecision } from '../approval/types.js';
+
+export class SessionPort extends Context.Tag('AgentSessionPort');
+ create(cwd: string, opts: { model: string; activeProfile: AgentProfileName; permissionMode: PermissionMode }, extra?: { parentSessionId?: string; agentName?: string }): Effect.Effect;
+ recordUser(state: SessionStoreState, content: string): Effect.Effect;
+ recordSystem(state: SessionStoreState, content: string): Effect.Effect;
+ recordAssistant(state: SessionStoreState, content: string, toolCalls: any[], usage?: TokenUsage): Effect.Effect;
+ recordToolResult(state: SessionStoreState, name: string, id: string, output: string): Effect.Effect;
+ setPermissionMode(cwd: string, sid: string, mode: PermissionMode): Effect.Effect;
+ setActiveProfile(cwd: string, sid: string, profile: AgentProfileName): Effect.Effect;
+}>() {}
+
+export class ToolExecutorPort extends Context.Tag('AgentToolExecutorPort');
+}>() {}
+
+export class CheckpointPort extends Context.Tag('AgentCheckpointPort');
+ snapshotFinal(cwd: string, sid: string, turnId: number): Effect.Effect;
+}>() {}
+
+export class HookPort extends Context.Tag('AgentHookPort')): Effect.Effect;
+ emitDecision(point: string, payload: Record): Effect.Effect;
+ disposeSession(sid: string): Effect.Effect;
+}>() {}
+
+export class ApprovalPort extends Context.Tag('AgentApprovalPort'); callId?: string; sessionId: string; projectPath?: string; permissionMode?: PermissionMode }): Effect.Effect;
+}>() {}
+
+export class SkillPort extends Context.Tag('AgentSkillPort');
+}>() {}
+
+export class McpPort extends Context.Tag('AgentMcpPort');
+}>() {}
+
+export class ContextPort extends Context.Tag('AgentContextPort');
+}>() {}
+
+export class MemoryPort extends Context.Tag('AgentMemoryPort');
+}>() {}
+
+export class LlmPort extends Context.Tag('AgentLlmPort');
+}>() {}
+
+export class RulesPort extends Context.Tag('AgentRulesPort')() {}
+
+export class TodoPort extends Context.Tag('AgentTodoPort');
+}>() {}
+
+/**
+ * 工具执行期依赖的注入能力。
+ *
+ * agent loop 在独立 runtime(Effect.runFork)中执行工具,而工具(todo_write、
+ * dispatch_agent 等)的 `execute()` 内部会 `yield*` 具体服务(TodoService、
+ * HookService、McpService、SubagentRunnerService)。这些服务是"工具执行期依赖",
+ * 与 agent 自身无关,agent 不应直接 import 它们的具体 Tag。
+ *
+ * 因此这里只暴露一个抽象能力:`provide` 把一个 effect 包装成"工具执行期服务已就绪"的
+ * effect。具体依赖哪些服务、如何注入,由组合根(layer.ts 的 ToolEnvLayer)负责,
+ * agent 对此一无所知 —— 这才是真正的依赖倒置。
+ */
+export interface ToolEnv {
+ provide(effect: Effect.Effect): Effect.Effect;
+}
+
+export class ToolEnvPort extends Context.Tag('AgentToolEnvPort');
+}>() {}
+
+/**
+ * 工具目录抽象:agent 只把"自己需要的工具名字名单"(toolNames)交给工具模块注册,
+ * 拿到"注册好的成品"(tools 描述列表 + lookup 查找),loop 里直接消费、不再查询。
+ *
+ * agent 不接触任何具体工具定义与注册逻辑 —— 名字名单由 agent 侧按 profile 静态给出
+ * (build/plan 各有各的名单),工具模块内部维护"名字 -> 定义"全量表并按名装配。
+ * MCP 工具是运行时动态对象(非静态名单可覆盖),故一并传入。
+ */
+export interface ToolCatalog {
+ tools: ToolDescription[];
+ lookup: ToolLookup;
+}
+
+export class ToolCatalogPort extends Context.Tag('AgentToolCatalogPort')[]): ToolCatalog;
+}>() {}
diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts
new file mode 100644
index 00000000..189ad53a
--- /dev/null
+++ b/packages/codingcode/src/agent/port.ts
@@ -0,0 +1,26 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { AgentEvent } from './types.js';
+import type { AgentProfileName } from './profile.js';
+import type { PermissionMode } from '../approval/types.js';
+
+export interface RunTurnOptions {
+ sessionId?: string;
+ cwd: string;
+ signal?: AbortSignal;
+ permissionMode?: PermissionMode;
+ model?: string;
+ activeProfile?: AgentProfileName;
+}
+
+export interface AgentShape {
+ runTurn(
+ input: string,
+ opts: RunTurnOptions
+ ): Effect.Effect<{
+ stream: AsyncGenerator;
+ sessionId: string;
+ }>;
+}
+
+export class AgentService extends Context.Tag('AgentService')() {}
diff --git a/packages/codingcode/src/agent/profile.ts b/packages/codingcode/src/agent/profile.ts
index d2f4291f..2fb0c544 100644
--- a/packages/codingcode/src/agent/profile.ts
+++ b/packages/codingcode/src/agent/profile.ts
@@ -1,16 +1,114 @@
-import { readFileSync } from 'fs';
+export type AgentProfileName = 'plan' | 'build';
+
+export interface AgentProfile {
+ name: AgentProfileName;
+ systemPrompt?: string;
+}
+
+import { readActiveProfileSync } from '../session/file-ops.js';
import type { DecisionHandler } from '../hooks/types.js';
-import { computePaths } from '../core/path.js';
-import type { AgentProfile } from '../subagent/types.js';
-import { BUILD_PROMPT, PLAN_PROMPT } from './prompt.js';
export const PLAN_PROFILE_NAME = 'plan' as const;
export const BUILD_PROFILE_NAME = 'build' as const;
+export const BUILD_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks.
+
+## How you work
+- Your text output is displayed to the user as formatted text. Tool calls and their results are shown separately —the user can see what tools you used and their outcomes.
+- Tools run behind a permission system. If a tool call is denied, the user declined it —adjust your approach, do not retry the same call verbatim.
+- Messages may contain tags injected by the system, not by the user. They contain useful operational information —always read and follow them.
+
+## Rules
+1. Read files before modifying them —never guess file contents
+2. Use search_code or search_files to locate code before reading —this is faster than reading entire files blindly
+3. Prefer editing existing files over creating new ones
+4. Make small, focused changes —avoid large rewrites
+5. Run tests or type-check after changes when applicable
+6. If the user's request is ambiguous, ask for clarification
+7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search):
+ a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window.
+ b. If you can clearly handle it without extensive file reading or searching, proceed yourself.
+ c. Otherwise, delegate the discovery task with dispatch_agent when a runtime-configured subagent is available.
+
+## Using your tools
+- **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work.
+- **Call multiple tools in parallel** when they are independent —for example, reading several files at once, or searching with different patterns. Do NOT make sequential calls when the calls don't depend on each other.
+- After editing a file, do NOT re-read it to verify —the edit tool already confirms success or reports failure. Only re-read if you suspect the edit did not apply correctly.
+- Reserve execute_command for actual system commands and terminal operations (git, npm, build, test). Do not use it for file operations that dedicated tools can handle.
+
+## Executing actions with care
+Consider the reversibility and blast radius of actions before taking them:
+- **Freely take** local, reversible actions: editing files, running tests, reading code.
+- **Confirm with the user before** hard-to-reverse or outward-facing actions: pushing code, deleting files/branches, force-pushing, modifying CI/CD pipelines, sending messages to external services.
+- **Never** use destructive commands (rm -rf /, sudo, git reset --hard, git push --force, git clean -f) unless explicitly requested and approved by the user.
+- When you encounter unexpected state (unfamiliar files, branches, or configuration), investigate before deleting or overwriting —it may be the user's in-progress work. Never revert changes you did not make.
+
+## Git operations
+- Do NOT commit changes unless the user explicitly asks you to.
+- Do NOT push to remote unless the user explicitly asks you to.
+- Do NOT use destructive git commands (git reset --hard, git push --force, git clean -f, git checkout -- .) unless explicitly requested and approved.
+- If you notice unexpected changes in the working tree that you did not make, investigate before acting —they may be the user's in-progress work.
+
+## Professional objectivity
+Prioritize technical accuracy over validating the user's beliefs. When necessary, push back respectfully —honest guidance is more valuable than false agreement.
+- Do not begin responses with conversational interjections ("Got it", "Sure", "Great question")
+- Do not apologize unnecessarily when results are unexpected
+
+## Follow existing conventions
+When modifying code, first look at the surrounding code's style (naming, frameworks, imports) and match it:
+- **Never assume a library is available** —check imports in neighboring files, or check the dependency file (package.json, cargo.toml, requirements.txt, etc.) before using it.
+- **When creating a new component**, first look at existing components to understand naming conventions, typing patterns, and framework choices.
+- **When editing code**, look at the surrounding context (especially imports) to understand the code's choice of frameworks and libraries, then make your change in the most idiomatic way.
+- **Comments**: default to writing no comments. Only add one when the WHY is non-obvious —a hidden constraint, a subtle invariant, or a workaround for a specific bug. Do not explain WHAT the code does.
+
+## Code references
+When referencing code, use the format \`file_path:line_number\` for easy navigation.
+
+## Output efficiency
+- Be concise. Lead with the answer or action, not with reasoning or preamble.
+- Skip filler words and unnecessary transitions. Do not restate what the user said —just do it.
+- When working on a multi-step task, give brief updates at key moments (when you find something, change direction, or hit a blocker). One sentence per update is enough.
+- When the task is done, give a one-to-two sentence summary of what changed. Do not narrate your entire process.
+- Match the response to the question: a simple question gets a direct answer, not headers and sections.
+
+
+Respond in the user's language. Use code blocks for code.`;
+
+export const PLAN_PROMPT = `You are a planning agent. Your role is to analyze the codebase and produce an implementation plan that the user reviews and approves before any code is written.
+
+You can read files and search code. You can submit a plan via the \`submit_plan\` tool — each call overwrites the previous plan file; use it to revise your plan based on user feedback.
+
+In plan profile, write_file / edit_file / execute_command are denied. The only write operation allowed is \`submit_plan\`.
+
+## Research process
+1. Understand the project structure and conventions
+2. Identify relevant files and existing patterns
+3. Analyze dependencies and potential impacts
+4. Assess complexity and risks
+5. Check for existing implementations or similar patterns
+
+## Output format
+When ready, call \`submit_plan({ title, plan_content: "..." })\` with a Markdown plan:
+- **Current state**: What exists today
+- **Key files**: Files that need modification or creation, with line references
+- **Dependencies and risks**: Breaking changes, third-party concerns
+- **Recommended approach**: Step-by-step implementation strategy
+- **Phases**: If complex, break into ordered phases
+
+## After submit_plan
+submit_plan returns synchronously after writing the plan file. Once you have called it, stop and wait for the user's decision — do not call submit_plan again until the user responds, and do not attempt to use any other write tool.
+
+The user's decision arrives as the next user message. The system has already handled the agent-profile switch (plan → build on approval, plan → plan on revise, no change on cancel); the message body itself is your signal:
+
+- "Implement"/"proceed"/"go ahead" (or any explicit approval) — the plan is approved. Acknowledge briefly and stop. The build agent will pick up the plan from the persisted file.
+- The body contains a revised plan (a Markdown document, often with explicit section headers, or with a "Revise the plan with these changes:" wrapper) — treat the body as the new plan_content, call \`submit_plan\` again with the same title and the revised content, then stop.
+- "Cancel"/"do not implement" — the plan is rejected. Acknowledge briefly and stop.
+
+Never re-call submit_plan on your own initiative. Never treat an implement message as a request for further exploration.`;
+
export const PLAN_PROFILE: AgentProfile = {
name: PLAN_PROFILE_NAME,
systemPrompt: PLAN_PROMPT,
- maxSteps: 180,
};
export const BUILD_PROFILE: AgentProfile = {
@@ -18,28 +116,54 @@ export const BUILD_PROFILE: AgentProfile = {
systemPrompt: BUILD_PROMPT,
};
-export function isPlanProfile(p: { name: string } | null | undefined): boolean {
- return p?.name === PLAN_PROFILE_NAME;
-}
-
-export const PLAN_PROFILE_ALLOWED_TOOLS: ReadonlySet = new Set([
+// 各 profile 的工具名字名单:agent 只把这份名单交给工具模块注册,工具模块按名查表装配。
+// build 含写工具、不含 submit_plan;plan 相反(只读 + submit_plan)。
+export const PLAN_TOOL_NAMES: readonly string[] = [
'read_file',
'search_files',
'search_code',
'fetch_url',
'submit_plan',
-]);
+];
+
+export const BUILD_TOOL_NAMES: readonly string[] = [
+ 'read_file',
+ 'write_file',
+ 'edit_file',
+ 'execute_command',
+ 'search_code',
+ 'search_files',
+ 'fetch_url',
+ 'web_search',
+ 'todo_write',
+ 'dispatch_agent',
+];
+
+// 运行时审批兜底(plan 模式 deny 非名单工具),从名单派生
+export const PLAN_PROFILE_ALLOWED_TOOLS: ReadonlySet = new Set(PLAN_TOOL_NAMES);
+
+export function isPlanProfile(p: { name: string } | null | undefined): boolean {
+ return p?.name === PLAN_PROFILE_NAME;
+}
+
+function isAgentProfileName(name: string): name is AgentProfileName {
+ return name === PLAN_PROFILE_NAME || name === BUILD_PROFILE_NAME;
+}
+
+export function resolveProfile(name: AgentProfileName): AgentProfile {
+ return name === PLAN_PROFILE_NAME ? PLAN_PROFILE : BUILD_PROFILE;
+}
+
+export function resolveSubagentProfile(name: string): AgentProfile | undefined {
+ return isAgentProfileName(name) ? resolveProfile(name) : undefined;
+}
+
+export function getToolNames(profile: AgentProfile | undefined): readonly string[] {
+ return isPlanProfile(profile) ? PLAN_TOOL_NAMES : BUILD_TOOL_NAMES;
+}
export function isSessionUsingPlanProfile(sessionId: string, cwd: string): boolean {
- try {
- const paths = computePaths(cwd, sessionId);
- const idx = JSON.parse(readFileSync(paths.indexPath, 'utf8')) as {
- activeProfile?: string;
- };
- return idx?.activeProfile === PLAN_PROFILE_NAME;
- } catch {
- return false;
- }
+ return readActiveProfileSync(cwd, sessionId) === PLAN_PROFILE_NAME;
}
export const planProfileGateHook: DecisionHandler = (payload) => {
diff --git a/packages/codingcode/src/agent/prompt.ts b/packages/codingcode/src/agent/prompt.ts
index 6b675856..c9372c3d 100644
--- a/packages/codingcode/src/agent/prompt.ts
+++ b/packages/codingcode/src/agent/prompt.ts
@@ -1,99 +1,13 @@
-import type { SystemPromptOptions } from './types.js';
-
-export const BUILD_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks.
-
-## How you work
-- Your text output is displayed to the user as formatted text. Tool calls and their results are shown separately —the user can see what tools you used and their outcomes.
-- Tools run behind a permission system. If a tool call is denied, the user declined it —adjust your approach, do not retry the same call verbatim.
-- Messages may contain tags injected by the system, not by the user. They contain useful operational information —always read and follow them.
-
-## Rules
-1. Read files before modifying them —never guess file contents
-2. Use search_code or search_files to locate code before reading —this is faster than reading entire files blindly
-3. Prefer editing existing files over creating new ones
-4. Make small, focused changes —avoid large rewrites
-5. Run tests or type-check after changes when applicable
-6. If the user's request is ambiguous, ask for clarification
-7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search):
- a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window.
- b. If you can clearly handle it without extensive file reading or searching, proceed yourself.
- c. Otherwise, delegate the discovery task with dispatch_agent when a runtime-configured subagent is available.
-
-## Using your tools
-- **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work.
-- **Call multiple tools in parallel** when they are independent —for example, reading several files at once, or searching with different patterns. Do NOT make sequential calls when the calls don't depend on each other.
-- After editing a file, do NOT re-read it to verify —the edit tool already confirms success or reports failure. Only re-read if you suspect the edit did not apply correctly.
-- Reserve execute_command for actual system commands and terminal operations (git, npm, build, test). Do not use it for file operations that dedicated tools can handle.
-
-## Executing actions with care
-Consider the reversibility and blast radius of actions before taking them:
-- **Freely take** local, reversible actions: editing files, running tests, reading code.
-- **Confirm with the user before** hard-to-reverse or outward-facing actions: pushing code, deleting files/branches, force-pushing, modifying CI/CD pipelines, sending messages to external services.
-- **Never** use destructive commands (rm -rf /, sudo, git reset --hard, git push --force, git clean -f) unless explicitly requested and approved by the user.
-- When you encounter unexpected state (unfamiliar files, branches, or configuration), investigate before deleting or overwriting —it may be the user's in-progress work. Never revert changes you did not make.
-
-## Git operations
-- Do NOT commit changes unless the user explicitly asks you to.
-- Do NOT push to remote unless the user explicitly asks you to.
-- Do NOT use destructive git commands (git reset --hard, git push --force, git clean -f, git checkout -- .) unless explicitly requested and approved.
-- If you notice unexpected changes in the working tree that you did not make, investigate before acting —they may be the user's in-progress work.
-
-## Professional objectivity
-Prioritize technical accuracy over validating the user's beliefs. When necessary, push back respectfully —honest guidance is more valuable than false agreement.
-- Do not begin responses with conversational interjections ("Got it", "Sure", "Great question")
-- Do not apologize unnecessarily when results are unexpected
-
-## Follow existing conventions
-When modifying code, first look at the surrounding code's style (naming, frameworks, imports) and match it:
-- **Never assume a library is available** —check imports in neighboring files, or check the dependency file (package.json, cargo.toml, requirements.txt, etc.) before using it.
-- **When creating a new component**, first look at existing components to understand naming conventions, typing patterns, and framework choices.
-- **When editing code**, look at the surrounding context (especially imports) to understand the code's choice of frameworks and libraries, then make your change in the most idiomatic way.
-- **Comments**: default to writing no comments. Only add one when the WHY is non-obvious —a hidden constraint, a subtle invariant, or a workaround for a specific bug. Do not explain WHAT the code does.
-
-## Code references
-When referencing code, use the format \`file_path:line_number\` for easy navigation.
-
-## Output efficiency
-- Be concise. Lead with the answer or action, not with reasoning or preamble.
-- Skip filler words and unnecessary transitions. Do not restate what the user said —just do it.
-- When working on a multi-step task, give brief updates at key moments (when you find something, change direction, or hit a blocker). One sentence per update is enough.
-- When the task is done, give a one-to-two sentence summary of what changed. Do not narrate your entire process.
-- Match the response to the question: a simple question gets a direct answer, not headers and sections.
-
-
-Respond in the user's language. Use code blocks for code.`;
-
-export const PLAN_PROMPT = `You are a planning agent. Your role is to analyze the codebase and produce an implementation plan that the user reviews and approves before any code is written.
-
-You can read files and search code. You can submit a plan via the \`submit_plan\` tool — each call overwrites the previous plan file; use it to revise your plan based on user feedback.
-
-In plan profile, write_file / edit_file / execute_command are denied. The only write operation allowed is \`submit_plan\`.
-
-## Research process
-1. Understand the project structure and conventions
-2. Identify relevant files and existing patterns
-3. Analyze dependencies and potential impacts
-4. Assess complexity and risks
-5. Check for existing implementations or similar patterns
-
-## Output format
-When ready, call \`submit_plan({ title, plan_content: "..." })\` with a Markdown plan:
-- **Current state**: What exists today
-- **Key files**: Files that need modification or creation, with line references
-- **Dependencies and risks**: Breaking changes, third-party concerns
-- **Recommended approach**: Step-by-step implementation strategy
-- **Phases**: If complex, break into ordered phases
-
-## After submit_plan
-submit_plan returns synchronously after writing the plan file. Once you have called it, stop and wait for the user's decision — do not call submit_plan again until the user responds, and do not attempt to use any other write tool.
-
-The user's decision arrives as the next user message. The system has already handled the agent-profile switch (plan → build on approval, plan → plan on revise, no change on cancel); the message body itself is your signal:
-
-- "Implement"/"proceed"/"go ahead" (or any explicit approval) — the plan is approved. Acknowledge briefly and stop. The build agent will pick up the plan from the persisted file.
-- The body contains a revised plan (a Markdown document, often with explicit section headers, or with a "Revise the plan with these changes:" wrapper) — treat the body as the new plan_content, call \`submit_plan\` again with the same title and the revised content, then stop.
-- "Cancel"/"do not implement" — the plan is rejected. Acknowledge briefly and stop.
+import { BUILD_PROMPT } from './profile.js';
+
+interface SystemPromptOptions {
+ cwd: string;
+ platform: string;
+ shell: string;
+ rules?: string;
+ profileSystemPrompt?: string;
+}
-Never re-call submit_plan on your own initiative. Never treat an implement message as a request for further exploration.`;
const DEFAULT_ENV_PROMPT = `## Environment
- Working directory: {{cwd}}
- Operating system: {{platform}}
diff --git a/packages/codingcode/src/agent/stream-adapter.ts b/packages/codingcode/src/agent/stream-adapter.ts
index c9fd1036..be4119f4 100644
--- a/packages/codingcode/src/agent/stream-adapter.ts
+++ b/packages/codingcode/src/agent/stream-adapter.ts
@@ -47,6 +47,13 @@ export async function* agentEventToStreamChunk(
case 'TodoUpdate':
yield { type: 'todo_update', items: event.items as any };
break;
+ case 'ContextCompressed':
+ yield {
+ type: 'context_compressed',
+ released: event.released,
+ promptEstimate: event.promptEstimate,
+ };
+ break;
case 'Usage':
yield {
type: 'usage',
@@ -55,13 +62,6 @@ export async function* agentEventToStreamChunk(
total: event.total,
};
break;
- case 'ReactiveCompact':
- yield {
- type: 'reactive_compact',
- released: event.released,
- promptEstimate: event.promptEstimate,
- };
- break;
}
}
}
diff --git a/packages/codingcode/src/agent/todo.ts b/packages/codingcode/src/agent/todo.ts
deleted file mode 100644
index c8d6ea90..00000000
--- a/packages/codingcode/src/agent/todo.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Effect } from 'effect';
-import type { Todo, TodoCounts } from './types.js';
-
-export const TODO_MAX_ITEMS = 20;
-export const TODO_MAX_STEP_LEN = 60;
-
-export function countByStatus(plan: Todo[]): TodoCounts {
- const c: TodoCounts = { pending: 0, in_progress: 0, completed: 0 };
- for (const t of plan) c[t.status]++;
- return c;
-}
-
-export class TodoService extends Effect.Service()('Todo', {
- sync: () => {
- const store = new Map();
-
- return {
- read(sessionId: string): Todo[] {
- return store.get(sessionId) ?? [];
- },
-
- write(sessionId: string, plan: Todo[]): void {
- store.set(sessionId, plan);
- },
-
- reset(): void {
- store.clear();
- },
- };
- },
-}) {}
diff --git a/packages/codingcode/src/agent/tool-catalog.ts b/packages/codingcode/src/agent/tool-catalog.ts
new file mode 100644
index 00000000..916fe2f6
--- /dev/null
+++ b/packages/codingcode/src/agent/tool-catalog.ts
@@ -0,0 +1,18 @@
+import { Layer } from 'effect';
+import { ToolCatalogPort } from './deps.js';
+import type { ToolCatalog } from './deps.js';
+import { createToolCatalog } from '../tools/catalog.js';
+
+/**
+ * ToolCatalogPort 的实现(组合根一侧)。
+ *
+ * agent 只 import ToolCatalogPort 并传入名字名单,这里才是唯一接触具体工具装配的地方。
+ * register 委托工具模块的 createToolCatalog:按名单从全量静态表查表注册,并合并 MCP 工具,
+ * 回传成品(tools 描述列表 + lookup 查找闭包)——agent 拿到后直接消费,不再 describe/get。
+ */
+export const ToolCatalogLayer: Layer.Layer = Layer.succeed(
+ ToolCatalogPort,
+ {
+ register: (toolNames, mcpTools): ToolCatalog => createToolCatalog(toolNames, mcpTools),
+ }
+);
diff --git a/packages/codingcode/src/agent/tool-env.ts b/packages/codingcode/src/agent/tool-env.ts
new file mode 100644
index 00000000..f4bacfb4
--- /dev/null
+++ b/packages/codingcode/src/agent/tool-env.ts
@@ -0,0 +1,42 @@
+import { Effect, Layer } from 'effect';
+import { TodoService } from '../todo/port.js';
+import { HookService } from '../hooks/port.js';
+import { McpService } from '../mcp/port.js';
+import { SubagentRunnerService } from '../subagent/port.js';
+import { ToolEnvPort } from './deps.js';
+import type { ToolEnv } from './deps.js';
+
+/**
+ * ToolEnvPort 的实现(组合根一侧)。
+ *
+ * 它把"工具执行期依赖"(TodoService / HookService / McpService / SubagentRunnerService)
+ * 从具体服务适配成 agent 所需的抽象注入能力。agent 只 import ToolEnvPort,不接触这里列出的
+ * 任何具体 Tag,从而完成依赖倒置。
+ *
+ * 关键点:SubagentRunnerService 依赖 AgentService(子代理递归调用主代理),若在 Layer
+ * 构造期直接 `yield*` 会形成循环。因此这里构造期不静态依赖任何服务 —— 仅返回
+ * `getToolEnv` 函数,`yield* SubagentRunnerService` 延迟到 getToolEnv 在运行时(外层
+ * AppRuntime 已就绪)才解析,从而打破递归循环。
+ */
+export const ToolEnvLayer: Layer.Layer = Layer.effect(
+ ToolEnvPort,
+ Effect.succeed({
+ getToolEnv: (): Effect.Effect =>
+ Effect.gen(function* () {
+ const todoSvc = yield* TodoService;
+ const hookSvc = yield* HookService;
+ const mcpSvc = yield* McpService;
+ const subagentSvc = yield* SubagentRunnerService;
+ const env: ToolEnv = {
+ provide: (effect: Effect.Effect) =>
+ effect.pipe(
+ Effect.provideService(TodoService, todoSvc),
+ Effect.provideService(HookService, hookSvc),
+ Effect.provideService(McpService, mcpSvc),
+ Effect.provideService(SubagentRunnerService, subagentSvc),
+ ) as Effect.Effect,
+ };
+ return env;
+ }),
+ })
+);
diff --git a/packages/codingcode/src/agent/types.ts b/packages/codingcode/src/agent/types.ts
index 4fb3cfce..c18672a9 100644
--- a/packages/codingcode/src/agent/types.ts
+++ b/packages/codingcode/src/agent/types.ts
@@ -1,35 +1,5 @@
import type { ToolCall } from '../core/types.js';
import type { AgentError } from '../core/error.js';
-import type { SessionStoreState } from '../session/types.js';
-import type { LLMClient } from '../llm/client.js';
-import type { ToolDefinition, ToolVisibilityPolicy } from '../tools/types.js';
-import type { AgentProfile } from '../subagent/types.js';
-
-export type TodoStatus = 'pending' | 'in_progress' | 'completed';
-
-export interface Todo {
- step: string;
- status: TodoStatus;
-}
-
-export interface TodoCounts {
- pending: number;
- in_progress: number;
- completed: number;
-}
-
-export interface SystemPromptOptions {
- cwd: string;
- platform: string;
- shell: string;
- rules?: string;
- profileSystemPrompt?: string;
-}
-
-export interface ResolvedConfig {
- maxSteps: number;
- maxStopContinuations: number;
-}
export type AgentEvent =
| { readonly _tag: 'LlmChunk'; readonly text: string }
@@ -54,12 +24,6 @@ export type AgentEvent =
readonly ok: boolean;
}
| { readonly _tag: 'Step'; readonly step: number; readonly max: number }
- | {
- readonly _tag: 'ReactiveCompact';
- readonly attempt: number;
- readonly released: number;
- readonly promptEstimate: number;
- }
| { readonly _tag: 'Error'; readonly error: AgentError }
| { readonly _tag: 'Done'; readonly content: string }
| {
@@ -70,27 +34,14 @@ export type AgentEvent =
}>;
}
| { readonly _tag: 'TurnId'; readonly turnId: number }
+ | {
+ readonly _tag: 'ContextCompressed';
+ readonly released: number;
+ readonly promptEstimate: number;
+ }
| {
readonly _tag: 'Usage';
readonly prompt: number;
readonly completion: number;
readonly total: number;
};
-
-export interface RunStreamOptions {
- state: SessionStoreState;
- llm: LLMClient;
- profile?: AgentProfile;
- systemOverride?: string;
- coreAllowlist?: ReadonlySet;
- toolPolicy?: ToolVisibilityPolicy;
- dispatchTool?: ToolDefinition;
- mcpTools?: ToolDefinition[];
- abortSignal?: AbortSignal;
- parentSessionId?: string;
- agentName?: string;
- maxStepsOverride?: number;
- maxStopContinuations?: number;
- approvalOverride?: import('../approval/index.js').ApprovalService;
- rulesText?: string;
-}
diff --git a/packages/codingcode/src/approval/approval.ts b/packages/codingcode/src/approval/approval.ts
new file mode 100644
index 00000000..9d056423
--- /dev/null
+++ b/packages/codingcode/src/approval/approval.ts
@@ -0,0 +1,48 @@
+import { Layer, Effect } from 'effect';
+import { HookService } from '../hooks/port.js';
+import type { PermissionMode } from './types.js';
+import { createRuleEngine, type RuleEngine } from './rule-engine.js';
+import { DEFAULT_DENY_RULES, DANGEROUS_TOOL_NAMES } from './presets.js';
+import { runPipeline } from './pipeline.js';
+import { ApprovalWaitService } from './wait-port.js';
+import { ApprovalService } from './port.js';
+
+export const ApprovalLayer = Layer.effect(ApprovalService, Effect.gen(function* () {
+ const hooks = yield* HookService;
+ const approvalWait = yield* ApprovalWaitService;
+ const ruleEngine: RuleEngine = createRuleEngine(DEFAULT_DENY_RULES);
+ const destructiveTools = new Set(DANGEROUS_TOOL_NAMES);
+
+ return {
+ evaluate: (request: {
+ tool: string;
+ input: Record;
+ context?: Record;
+ callId?: string;
+ sessionId: string;
+ projectPath?: string;
+ permissionMode?: PermissionMode;
+ }): any =>
+ runPipeline(
+ {
+ tool: request.tool,
+ input: request.input,
+ context: request.context,
+ callId: request.callId,
+ },
+ {
+ ruleEngine,
+ destructiveTools,
+ permissionMode: request.permissionMode ?? 'default',
+ onAlways: (rule) => ruleEngine.addRule(rule),
+ onNever: (rule) => ruleEngine.addRule(rule),
+ sessionId: request.sessionId,
+ projectPath: request.projectPath,
+ callId: request.callId,
+ }
+ ).pipe(
+ Effect.provideService(HookService, hooks),
+ Effect.provideService(ApprovalWaitService, approvalWait)
+ ),
+ };
+} as any));
diff --git a/packages/codingcode/src/approval/confirmation.ts b/packages/codingcode/src/approval/confirmation.ts
index a41bbac1..04b5182f 100644
--- a/packages/codingcode/src/approval/confirmation.ts
+++ b/packages/codingcode/src/approval/confirmation.ts
@@ -1,6 +1,6 @@
import { Effect } from 'effect';
import type { PermissionRule } from './types.js';
-import { ApprovalWaitService } from './async-confirm.js';
+import { ApprovalWaitService } from './wait-port.js';
export type ConfirmResult =
| { type: 'allow' }
diff --git a/packages/codingcode/src/approval/index.ts b/packages/codingcode/src/approval/index.ts
deleted file mode 100644
index 251c7597..00000000
--- a/packages/codingcode/src/approval/index.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import { Effect } from 'effect';
-import { HookService } from '../hooks/registry.js';
-import type { PermissionMode, PermissionRule, ApprovalDecision } from './types.js';
-import { createRuleEngine, type RuleEngine } from './rule-engine.js';
-import { DEFAULT_DENY_RULES, READONLY_TOOL_NAMES, DANGEROUS_TOOL_NAMES } from './presets.js';
-import { runPipeline } from './pipeline.js';
-import { ApprovalWaitService } from './async-confirm.js';
-
-export class ApprovalService extends Effect.Service()('Approval', {
- effect: Effect.gen(function* () {
- const hooks = yield* HookService;
- const approvalWait = yield* ApprovalWaitService;
- const ruleEngine: RuleEngine = createRuleEngine(DEFAULT_DENY_RULES);
- const destructiveTools = new Set(DANGEROUS_TOOL_NAMES);
- const readonlyTools = new Set(READONLY_TOOL_NAMES);
-
- function makeForkedService(
- engine: RuleEngine,
- permMode: PermissionMode,
- roTools: Set,
- destTools: Set
- ): ApprovalService {
- let currentPermMode = permMode;
- return ApprovalService.make({
- evaluate: (request: {
- tool: string;
- input: Record;
- context?: Record;
- callId?: string;
- sessionId: string;
- projectPath?: string;
- }): Effect.Effect =>
- runPipeline(
- {
- tool: request.tool,
- input: request.input,
- context: request.context,
- callId: request.callId,
- },
- {
- ruleEngine: engine,
- readonlyTools: roTools,
- destructiveTools: destTools,
- permissionMode: currentPermMode,
- onAlways: (rule) => engine.addRule(rule),
- onNever: (rule) => engine.addRule(rule),
- sessionId: request.sessionId,
- projectPath: request.projectPath,
- callId: request.callId,
- }
- ).pipe(
- Effect.provideService(HookService, hooks),
- Effect.provideService(ApprovalWaitService, approvalWait)
- ),
- addRule: (rule: PermissionRule): Effect.Effect =>
- Effect.sync(() => engine.addRule(rule)),
- removeRule: (id: string): Effect.Effect => Effect.sync(() => engine.removeRule(id)),
- setPermissionMode: (mode: PermissionMode): Effect.Effect =>
- Effect.sync(() => {
- currentPermMode = mode;
- }),
- getPermissionMode: (): PermissionMode => currentPermMode,
- fork: (opts?: {
- extraDenyRules?: PermissionRule[];
- readonly?: boolean;
- permissionMode?: PermissionMode;
- }): Effect.Effect =>
- Effect.sync(() => {
- const nextEngine = createRuleEngine(engine.getAllRules());
- if (opts?.extraDenyRules) {
- for (const rule of opts.extraDenyRules) {
- nextEngine.addRule(rule);
- }
- }
- if (opts?.readonly) {
- for (const toolName of DANGEROUS_TOOL_NAMES) {
- nextEngine.addRule({
- id: `readonly-${toolName}`,
- action: 'deny' as const,
- toolPattern: toolName,
- source: 'system' as const,
- });
- }
- }
- return makeForkedService(
- nextEngine,
- opts?.permissionMode ?? currentPermMode,
- new Set(roTools),
- new Set(destTools)
- );
- }),
- });
- }
-
- return {
- evaluate: (request: {
- tool: string;
- input: Record;
- context?: Record;
- callId?: string;
- sessionId: string;
- projectPath?: string;
- }): Effect.Effect =>
- runPipeline(
- {
- tool: request.tool,
- input: request.input,
- context: request.context,
- callId: request.callId,
- },
- {
- ruleEngine,
- readonlyTools,
- destructiveTools,
- permissionMode: 'default',
- onAlways: (rule) => ruleEngine.addRule(rule),
- onNever: (rule) => ruleEngine.addRule(rule),
- sessionId: request.sessionId,
- projectPath: request.projectPath,
- callId: request.callId,
- }
- ).pipe(
- Effect.provideService(HookService, hooks),
- Effect.provideService(ApprovalWaitService, approvalWait)
- ),
-
- addRule: (rule: PermissionRule): Effect.Effect =>
- Effect.sync(() => ruleEngine.addRule(rule)),
-
- removeRule: (id: string): Effect.Effect => Effect.sync(() => ruleEngine.removeRule(id)),
-
- setPermissionMode: (_mode: PermissionMode): Effect.Effect =>
- Effect.sync(() => {
- /* no-op at root; only fork children maintain their own currentPermMode */
- }),
-
- getPermissionMode: (): PermissionMode => 'default',
-
- fork: (opts?: {
- extraDenyRules?: PermissionRule[];
- readonly?: boolean;
- permissionMode?: PermissionMode;
- }): Effect.Effect =>
- Effect.sync(() => {
- const parentRules = ruleEngine.getAllRules();
- const childEngine = createRuleEngine(parentRules);
- if (opts?.extraDenyRules) {
- for (const rule of opts.extraDenyRules) {
- childEngine.addRule(rule);
- }
- }
- if (opts?.readonly) {
- const denyRules: PermissionRule[] = DANGEROUS_TOOL_NAMES.map((toolName) => ({
- id: `readonly-${toolName}`,
- action: 'deny' as const,
- toolPattern: toolName,
- source: 'system' as const,
- }));
- for (const rule of denyRules) {
- childEngine.addRule(rule);
- }
- }
- return makeForkedService(
- childEngine,
- opts?.permissionMode ?? 'default',
- new Set(readonlyTools),
- new Set(destructiveTools)
- );
- }),
- };
- }),
-}) {}
diff --git a/packages/codingcode/src/approval/pipeline.ts b/packages/codingcode/src/approval/pipeline.ts
index d15a0921..3739c67e 100644
--- a/packages/codingcode/src/approval/pipeline.ts
+++ b/packages/codingcode/src/approval/pipeline.ts
@@ -2,12 +2,11 @@ import { Effect } from 'effect';
import type { ApprovalDecision, PermissionMode, PermissionRule, ToolCallRequest } from './types.js';
import type { RuleEngine } from './rule-engine.js';
import { userConfirmAsync } from './confirmation.js';
-import { ApprovalWaitService } from './async-confirm.js';
-import { HookService } from '../hooks/registry.js';
+import { ApprovalWaitService } from './wait-port.js';
+import { HookService } from '../hooks/port.js';
export interface PipelineOptions {
ruleEngine: RuleEngine;
- readonlyTools: Set;
destructiveTools: Set;
permissionMode: PermissionMode;
/** Called when user selects Always — allows caller to persist the rule. */
@@ -25,7 +24,6 @@ export interface PipelineOptions {
const LAYER_NAMES = [
'RuleEngine',
- 'ReadonlyWhitelist',
'PermissionMode',
'HookPreToolUse',
'UserConfirmation',
@@ -35,10 +33,10 @@ const LAYER_NAMES = [
export function runPipeline(
request: ToolCallRequest,
opts: PipelineOptions
-): Effect.Effect {
+): any {
return Effect.gen(function* () {
- const hooks = yield* HookService;
- const approvalWait = yield* ApprovalWaitService;
+ const hooks: any = yield* HookService;
+ const approvalWait: any = yield* ApprovalWaitService;
const asyncConfirm = yield* approvalWait.hasEmitter(opts.sessionId);
const layers: string[] = [];
@@ -52,35 +50,23 @@ export function runPipeline(
}
}
- // Layer 2: Read-only Whitelist
- {
- if (opts.readonlyTools.has(request.tool)) {
- const result: ApprovalDecision = {
- type: 'allow',
- source: 'readonly-whitelist',
- };
- layers.push(LAYER_NAMES[1]);
- const final = yield* recordAuditAndReturn(hooks, request, result, layers);
- return final;
- }
- }
-
- // Layer 3: Permission Mode
+ // Layer 2: Permission Mode — the single auto-allow gate. Read-only tools
+ // are NOT unconditionally whitelisted; acceptEdits covers them as
+ // non-destructive. In default mode nothing is auto-allowed here.
{
const modeResult = applyPermissionMode(
request.tool,
opts.permissionMode,
- opts.readonlyTools,
opts.destructiveTools
);
if (modeResult) {
- layers.push(LAYER_NAMES[2]);
+ layers.push(LAYER_NAMES[1]);
const final = yield* recordAuditAndReturn(hooks, request, modeResult, layers);
return final;
}
}
- // Layer 4: Hook PreToolUse
+ // Layer 3: Hook PreToolUse
{
const hookResult = yield* Effect.gen(function* () {
const result = yield* hooks.emitDecision('tool.approval.pre', {
@@ -95,7 +81,7 @@ export function runPipeline(
return result;
});
if (hookResult) {
- layers.push(LAYER_NAMES[3]);
+ layers.push(LAYER_NAMES[2]);
if (hookResult.decision === 'deny') {
const result: ApprovalDecision = {
type: 'deny',
@@ -119,9 +105,9 @@ export function runPipeline(
}
}
- // Layer 5: User Confirmation
+ // Layer 4: User Confirmation
{
- layers.push(LAYER_NAMES[4]);
+ layers.push(LAYER_NAMES[3]);
if (request.tool === 'submit_plan') {
const result: ApprovalDecision = {
@@ -176,7 +162,6 @@ export function runPipeline(
function applyPermissionMode(
tool: string,
mode: PermissionMode,
- readonlyTools: Set,
destructiveTools: Set
): ApprovalDecision | null {
switch (mode) {
@@ -185,7 +170,8 @@ function applyPermissionMode(
return { type: 'allow', source: 'permission-mode' };
case 'acceptEdits':
- // Accept edits: read-only + edit tools auto-allow, destructive tools need confirmation
+ // Accept edits: non-destructive tools (read-only + edit) auto-allow,
+ // destructive tools need confirmation
if (!destructiveTools.has(tool)) {
return { type: 'allow', source: 'permission-mode' };
}
@@ -198,13 +184,13 @@ function applyPermissionMode(
}
function recordAuditAndReturn(
- hooks: HookService,
+ hooks: any,
request: ToolCallRequest,
decision: ApprovalDecision,
passedLayers: string[]
-): Effect.Effect {
+): any {
return Effect.gen(function* () {
- passedLayers.push(LAYER_NAMES[5]);
+ passedLayers.push(LAYER_NAMES[4]);
yield* hooks.emit('tool.approval.post', {
tool: request.tool,
input: request.input,
diff --git a/packages/codingcode/src/approval/port.ts b/packages/codingcode/src/approval/port.ts
new file mode 100644
index 00000000..d792e430
--- /dev/null
+++ b/packages/codingcode/src/approval/port.ts
@@ -0,0 +1,9 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { PermissionMode, ApprovalDecision } from './types.js';
+
+export interface ApprovalShape {
+ evaluate(request: { tool: string; input: Record; context?: Record; callId?: string; sessionId: string; projectPath?: string; permissionMode?: PermissionMode }): Effect.Effect;
+}
+
+export class ApprovalService extends Context.Tag('Approval')() {}
diff --git a/packages/codingcode/src/approval/presets.ts b/packages/codingcode/src/approval/presets.ts
index c611e4f4..40582368 100644
--- a/packages/codingcode/src/approval/presets.ts
+++ b/packages/codingcode/src/approval/presets.ts
@@ -84,14 +84,4 @@ export const DEFAULT_DENY_RULES: PermissionRule[] = [
},
];
-export const READONLY_TOOL_NAMES: string[] = [
- 'read_file',
- 'search_code',
- 'search_files',
- 'fetch_url',
- 'web_search',
- 'dispatch_agent',
- 'todo_write',
-];
-
export const DANGEROUS_TOOL_NAMES: string[] = ['execute_command'];
diff --git a/packages/codingcode/src/approval/wait-port.ts b/packages/codingcode/src/approval/wait-port.ts
new file mode 100644
index 00000000..8df55c24
--- /dev/null
+++ b/packages/codingcode/src/approval/wait-port.ts
@@ -0,0 +1,15 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { ConfirmResult } from './confirmation.js';
+
+export interface ApprovalWaitShape {
+ waitForConfirm(id: string, sessionId: string): Effect.Effect;
+ resolveConfirm(id: string, sessionId: string, result: ConfirmResult): Effect.Effect;
+ emitApprovalRequest(sessionId: string, id: string, tool: string, args: Record): Effect.Effect;
+ registerEmitter(sessionId: string, fn: (id: string, tool: string, args: Record) => void): Effect.Effect;
+ delegateEmitter(childSessionId: string, parentSessionId: string): Effect.Effect;
+ unregisterEmitter(sessionId: string): Effect.Effect;
+ hasEmitter(sessionId: string): Effect.Effect;
+}
+
+export class ApprovalWaitService extends Context.Tag('ApprovalWait')() {}
diff --git a/packages/codingcode/src/approval/async-confirm.ts b/packages/codingcode/src/approval/wait.ts
similarity index 77%
rename from packages/codingcode/src/approval/async-confirm.ts
rename to packages/codingcode/src/approval/wait.ts
index e0228691..d76aa5e7 100644
--- a/packages/codingcode/src/approval/async-confirm.ts
+++ b/packages/codingcode/src/approval/wait.ts
@@ -1,13 +1,13 @@
-import { Effect, Deferred } from 'effect';
+import { Layer, Effect, Deferred } from 'effect';
import type { ConfirmResult } from './confirmation.js';
+import { ApprovalWaitService } from './wait-port.js';
interface PendingEntry {
deferred: Deferred.Deferred;
sessionId: string;
}
-export class ApprovalWaitService extends Effect.Service()('ApprovalWait', {
- effect: Effect.gen(function* () {
+export const ApprovalWaitLayer = Layer.effect(ApprovalWaitService, Effect.gen(function* () {
const pendingConfirmations = new Map();
const approvalEmitters = new Map<
string,
@@ -24,27 +24,17 @@ export class ApprovalWaitService extends Effect.Service()('
resolveConfirm: (
id: string,
- _sessionId: string,
+ sessionId: string,
result: ConfirmResult
): Effect.Effect =>
Effect.sync(() => {
const entry = pendingConfirmations.get(id);
- if (!entry) return false;
+ if (!entry || entry.sessionId !== sessionId) return false;
pendingConfirmations.delete(id);
Deferred.unsafeDone(entry.deferred, Effect.succeed(result));
return true;
}),
- getPending: (sessionId?: string): Effect.Effect =>
- Effect.sync(() => {
- if (sessionId) {
- return Array.from(pendingConfirmations.entries())
- .filter(([_, e]) => e.sessionId === sessionId)
- .map(([id]) => id);
- }
- return Array.from(pendingConfirmations.keys());
- }),
-
emitApprovalRequest: (
sessionId: string,
id: string,
@@ -79,5 +69,4 @@ export class ApprovalWaitService extends Effect.Service()('
hasEmitter: (sessionId: string): Effect.Effect =>
Effect.sync(() => approvalEmitters.has(sessionId)),
};
- }),
-}) {}
+}));
diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint.ts
similarity index 50%
rename from packages/codingcode/src/checkpoint/checkpoint-service.ts
rename to packages/codingcode/src/checkpoint/checkpoint.ts
index 165d4d88..1a0e50cf 100644
--- a/packages/codingcode/src/checkpoint/checkpoint-service.ts
+++ b/packages/codingcode/src/checkpoint/checkpoint.ts
@@ -1,18 +1,16 @@
-import { Effect } from 'effect';
-import { createHash } from 'crypto';
+import { Layer, Effect } from 'effect';
import { resolve } from 'path';
import { ShadowGit } from './shadow-git.js';
import { ProjectLock } from './project-lock.js';
import { normalizePath } from '../core/path.js';
-import { shortSid, commitMsg, toGitPath, hashWorkspaceFile, ProjectCache } from './utils.js';
-import { readRestoreEntry, writeRestoreEntry } from './undo-store.js';
+import { commitMsg, toGitPath, ProjectCache } from './utils.js';
import { getCompletedTurnsFor, getTurnRestorePlan, getRollbackToTurnPlan } from './turn-query.js';
import { emptyRollbackResult, executeRollback } from './rollback-engine.js';
+import { CheckpointService } from './port.js';
// ---- Effect Service ----
-export class CheckpointService extends Effect.Service()('Checkpoint', {
- effect: Effect.gen(function* () {
+export const CheckpointLayer = Layer.effect(CheckpointService, Effect.gen(function* () {
const shadowGitByProject = new ProjectCache(10);
const lockByProject = new ProjectCache(10);
@@ -50,6 +48,11 @@ export class CheckpointService extends Effect.Service()('Chec
doSnapshotFinal(sg, sessionId, candidate);
}
+ function latestCompletedTurn(sg: ShadowGit, sessionId: string): number {
+ const completed = getCompletedTurnsFor(sg, sessionId);
+ return completed.length > 0 ? completed[completed.length - 1]! : 0;
+ }
+
return {
snapshotBaseline: (projectPath: string, sessionId: string, turnId: number) =>
Effect.sync(() => {
@@ -73,47 +76,11 @@ export class CheckpointService extends Effect.Service()('Chec
doSnapshotFinal(sg, sessionId, turnId);
}),
- getCompletedTurns: (projectPath: string, sessionId: string) =>
- Effect.sync(() => {
- const sg = ensure(projectPath);
- repairIncompleteTurn(sg, sessionId);
- return getCompletedTurnsFor(sg, sessionId);
- }),
-
- getCheckpoints: (projectPath: string, sessionId: string) =>
- Effect.sync(() => {
- const sg = ensure(projectPath);
- repairIncompleteTurn(sg, sessionId);
- const prefix = `turn-${shortSid(sessionId)}-`;
- const completedTurns = getCompletedTurnsFor(sg, sessionId);
- const result: Array<{
- turnId: number;
- files: string[];
- }> = [];
-
- for (const i of completedTurns) {
- const bCommit = sg.findCommitByMessage(`${prefix}${i}-baseline`);
- if (!bCommit) continue;
- const fCommit = sg.findCommitByMessage(`${prefix}${i}-final`);
- if (!fCommit) continue;
-
- const allChanges = sg.diffFiles(bCommit, fCommit);
- const files = [
- ...new Set(allChanges.map((c) => normalizePath(resolve(projectPath, c.file)))),
- ];
-
- result.push({ turnId: i, files });
- }
- return result;
- }),
-
getCheckpointDiff: (projectPath: string, sessionId: string, turnId?: number) =>
Effect.sync(() => {
const sg = ensure(projectPath);
repairIncompleteTurn(sg, sessionId);
- const completedTurns = getCompletedTurnsFor(sg, sessionId);
- const latestTurnId =
- turnId ?? (completedTurns.length > 0 ? completedTurns[completedTurns.length - 1]! : 0);
+ const latestTurnId = turnId ?? latestCompletedTurn(sg, sessionId);
if (latestTurnId === 0) {
return { turnId: 0, files: [] };
}
@@ -156,23 +123,18 @@ export class CheckpointService extends Effect.Service()('Chec
revertCheckpointFiles: (
projectPath: string,
sessionId: string,
- turnId: number,
+ turnId: number | undefined,
files: string[]
) =>
Effect.sync(() => {
const sg = ensure(projectPath);
- const plan = getTurnRestorePlan(sg, sessionId, turnId);
+ const targetTurnId = turnId ?? latestCompletedTurn(sg, sessionId);
+ if (targetTurnId === 0) return emptyRollbackResult(0);
+ const plan = getTurnRestorePlan(sg, sessionId, targetTurnId);
if (!plan) {
- return emptyRollbackResult(turnId);
+ return emptyRollbackResult(targetTurnId);
}
- return executeRollback(
- sessionId,
- plan,
- files,
- 'checkpoint-files',
- sg,
- lockFor(projectPath)
- );
+ return executeRollback(plan, files, sg, lockFor(projectPath));
}),
previewRollbackDiff: (projectPath: string, sessionId: string, throughTurnId: number) =>
@@ -212,123 +174,10 @@ export class CheckpointService extends Effect.Service()('Chec
throughTurnId,
affectedTurns: plan.affectedTurns,
selectedFiles: [],
- restoreEntry: null,
- };
- }
-
- return executeRollback(
- sessionId,
- plan,
- selectedFiles,
- 'rollback-to-turn',
- sg,
- lockFor(projectPath)
- );
- }),
-
- undoLastCodeRollback: (
- projectPath: string,
- sessionId: string,
- opts?: { force?: boolean; files?: string[] }
- ) =>
- Effect.sync(() => {
- const sg = ensure(projectPath);
- const entry = readRestoreEntry(sg.gitDir, sessionId);
- if (!entry) {
- return {
- restored: false,
- conflict: false,
- conflictFiles: [],
- restoredFiles: [],
- remainingRolledBack: [],
};
}
- const normalizedOptsFiles =
- opts?.files && opts.files.length > 0
- ? new Set(opts.files.map((f) => normalizePath(f).toLowerCase()))
- : null;
- const filesToRestore = normalizedOptsFiles
- ? entry.selectedFiles.filter((f) =>
- normalizedOptsFiles.has(normalizePath(f).toLowerCase())
- )
- : [...entry.selectedFiles];
-
- if (filesToRestore.length === 0) {
- return {
- restored: false,
- conflict: false,
- conflictFiles: [],
- restoredFiles: [],
- remainingRolledBack: entry.selectedFiles,
- };
- }
-
- const baselineCommit = sg.findCommitByMessage(
- commitMsg(sessionId, entry.throughTurnId, 'baseline')
- );
- const conflictFiles: string[] = [];
-
- if (baselineCommit) {
- for (const f of filesToRestore) {
- const gitPath = toGitPath(projectPath, f);
- const currentHash = hashWorkspaceFile(projectPath, f);
- const baselineContent = sg.showFile(baselineCommit, gitPath);
- const baselineHash =
- baselineContent !== null
- ? createHash('sha256').update(baselineContent).digest('hex')
- : null;
-
- if (currentHash !== baselineHash) {
- conflictFiles.push(f);
- }
- }
- }
-
- if (conflictFiles.length > 0 && !opts?.force) {
- return {
- restored: false,
- conflict: true,
- conflictFiles,
- restoredFiles: [],
- remainingRolledBack: entry.selectedFiles,
- };
- }
-
- const lock = lockFor(projectPath);
- lock.lock();
- try {
- sg.checkoutFiles(entry.safetyCommit, filesToRestore);
-
- const remainingFiles = entry.selectedFiles.filter(
- (f) =>
- !filesToRestore.some(
- (rf) => normalizePath(rf).toLowerCase() === normalizePath(f).toLowerCase()
- )
- );
- if (remainingFiles.length === 0) {
- writeRestoreEntry(sg.gitDir, sessionId, null);
- } else {
- writeRestoreEntry(sg.gitDir, sessionId, { ...entry, selectedFiles: remainingFiles });
- }
-
- return {
- restored: true,
- conflict: conflictFiles.length > 0,
- conflictFiles,
- restoredFiles: filesToRestore,
- remainingRolledBack: remainingFiles,
- };
- } finally {
- lock.unlock();
- }
- }),
-
- getLatestRestoreEntry: (projectPath: string, sessionId: string) =>
- Effect.sync(() => {
- const sg = ensure(projectPath);
- return readRestoreEntry(sg.gitDir, sessionId);
+ return executeRollback(plan, selectedFiles, sg, lockFor(projectPath));
}),
};
- }),
-}) {}
+}));
diff --git a/packages/codingcode/src/checkpoint/port.ts b/packages/codingcode/src/checkpoint/port.ts
new file mode 100644
index 00000000..f712fc5b
--- /dev/null
+++ b/packages/codingcode/src/checkpoint/port.ts
@@ -0,0 +1,14 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { CheckpointDiff, CodeRollbackResult, RollbackPreviewDiff } from './types.js';
+
+export interface CheckpointShape {
+ snapshotBaseline(projectPath: string, sessionId: string, turnId: number): Effect.Effect;
+ snapshotFinal(projectPath: string, sessionId: string, turnId: number): Effect.Effect;
+ getCheckpointDiff(projectPath: string, sessionId: string, turnId?: number): Effect.Effect;
+ revertCheckpointFiles(projectPath: string, sessionId: string, turnId: number | undefined, files: string[]): Effect.Effect;
+ previewRollbackDiff(projectPath: string, sessionId: string, throughTurnId: number): Effect.Effect;
+ rollbackCodeToTurn(projectPath: string, sessionId: string, throughTurnId: number): Effect.Effect;
+}
+
+export class CheckpointService extends Context.Tag('Checkpoint')() {}
diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts
index a543660c..76bbe4e2 100644
--- a/packages/codingcode/src/checkpoint/rollback-engine.ts
+++ b/packages/codingcode/src/checkpoint/rollback-engine.ts
@@ -1,10 +1,6 @@
-import { createHash } from 'crypto';
-import { normalizePath } from '../core/path.js';
import type { ShadowGit } from './shadow-git.js';
import type { ProjectLock } from './project-lock.js';
-import type { CodeRollbackResult, CodeRestoreEntry, RestorePlan } from './types.js';
-import { commitMsg } from './utils.js';
-import { readRestoreEntry, writeRestoreEntry } from './undo-store.js';
+import type { CodeRollbackResult, RestorePlan } from './types.js';
export function emptyRollbackResult(turnId: number): CodeRollbackResult {
return {
@@ -12,15 +8,12 @@ export function emptyRollbackResult(turnId: number): CodeRollbackResult {
throughTurnId: turnId,
affectedTurns: [],
selectedFiles: [],
- restoreEntry: null,
};
}
export function executeRollback(
- sessionId: string,
plan: RestorePlan,
selectedFiles: string[],
- action: CodeRestoreEntry['action'],
sg: ShadowGit,
lock: ProjectLock
): CodeRollbackResult {
@@ -30,60 +23,18 @@ export function executeRollback(
throughTurnId: plan.throughTurnId,
affectedTurns: plan.affectedTurns,
selectedFiles: [],
- restoreEntry: null,
};
}
lock.lock();
try {
- let safetyCommit: string;
- const existingEntry = readRestoreEntry(sg.gitDir, sessionId);
-
- if (
- existingEntry &&
- existingEntry.throughTurnId === plan.throughTurnId &&
- existingEntry.safetyCommit
- ) {
- safetyCommit = existingEntry.safetyCommit;
- } else {
- safetyCommit = sg.commit(commitMsg(sessionId, plan.throughTurnId, 'revert-safety'));
- }
-
- const combinedFiles =
- existingEntry && existingEntry.throughTurnId === plan.throughTurnId
- ? [
- ...new Map(
- [...existingEntry.selectedFiles, ...selectedFiles].map((f) => [
- normalizePath(f).toLowerCase(),
- f,
- ])
- ).values(),
- ]
- : selectedFiles;
-
- const entry: CodeRestoreEntry = {
- id: createHash('sha256')
- .update(`${sessionId}-${plan.throughTurnId}-${Date.now()}`)
- .digest('hex')
- .slice(0, 12),
- sessionId,
- action,
- throughTurnId: plan.throughTurnId,
- affectedTurns: plan.affectedTurns,
- selectedFiles: combinedFiles,
- safetyCommit,
- timestamp: new Date().toISOString(),
- };
- writeRestoreEntry(sg.gitDir, sessionId, entry);
-
sg.checkoutFiles(plan.baseline, selectedFiles);
return {
reverted: true,
throughTurnId: plan.throughTurnId,
affectedTurns: plan.affectedTurns,
- selectedFiles: combinedFiles,
- restoreEntry: entry,
+ selectedFiles,
};
} finally {
lock.unlock();
diff --git a/packages/codingcode/src/checkpoint/types.ts b/packages/codingcode/src/checkpoint/types.ts
index 74ccddf6..72935b88 100644
--- a/packages/codingcode/src/checkpoint/types.ts
+++ b/packages/codingcode/src/checkpoint/types.ts
@@ -14,15 +14,6 @@ export interface CodeRollbackResult {
throughTurnId: number;
affectedTurns: number[];
selectedFiles: string[];
- restoreEntry: CodeRestoreEntry | null;
-}
-
-export interface CodeRollbackUndoResult {
- restored: boolean;
- conflict: boolean;
- conflictFiles: string[];
- restoredFiles: string[];
- remainingRolledBack: string[];
}
export interface RollbackPreviewDiff {
@@ -31,29 +22,8 @@ export interface RollbackPreviewDiff {
diff: string;
}
-export interface CodeRestoreEntry {
- id: string;
- sessionId: string;
- action: 'checkpoint-files' | 'rollback-to-turn';
- throughTurnId: number;
- affectedTurns: number[];
- selectedFiles: string[];
- safetyCommit: string;
- timestamp: string;
-}
-
export interface RestorePlan {
throughTurnId: number;
affectedTurns: number[];
baseline: string;
}
-
-export interface RollbackState {
- context: { active: boolean; currentThroughTurnId: number | null };
- code: {
- canUndoLast: boolean;
- lastEntry: CodeRestoreEntry | null;
- revertedFiles: string[];
- lastEntryId: string | null;
- };
-}
diff --git a/packages/codingcode/src/checkpoint/undo-store.ts b/packages/codingcode/src/checkpoint/undo-store.ts
deleted file mode 100644
index c0afd6af..00000000
--- a/packages/codingcode/src/checkpoint/undo-store.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
-import { join } from 'path';
-import type { CodeRestoreEntry } from './types.js';
-import { shortSid } from './utils.js';
-
-function restorePath(gitDir: string, sessionId: string): string {
- return join(gitDir, '..', `last-restore-${shortSid(sessionId)}.json`);
-}
-
-export function readRestoreEntry(gitDir: string, sessionId: string): CodeRestoreEntry | null {
- const path = restorePath(gitDir, sessionId);
- if (!existsSync(path)) return null;
- try {
- return JSON.parse(readFileSync(path, 'utf8')) as CodeRestoreEntry;
- } catch {
- return null;
- }
-}
-
-export function writeRestoreEntry(
- gitDir: string,
- sessionId: string,
- entry: CodeRestoreEntry | null
-): void {
- const path = restorePath(gitDir, sessionId);
- if (!entry) {
- try {
- unlinkSync(path);
- } catch {
- /* ignore */
- }
- } else {
- writeFileSync(path, JSON.stringify(entry, null, 2), 'utf8');
- }
-}
diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts
index b3b8e56a..f3fa0eff 100644
--- a/packages/codingcode/src/cli.ts
+++ b/packages/codingcode/src/cli.ts
@@ -1,13 +1,13 @@
import { Effect } from 'effect';
import { serve } from '@hono/node-server';
-import { LLMFactoryService } from './llm/factory.js';
+import { LLMFactoryService } from './llm/port.js';
import { createServer } from './server/index.js';
import { createAppRuntime } from './layer.js';
import { loadConfig, ensureUserConfig } from '@codingcode/infra/config';
import { WorkspaceService, parseWorkspaceArgs } from './core/workspace.js';
import { findAvailablePort } from './server/port-discovery.js';
import { AgentError } from './core/error.js';
-import { SchedulerService } from './scheduler/service.js';
+import { SchedulerService } from './scheduler/port.js';
async function main() {
const installRoot = process.cwd();
diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts
deleted file mode 100644
index 9f7629b5..00000000
--- a/packages/codingcode/src/client/http.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-import type { AgentClient, StreamChunk } from './types.js';
-import type { McpServerConfig } from '../mcp/types.js';
-import type { UserHookConfig } from '../hooks/types.js';
-import type { PermissionMode } from '../approval/types.js';
-import { parseSseStream } from './sse.js';
-import { createHttpClients } from './http/index.js';
-
-export async function createHttpClient(serverUrl: string): Promise {
- let currentSessionId: string | undefined;
- const clients = createHttpClients(serverUrl);
-
- return {
- async *sendMessage(input: string, cwd?: string): AsyncGenerator {
- const response = await fetch(
- `${serverUrl}/api/sessions/${currentSessionId || '_'}/messages`,
- {
- method: 'POST',
- body: JSON.stringify({ input, cwd: cwd ?? '' }),
- headers: { 'Content-Type': 'application/json' },
- }
- );
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
-
- for await (const data of parseSseStream(response)) {
- switch (data.type) {
- case 'session_id':
- currentSessionId = data.sessionId as string;
- yield { type: 'session_id', sessionId: data.sessionId as string };
- break;
- case 'turn_id':
- yield { type: 'turn_id', turnId: data.turnId as number };
- break;
- case 'text':
- yield {
- type: 'text',
- text: data.text as string,
- messageId: data.messageId as number | undefined,
- };
- break;
- case 'message':
- yield {
- type: 'message',
- id: data.id as number,
- content: data.content as string,
- partial: false,
- };
- break;
- case 'approval_request':
- yield {
- type: 'approval_request',
- id: data.id as string,
- tool: data.tool as string,
- args: data.args as Record,
- };
- break;
- case 'tool_start':
- yield {
- type: 'tool_start',
- id: data.id as string,
- name: data.name as string,
- args: data.args as Record,
- };
- break;
- case 'tool_result':
- yield {
- type: 'tool_result',
- id: data.id as string,
- name: data.name as string,
- output: data.output as string,
- ok: data.ok as boolean,
- };
- break;
- case 'tool_denied':
- yield {
- type: 'tool_denied',
- id: data.id as string,
- name: data.name as string,
- reason: data.reason as string,
- };
- break;
- case 'todo_update':
- yield { type: 'todo_update', items: data.items as any };
- break;
- case 'usage':
- yield {
- type: 'usage',
- prompt: data.prompt as number,
- completion: data.completion as number,
- total: data.total as number,
- };
- break;
- case 'error':
- yield { type: 'error', message: data.message as string, code: data.code as string };
- return;
- case 'done':
- break;
- case 'complete':
- return;
- }
- }
- },
-
- async sendApprovalResponse(id: string, response: string) {
- if (!currentSessionId) return;
- await clients.agent.sendApprovalResponse({
- sessionId: currentSessionId,
- approvalId: id,
- response,
- });
- },
-
- async resumeSession(sid: string) {
- currentSessionId = sid;
- return clients.sessions.resumeSession({ sessionId: sid, cwd: '' });
- },
-
- async listSessions() {
- return clients.sessions.listSessions({ cwd: '' });
- },
-
- async listModels() {
- return clients.models.listModels();
- },
-
- async switchModel(id: string) {
- await clients.models.switchModel({ id });
- },
-
- getSessionId() {
- return currentSessionId ?? 'unknown';
- },
-
- async getCheckpoints() {
- return clients.agent.getCheckpoints();
- },
- async getCheckpointDiff(turnId?: number) {
- return clients.agent.getCheckpointDiff(turnId);
- },
- async revertCheckpointFiles(turnId: number, files: string[]) {
- return clients.agent.revertCheckpointFiles(turnId, files);
- },
- async previewRollbackDiff(throughTurnId: number) {
- return clients.agent.previewRollbackDiff(throughTurnId);
- },
- async rollbackCodeToTurn(throughTurnId: number) {
- return clients.agent.rollbackCodeToTurn(throughTurnId);
- },
- async rollbackContext(throughTurnId: number) {
- const res = await clients.agent.rollbackContext(throughTurnId);
- return {
- turns: (res as any).turns ?? [],
- rollbackState: (res as any).rollbackState ?? { active: false, currentThroughTurnId: null },
- };
- },
- async rollbackBothToTurn(throughTurnId: number) {
- const res = await clients.agent.rollbackBothToTurn(throughTurnId);
- return {
- turns: (res as any).turns ?? [],
- codeResult: (res as any).codeResult ?? {
- reverted: false,
- throughTurnId,
- affectedTurns: [],
- selectedFiles: [],
- restoreEntry: null,
- },
- rollbackState: (res as any).rollbackState ?? { active: false, currentThroughTurnId: null },
- };
- },
- async undoLastCodeRollback(force?: boolean, files?: string[]) {
- return clients.agent.undoLastCodeRollback(force, files);
- },
- async getRollbackState() {
- return clients.agent.getRollbackState();
- },
- async forkSession(atTurnId?: number) {
- return clients.agent.forkSession(atTurnId);
- },
-
- async compact() {
- if (!currentSessionId) return;
- await clients.agent.compact({ sessionId: currentSessionId, cwd: '' });
- },
-
- async getMemoryEnabled() {
- const data = await clients.settings.getMemoryConfig();
- return data.enabled;
- },
-
- async setMemoryEnabled(enabled: boolean) {
- await clients.settings.setMemoryEnabled(enabled);
- },
-
- async getMemoryConfig() {
- return clients.settings.getMemoryConfig();
- },
-
- async setTypeDisabled(name: string, disabled: boolean) {
- await clients.settings.setMemoryTypeDisabled(name, disabled);
- },
-
- async addExtraType(type: { name: string; description: string }) {
- await clients.settings.addMemoryExtraType(type);
- },
-
- async updateExtraType(name: string, type: { name: string; description: string }) {
- await clients.settings.updateMemoryExtraType(name, type);
- },
-
- async deleteExtraType(name: string) {
- await clients.settings.deleteMemoryExtraType(name);
- },
-
- async getMcpStatus({ cwd }: { cwd: string }) {
- return clients.settings.getMcpStatus({ cwd });
- },
-
- async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) {
- await clients.settings.setMcpDisabled(body);
- },
-
- async resetMcpDisabled(body: { name: string; cwd: string }) {
- await clients.settings.resetMcpDisabled(body);
- },
-
- async listSkills() {
- return clients.settings.listSkills();
- },
-
- async createMcpServer(server: McpServerConfig, { cwd }: { cwd: string }) {
- await clients.settings.createMcpServer({ cwd, server });
- },
-
- async updateMcpServer(name: string, server: McpServerConfig, { cwd }: { cwd: string }) {
- await clients.settings.updateMcpServer({ cwd, name, server });
- },
-
- async deleteMcpServer(name: string, { cwd }: { cwd: string }) {
- await clients.settings.deleteMcpServer({ cwd, name });
- },
-
- async listHooks({ cwd }: { cwd: string }) {
- return clients.settings.listHooks({ cwd });
- },
-
- async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }) {
- await clients.settings.setHookDisabled(body);
- },
-
- async resetHookDisabled(body: { name: string; cwd: string }) {
- await clients.settings.resetHookDisabled(body);
- },
-
- async createHook(hook: UserHookConfig, { cwd }: { cwd: string }) {
- await clients.settings.createHook({ cwd, hook });
- },
-
- async updateHook(name: string, hook: UserHookConfig, { cwd }: { cwd: string }) {
- await clients.settings.updateHook({ cwd, name, hook });
- },
-
- async deleteHook(name: string, { cwd }: { cwd: string }) {
- await clients.settings.deleteHook({ cwd, name });
- },
-
- async getPermissionMode(input: { sessionId: string; cwd: string }) {
- return clients.settings.getGlobalPermissionMode(input);
- },
-
- async setPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }) {
- await clients.settings.setGlobalPermissionMode(input);
- },
- };
-}
diff --git a/packages/codingcode/src/client/http/agent-runtime.ts b/packages/codingcode/src/client/http/agent-runtime.ts
index 3c8dfa0b..28fb6df0 100644
--- a/packages/codingcode/src/client/http/agent-runtime.ts
+++ b/packages/codingcode/src/client/http/agent-runtime.ts
@@ -15,7 +15,6 @@ export interface AgentRuntimeClient {
}): Promise;
compact(input: { sessionId: string; cwd: string }): Promise;
- getCheckpoints(): Promise>;
getCheckpointDiff(turnId?: number): Promise;
revertCheckpointFiles(
turnId: number,
@@ -29,18 +28,11 @@ export interface AgentRuntimeClient {
): Promise;
rollbackContext(throughTurnId: number): Promise<{
turns: Array<{ id: string; items: object[]; status: string }>;
- rollbackState: import('../../checkpoint/types.js').RollbackState;
}>;
rollbackBothToTurn(throughTurnId: number): Promise<{
turns: Array<{ id: string; items: object[]; status: string }>;
codeResult: import('../../checkpoint/types.js').CodeRollbackResult;
- rollbackState: import('../../checkpoint/types.js').RollbackState;
}>;
- undoLastCodeRollback(
- force?: boolean,
- files?: string[]
- ): Promise;
- getRollbackState(): Promise;
forkSession(atTurnId?: number): Promise<{
sessionId: string;
turns: Array<{ id: string; items: object[]; status: string }>;
@@ -94,13 +86,6 @@ export function createHttpAgentClient(
args: data.args as Record,
};
break;
- case 'plan_ready':
- yield {
- type: 'plan_ready',
- sessionId: data.sessionId as string,
- title: data.title as string,
- };
- break;
case 'tool_start':
yield {
type: 'tool_start',
@@ -129,6 +114,13 @@ export function createHttpAgentClient(
case 'todo_update':
yield { type: 'todo_update', items: data.items as any };
break;
+ case 'context_compressed':
+ yield {
+ type: 'context_compressed',
+ released: data.released as number,
+ promptEstimate: data.promptEstimate as number,
+ };
+ break;
case 'usage':
yield {
type: 'usage',
@@ -137,13 +129,6 @@ export function createHttpAgentClient(
total: data.total as number,
};
break;
- case 'reactive_compact':
- yield {
- type: 'reactive_compact',
- released: data.released as number,
- promptEstimate: data.promptEstimate as number,
- };
- break;
case 'error':
yield { type: 'error', message: data.message as string, code: data.code as string };
return;
@@ -163,10 +148,6 @@ export function createHttpAgentClient(
await apiPost(`/api/sessions/${sessionId}/compact`, { cwd });
},
- async getCheckpoints() {
- return apiGet('/api/checkpoints');
- },
-
async getCheckpointDiff(turnId?: number) {
const segment = turnId != null ? String(turnId) : 'latest';
return apiGet(`/api/sessions/_/checkpoints/${segment}/diff?cwd=_`);
@@ -195,14 +176,6 @@ export function createHttpAgentClient(
return apiPost(`/api/sessions/_/rollback-both-to-turn?cwd=_`, { throughTurnId });
},
- async undoLastCodeRollback(force?: boolean, files?: string[]) {
- return apiPost(`/api/sessions/_/undo-code-rollback?cwd=_`, { force, files });
- },
-
- async getRollbackState() {
- return apiGet('/api/sessions/_/rollback-state?cwd=_');
- },
-
async forkSession(atTurnId?: number) {
return apiPost('/api/sessions/_/fork?cwd=_', { atTurnId });
},
diff --git a/packages/codingcode/src/client/http/models.ts b/packages/codingcode/src/client/http/models.ts
index 416f5df6..1f97d49e 100644
--- a/packages/codingcode/src/client/http/models.ts
+++ b/packages/codingcode/src/client/http/models.ts
@@ -1,4 +1,4 @@
-import type { SelectableModel } from '../../llm/factory.js';
+import type { SelectableModel } from '../../llm/port.js';
import type { createRequestHelpers } from './request.js';
export interface ModelClient {
diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts
index 99551462..b5854bd4 100644
--- a/packages/codingcode/src/client/http/sessions.ts
+++ b/packages/codingcode/src/client/http/sessions.ts
@@ -2,12 +2,10 @@ import type { PermissionMode } from '../../approval/types.js';
import type {
CheckpointDiff,
CodeRollbackResult,
- CodeRollbackUndoResult,
RollbackPreviewDiff,
- RollbackState,
} from '../../checkpoint/types.js';
import type { SessionEvent, SessionIndex } from '../../session/types.js';
-import type { AgentProfileName } from '../../subagent/types.js';
+import type { AgentProfileName } from '../../agent/profile.js';
import type { createRequestHelpers } from './request.js';
export interface SessionClient {
@@ -67,19 +65,11 @@ export interface SessionClient {
sessionId: string;
cwd: string;
throughTurnId: number;
- }): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>;
+ }): Promise<{ turns: SessionEvent[] }>;
rollbackBothToTurn(input: { sessionId: string; cwd: string; throughTurnId: number }): Promise<{
turns: SessionEvent[];
codeResult: CodeRollbackResult;
- rollbackState: RollbackState;
}>;
- undoLastCodeRollback(input: {
- sessionId: string;
- cwd: string;
- force?: boolean;
- files?: string[];
- }): Promise;
- getRollbackState(input: { sessionId: string; cwd: string }): Promise;
forkSession(input: {
sessionId: string;
cwd: string;
@@ -168,14 +158,6 @@ export function createHttpSessionClient(
return apiPost(`/api/sessions/${sessionId}/rollback-both-to-turn`, { cwd, throughTurnId });
},
- async undoLastCodeRollback({ sessionId, cwd, force, files }) {
- return apiPost(`/api/sessions/${sessionId}/undo-code-rollback`, { cwd, force, files });
- },
-
- async getRollbackState({ sessionId, cwd }) {
- return apiGet(`/api/sessions/${sessionId}/rollback-state?cwd=${encodeURIComponent(cwd)}`);
- },
-
async forkSession({ sessionId, cwd, atTurnId }) {
return apiPost(`/api/sessions/${sessionId}/fork`, { cwd, atTurnId });
},
diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts
index 5768be68..18f512cb 100644
--- a/packages/codingcode/src/client/http/settings.ts
+++ b/packages/codingcode/src/client/http/settings.ts
@@ -7,14 +7,9 @@ export interface SettingsClient {
getMemoryEnabled(): Promise;
getMemoryConfig(): Promise<{
enabled: boolean;
- types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>;
model: string;
}>;
setMemoryEnabled(enabled: boolean): Promise;
- setMemoryTypeDisabled(name: string, disabled: boolean): Promise;
- addMemoryExtraType(type: { name: string; description: string }): Promise;
- updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise;
- deleteMemoryExtraType(name: string): Promise;
setMemoryModel(model: string): Promise<{ model: string }>;
getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>;
setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>;
@@ -74,22 +69,6 @@ export function createHttpSettingsClient(
await apiPost('/api/settings/memory/enabled', { enabled });
},
- async setMemoryTypeDisabled(name, disabled) {
- await apiPost('/api/settings/memory/type-disabled', { name, disabled });
- },
-
- async addMemoryExtraType(type) {
- await apiPost('/api/settings/memory/extra-type', type);
- },
-
- async updateMemoryExtraType(name, type) {
- await apiPut(`/api/settings/memory/extra-type/${encodeURIComponent(name)}`, type);
- },
-
- async deleteMemoryExtraType(name) {
- await apiDelete(`/api/settings/memory/extra-type/${encodeURIComponent(name)}`);
- },
-
async getMcpStatus({ cwd }) {
return apiGet(`/api/settings/mcp${qsCwd(cwd)}`);
},
diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts
index 63756f83..21746c8b 100644
--- a/packages/codingcode/src/client/types.ts
+++ b/packages/codingcode/src/client/types.ts
@@ -1,16 +1,3 @@
-import type { PermissionMode } from '../approval/types.js';
-import type { McpServerConfig, McpStatus } from '../mcp/types.js';
-import type { UserHookConfig } from '../hooks/types.js';
-import type { SessionEvent, SessionIndex } from '../session/types.js';
-import type { SelectableModel } from '../llm/factory.js';
-import type {
- CheckpointDiff,
- CodeRollbackResult,
- CodeRollbackUndoResult,
- RollbackPreviewDiff,
- RollbackState,
-} from '../checkpoint/types.js';
-
export type StreamChunk =
| { type: 'session_id'; sessionId: string }
| { type: 'turn_id'; turnId: number }
@@ -22,68 +9,11 @@ export type StreamChunk =
tool: string;
args: Record;
}
- | { type: 'plan_ready'; sessionId: string; title: string }
| { type: 'tool_start'; id: string; name: string; args: Record }
| { type: 'tool_result'; id: string; name: string; output: string; ok: boolean }
| { type: 'tool_denied'; id: string; name: string; reason: string }
| { type: 'error'; message: string; code: string }
| { type: 'done' }
| { type: 'todo_update'; items: ReadonlyArray<{ step: string; status: string }> }
- | { type: 'usage'; prompt: number; completion: number; total: number }
- | { type: 'reactive_compact'; released: number; promptEstimate: number };
-
-export interface AgentClient {
- sendMessage(input: string, cwd?: string): AsyncGenerator;
- sendApprovalResponse(id: string, response: string): Promise;
- resumeSession(sid: string): Promise;
- listSessions(): Promise;
- listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>;
- switchModel(id: string): Promise;
- getSessionId(): string;
- getCheckpoints(): Promise>;
- getCheckpointDiff(turnId?: number): Promise;
- revertCheckpointFiles(turnId: number, files: string[]): Promise;
- previewRollbackDiff(throughTurnId: number): Promise;
- rollbackCodeToTurn(throughTurnId: number): Promise;
- rollbackContext(
- throughTurnId: number
- ): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>;
- rollbackBothToTurn(throughTurnId: number): Promise<{
- turns: SessionEvent[];
- codeResult: CodeRollbackResult;
- rollbackState: RollbackState;
- }>;
- undoLastCodeRollback(force?: boolean, files?: string[]): Promise;
- getRollbackState(): Promise;
- forkSession(atTurnId?: number): Promise<{
- sessionId: string;
- turns: Array<{ id: string; items: object[]; status: string }>;
- }>;
- compact(): Promise;
- getMemoryEnabled(): Promise;
- setMemoryEnabled(enabled: boolean): Promise;
- getMemoryConfig(): Promise<{
- enabled: boolean;
- types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>;
- model: string;
- }>;
- setTypeDisabled(name: string, disabled: boolean): Promise;
- addExtraType(type: { name: string; description: string }): Promise;
- updateExtraType(name: string, type: { name: string; description: string }): Promise;
- deleteExtraType(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>;
- listHooks(query: { cwd: string }): Promise;
- setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise;
- resetHookDisabled(body: { name: string; cwd: 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(input: { sessionId: string; cwd: string }): Promise;
- setPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise;
-}
+ | { type: 'context_compressed'; released: number; promptEstimate: number }
+ | { type: 'usage'; prompt: number; completion: number; total: number };
diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/context.ts
similarity index 76%
rename from packages/codingcode/src/context/service.ts
rename to packages/codingcode/src/context/context.ts
index da9e6191..d3109637 100644
--- a/packages/codingcode/src/context/service.ts
+++ b/packages/codingcode/src/context/context.ts
@@ -1,13 +1,12 @@
-import { Effect } from 'effect';
+import { Layer, Effect } from 'effect';
import { randomUUID } from 'crypto';
import { readFileSync, existsSync } from 'fs';
import { loadConfig } from '@codingcode/infra/config';
import type { Message } from '../core/types.js';
-import { SessionService } from '../session/store.js';
+import { SessionService } from '../session/port.js';
import { estimateTokens, estimateMessageTokens } from '../core/util.js';
-import { appendLine, readHistory } from '../session/file-ops.js';
import { resolveLLM } from '../llm/llm-resolver.js';
-import { LLMFactoryService } from '../llm/factory.js';
+import { LLMFactoryService } from '../llm/port.js';
import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js';
import type {
SessionEvent,
@@ -17,7 +16,8 @@ import type {
SummaryEvent,
} from '../session/types.js';
import type { LLMClient } from '../llm/client.js';
-import type { BuildResult, CompressResult } from './types.js';
+import { ContextService } from './port.js';
+import type { BuildResult, CompressResult } from './port.js';
const COMPACTABLE_TOOLS = new Set([
'read_file',
@@ -34,6 +34,7 @@ const MICRO_COMPACT_THRESHOLD = 0.25;
const MICRO_COMPACT_MIN_CHARS = 120;
const COMPACTION_THRESHOLD = 0.85;
const KEEP_RECENT_TURNS = 1;
+const MAX_AUTO_COMPACT_PASSES = 3;
// --- Internal: visibility computation for LLM context ---
@@ -179,21 +180,25 @@ export function buildContextMessages(
return filtered;
}
-/** Estimate prompt tokens for a session's jsonl file */
-export function estimatePromptTokens(jsonlPath: string): number {
- const events = readHistory(jsonlPath);
+/** Estimate prompt tokens for a filtered event stream */
+export function estimatePromptTokensFrom(events: SessionEvent[]): number {
const { visible, compactedTurnIds } = filterForContext(events);
return estimateTokens(buildContextMessages(visible, compactedTurnIds));
}
-export class ContextService extends Effect.Service()('Context', {
- effect: Effect.gen(function* () {
+interface PayloadState {
+ jsonlPath: string;
+ currentTurnId: number;
+ visible: SessionEvent[];
+ compactedTurnIds: Set;
+}
+
+export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () {
const session = yield* SessionService;
const factory = yield* LLMFactoryService;
- const assemblePayload = (transcriptPath: string, contextWindow: number): BuildResult => {
- const jsonlPath = transcriptPath;
- let events = session.readHistoryFile(jsonlPath);
+ const readState = (transcriptPath: string): PayloadState => {
+ const jsonlPath = transcriptPath;
let currentTurnId = 0;
const idxPath = transcriptPath.replace('.jsonl', '.index.json');
if (existsSync(idxPath)) {
@@ -202,43 +207,27 @@ export class ContextService extends Effect.Service()('Context',
currentTurnId = idx?.currentTurnId ?? 0;
} catch {}
}
+ const { visible, compactedTurnIds } = filterForContext(session.readEvents(jsonlPath));
+ return { jsonlPath, currentTurnId, visible, compactedTurnIds };
+ };
- let { visible, compactedTurnIds } = filterForContext(events);
-
- const preEstimate = estimateTokens(buildContextMessages(visible, compactedTurnIds));
-
- const didCompact = applyOldTurnCompaction(
- visible,
- currentTurnId,
- preEstimate,
- contextWindow,
- jsonlPath
- );
+ const estimateFor = (s: PayloadState): number =>
+ estimateTokens(buildContextMessages(s.visible, s.compactedTurnIds));
- if (didCompact) {
- events = session.readHistoryFile(jsonlPath);
- ({ visible, compactedTurnIds } = filterForContext(events));
+ // 微压缩:确定性截断旧 turn 的长工具输出;是否需要压缩由本模块内部判断
+ function runMicroCompact(s: PayloadState, contextWindow: number): PayloadState {
+ if (estimateFor(s) <= contextWindow * MICRO_COMPACT_THRESHOLD) return s;
+ if (applyOldTurnCompact(s.visible, s.currentTurnId, s.jsonlPath)) {
+ return readState(s.jsonlPath);
}
+ return s;
+ }
- const messages = buildContextMessages(visible, compactedTurnIds);
- return {
- messages,
- compactedEvents: visible,
- promptEstimate: estimateTokens(messages),
- currentTurnId,
- compactedTurnIds,
- };
- };
-
- function applyOldTurnCompaction(
+ function applyOldTurnCompact(
events: SessionEvent[],
currentTurnId: number,
- promptEstimate: number,
- contextWindow: number,
jsonlPath: string
): boolean {
- if (promptEstimate <= contextWindow * MICRO_COMPACT_THRESHOLD) return false;
-
const compactedTurnIds = new Set();
for (const ev of events) {
if (ev.type === 'compact') {
@@ -270,78 +259,16 @@ export class ContextService extends Effect.Service()('Context',
startTurnId,
endTurnId,
};
- appendLine(jsonlPath, compactEvent);
+ session.appendEvent(jsonlPath, compactEvent);
return true;
}
- const compactIfNeeded = async (
- transcriptPath: string,
- messages: Message[],
- modelMaxTokens: number,
- llm: LLMClient | null
- ): Promise => {
- const promptEstimate = estimateTokens(messages);
- const threshold = modelMaxTokens * COMPACTION_THRESHOLD;
- if (promptEstimate <= threshold) {
- return { didCompress: false, released: 0, promptEstimate };
- }
-
- const result = await compactWithLLM(transcriptPath, modelMaxTokens, llm, promptEstimate);
-
- return result;
- };
-
- const compactWithLLM = async (
- transcriptPath: string,
- modelMaxTokens: number,
- llm: LLMClient | null,
- usage?: number
- ): Promise => {
- let released = 0;
- let preEstimate = usage;
-
- const threshold = modelMaxTokens * COMPACTION_THRESHOLD;
- if (usage === undefined || usage - released > threshold) {
- const { compactedEvents, currentTurnId, compactedTurnIds, promptEstimate } =
- assemblePayload(transcriptPath, modelMaxTokens);
- preEstimate = promptEstimate;
- released += await tryCompaction(
- transcriptPath,
- llm,
- compactedEvents,
- currentTurnId,
- compactedTurnIds
- );
- }
-
- if (released <= 0) {
- return {
- didCompress: false,
- released: 0,
- promptEstimate: preEstimate ?? 0,
- };
- }
-
- const postPayload = assemblePayload(transcriptPath, modelMaxTokens);
- return {
- didCompress: true,
- released,
- promptEstimate: estimateTokens(postPayload.messages),
- messages: postPayload.messages,
- };
- };
-
- async function tryCompaction(
- transcriptPath: string,
- llm: LLMClient | null,
- compactedEvents: SessionEvent[],
- currentTurnId: number,
- compactedTurnIds: Set
- ): Promise {
- const endTurn = currentTurnId - KEEP_RECENT_TURNS - 1;
+ // LLM 摘要压缩(老 turn → summary 事件),失败返回 0 释放量
+ async function tryCompaction(s: PayloadState, llm: LLMClient | null): Promise {
+ const endTurn = s.currentTurnId - KEEP_RECENT_TURNS - 1;
if (endTurn < 1) return 0;
- const inRange = compactedEvents.filter((ev) => {
+ const inRange = s.visible.filter((ev) => {
if (ev.type === 'session_meta') return false;
if ('turnId' in ev && (ev as any).turnId >= 1 && (ev as any).turnId <= endTurn) return true;
return false;
@@ -351,7 +278,7 @@ export class ContextService extends Effect.Service()('Context',
const targetEvents = getIncrementalEvents(inRange);
if (targetEvents.length === 0) return 0;
- const msgs = buildContextMessages(targetEvents, compactedTurnIds);
+ const msgs = buildContextMessages(targetEvents, s.compactedTurnIds);
const totalTokens = estimateTokens(msgs);
let compactionLlm = await Effect.runPromise(
@@ -379,12 +306,63 @@ export class ContextService extends Effect.Service()('Context',
endTurnId,
summaryText: summary,
};
- appendLine(transcriptPath, summaryEvent);
+ session.appendEvent(s.jsonlPath, summaryEvent);
const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary };
return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg));
}
+ // 组装时内部决定是否需要 LLM 压缩;是否压缩、didCompress 均不对外暴露,
+ // 仅把释放量作为状态回报给组装结果
+ async function summarizeToFit(
+ s: PayloadState,
+ contextWindow: number,
+ llm: LLMClient | null
+ ): Promise<{ state: PayloadState; released: number }> {
+ let cur = s;
+ let releasedTotal = 0;
+ for (let i = 0; i < MAX_AUTO_COMPACT_PASSES; i++) {
+ if (estimateFor(cur) <= contextWindow * COMPACTION_THRESHOLD) break;
+ const released = await tryCompaction(cur, llm);
+ if (released <= 0) break;
+ releasedTotal += released;
+ cur = readState(cur.jsonlPath);
+ }
+ return { state: cur, released: releasedTotal };
+ }
+
+ const assemblePayload = async (
+ transcriptPath: string,
+ contextWindow: number,
+ llm: LLMClient | null
+ ): Promise => {
+ let s = readState(transcriptPath);
+ s = runMicroCompact(s, contextWindow);
+ const { state, released } = await summarizeToFit(s, contextWindow, llm);
+ return {
+ messages: buildContextMessages(state.visible, state.compactedTurnIds),
+ compressed: released > 0,
+ released,
+ promptEstimate: estimateFor(state),
+ };
+ };
+
+ const compactWithLLM = async (
+ transcriptPath: string,
+ modelMaxTokens: number,
+ llm: LLMClient | null,
+ usage?: number
+ ): Promise => {
+ let s = runMicroCompact(readState(transcriptPath), modelMaxTokens);
+ const preEstimate = usage ?? estimateFor(s);
+ const released = await tryCompaction(s, llm);
+ if (released <= 0) {
+ return { didCompress: false, released: 0, promptEstimate: preEstimate };
+ }
+ s = readState(transcriptPath);
+ return { didCompress: true, released, promptEstimate: estimateFor(s) };
+ };
+
function getIncrementalEvents(inRange: SessionEvent[]): SessionEvent[] {
const existingSummary = [...inRange]
.reverse()
@@ -439,8 +417,6 @@ export class ContextService extends Effect.Service()('Context',
return {
assemblePayload,
- compactIfNeeded,
compactWithLLM,
};
- }),
-}) {}
+}));
diff --git a/packages/codingcode/src/context/port.ts b/packages/codingcode/src/context/port.ts
new file mode 100644
index 00000000..b1efd27b
--- /dev/null
+++ b/packages/codingcode/src/context/port.ts
@@ -0,0 +1,26 @@
+import { Context } from 'effect';
+import type { Message } from '../core/types.js';
+import type { LLMClient } from '../llm/client.js';
+
+export interface BuildResult {
+ messages: Message[];
+ /** 本次组装是否发生过 LLM 摘要压缩(决策在 context 内部,仅上报结果) */
+ compressed: boolean;
+ /** 摘要压缩释放的 token 数(未压缩为 0) */
+ released: number;
+ /** 组装后上下文估算 token 数 */
+ promptEstimate: number;
+}
+
+export interface CompressResult {
+ didCompress: boolean;
+ released: number;
+ promptEstimate: number;
+}
+
+export interface ContextShape {
+ assemblePayload(transcriptPath: string, contextWindow: number, llm: LLMClient | null): Promise;
+ compactWithLLM(transcriptPath: string, modelMaxTokens: number, llm: LLMClient | null, usage?: number): Promise;
+}
+
+export class ContextService extends Context.Tag('Context')() {}
diff --git a/packages/codingcode/src/context/types.ts b/packages/codingcode/src/context/types.ts
deleted file mode 100644
index 66f0118a..00000000
--- a/packages/codingcode/src/context/types.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { Message } from '../core/types.js';
-import type { SessionEvent } from '../session/types.js';
-
-export interface BuildResult {
- messages: Message[];
- compactedEvents: SessionEvent[];
- promptEstimate: number;
- currentTurnId: number;
- compactedTurnIds: Set;
-}
-
-export interface CompressResult {
- didCompress: boolean;
- released: number;
- promptEstimate: number;
- messages?: Message[];
-}
diff --git a/packages/codingcode/src/direct/agent-runtime.ts b/packages/codingcode/src/direct/agent-runtime.ts
index 578be493..5fbdc811 100644
--- a/packages/codingcode/src/direct/agent-runtime.ts
+++ b/packages/codingcode/src/direct/agent-runtime.ts
@@ -1,13 +1,11 @@
import { Effect } from 'effect';
-import { sendMessage } from '../agent/agent.js';
-import { ApprovalWaitService } from '../approval/async-confirm.js';
+import { AgentService } from '../agent/port.js';
+import { ApprovalWaitService } from '../approval/wait-port.js';
import { parseApprovalResponse } from '../approval/response.js';
-import { ContextService } from '../context/service.js';
-import { HookService } from '../hooks/registry.js';
-import { SessionService } from '../session/store.js';
-import { CheckpointService } from '../checkpoint/checkpoint-service.js';
-import { readUIHistory } from '../session/ui-history.js';
-import { findUserMessageForTurn } from '../session/ui-history.js';
+import { ContextService } from '../context/port.js';
+import { SessionService } from '../session/port.js';
+import { CheckpointService } from '../checkpoint/port.js';
+import { computePaths } from '../core/path.js';
import type { StreamChunk } from '../client/types.js';
import { agentEventToStreamChunk } from '../agent/stream-adapter.js';
import type { AppRuntime } from '../layer.js';
@@ -26,7 +24,6 @@ export interface AgentRuntimeClient {
}): Promise;
compact(input: { sessionId: string; cwd: string }): Promise;
- getCheckpoints(cwd: string): Promise>;
getCheckpointDiff(
cwd: string,
turnId?: number
@@ -35,21 +32,20 @@ export interface AgentRuntimeClient {
cwd: string,
turnId: number,
files: string[]
- ): Promise;
+ ): Promise;
previewRollbackDiff(
cwd: string,
throughTurnId: number
- ): Promise;
+ ): Promise;
rollbackCodeToTurn(
cwd: string,
throughTurnId: number
- ): Promise;
+ ): Promise;
rollbackContext(
cwd: string,
throughTurnId: number
): Promise<{
turns: Array<{ id: string; items: object[]; status: string }>;
- rollbackState: import('../checkpoint/types.js').RollbackState;
}>;
rollbackBothToTurn(
cwd: string,
@@ -57,14 +53,7 @@ export interface AgentRuntimeClient {
): Promise<{
turns: Array<{ id: string; items: object[]; status: string }>;
codeResult: import('../checkpoint/types.js').CodeRollbackResult;
- rollbackState: import('../checkpoint/types.js').RollbackState;
}>;
- undoLastCodeRollback(
- cwd: string,
- force?: boolean,
- files?: string[]
- ): Promise;
- getRollbackState(cwd: string): Promise;
forkSession(
cwd: string,
atTurnId?: number
@@ -79,32 +68,27 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
return {
async *sendMessage(input, { sessionId, cwd }) {
- const opts: Parameters[4] = {};
+ const runOpts: any = { cwd };
if (!sessionId) {
- opts.activeProfile = 'build';
- opts.permissionMode = 'default';
- opts.model = llm.modelInfo.model;
+ runOpts.activeProfile = 'build';
+ runOpts.permissionMode = 'default';
}
- const program = sendMessage(sessionId || undefined, input, cwd, llm, opts);
- const { stream: agentGen, sessionId: resolvedSessionId } = (await rt.runPromise(
- program
- )) as any;
+ const { stream: agentGen, sessionId: resolvedSessionId } = await rt.runPromise(
+ Effect.gen(function* () {
+ const agent = yield* AgentService;
+ return yield* agent.runTurn(input, { sessionId: sessionId || undefined, ...runOpts });
+ })
+ );
currentSessionId = resolvedSessionId;
yield { type: 'session_id', sessionId: resolvedSessionId };
let notifyApproval: ((req: StreamChunk) => void) | null = null;
- let notifyPlan: ((req: StreamChunk) => void) | null = null;
const waitService = await rt.runPromise(
Effect.gen(function* () {
return yield* ApprovalWaitService;
})
);
- const hookService = await rt.runPromise(
- Effect.gen(function* () {
- return yield* HookService;
- })
- );
Effect.runSync(
waitService.registerEmitter(
resolvedSessionId,
@@ -113,20 +97,6 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
}
)
);
- const unregisterPlanReady = Effect.runSync(
- hookService.register('plan.ready', (payload) => {
- const p = payload as {
- sessionId?: string;
- title?: string;
- };
- if (p.sessionId !== resolvedSessionId) return;
- notifyPlan?.({
- type: 'plan_ready',
- sessionId: p.sessionId ?? '',
- title: p.title ?? '',
- });
- })
- );
try {
const gen = agentEventToStreamChunk(agentGen);
@@ -134,13 +104,9 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
let currentApprovalPromise = new Promise((resolve) => {
notifyApproval = resolve;
});
- let currentPlanPromise = new Promise((resolve) => {
- notifyPlan = resolve;
- });
while (true) {
const approvalPromise = currentApprovalPromise;
- const planPromise = currentPlanPromise;
const winner = await Promise.race([
pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({
tag: 'chunk',
@@ -150,10 +116,6 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
tag: 'approval',
value: req,
})),
- planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({
- tag: 'plan',
- value: req,
- })),
]);
if (winner.tag === 'chunk') {
@@ -162,24 +124,15 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
currentApprovalPromise = new Promise((resolve) => {
notifyApproval = resolve;
});
- currentPlanPromise = new Promise((resolve) => {
- notifyPlan = resolve;
- });
pending = gen.next();
- } else if (winner.tag === 'approval') {
+ } else {
yield winner.value;
currentApprovalPromise = new Promise((resolve) => {
notifyApproval = resolve;
});
- } else {
- yield winner.value;
- currentPlanPromise = new Promise((resolve) => {
- notifyPlan = resolve;
- });
}
}
} finally {
- unregisterPlanReady();
Effect.runSync(waitService.unregisterEmitter(resolvedSessionId));
}
},
@@ -201,21 +154,12 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
const context = yield* ContextService;
const state = yield* session.load(cwd, sessionId);
return yield* Effect.promise(() =>
- context.compactWithLLM(session.getTranscriptPath(state), llm.modelInfo.maxTokens, null)
+ context.compactWithLLM(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, llm.modelInfo.maxTokens, null)
);
})
);
},
- async getCheckpoints(cwd: string) {
- return rt.runPromise(
- Effect.gen(function* () {
- const checkpoint = yield* CheckpointService;
- return yield* checkpoint.getCheckpoints(cwd, currentSessionId);
- })
- );
- },
-
async getCheckpointDiff(cwd: string, turnId?: number) {
return rt.runPromise(
Effect.gen(function* () {
@@ -258,22 +202,13 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
const session = yield* SessionService;
const state = yield* session.load(cwd, currentSessionId);
yield* session.rollbackToTurn(state, throughTurnId, 'user rollback');
- const turns = readUIHistory(currentSessionId, cwd);
- const rollbackState: import('../checkpoint/types.js').RollbackState = {
- context: { active: true, currentThroughTurnId: throughTurnId },
- code: {
- canUndoLast: false,
- lastEntry: null,
- revertedFiles: [],
- lastEntryId: null,
- },
- };
- return { turns, rollbackState };
+ const turns = yield* session.readUITurns(currentSessionId, cwd);
+ return { turns };
})
);
},
- async rollbackBothToTurn(cwd: string, throughTurnId: number) {
+ async rollbackBothToTurn(cwd: string, throughTurnId: number): Promise {
return rt.runPromise(
Effect.gen(function* () {
const session = yield* SessionService;
@@ -285,47 +220,8 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
throughTurnId
);
yield* session.rollbackToTurn(state, throughTurnId, 'user rollback');
- const turns = readUIHistory(currentSessionId, cwd);
- const rollbackState: import('../checkpoint/types.js').RollbackState = {
- context: { active: true, currentThroughTurnId: throughTurnId },
- code: {
- canUndoLast: false,
- lastEntry: null,
- revertedFiles: [],
- lastEntryId: null,
- },
- };
- return { turns, codeResult, rollbackState };
- })
- );
- },
-
- async undoLastCodeRollback(cwd: string, force?: boolean, files?: string[]) {
- return rt.runPromise(
- Effect.gen(function* () {
- const checkpoint = yield* CheckpointService;
- return yield* checkpoint.undoLastCodeRollback(cwd, currentSessionId, {
- force,
- files,
- });
- })
- );
- },
-
- async getRollbackState(cwd: string) {
- return rt.runPromise(
- Effect.gen(function* () {
- const checkpoint = yield* CheckpointService;
- const entry = yield* checkpoint.getLatestRestoreEntry(cwd, currentSessionId);
- return {
- context: { active: false, currentThroughTurnId: null },
- code: {
- canUndoLast: entry !== null,
- lastEntry: entry,
- revertedFiles: entry?.selectedFiles ?? [],
- lastEntryId: entry?.id ?? null,
- },
- };
+ const turns = yield* session.readUITurns(currentSessionId, cwd);
+ return { turns, codeResult };
})
);
},
@@ -336,7 +232,7 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu
const session = yield* SessionService;
const state = yield* session.load(cwd, currentSessionId);
const newSessionId = yield* session.forkSession(state, atTurnId ?? 0);
- const turns = readUIHistory(newSessionId, cwd);
+ const turns = yield* session.readUITurns(newSessionId, cwd);
return { sessionId: newSessionId, turns };
})
);
diff --git a/packages/codingcode/src/direct/models.ts b/packages/codingcode/src/direct/models.ts
index 57942dc1..b3e30c1d 100644
--- a/packages/codingcode/src/direct/models.ts
+++ b/packages/codingcode/src/direct/models.ts
@@ -1,6 +1,6 @@
import { Effect } from 'effect';
-import { LLMFactoryService } from '../llm/factory.js';
-import type { SelectableModel } from '../llm/factory.js';
+import { LLMFactoryService } from '../llm/port.js';
+import type { SelectableModel } from '../llm/port.js';
import type { AppRuntime } from '../layer.js';
export interface ModelClient {
diff --git a/packages/codingcode/src/direct/sessions.ts b/packages/codingcode/src/direct/sessions.ts
index 6b3d191b..c4c74287 100644
--- a/packages/codingcode/src/direct/sessions.ts
+++ b/packages/codingcode/src/direct/sessions.ts
@@ -1,19 +1,16 @@
import { Effect } from 'effect';
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join } from 'path';
-import { SessionService } from '../session/store.js';
-import { deleteSession } from '../session/file-ops.js';
+import { SessionService } from '../session/port.js';
import { encodeProjectPath, getProjectBaseDir } from '../core/path.js';
import type { PermissionMode } from '../approval/types.js';
import type {
CheckpointDiff,
CodeRollbackResult,
- CodeRollbackUndoResult,
RollbackPreviewDiff,
- RollbackState,
} from '../checkpoint/types.js';
import type { SessionEvent, SessionIndex } from '../session/types.js';
-import type { AgentProfileName } from '../subagent/types.js';
+import type { AgentProfileName } from '../agent/profile.js';
import type { AppRuntime } from '../layer.js';
export interface SessionClient {
@@ -74,19 +71,11 @@ export interface SessionClient {
sessionId: string;
cwd: string;
throughTurnId: number;
- }): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>;
+ }): Promise<{ turns: SessionEvent[] }>;
rollbackBothToTurn(input: { sessionId: string; cwd: string; throughTurnId: number }): Promise<{
turns: SessionEvent[];
codeResult: CodeRollbackResult;
- rollbackState: RollbackState;
}>;
- undoLastCodeRollback(input: {
- sessionId: string;
- cwd: string;
- force?: boolean;
- files?: string[];
- }): Promise;
- getRollbackState(input: { sessionId: string; cwd: string }): Promise;
forkSession(input: {
sessionId: string;
cwd: string;
@@ -140,7 +129,12 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient {
},
async deleteSession({ sessionId, cwd }) {
- deleteSession(sessionId, cwd);
+ await rt.runPromise(
+ Effect.gen(function* () {
+ const session = yield* SessionService;
+ yield* session.deleteSession(sessionId, cwd);
+ })
+ );
},
async getSessionProfile({ sessionId, cwd }) {
@@ -177,7 +171,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient {
Effect.gen(function* () {
const session = yield* SessionService;
const state = yield* session.load(cwd, sessionId);
- return yield* session.getPermissionMode(state);
+ return state.permissionMode;
})
);
return mode as PermissionMode;
@@ -187,8 +181,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient {
return rt.runPromise(
Effect.gen(function* () {
const session = yield* SessionService;
- const state = yield* session.load(cwd, sessionId);
- yield* session.setPermissionMode(state, mode);
+ yield* session.setPermissionMode(cwd, sessionId, mode);
})
);
},
@@ -223,7 +216,6 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient {
throughTurnId: 0,
affectedTurns: [],
selectedFiles: [],
- restoreEntry: null,
};
},
async previewRollbackDiff() {
@@ -235,21 +227,11 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient {
throughTurnId: 0,
affectedTurns: [],
selectedFiles: [],
- restoreEntry: null,
};
},
async rollbackContext() {
return {
turns: [] as SessionEvent[],
- rollbackState: {
- context: { active: false, currentThroughTurnId: null },
- code: {
- canUndoLast: false,
- lastEntry: null,
- revertedFiles: [] as string[],
- lastEntryId: null,
- },
- } as RollbackState,
};
},
async rollbackBothToTurn() {
@@ -260,32 +242,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient {
throughTurnId: 0,
affectedTurns: [],
selectedFiles: [],
- restoreEntry: null,
},
- rollbackState: {
- context: { active: false, currentThroughTurnId: null },
- code: {
- canUndoLast: false,
- lastEntry: null,
- revertedFiles: [] as string[],
- lastEntryId: null,
- },
- } as RollbackState,
- };
- },
- async undoLastCodeRollback() {
- return {
- restored: false,
- conflict: false,
- conflictFiles: [],
- restoredFiles: [],
- remainingRolledBack: [],
- };
- },
- async getRollbackState() {
- return {
- context: { active: false, currentThroughTurnId: null },
- code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null },
};
},
async forkSession({ sessionId, cwd, atTurnId }) {
diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts
index f9b282c3..567bc98e 100644
--- a/packages/codingcode/src/direct/settings.ts
+++ b/packages/codingcode/src/direct/settings.ts
@@ -1,7 +1,7 @@
import { Effect } from 'effect';
-import { McpService } from '../mcp/index.js';
+import { McpService } from '../mcp/port.js';
import type { McpServerConfig, McpStatus } from '../mcp/types.js';
-import { SkillService } from '../skills/service.js';
+import { SkillService } from '../skills/port.js';
import type { PermissionMode } from '../approval/types.js';
import type { UserHookConfig } from '../hooks/types.js';
import { isGlobalCwd } from '../core/workspace.js';
@@ -26,15 +26,8 @@ import {
resetProjectHookDisabledState,
} from '../hooks/config.js';
import { setHookRuntimeEnabled } from '../hooks/executor.js';
-import {
- getMemoryConfig,
- getAllTypesWithStatus,
- setMemoryTypeDisabled,
- addMemoryExtraType as _addMemoryExtraType,
- updateMemoryExtraType as _updateMemoryExtraType,
- deleteMemoryExtraType as _deleteMemoryExtraType,
-} from '../memory/config.js';
-import { MemoryService } from '../memory/index.js';
+import { getMemoryConfig } from '../memory/config.js';
+import { MemoryService } from '../memory/port.js';
import { AlreadyExistsError, NotFoundError } from '../core/error.js';
import {
loadConfig,
@@ -42,20 +35,15 @@ import {
updateContextCompactionModel,
} from '@codingcode/infra/config';
import type { AppRuntime } from '../layer.js';
-import { SessionService } from '../session/store.js';
+import { SessionService } from '../session/port.js';
export interface SettingsClient {
getMemoryEnabled(): Promise;
getMemoryConfig(): Promise<{
enabled: boolean;
- types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>;
model: string;
}>;
setMemoryEnabled(enabled: boolean): Promise;
- setMemoryTypeDisabled(name: string, disabled: boolean): Promise;
- addMemoryExtraType(type: { name: string; description: string }): Promise;
- updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise;
- deleteMemoryExtraType(name: string): Promise;
setMemoryModel(model: string): Promise<{ model: string }>;
getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>;
setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>;
@@ -238,7 +226,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient {
async getMemoryConfig() {
const cfg = getMemoryConfig();
- return { enabled: cfg.enabled, types: getAllTypesWithStatus(cfg), model: cfg.model };
+ return { enabled: cfg.enabled, model: cfg.model };
},
async setMemoryEnabled(enabled) {
@@ -265,26 +253,6 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient {
return { compactionModel };
},
- async setMemoryTypeDisabled(name, disabled) {
- setMemoryTypeDisabled(name, disabled);
- },
-
- async addMemoryExtraType(type) {
- _addMemoryExtraType({ name: type.name, description: type.description, enabled: true });
- },
-
- async updateMemoryExtraType(name, type) {
- _updateMemoryExtraType(name, {
- name: type.name,
- description: type.description,
- enabled: true,
- });
- },
-
- async deleteMemoryExtraType(name) {
- _deleteMemoryExtraType(name);
- },
-
async getMcpStatus({ cwd }) {
const projectCwd = isGlobalCwd(cwd) ? process.cwd() : cwd;
const runtime = await rt.runPromise(
@@ -426,7 +394,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient {
Effect.gen(function* () {
const session = yield* SessionService;
const state = yield* session.load(input.cwd, input.sessionId);
- return yield* session.getPermissionMode(state);
+ return state.permissionMode;
})
);
},
@@ -439,8 +407,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient {
await rt.runPromise(
Effect.gen(function* () {
const session = yield* SessionService;
- const state = yield* session.load(input.cwd, input.sessionId);
- yield* session.setPermissionMode(state, input.mode);
+ yield* session.setPermissionMode(input.cwd, input.sessionId, input.mode);
})
);
},
diff --git a/packages/codingcode/src/hooks/registry.ts b/packages/codingcode/src/hooks/hooks.ts
similarity index 76%
rename from packages/codingcode/src/hooks/registry.ts
rename to packages/codingcode/src/hooks/hooks.ts
index 3e63e123..c4a1703a 100644
--- a/packages/codingcode/src/hooks/registry.ts
+++ b/packages/codingcode/src/hooks/hooks.ts
@@ -1,4 +1,4 @@
-import { Effect } from 'effect';
+import { Layer, Effect } from 'effect';
import { resolveHookConfigs, resolveHookDisabled } from './config.js';
import {
executeHookCommand,
@@ -6,6 +6,7 @@ import {
isHookRuntimeEnabled,
} from './executor.js';
import { createLogger } from '@codingcode/infra/logger';
+import { HookService } from './port.js';
import type {
HookPoint,
HookDecision,
@@ -19,8 +20,7 @@ import type {
const logger = createLogger();
-export class HookService extends Effect.Service()('HookService', {
- effect: Effect.gen(function* () {
+export const HookLayer = Layer.effect(HookService, Effect.gen(function* () {
let entryCounter = 0;
const globalHooks = new Map();
const hooksByProject = new Map>();
@@ -223,78 +223,10 @@ export class HookService extends Effect.Service()('HookService', {
hooksByProject.set(projectPath, projectMap);
}),
- attachSessionHooks: (
- sessionId: string,
- hooks: {
- name: string;
- point: HookPoint;
- type: 'observer' | 'decision';
- command: string;
- args?: string[];
- priority?: number;
- }[]
- ): Effect.Effect =>
- Effect.sync(() => {
- const sessionMap = new Map();
- for (const hc of hooks) {
- const observerHandler: ObserverHandler = (payload) =>
- Effect.tryPromise({
- try: () =>
- executeHookCommand({ command: hc.command, args: hc.args, env: {} }, payload),
- catch: (e) => logger.error(`session hook ${hc.name} error:`, e),
- }).pipe(Effect.ignore);
- const decisionHandler: DecisionHandler = (payload) =>
- Effect.tryPromise({
- try: () =>
- executeDecisionHookCommand(
- { command: hc.command, args: hc.args, env: {} },
- payload
- ),
- catch: (e) => {
- logger.error(`session decision hook ${hc.name} error:`, e);
- return null;
- },
- }) as unknown as Promise;
- const entry: HandlerEntry = {
- id: `session-${hc.name}-${++entryCounter}`,
- handler: hc.type === 'observer' ? observerHandler : decisionHandler,
- priority: hc.priority ?? 0,
- source: 'user',
- type: hc.type,
- };
- const set = sessionMap.get(hc.point) ?? [];
- set.push(entry);
- sessionMap.set(hc.point, set);
- }
- hooksBySession.set(sessionId, sessionMap);
- }),
-
- disableHook: (projectPath: string, name: string): Effect.Effect =>
- Effect.sync(() => {
- let set = disabledHooksByProject.get(projectPath);
- if (!set) {
- set = new Set();
- disabledHooksByProject.set(projectPath, set);
- }
- set.add(name);
- }),
-
- enableHook: (projectPath: string, name: string): Effect.Effect =>
- Effect.sync(() => {
- disabledHooksByProject.get(projectPath)?.delete(name);
- }),
-
disposeSession: (sessionId: string): Effect.Effect =>
Effect.sync(() => {
hooksBySession.delete(sessionId);
disabledHooksBySession.delete(sessionId);
}),
-
- disposeProject: (projectPath: string): Effect.Effect =>
- Effect.sync(() => {
- hooksByProject.delete(projectPath);
- disabledHooksByProject.delete(projectPath);
- }),
};
- }),
-}) {}
+}));
diff --git a/packages/codingcode/src/hooks/port.ts b/packages/codingcode/src/hooks/port.ts
new file mode 100644
index 00000000..f3efa197
--- /dev/null
+++ b/packages/codingcode/src/hooks/port.ts
@@ -0,0 +1,14 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { HookPoint, HookDecision, ObserverHandler, DecisionHandler } from './types.js';
+
+export interface HookShape {
+ register(point: HookPoint, handler: ObserverHandler, opts?: { source?: 'system' | 'user' }): Effect.Effect<() => void>;
+ registerDecision(point: HookPoint, handler: DecisionHandler, opts?: { priority?: number; source?: 'system' | 'user' }): Effect.Effect<() => void>;
+ emit(point: HookPoint, payload: Record): Effect.Effect;
+ emitDecision(point: HookPoint, payload: Record): Effect.Effect;
+ reloadUserHooks(projectPath: string): Effect.Effect;
+ disposeSession(sessionId: string): Effect.Effect;
+}
+
+export class HookService extends Context.Tag('HookService')() {}
diff --git a/packages/codingcode/src/hooks/types.ts b/packages/codingcode/src/hooks/types.ts
index 34193d77..981d94c0 100644
--- a/packages/codingcode/src/hooks/types.ts
+++ b/packages/codingcode/src/hooks/types.ts
@@ -18,8 +18,7 @@ export type HookPoint =
| 'agent.turn.end'
| 'agent.subagent.spawn.before'
| 'agent.subagent.spawn.after'
- | 'agent.subagent.complete'
- | 'plan.ready';
+ | 'agent.subagent.complete';
export interface HookDecision {
decision?: 'allow' | 'deny' | 'ask' | 'continue';
diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts
index 818fa603..6d011a06 100644
--- a/packages/codingcode/src/layer.ts
+++ b/packages/codingcode/src/layer.ts
@@ -1,115 +1,166 @@
import { Context, Layer, Effect, ManagedRuntime } from 'effect';
-import { AgentService } from './agent/agent.js';
-import { SessionService } from './session/store.js';
-import { HookService } from './hooks/registry.js';
-import { McpService } from './mcp/index.js';
-import { SkillService } from './skills/service.js';
-import { ApprovalService } from './approval/index.js';
-import { ApprovalWaitService } from './approval/async-confirm.js';
-import { ToolExecutorService } from './tools/executor.js';
-import { CheckpointService } from './checkpoint/checkpoint-service.js';
-import { ProjectRuntimeService } from './runtime/project-runtime.js';
-import { LLMFactoryService } from './llm/factory.js';
+import { HookLayer } from './hooks/hooks.js';
+import { RulesLayer } from './rules/rules.js';
+import { SkillLayer } from './skills/skills.js';
+import { LlmLayer } from './llm/llm.js';
+import { McpLayer } from './mcp/mcp.js';
+import { CheckpointLayer } from './checkpoint/checkpoint.js';
+import { ApprovalLayer } from './approval/approval.js';
+import { ApprovalWaitLayer } from './approval/wait.js';
+import { TodoLayer } from './todo/todo.js';
+import { SessionLayer } from './session/session.js';
+import { ToolExecutorLayer } from './tools/tools.js';
+import { ContextLayer } from './context/context.js';
+import { MemoryLayer } from './memory/memory.js';
+import { AgentLayer } from './agent/agent.js';
+import { ToolEnvLayer } from './agent/tool-env.js';
+import { ToolCatalogLayer } from './agent/tool-catalog.js';
+import { SubagentRunnerLayer } from './subagent/subagent.js';
+import { SchedulerLayer } from './scheduler/scheduler.js';
import { WorkspaceService } from './core/workspace.js';
-import { TodoService } from './agent/todo.js';
-import { SubagentRunnerService } from './subagent/runner-service.js';
-import { RulesService } from './rules/index.js';
-import { MemoryService } from './memory/index.js';
-import { ContextService } from './context/service.js';
-import { SchedulerService } from './scheduler/service.js';
import { planProfileGateHook } from './agent/profile.js';
-export const WorkspaceLayer = WorkspaceService.Default;
-export const TodoLayer = TodoService.Default;
-export const RulesLayer = RulesService.Default;
-export const SessionLayer = SessionService.Default;
-export const LLMFactoryLayer = LLMFactoryService.Default.pipe(Layer.provide(WorkspaceLayer));
-export const MemoryLayer = MemoryService.Default.pipe(Layer.provide(LLMFactoryLayer));
-export const ContextLayer = ContextService.Default.pipe(
- Layer.provide(Layer.mergeAll(SessionLayer, LLMFactoryLayer))
-);
-export const HookLayer = HookService.Default;
-export const SkillLayer = SkillService.Default;
-export const CheckpointLayer = CheckpointService.Default;
-export const ApprovalWaitLayer = ApprovalWaitService.Default;
-export const McpLayer = McpService.Default;
-export const SchedulerLayer = SchedulerService.Default;
-export const ProjectRuntimeLayer = ProjectRuntimeService.Default.pipe(
- Layer.provide(Layer.mergeAll(HookLayer, McpLayer, RulesLayer, SessionLayer))
+import { HookService } from './hooks/port.js';
+import { RulesService } from './rules/port.js';
+import { SkillService } from './skills/port.js';
+import { LLMFactoryService } from './llm/port.js';
+import { McpService } from './mcp/port.js';
+import { CheckpointService } from './checkpoint/port.js';
+import { ApprovalService } from './approval/port.js';
+import { ApprovalWaitService } from './approval/wait-port.js';
+import { TodoService } from './todo/port.js';
+import { SessionService } from './session/port.js';
+import { ToolExecutorService } from './tools/port.js';
+import { ContextService } from './context/port.js';
+import { MemoryService } from './memory/port.js';
+
+import {
+ SessionPort, ToolExecutorPort, CheckpointPort, HookPort,
+ ApprovalPort, SkillPort, McpPort, ContextPort, MemoryPort,
+ LlmPort, RulesPort, TodoPort,
+} from './agent/deps.js';
+
+// adapter layers: map full services to agent's narrow ports
+const AgentSessionAdapter = Layer.effect(SessionPort, Effect.gen(function* () {
+ const s = yield* SessionService;
+ return {
+ load: s.load.bind(s), create: s.create.bind(s),
+ recordUser: s.recordUser.bind(s), recordSystem: s.recordSystem.bind(s), recordAssistant: s.recordAssistant.bind(s),
+ recordToolResult: s.recordToolResult.bind(s),
+ setPermissionMode: s.setPermissionMode.bind(s),
+ setActiveProfile: s.setActiveProfile.bind(s),
+ };
+}));
+
+const AgentToolExecutorAdapter = Layer.effect(ToolExecutorPort, Effect.gen(function* () {
+ const e = yield* ToolExecutorService;
+ return { executeBatch: e.executeBatch.bind(e) };
+}));
+
+const AgentCheckpointAdapter = Layer.effect(CheckpointPort, Effect.gen(function* () {
+ const c = yield* CheckpointService;
+ return { snapshotBaseline: c.snapshotBaseline.bind(c), snapshotFinal: c.snapshotFinal.bind(c) };
+}));
+
+const AgentHookAdapter = Layer.effect(HookPort, Effect.gen(function* () {
+ const h = yield* HookService;
+ return { emit: h.emit.bind(h), emitDecision: h.emitDecision.bind(h), disposeSession: h.disposeSession.bind(h) };
+}));
+
+const AgentApprovalAdapter = Layer.effect(ApprovalPort, Effect.gen(function* () {
+ const a = yield* ApprovalService;
+ return { evaluate: a.evaluate.bind(a) };
+}));
+
+const AgentSkillAdapter = Layer.effect(SkillPort, Effect.gen(function* () {
+ const s = yield* SkillService;
+ return { extractSkill: s.extractSkill.bind(s) };
+}));
+
+const AgentMcpAdapter = Layer.effect(McpPort, Effect.gen(function* () {
+ const m = yield* McpService;
+ return { listProjectMcpTools: m.listProjectMcpTools.bind(m), syncConnections: m.syncConnections.bind(m) };
+}));
+
+const AgentContextAdapter = Layer.effect(ContextPort, Effect.gen(function* () {
+ const c = yield* ContextService;
+ return {
+ assemblePayload: c.assemblePayload.bind(c),
+ };
+}));
+
+const AgentMemoryAdapter = Layer.effect(MemoryPort, Effect.gen(function* () {
+ const m = yield* MemoryService;
+ return { loadMemoryForPrompt: m.loadMemoryForPrompt.bind(m), flushSessionToMemory: m.flushSessionToMemory.bind(m) };
+}));
+
+const AgentLlmAdapter = Layer.effect(LlmPort, Effect.gen(function* () {
+ const f = yield* LLMFactoryService;
+ return { getLLMClient: f.getLLMClient.bind(f) };
+}));
+
+const AgentRulesAdapter = Layer.effect(RulesPort, Effect.gen(function* () {
+ const r = yield* RulesService;
+ return { getAllRules: r.getAllRules.bind(r), evictProjectRules: r.evictProjectRules.bind(r) };
+}));
+
+const AgentTodoAdapter = Layer.effect(TodoPort, Effect.gen(function* () {
+ const t = yield* TodoService;
+ return { read: t.read.bind(t) };
+}));
+
+const AgentDepsAdapter = Layer.mergeAll(
+ AgentSessionAdapter, AgentToolExecutorAdapter, AgentCheckpointAdapter,
+ AgentHookAdapter, AgentApprovalAdapter, AgentSkillAdapter, AgentMcpAdapter,
+ AgentContextAdapter, AgentMemoryAdapter, AgentLlmAdapter, AgentRulesAdapter,
+ AgentTodoAdapter,
);
-export const ApprovalLayer = ApprovalService.Default.pipe(
- Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer))
+
+// base layers
+const InfraLayer = Layer.mergeAll(
+ WorkspaceService.Default, HookLayer, RulesLayer, SkillLayer, McpLayer, ApprovalWaitLayer, TodoLayer,
);
-export const SystemHookLayer = HookLayer.pipe(
+const LlmWithDeps = LlmLayer.pipe(Layer.provide(WorkspaceService.Default));
+const ApprovalWithDeps = ApprovalLayer.pipe(Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)));
+const ToolExecutorWithDeps = ToolExecutorLayer.pipe(Layer.provide(Layer.mergeAll(HookLayer, ApprovalLayer)));
+const ContextWithDeps = ContextLayer.pipe(Layer.provide(Layer.mergeAll(SessionLayer, LlmWithDeps)));
+const MemoryWithDeps = MemoryLayer.pipe(Layer.provide(LlmWithDeps));
+
+// system hook registration
+const SystemHookLayer = HookLayer.pipe(
Layer.tap((context) =>
Effect.gen(function* () {
const hooks = Context.get(context, HookService);
yield* hooks.registerDecision('tool.approval.pre', planProfileGateHook, {
- priority: -1000,
- source: 'system',
+ priority: -1000, source: 'system',
});
})
)
);
-/** ToolExecutor depends on HookLayer + ApprovalLayer. */
-const ExecutorDeps = Layer.mergeAll(HookLayer, ApprovalLayer);
-const ExecutorLayer = ToolExecutorService.Default.pipe(Layer.provide(ExecutorDeps));
-
-/** Agent depends on ToolExecutor + HookLayer + ApprovalLayer + ApprovalWaitLayer + Session + Checkpoint + ProjectRuntime + Skill + LLMFactory + Todo + Rules + Context + Memory. */
-const AgentDeps = Layer.mergeAll(
- ExecutorLayer,
- ApprovalLayer,
- ApprovalWaitLayer,
- SessionLayer,
- CheckpointLayer,
- McpLayer,
- SkillLayer,
- LLMFactoryLayer,
- HookLayer,
- ProjectRuntimeLayer,
- TodoLayer,
- RulesLayer,
- ContextLayer,
- MemoryLayer
+// agent with deps
+const AgentWithDeps = AgentLayer.pipe(
+ Layer.provide(Layer.mergeAll(AgentDepsAdapter, ToolEnvLayer, ToolCatalogLayer, InfraLayer, SessionLayer, ToolExecutorWithDeps, ApprovalWithDeps, ContextWithDeps, MemoryWithDeps, CheckpointLayer))
);
-const AgentWithDeps = AgentService.Default.pipe(Layer.provide(AgentDeps));
-
-/** SubagentRunnerService delegates to AgentService.runStream. */
-const SubagentRunnerLayer = Layer.effect(
- SubagentRunnerService,
- Effect.gen(function* () {
- const agent = yield* AgentService;
- return SubagentRunnerService.make({ runStream: agent.runStream });
- })
-).pipe(Layer.provide(AgentWithDeps));
-
-/** Final application layer — all services merged. */
+
+// subagent runner (depends on agent)
+const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps));
+
export const AppLayer = Layer.mergeAll(
- AgentWithDeps,
- SubagentRunnerLayer,
- ExecutorLayer,
+ InfraLayer,
+ LlmWithDeps,
+ ApprovalWithDeps,
SessionLayer,
- HookLayer,
- McpLayer,
- SkillLayer,
- ApprovalLayer,
- ApprovalWaitLayer,
+ ToolExecutorWithDeps,
+ ContextWithDeps,
+ MemoryWithDeps,
CheckpointLayer,
- ProjectRuntimeLayer,
- LLMFactoryLayer,
- WorkspaceLayer,
- TodoLayer,
- RulesLayer,
- MemoryLayer,
- ContextLayer,
+ AgentWithDeps,
+ SubagentWithDeps,
SchedulerLayer,
- SystemHookLayer
+ SystemHookLayer,
);
-/** Create the application ManagedRuntime from AppLayer. */
export const createAppRuntime = () => ManagedRuntime.make(AppLayer as any);
-
-/** Concrete runtime type for the application. */
export type AppRuntime = ManagedRuntime.ManagedRuntime;
diff --git a/packages/codingcode/src/llm/llm-resolver.ts b/packages/codingcode/src/llm/llm-resolver.ts
index 21a09d0a..35481ff7 100644
--- a/packages/codingcode/src/llm/llm-resolver.ts
+++ b/packages/codingcode/src/llm/llm-resolver.ts
@@ -1,6 +1,6 @@
import { Effect } from 'effect';
import { AgentError } from '../core/error.js';
-import { LLMFactoryService } from './factory.js';
+import { LLMFactoryService } from './port.js';
import type { LLMClient } from './client.js';
export function resolveLLM(
diff --git a/packages/codingcode/src/llm/factory.ts b/packages/codingcode/src/llm/llm.ts
similarity index 98%
rename from packages/codingcode/src/llm/factory.ts
rename to packages/codingcode/src/llm/llm.ts
index 2c94c899..abe8431e 100644
--- a/packages/codingcode/src/llm/factory.ts
+++ b/packages/codingcode/src/llm/llm.ts
@@ -1,12 +1,13 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
-import { Effect } from 'effect';
+import { Layer, Effect } from 'effect';
import { AgentError } from '../core/error.js';
import { WorkspaceService } from '../core/workspace.js';
import type { LLMClient } from './client.js';
import { OpenAIProvider } from './providers/openai.js';
import { DeepSeekProvider } from './providers/deepseek.js';
import { updateActiveModel } from '@codingcode/infra/config';
+import { LLMFactoryService } from './port.js';
export interface ModelDescriptor {
id: string;
@@ -57,8 +58,7 @@ function flattenModels(cat: ProviderCatalog): SelectableModel[] {
return result;
}
-export class LLMFactoryService extends Effect.Service()('LLMFactory', {
- effect: Effect.gen(function* () {
+export const LlmLayer = Layer.effect(LLMFactoryService, Effect.gen(function* () {
const workspace = yield* WorkspaceService;
let catalog: ProviderCatalog | null = null;
let currentEntry: SelectableModel | null = null;
@@ -276,5 +276,4 @@ export class LLMFactoryService extends Effect.Service()('LLMF
return currentClient;
}),
};
- }),
-}) {}
+}));
diff --git a/packages/codingcode/src/llm/port.ts b/packages/codingcode/src/llm/port.ts
new file mode 100644
index 00000000..e17ce6ca
--- /dev/null
+++ b/packages/codingcode/src/llm/port.ts
@@ -0,0 +1,19 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { AgentError } from '../core/error.js';
+import type { LLMClient } from './client.js';
+
+export interface ModelDescriptor { id: string; name: string; context_window?: number }
+export interface ProviderEntry { name: string; driver: string; base_url: string; api_key_env: string; default_model: string; models: ModelDescriptor[] }
+export interface SelectableModel { id: string; provider: string; driver: string; name: string; model: string; base_url: string; api_key_env: string; context_window: number }
+
+export interface LLMFactoryShape {
+ listModels(): Effect.Effect;
+ findModel(target: string): Effect.Effect;
+ getActiveEntry(): Effect.Effect;
+ switchModel(id: string): Effect.Effect;
+ createClient(entry: SelectableModel): Effect.Effect;
+ getLLMClient(): Effect.Effect;
+}
+
+export class LLMFactoryService extends Context.Tag('LLMFactory')() {}
diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts
index 97ec05c7..c16692c1 100644
--- a/packages/codingcode/src/llm/providers/deepseek.ts
+++ b/packages/codingcode/src/llm/providers/deepseek.ts
@@ -5,7 +5,7 @@ import { AgentError } from '../../core/error.js';
import { mapLlmError } from '../errors.js';
import type { LLMClient } from '../client.js';
import type { LLMRequest, LLMResponse } from '../types.js';
-import type { SelectableModel } from '../factory.js';
+import type { SelectableModel } from '../port.js';
import { convertMessages, convertTools, parseResponseMessages } from './shared.js';
export class DeepSeekProvider implements LLMClient {
diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts
index b3479a1c..0cdec077 100644
--- a/packages/codingcode/src/llm/providers/openai.ts
+++ b/packages/codingcode/src/llm/providers/openai.ts
@@ -5,7 +5,7 @@ import { AgentError } from '../../core/error.js';
import { mapLlmError } from '../errors.js';
import type { LLMClient } from '../client.js';
import type { LLMRequest, LLMResponse } from '../types.js';
-import type { SelectableModel } from '../factory.js';
+import type { SelectableModel } from '../port.js';
import { convertMessages, convertTools, parseResponseMessages } from './shared.js';
export class OpenAIProvider implements LLMClient {
diff --git a/packages/codingcode/src/mcp/index.ts b/packages/codingcode/src/mcp/mcp.ts
similarity index 98%
rename from packages/codingcode/src/mcp/index.ts
rename to packages/codingcode/src/mcp/mcp.ts
index 8ea59e54..4a74bffd 100644
--- a/packages/codingcode/src/mcp/index.ts
+++ b/packages/codingcode/src/mcp/mcp.ts
@@ -1,7 +1,8 @@
-import { Effect } from 'effect';
+import { Effect, Layer } from 'effect';
import { z } from 'zod';
import { resolveMcpConfig, resolveMcpDisabled } from './config.js';
import { McpClient } from './client.js';
+import { McpService } from './port.js';
import type { McpServerConfig, McpStatus } from './types.js';
import type { ToolDefinition } from '../tools/types.js';
import { createLogger } from '@codingcode/infra/logger';
@@ -30,8 +31,7 @@ interface LeaseEntry {
type ProjectPath = string;
type ServerName = string;
-export class McpService extends Effect.Service()('Mcp', {
- effect: Effect.sync(() => {
+export const McpLayer = Layer.effect(McpService, Effect.sync(() => {
const clientsByProject = new Map>();
const leasesBySession = new Map>();
const disabledMcpByProject = new Map>();
@@ -318,8 +318,8 @@ export class McpService extends Effect.Service()('Mcp', {
configCache.delete(projectPath);
}),
};
- }),
-}) {}
+ }
+));
function namespacedName(serverName: string, toolName: string): string {
return `${serverName}:${toolName}`;
diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts
new file mode 100644
index 00000000..6caac1b7
--- /dev/null
+++ b/packages/codingcode/src/mcp/port.ts
@@ -0,0 +1,19 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { ToolDefinition } from '../tools/types.js';
+import type { McpStatus } from './types.js';
+
+export interface McpShape {
+ syncConnections(projectPath: string): Effect.Effect;
+ connectServers(projectPath: string, sessionId: string, names: string[]): Effect.Effect;
+ disconnectServers(projectPath: string, sessionId: string, names: string[]): Effect.Effect