diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 4295ff20..7f018376 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -26,6 +26,7 @@ jobs:
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
+ - 'pnpm-workspace.yaml'
- 'tsconfig*.json'
- 'vitest.config.ts'
- 'eslint.config.mjs'
@@ -37,12 +38,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v3
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.3.0
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- - run: pnpm install
+ - run: pnpm install --frozen-lockfile
- run: pnpm run lint
typecheck:
@@ -51,12 +54,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v3
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.3.0
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- - run: pnpm install
+ - run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
test:
@@ -65,12 +70,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v3
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.3.0
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- - run: pnpm install
+ - run: pnpm install --frozen-lockfile
- run: pnpm vitest run
build-desktop:
@@ -79,10 +86,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v3
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.3.0
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- - run: pnpm install
+ - run: pnpm install --frozen-lockfile
- run: pnpm --filter @codingcode/desktop run build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a967c9ef..299054da 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,6 +27,8 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
+ with:
+ version: 11.3.0
- name: Setup Node.js
uses: actions/setup-node@v4
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 491a887c..00000000
--- a/.gitignore
+++ /dev/null
@@ -1,53 +0,0 @@
-node_modules/
-dist/
-packages/desktop/out/
-.env
-.env.example
-*.lockb
-.DS_Store
-.claude/
-.codingcode/
-docs-hidden/
-typecheck-output.txt
-neoneo-v2
-cb_test.json
-err_test.json
-lose_match.json
-ndk2.json
-.gitignore
-ndk3.json
-replay_full.json
-test/tank-v2.test.ts
-.gitignore
-
-# TypeScript build cache
-*.tsbuildinfo
-# electron-vite config cache
-packages/desktop/electron.vite.config.*.mjs
-# Vite cache
-.vite/
-
-# Test output artifacts from shell redirection
-test-output*.txt
-client-tests.txt
-desktop-test.txt
-h-test.txt
-test-fallback.txt
-
-# Compiled JS artifacts leaked into src directories
-packages/infra/src/*.js
-packages/infra/src/*.d.ts
-
-# TypeScript dist-test build artifacts
-dist-test/
-
-# Desktop sub-package residual workspace files
-packages/desktop/pnpm-lock.yaml
-packages/desktop/pnpm-workspace.yaml
-
-# Defensive: ignore any stray test temp dirs that may slip into the workspace.
-.test-*
-# OS / editor cruft
-Thumbs.db
-.idea/
-.vscode/
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/package.json b/package.json
index daa5b99a..265005ac 100644
--- a/package.json
+++ b/package.json
@@ -55,10 +55,5 @@
"typescript-eslint": "^8.60.1",
"vite": "^6.4.2",
"vitest": "^3.0.0"
- },
- "pnpm": {
- "overrides": {
- "vite": "^6.4.2"
- }
}
}
diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json
index 6ad3221d..61c3045d 100644
--- a/packages/codingcode/package.json
+++ b/packages/codingcode/package.json
@@ -8,44 +8,22 @@
},
"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/types": "./src/client/types.ts",
- "./client/http": "./src/client/http.ts",
- "./client/http-clients": "./src/client/http/index.ts",
+ "./client": "./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/frame": "./src/core/frame.ts",
+ "./core/error": "./src/core/error.ts",
+ "./core/types": "./src/core/types.ts",
+ "./checkpoint/types": "./src/checkpoint/types.ts",
+ "./session/port": "./src/session/port.ts",
+ "./mcp/types": "./src/mcp/types.ts",
+ "./hooks/types": "./src/hooks/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..906ded72 100644
--- a/packages/codingcode/src/agent/agent.ts
+++ b/packages/codingcode/src/agent/agent.ts
@@ -1,246 +1,189 @@
-import { Effect, Queue, Stream, Fiber } from 'effect';
-import type { Message } from '../core/types.js';
+import { Effect, Either, 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 { FrameBody, FrameError, ResponseMeta, Transition, ToolOutcome } from '../core/frame.js';
+import { isTurnEnd } from '../core/frame.js';
+import type { ToolCall } from '../core/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);
+function toFrameError(e: AgentError): FrameError {
+ return { message: e.message, code: e.code };
+}
- 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 profileName = opts.activeProfile ?? state.activeProfile;
+ const effectivePerm = opts.permissionMode ?? state.permissionMode;
+ if (opts.permissionMode) {
+ yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode);
}
- 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;
+ if (opts.activeProfile) {
+ yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile);
+ }
+
+ state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd);
- const profile = runtime.resolveMainAgentProfile(normalizedCwd, state.sessionId);
- const policy = runtime.getToolPolicy(profile);
+ const profile: AgentProfile | undefined = profileName ? resolveProfile(profileName) : undefined;
- const dispatchTool = yield* createDispatchAgentTool();
+ // get MCP tools
+ const mcpTools = mcp.listProjectMcpTools(normalizedCwd);
- const activeLlm = llm;
- const effectiveMaxSteps = profile?.maxSteps;
- const effectiveApproval: any = options?.approvalOverride;
+ const catalog = toolCatalog.register(getToolNames(profile), mcpTools);
- const mcpTools = mcp.listProjectMcpTools(normalizedCwd);
+ const toolEnv = yield* toolEnvPort.getToolEnv();
- const turnId = session.incrementTurn(state);
- const [, actualInput] = yield* skills.extractSkill(state.cwd, input);
+ // record user (increments turn) + extract skill
+ const [, actualInput] = yield* skills.extractSkill(state.cwd, input);
+ const userEvent = yield* session.recordUser(state, actualInput);
- yield* session.recordUser(state, actualInput);
+ // checkpoint baseline
+ yield* checkpoint.snapshotBaseline(state.cwd, sessionId, userEvent.turnId);
- yield* checkpoint.snapshotBaseline(state.cwd, sid, turnId);
+ // get rules text
+ const rulesText = rules.getAllRules(state.cwd);
- 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.takeUntil((body: FrameBody) => isTurnEnd(body))
+ );
+ for await (const body of Stream.toAsyncIterable(stream) as AsyncIterable) {
+ yield body;
+ }
+ })();
+ }
+
+ 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;
+
+ let ended = false;
+ const offerEnd = (transition: Extract) =>
+ Effect.sync(() => {
+ if (ended) return;
+ ended = true;
+ Effect.runSync(q.offer({ family: 'transition', transition }));
+ });
+
+ return Effect.gen(function* () {
+ const basePrompt = buildSystemPrompt({
cwd: projectPath,
platform: process.platform,
shell: process.env.SHELL || process.env.ComSpec || 'bash',
@@ -248,318 +191,195 @@ 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* q.offer({ _tag: 'TurnId', turnId: state.currentTurnId });
+ yield* hooks.emit('agent.turn.start', { sessionId: sid });
+ yield* q.offer({ family: 'transition', transition: { to: 'start', turnId: state.currentTurnId } });
- for (let step = 0; step < effectiveMaxSteps; step++) {
- yield* q.offer({ _tag: 'Step', step: step + 1, max: effectiveMaxSteps });
+ for (let step = 0; step < maxSteps; step++) {
+ yield* hooks.emitDecision('agent.step.before', { sessionId: sid, step: step + 1 });
- const allowedByPolicy = opts.toolPolicy?.allowedTools;
- const tools = registry.describe(allowedByPolicy);
- const toolLookup: ToolLookup = (name: string) => registry.get(name, allowedByPolicy);
- const systemWithCatalog = system;
+ if (step === 0) {
+ yield* q.offer({ family: 'transition', transition: { to: 'executing' } });
+ }
- const stepBeforePayload = { sessionId, step: step + 1 };
- yield* hooks.emitDecision('agent.step.before', stepBeforePayload);
+ const transcriptPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath;
- const compressResult = yield* Effect.tryPromise({
- try: () =>
- context.compactIfNeeded(
- session.getTranscriptPath(state),
- messages,
- llm.modelInfo.maxTokens,
- llm
- ),
+ const willCompact = yield* Effect.either(Effect.tryPromise({
+ try: () => context.willCompact(transcriptPath, llm.modelInfo.maxTokens),
catch: (e) => new AgentError('LLM_FAILED', String(e)),
- });
- if (compressResult.didCompress && compressResult.messages) {
- yield* q.offer({
- _tag: 'ReactiveCompact',
- attempt: 1,
- released: compressResult.released,
- promptEstimate: compressResult.promptEstimate,
- });
+ }));
+ if (Either.isLeft(willCompact)) {
+ yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(willCompact.left) });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' });
+ return Result.err(willCompact.left);
+ }
+ if (willCompact.right) {
+ yield* q.offer({ family: 'transition', transition: { to: 'compress' } });
+ }
- messages = compressResult.messages;
- state.usage = undefined;
+ const assembled = yield* Effect.either(Effect.tryPromise({
+ try: () => context.assemblePayload(transcriptPath, llm.modelInfo.maxTokens, llm),
+ catch: (e) => new AgentError('LLM_FAILED', String(e)),
+ }));
+ if (Either.isLeft(assembled)) {
+ yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(assembled.left) });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' });
+ return Result.err(assembled.left);
+ }
+ if (willCompact.right) {
+ yield* q.offer({ family: 'transition', transition: { to: 'executing' } });
}
- const llmMessages = [...messages];
+ const llmMessages = [...assembled.right];
- const { stream: rawStream, response: respPromise } = llm.completeStream(
- {
- messages: llmMessages,
- system: systemWithCatalog,
- tools,
- maxSteps: 1,
- },
- opts.abortSignal
- );
+ let content = '';
+ const toolCalls: ToolCall[] = [];
+ let responded: ResponseMeta = {};
- yield* Effect.tryPromise({
+ const streamed = yield* Effect.either(Effect.tryPromise({
try: async () => {
- for await (const chunk of rawStream) {
- if (opts.abortSignal?.aborted) break;
- Effect.runSync(q.offer({ _tag: 'LlmChunk', text: chunk }));
+ for await (const part of llm.completeStream({ messages: llmMessages, system, tools, maxSteps: 1 }, abortSignal)) {
+ if (abortSignal?.aborted) break;
+ if (part.type === 'text') {
+ content += part.text;
+ Effect.runSync(q.offer({ family: 'event', event: { type: 'text_delta', text: part.text } }));
+ } else if (part.type === 'tool_call') {
+ toolCalls.push({ id: part.id, name: part.name, arguments: part.args });
+ Effect.runSync(q.offer({
+ family: 'event',
+ event: { type: 'tool_call', id: part.id, name: part.name, args: part.args },
+ }));
+ } else {
+ responded = part.usage ? { usage: part.usage } : {};
+ Effect.runSync(q.offer({
+ family: 'transition',
+ transition: { to: 'executing', responded },
+ }));
+ }
}
},
- catch: (e) => new AgentError('LLM_FAILED', String(e)),
- });
-
- const llmResult = 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',
- });
- break;
+ catch: (e) => (e instanceof AgentError ? e : new AgentError('LLM_FAILED', String(e))),
+ }));
+ if (Either.isLeft(streamed)) {
+ yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(streamed.left) });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' });
+ return Result.err(streamed.left);
}
- 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,
- });
- }
-
- 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,
- });
+ if (toolCalls.length === 0) {
+ yield* session.recordAssistant(state, content, [], responded.usage);
+ const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId: sid, 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')
- );
+ const loopErr = new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded');
+ yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(loopErr) });
+ 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(loopErr);
}
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* offerEnd({ to: 'end', reason: 'done' });
+ lastResult = Result.ok(content);
+ 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 ?? {},
- });
+ yield* session.recordAssistant(state, content, toolCalls, responded.usage);
+
+ const approvedCalls: any[] = [];
+ const deniedResults: any[] = [];
+ for (const tc of toolCalls) {
+ const decision = yield* approval.evaluate({
+ tool: tc.name,
+ input: tc.arguments ?? {},
+ callId: tc.id,
+ sessionId: state.sessionId,
+ projectPath,
+ permissionMode,
+ profile: profile?.name,
+ });
+ if (decision.type === 'deny') {
+ deniedResults.push({ type: 'denied', id: tc.id, name: tc.name, reason: decision.reason });
+ } else {
+ approvedCalls.push(tc);
}
}
- const record = 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,
- });
+ const approvedResults = approvedCalls.length > 0
+ ? yield* executor.executeBatch(approvedCalls, state.sessionId, {
+ turnId: state.currentTurnId, projectPath, signal: abortSignal, toolLookup,
+ })
+ : [];
+
+ const allResults = [...approvedResults, ...deniedResults];
let todoPrinted = false;
for (const r of allResults) {
const resultOut = r.type === 'denied' ? '' : r.output;
yield* session.recordToolResult(state, r.name, r.id, resultOut);
- 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 });
- }
- if (!todoPrinted && r.name === 'todo_write') {
- yield* q.offer({ _tag: 'TodoUpdate', items: todo.read(sessionId) });
- 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 ?? '');
+ const outcome: ToolOutcome = r.type === 'denied'
+ ? { status: 'denied', reason: r.reason }
+ : r.type === 'ok'
+ ? { status: 'ok', output: resultOut }
+ : { status: 'error', output: resultOut };
+ const todos = !todoPrinted && r.type === 'ok' && r.name === 'todo_write'
+ ? todo.read(sid)
+ : undefined;
+ if (todos) todoPrinted = true;
+ yield* q.offer({
+ family: 'event',
+ event: {
+ type: 'tool_result', id: r.id, name: r.name, outcome,
+ ...(todos ? { todos } : {}),
+ },
+ });
}
}
- 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));
- })
- )
- );
-}
+ const maxErr = AgentError.maxStepsReached(maxSteps);
+ yield* offerEnd({ to: 'end', reason: 'maxSteps' });
+ yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'maxSteps' });
+ return Result.err(maxErr);
+ }).pipe(
+ Effect.interruptible,
+ Effect.onInterrupt(() =>
+ Effect.gen(function* () {
+ yield* offerEnd({ to: 'end', reason: 'aborted' });
+ yield* hooks.emit('agent.turn.end', { sessionId: opts.sid, turnId: opts.state.currentTurnId, status: 'aborted' }).pipe(Effect.ignore);
+ })
+ ),
+ Effect.ensuring(
+ Effect.gen(function* () {
+ yield* offerEnd({
+ to: 'end',
+ reason: 'error',
+ error: { message: 'agent terminated without end frame', code: 'AGENT_TERMINATED' },
+ });
+ 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..2da55f6b
--- /dev/null
+++ b/packages/codingcode/src/agent/deps.ts
@@ -0,0 +1,100 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { AgentError } from '../core/error.js';
+import type { ToolCall, Message, ToolDescription, TodoItem } from '../core/types.js';
+import type { AssistantEvent, ToolResultEvent, TokenUsage, UserEvent } from '../session/types.js';
+import type { SessionStoreState } from '../session/types.js';
+import type { HookDecision } from '../hooks/types.js';
+import type { Skill } from '../skills/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 { ProfileName } from '../core/types.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: ProfileName; 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: ToolCall[], 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: ProfileName): 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; profile?: ProfileName }): Effect.Effect;
+}>() {}
+
+export class SkillPort extends Context.Tag('AgentSkillPort');
+}>() {}
+
+export class McpPort extends Context.Tag('AgentMcpPort');
+}>() {}
+
+export class ContextPort extends Context.Tag('AgentContextPort');
+ assemblePayload(
+ transcriptPath: string,
+ contextWindow: number,
+ llm: LLMClient | null
+ ): Promise;
+}>() {}
+
+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')() {}
+
+export interface ToolEnv {
+ provide(effect: Effect.Effect): Effect.Effect;
+}
+
+export class ToolEnvPort extends Context.Tag('AgentToolEnvPort');
+}>() {}
+
+export interface ToolCatalog {
+ tools: ToolDescription[];
+ lookup: ToolLookup;
+}
+
+export class ToolCatalogPort extends Context.Tag('AgentToolCatalogPort')() {}
diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts
new file mode 100644
index 00000000..a7fe3ebb
--- /dev/null
+++ b/packages/codingcode/src/agent/port.ts
@@ -0,0 +1,26 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { FrameBody } from '../core/frame.js';
+import type { ProfileName } from '../core/types.js';
+import type { PermissionMode } from '../approval/types.js';
+
+export interface RunTurnOptions {
+ sessionId?: string;
+ cwd: string;
+ signal?: AbortSignal;
+ permissionMode?: PermissionMode;
+ model?: string;
+ activeProfile?: ProfileName;
+}
+
+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..50d6adf4 100644
--- a/packages/codingcode/src/agent/profile.ts
+++ b/packages/codingcode/src/agent/profile.ts
@@ -1,16 +1,113 @@
-import { readFileSync } from 'fs';
-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';
+import type { ProfileName } from '../core/types.js';
+
+export interface AgentProfile {
+ name: ProfileName;
+ systemPrompt?: string;
+}
+
+import { PLAN_ALLOWED_TOOLS } from '../approval/types.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,42 +115,45 @@ export const BUILD_PROFILE: AgentProfile = {
systemPrompt: BUILD_PROMPT,
};
-export function isPlanProfile(p: { name: string } | null | undefined): boolean {
- return p?.name === PLAN_PROFILE_NAME;
-}
+// 各 profile 的工具名字名单:agent 只把这份名单交给工具模块注册,工具模块按名查表装配。
+// build 含写工具、不含 submit_plan;plan 相反(只读 + submit_plan),名单即审批层白名单。
+export const PLAN_TOOL_NAMES: readonly string[] = [...PLAN_ALLOWED_TOOLS];
-export const PLAN_PROFILE_ALLOWED_TOOLS: ReadonlySet = new Set([
+export const BUILD_TOOL_NAMES: readonly string[] = [
'read_file',
- 'search_files',
+ 'write_file',
+ 'edit_file',
+ 'execute_command',
'search_code',
+ 'search_files',
'fetch_url',
- 'submit_plan',
-]);
-
-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;
- }
+ 'web_search',
+ 'todo_write',
+ 'dispatch_agent',
+];
+
+// 运行时审批兜底(plan 模式 deny 非名单工具),从名单派生
+export function isPlanProfile(p: { name: string } | null | undefined): boolean {
+ return p?.name === PLAN_PROFILE_NAME;
}
-export const planProfileGateHook: DecisionHandler = (payload) => {
- const sessionId = payload.sessionId as string | undefined;
- const projectPath = payload.projectPath as string | undefined;
- if (!sessionId || !projectPath) return null;
- if (!isSessionUsingPlanProfile(sessionId, projectPath)) return null;
+export function isAgentProfileName(name: string): name is ProfileName {
+ return name === PLAN_PROFILE_NAME || name === BUILD_PROFILE_NAME;
+}
- const toolName = payload.toolName as string | undefined;
- if (!toolName) return null;
- if (PLAN_PROFILE_ALLOWED_TOOLS.has(toolName)) return null;
+export function resolveProfile(name: ProfileName): AgentProfile {
+ return name === PLAN_PROFILE_NAME ? PLAN_PROFILE : BUILD_PROFILE;
+}
- return {
- decision: 'deny',
- reason: 'Write operations denied in plan profile. Use submit_plan to submit a plan.',
- };
-};
+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 const AVAILABLE_PROFILES: Array<{ name: ProfileName; description: string }> = [
+ { name: PLAN_PROFILE_NAME, description: 'Planning agent' },
+ { name: BUILD_PROFILE_NAME, description: 'Build agent' },
+];
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
deleted file mode 100644
index c9fd1036..00000000
--- a/packages/codingcode/src/agent/stream-adapter.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import type { AgentEvent } from './types.js';
-import type { StreamChunk } from '../client/types.js';
-
-export async function* agentEventToStreamChunk(
- source: AsyncGenerator
-): AsyncGenerator {
- let currentStep = 0;
- for await (const event of source) {
- switch (event._tag) {
- case 'Step':
- currentStep = event.step;
- break;
- case 'TurnId':
- yield { type: 'turn_id', turnId: event.turnId };
- break;
- case 'LlmChunk':
- yield { type: 'text', text: event.text, messageId: currentStep };
- break;
- case 'Assistant':
- yield { type: 'message', id: currentStep, content: event.content, partial: false };
- break;
- case 'ToolStart':
- yield { type: 'tool_start', id: event.id, name: event.name, args: event.args };
- break;
- case 'ToolResult':
- yield {
- type: 'tool_result',
- id: event.id,
- name: event.name,
- output: event.output,
- ok: event.ok,
- };
- break;
- case 'ToolDenied':
- yield { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason };
- break;
- case 'Error':
- yield {
- type: 'error',
- message: event.error.message ?? String(event.error),
- code: event.error.code,
- };
- break;
- case 'Done':
- yield { type: 'done' };
- break;
- case 'TodoUpdate':
- yield { type: 'todo_update', items: event.items as any };
- break;
- case 'Usage':
- yield {
- type: 'usage',
- prompt: event.prompt,
- completion: event.completion,
- 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..dbaeede1
--- /dev/null
+++ b/packages/codingcode/src/agent/tool-catalog.ts
@@ -0,0 +1,11 @@
+import { Layer } from 'effect';
+import { ToolCatalogPort } from './deps.js';
+import type { ToolCatalog } from './deps.js';
+import { createToolCatalog } from '../tools/catalog.js';
+
+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..974f8a8b
--- /dev/null
+++ b/packages/codingcode/src/agent/tool-env.ts
@@ -0,0 +1,30 @@
+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';
+
+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
deleted file mode 100644
index 4fb3cfce..00000000
--- a/packages/codingcode/src/agent/types.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-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 }
- | { readonly _tag: 'Assistant'; readonly content: string; readonly toolCalls?: ToolCall[] }
- | {
- readonly _tag: 'ToolStart';
- readonly id: string;
- readonly name: string;
- readonly args: Record;
- }
- | {
- readonly _tag: 'ToolDenied';
- readonly id: string;
- readonly name: string;
- readonly reason: string;
- }
- | {
- readonly _tag: 'ToolResult';
- readonly id: string;
- readonly name: string;
- readonly output: string;
- 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 }
- | {
- readonly _tag: 'TodoUpdate';
- readonly items: ReadonlyArray<{
- readonly step: string;
- readonly status: 'pending' | 'in_progress' | 'completed';
- }>;
- }
- | { readonly _tag: 'TurnId'; readonly turnId: 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/pipeline.ts b/packages/codingcode/src/approval/approval.ts
similarity index 65%
rename from packages/codingcode/src/approval/pipeline.ts
rename to packages/codingcode/src/approval/approval.ts
index d15a0921..f9851522 100644
--- a/packages/codingcode/src/approval/pipeline.ts
+++ b/packages/codingcode/src/approval/approval.ts
@@ -1,44 +1,93 @@
-import { Effect } from 'effect';
+import { Layer, Effect } from 'effect';
+import { HookService } from '../hooks/port.js';
import type { ApprovalDecision, PermissionMode, PermissionRule, ToolCallRequest } from './types.js';
-import type { RuleEngine } from './rule-engine.js';
+import type { ProfileName } from '../core/types.js';
+import { PLAN_ALLOWED_TOOLS } from './types.js';
+import { createRuleEngine, 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 { ApprovalService } from './port.js';
-export interface PipelineOptions {
+const DANGEROUS_TOOL_NAMES = ['execute_command'];
+
+const LAYER_NAMES = [
+ 'RuleEngine',
+ 'PermissionMode',
+ 'HookPreToolUse',
+ 'UserConfirmation',
+ 'AuditLog',
+] as const;
+
+interface PipelineOptions {
ruleEngine: RuleEngine;
- readonlyTools: Set;
destructiveTools: Set;
permissionMode: PermissionMode;
- /** Called when user selects Always — allows caller to persist the rule. */
+ profile?: ProfileName;
onAlways?: (rule: PermissionRule) => void;
- /** Called when user selects Never — allows caller to persist the rule. */
onNever?: (rule: PermissionRule) => void;
- /** Session ID for session-scoped approval routing. */
sessionId: string;
- /** Project path for session-scoped approval routing (used by decision hooks
- * that need to inspect the session's runtime state). */
projectPath?: string;
- /** Optional LLM ToolCall ID to use as approval request ID. */
callId?: string;
}
-const LAYER_NAMES = [
- 'RuleEngine',
- 'ReadonlyWhitelist',
- 'PermissionMode',
- 'HookPreToolUse',
- 'UserConfirmation',
- 'AuditLog',
-] as const;
+function applyPermissionMode(
+ tool: string,
+ mode: PermissionMode,
+ profile: ProfileName | undefined,
+ destructiveTools: Set
+): ApprovalDecision | null {
+ if (profile === 'plan') {
+ if (PLAN_ALLOWED_TOOLS.has(tool)) {
+ return { type: 'allow', source: 'permission-mode' };
+ }
+ return {
+ type: 'deny',
+ reason: 'Write operations denied in plan profile. Use submit_plan to submit a plan.',
+ source: 'permission-mode',
+ };
+ }
+
+ switch (mode) {
+ case 'bypass':
+ return { type: 'allow', source: 'permission-mode' };
+
+ case 'acceptEdits':
+ if (!destructiveTools.has(tool)) {
+ return { type: 'allow', source: 'permission-mode' };
+ }
+ return null;
+
+ case 'default':
+ default:
+ return null;
+ }
+}
+
+function recordAuditAndReturn(
+ hooks: any,
+ request: ToolCallRequest,
+ decision: ApprovalDecision,
+ passedLayers: string[]
+): any {
+ return Effect.gen(function* () {
+ passedLayers.push(LAYER_NAMES[4]);
+ yield* hooks.emit('tool.approval.post', {
+ tool: request.tool,
+ input: request.input,
+ decision,
+ layers: passedLayers,
+ });
+ return decision;
+ });
+}
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 +101,22 @@ 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
{
const modeResult = applyPermissionMode(
request.tool,
opts.permissionMode,
- opts.readonlyTools,
+ opts.profile,
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 +131,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',
@@ -110,7 +146,6 @@ export function runPipeline(
const final = yield* recordAuditAndReturn(hooks, request, result, layers);
return final;
}
- // 'ask' or no decision → continue to user confirmation
const nextRequest: ToolCallRequest = { ...request };
if (hookResult.modifiedInput) {
nextRequest.input = hookResult.modifiedInput;
@@ -119,18 +154,9 @@ export function runPipeline(
}
}
- // Layer 5: User Confirmation
+ // Layer 4: User Confirmation
{
- layers.push(LAYER_NAMES[4]);
-
- if (request.tool === 'submit_plan') {
- const result: ApprovalDecision = {
- type: 'allow',
- source: 'system-plan-self-handles',
- };
- const final = yield* recordAuditAndReturn(hooks, request, result, layers);
- return final;
- }
+ layers.push(LAYER_NAMES[3]);
if (!asyncConfirm) {
const result: ApprovalDecision = {
@@ -173,44 +199,44 @@ export function runPipeline(
});
}
-function applyPermissionMode(
- tool: string,
- mode: PermissionMode,
- readonlyTools: Set,
- destructiveTools: Set
-): ApprovalDecision | null {
- switch (mode) {
- case 'bypass':
- // Bypass mode: everything allowed (sandbox still restricts at OS level)
- return { type: 'allow', source: 'permission-mode' };
-
- case 'acceptEdits':
- // Accept edits: read-only + edit tools auto-allow, destructive tools need confirmation
- if (!destructiveTools.has(tool)) {
- return { type: 'allow', source: 'permission-mode' };
- }
- return null; // Continue to next layers
-
- case 'default':
- default:
- return null; // Continue to next layers
- }
-}
-
-function recordAuditAndReturn(
- hooks: HookService,
- request: ToolCallRequest,
- decision: ApprovalDecision,
- passedLayers: string[]
-): Effect.Effect {
- return Effect.gen(function* () {
- passedLayers.push(LAYER_NAMES[5]);
- yield* hooks.emit('tool.approval.post', {
- tool: request.tool,
- input: request.input,
- decision,
- layers: passedLayers,
- });
- return decision;
- });
-}
+export const ApprovalLayer = Layer.effect(ApprovalService, Effect.gen(function* () {
+ const hooks = yield* HookService;
+ const approvalWait = yield* ApprovalWaitService;
+ const ruleEngine: RuleEngine = createRuleEngine();
+ const destructiveTools = new Set(DANGEROUS_TOOL_NAMES);
+
+ return {
+ evaluate: (request: {
+ tool: string;
+ input: Record;
+ context?: Record;
+ callId?: string;
+ sessionId: string;
+ projectPath?: string;
+ permissionMode?: PermissionMode;
+ profile?: ProfileName;
+ }): any =>
+ runPipeline(
+ {
+ tool: request.tool,
+ input: request.input,
+ context: request.context,
+ callId: request.callId,
+ },
+ {
+ ruleEngine,
+ destructiveTools,
+ permissionMode: request.permissionMode ?? 'default',
+ profile: request.profile,
+ 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..6c3f6045 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' }
@@ -23,3 +23,36 @@ export function userConfirmAsync(
return yield* waitSvc.waitForConfirm(id, sessionId);
});
}
+
+export function parseApprovalResponse(response: string): ConfirmResult {
+ switch (response) {
+ case 'allow':
+ return { type: 'allow' };
+ case 'deny':
+ return { type: 'deny' };
+ case 'always':
+ return {
+ type: 'always',
+ rule: {
+ id: `user-allow-${Date.now()}`,
+ action: 'allow',
+ toolPattern: '*',
+ reason: 'User always allows',
+ source: 'user',
+ },
+ };
+ case 'never':
+ return {
+ type: 'never',
+ rule: {
+ id: `user-deny-${Date.now()}`,
+ action: 'deny',
+ toolPattern: '*',
+ reason: 'User never allows',
+ source: 'user',
+ },
+ };
+ default:
+ return { type: 'deny' };
+ }
+}
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/port.ts b/packages/codingcode/src/approval/port.ts
new file mode 100644
index 00000000..993a2297
--- /dev/null
+++ b/packages/codingcode/src/approval/port.ts
@@ -0,0 +1,10 @@
+import { Context } from 'effect';
+import type { Effect } from 'effect';
+import type { ProfileName } from '../core/types.js';
+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; profile?: ProfileName }): 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
deleted file mode 100644
index c611e4f4..00000000
--- a/packages/codingcode/src/approval/presets.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import type { PermissionRule } from './types.js';
-
-export const DEFAULT_DENY_RULES: PermissionRule[] = [
- {
- id: 'deny-rm-rf-root',
- action: 'deny',
- toolPattern: '*',
- argPattern: 'rm -rf /*',
- reason: 'rm -rf / is not allowed',
- priority: 100,
- source: 'system',
- },
- {
- id: 'deny-sudo-raw',
- action: 'deny',
- toolPattern: '*',
- argPattern: 'sudo *',
- reason: 'Elevated commands require explicit user confirmation',
- priority: 90,
- source: 'system',
- },
- {
- id: 'deny-curl-sh',
- action: 'deny',
- toolPattern: '*',
- argPattern: 'curl */sh',
- reason: 'Piping curl to shell is not allowed',
- priority: 90,
- source: 'system',
- },
- {
- id: 'deny-chmod-suid',
- action: 'deny',
- toolPattern: '*',
- argPattern: 'chmod u+s *',
- reason: 'Setting SUID bit is not allowed',
- priority: 90,
- source: 'system',
- },
- {
- id: 'deny-shutdown',
- action: 'deny',
- toolPattern: '*',
- argPattern: 'shutdown',
- reason: 'System shutdown is not allowed',
- priority: 80,
- source: 'system',
- },
- {
- id: 'deny-etc-shadow-read',
- action: 'deny',
- toolPattern: 'read_file',
- argPattern: '**/etc/shadow',
- reason: 'Reading /etc/shadow is not allowed',
- priority: 100,
- source: 'system',
- },
- {
- id: 'deny-etc-passwd-read',
- action: 'deny',
- toolPattern: 'read_file',
- argPattern: '**/etc/passwd',
- reason: 'Reading /etc/passwd is not allowed',
- priority: 100,
- source: 'system',
- },
- {
- id: 'ask-ssh-key',
- action: 'ask',
- toolPattern: 'read_file',
- argPattern: '**/.ssh/**',
- reason: 'Reading SSH keys requires confirmation',
- priority: 50,
- source: 'system',
- },
- {
- id: 'ask-env-file',
- action: 'ask',
- toolPattern: 'read_file',
- argPattern: '**/.env*',
- reason: 'Reading environment files requires confirmation',
- priority: 50,
- source: 'system',
- },
-];
-
-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/response.ts b/packages/codingcode/src/approval/response.ts
deleted file mode 100644
index fc76704b..00000000
--- a/packages/codingcode/src/approval/response.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { ConfirmResult } from './confirmation.js';
-
-export function parseApprovalResponse(response: string): ConfirmResult {
- switch (response) {
- case 'allow':
- return { type: 'allow' };
- case 'deny':
- return { type: 'deny' };
- case 'always':
- return {
- type: 'always',
- rule: {
- id: `user-allow-${Date.now()}`,
- action: 'allow',
- toolPattern: '*',
- reason: 'User always allows',
- source: 'user',
- },
- };
- case 'never':
- return {
- type: 'never',
- rule: {
- id: `user-deny-${Date.now()}`,
- action: 'deny',
- toolPattern: '*',
- reason: 'User never allows',
- source: 'user',
- },
- };
- default:
- return { type: 'deny' };
- }
-}
diff --git a/packages/codingcode/src/approval/types.ts b/packages/codingcode/src/approval/types.ts
index f86b45b2..834afe62 100644
--- a/packages/codingcode/src/approval/types.ts
+++ b/packages/codingcode/src/approval/types.ts
@@ -6,6 +6,16 @@ export const PERMISSION_MODES: readonly PermissionMode[] = [
'bypass',
] as const;
+// plan 权限模式只允许这组工具(只读 + submit_plan),其余一律 deny。
+// 作为审批层的权威白名单,agent 侧的工具可见性名单也从这里派生。
+export const PLAN_ALLOWED_TOOLS: ReadonlySet = new Set([
+ 'read_file',
+ 'search_files',
+ 'search_code',
+ 'fetch_url',
+ 'submit_plan',
+]);
+
export function isPermissionMode(value: unknown): value is PermissionMode {
return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value);
}
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/contracts.ts b/packages/codingcode/src/client/contracts.ts
new file mode 100644
index 00000000..d4c18487
--- /dev/null
+++ b/packages/codingcode/src/client/contracts.ts
@@ -0,0 +1,155 @@
+import type { PermissionMode } from '../approval/types.js';
+import type { ProfileName, TokenUsage } from '../core/types.js';
+import type {
+ CheckpointDiff,
+ CodeRollbackResult,
+ RollbackPreviewDiff,
+} from '../checkpoint/types.js';
+import type { SelectableModel } from '../llm/port.js';
+import type { McpServerConfig, McpStatus } from '../mcp/types.js';
+import type { UserHookConfig } from '../hooks/types.js';
+import type {SessionIndex } from '../session/types.js';
+import type { UITurn } from '../session/port.js';
+import type { AVAILABLE_PROFILES } from '../agent/profile.js';
+import type { Frame } from '../core/frame.js';
+
+export type { TokenUsage, CheckpointDiff, CodeRollbackResult, RollbackPreviewDiff };
+
+export type AvailableProfiles = typeof AVAILABLE_PROFILES;
+
+export interface SessionProfileInfo {
+ activeProfile: ProfileName;
+ permissionMode: PermissionMode;
+ cwd: string;
+ available: AvailableProfiles;
+}
+
+export interface SessionPlanFile {
+ content: string;
+ path: string;
+ directory: string;
+ exists: boolean;
+}
+
+export interface RollbackContextResult {
+ turns: UITurn[];
+}
+
+export interface RollbackBothResult {
+ turns: UITurn[];
+ codeResult: CodeRollbackResult;
+}
+
+export interface ForkResult {
+ sessionId: string;
+ turns: UITurn[];
+}
+
+export interface SessionClient {
+ createSession(input: {
+ cwd: string;
+ activeProfile: ProfileName;
+ permissionMode: PermissionMode;
+ model: string;
+ }): Promise<{ sessionId: string }>;
+ resumeSession(input: { sessionId: string; cwd: string }): Promise;
+ listSessions(input: { cwd: string }): Promise;
+ getSessionHistory(input: { sessionId: string; cwd: string }): Promise;
+ deleteSession(input: { sessionId: string; cwd: string }): Promise;
+ getSessionProfile(input: { sessionId: string; cwd: string }): Promise;
+ setSessionProfile(input: {
+ sessionId: string;
+ cwd: string;
+ activeProfile: ProfileName;
+ }): Promise<{ activeProfile: ProfileName; permissionMode: PermissionMode }>;
+ getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise;
+ setSessionPermissionMode(input: {
+ sessionId: string;
+ cwd: string;
+ mode: PermissionMode;
+ }): Promise;
+ getSessionPlan(input: { sessionId: string; cwd: string }): Promise;
+
+ getCheckpointDiff(input: {
+ sessionId: string;
+ cwd: string;
+ turnId?: number;
+ }): Promise;
+ revertCheckpointFiles(input: {
+ sessionId: string;
+ cwd: string;
+ files: string[];
+ }): Promise;
+ previewRollbackDiff(input: {
+ sessionId: string;
+ cwd: string;
+ throughTurnId: number;
+ }): Promise;
+ rollbackCodeToTurn(input: {
+ sessionId: string;
+ cwd: string;
+ throughTurnId: number;
+ }): Promise;
+ rollbackContext(input: {
+ sessionId: string;
+ cwd: string;
+ throughTurnId: number;
+ }): Promise;
+ rollbackBothToTurn(input: {
+ sessionId: string;
+ cwd: string;
+ throughTurnId: number;
+ }): Promise;
+ forkSession(input: {
+ sessionId: string;
+ cwd: string;
+ atTurnId?: number;
+ }): Promise;
+}
+
+export interface AgentRuntimeClient {
+ sendMessage(
+ input: string,
+ options: { sessionId?: string; cwd: string; signal?: AbortSignal }
+ ): AsyncGenerator;
+
+ sendApprovalResponse(input: {
+ sessionId: string;
+ approvalId: string;
+ response: string;
+ }): Promise;
+ compact(input: { sessionId: string; cwd: string }): Promise;
+}
+
+export interface ModelClient {
+ listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>;
+ switchModel(input: { id: string }): Promise;
+}
+
+export interface SettingsClient {
+ getMemoryEnabled(): Promise;
+ getMemoryConfig(): Promise<{ enabled: boolean; model: string }>;
+ setMemoryEnabled(enabled: boolean): Promise;
+ setMemoryModel(model: string): Promise<{ model: string }>;
+ getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>;
+ setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>;
+ getMcpStatus(input: { cwd: string }): Promise;
+ setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise;
+ resetMcpDisabled(body: { name: string; cwd: string }): Promise;
+ createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise;
+ updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise;
+ deleteMcpServer(input: { cwd: string; name: string }): Promise;
+ listSkills(): Promise>;
+ listHooks(input: { cwd: string }): Promise;
+ createHook(input: { cwd: string; hook: UserHookConfig }): Promise;
+ updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise;
+ deleteHook(input: { cwd: string; name: string }): Promise;
+ setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise;
+ resetHookDisabled(body: { name: string; cwd: string }): Promise;
+ getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise;
+ setGlobalPermissionMode(input: {
+ sessionId: string;
+ cwd: string;
+ mode: PermissionMode;
+ }): Promise;
+}
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..cc3b4c4c 100644
--- a/packages/codingcode/src/client/http/agent-runtime.ts
+++ b/packages/codingcode/src/client/http/agent-runtime.ts
@@ -1,57 +1,13 @@
-import type { StreamChunk } from '../types.js';
+import type { AgentRuntimeClient } from '../contracts.js';
+import { decodeFrame } from '../../core/frame-io.js';
import { parseSseStream } from '../sse.js';
import type { createRequestHelpers } from './request.js';
-export interface AgentRuntimeClient {
- sendMessage(
- input: string,
- options: { sessionId?: string; cwd: string; signal?: AbortSignal }
- ): AsyncGenerator;
-
- sendApprovalResponse(input: {
- sessionId: string;
- approvalId: string;
- response: string;
- }): Promise;
- compact(input: { sessionId: string; cwd: string }): Promise;
-
- 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: 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 }>;
- }>;
-}
-
export function createHttpAgentClient(
baseUrl: string,
request: ReturnType
): AgentRuntimeClient {
- const { apiPost, apiGet } = request;
+ const { apiPost } = request;
return {
async *sendMessage(input, { sessionId, cwd, signal }) {
@@ -64,94 +20,12 @@ export function createHttpAgentClient(
if (!response.ok) throw new Error(`HTTP ${response.status}`);
for await (const data of parseSseStream(response)) {
- switch (data.type) {
- case 'session_id':
- 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 'plan_ready':
- yield {
- type: 'plan_ready',
- sessionId: data.sessionId as string,
- title: data.title as string,
- };
- 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 '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;
- case 'done':
- break;
- case 'complete':
- return;
+ const decoded = decodeFrame(data);
+ if (!decoded.ok) {
+ console.warn(`[agent-runtime] dropped frame (${decoded.reason})`, decoded.raw);
+ continue;
}
+ yield decoded.frame;
}
},
@@ -162,49 +36,7 @@ export function createHttpAgentClient(
async compact({ sessionId, cwd }) {
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=_`);
- },
-
- async revertCheckpointFiles(turnId: number, files: string[]) {
- return apiPost(`/api/sessions/_/checkpoints/latest/revert-files?cwd=_`, {
- turnId,
- files,
- });
- },
-
- async previewRollbackDiff(throughTurnId: number) {
- return apiGet(`/api/sessions/_/rollback-preview?cwd=_&throughTurnId=${throughTurnId}`);
- },
-
- async rollbackCodeToTurn(throughTurnId: number) {
- return apiPost(`/api/sessions/_/rollback-code-to-turn?cwd=_`, { throughTurnId });
- },
-
- async rollbackContext(throughTurnId: number) {
- return apiPost(`/api/sessions/_/rollback-context?cwd=_`, { throughTurnId });
- },
-
- async rollbackBothToTurn(throughTurnId: number) {
- 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 });
- },
};
}
+
+export type { AgentRuntimeClient };
diff --git a/packages/codingcode/src/client/http/index.ts b/packages/codingcode/src/client/http/index.ts
index 59edb1de..a3394fad 100644
--- a/packages/codingcode/src/client/http/index.ts
+++ b/packages/codingcode/src/client/http/index.ts
@@ -1,8 +1,14 @@
import { createRequestHelpers } from './request.js';
-import { createHttpAgentClient, type AgentRuntimeClient } from './agent-runtime.js';
-import { createHttpSessionClient, type SessionClient } from './sessions.js';
-import { createHttpModelClient, type ModelClient } from './models.js';
-import { createHttpSettingsClient, type SettingsClient } from './settings.js';
+import { createHttpAgentClient } from './agent-runtime.js';
+import { createHttpSessionClient } from './sessions.js';
+import { createHttpModelClient } from './models.js';
+import { createHttpSettingsClient } from './settings.js';
+import type {
+ AgentRuntimeClient,
+ SessionClient,
+ ModelClient,
+ SettingsClient,
+} from '../contracts.js';
export type { AgentRuntimeClient, SessionClient, ModelClient, SettingsClient };
diff --git a/packages/codingcode/src/client/http/models.ts b/packages/codingcode/src/client/http/models.ts
index 416f5df6..a8637333 100644
--- a/packages/codingcode/src/client/http/models.ts
+++ b/packages/codingcode/src/client/http/models.ts
@@ -1,11 +1,6 @@
-import type { SelectableModel } from '../../llm/factory.js';
+import type { ModelClient } from '../contracts.js';
import type { createRequestHelpers } from './request.js';
-export interface ModelClient {
- listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>;
- switchModel(input: { id: string }): Promise;
-}
-
export function createHttpModelClient(
request: ReturnType
): ModelClient {
@@ -21,3 +16,5 @@ export function createHttpModelClient(
},
};
}
+
+export type { ModelClient };
diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts
index 99551462..11da400b 100644
--- a/packages/codingcode/src/client/http/sessions.ts
+++ b/packages/codingcode/src/client/http/sessions.ts
@@ -1,92 +1,8 @@
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 { SessionIndex } from '../../session/types.js';
+import type { SessionClient } from '../contracts.js';
import type { createRequestHelpers } from './request.js';
-export interface SessionClient {
- createSession(input: {
- cwd: string;
- activeProfile: AgentProfileName;
- permissionMode: PermissionMode;
- model: string;
- }): Promise<{ sessionId: string }>;
- resumeSession(input: { sessionId: string; cwd: string }): Promise;
- listSessions(input: { cwd: string }): Promise;
- getSessionHistory(input: { sessionId: string; cwd: string }): Promise;
- deleteSession(input: { sessionId: string; cwd: string }): Promise;
- getSessionProfile(input: { sessionId: string; cwd: string }): Promise<{
- activeProfile: AgentProfileName;
- permissionMode: PermissionMode;
- cwd: string;
- available: Array<{ name: string; description: string }>;
- }>;
- setSessionProfile(input: {
- sessionId: string;
- cwd: string;
- activeProfile: AgentProfileName;
- }): Promise<{ activeProfile: AgentProfileName; permissionMode: PermissionMode }>;
- getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise;
- setSessionPermissionMode(input: {
- sessionId: string;
- cwd: string;
- mode: PermissionMode;
- }): Promise;
- getSessionPlan(input: {
- sessionId: string;
- cwd: string;
- }): Promise<{ content: string; path: string; directory: string; exists: boolean }>;
-
- getCheckpointDiff(input: {
- sessionId: string;
- cwd: string;
- turnId?: number;
- }): Promise;
- revertCheckpointFiles(input: {
- sessionId: string;
- cwd: string;
- files: string[];
- }): Promise;
- previewRollbackDiff(input: {
- sessionId: string;
- cwd: string;
- throughTurnId: number;
- }): Promise;
- rollbackCodeToTurn(input: {
- sessionId: string;
- cwd: string;
- throughTurnId: number;
- }): Promise;
- rollbackContext(input: {
- sessionId: string;
- cwd: string;
- throughTurnId: number;
- }): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>;
- 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;
- atTurnId?: number;
- }): Promise<{ sessionId: string; turns: SessionEvent[] }>;
-}
-
export function createHttpSessionClient(
request: ReturnType
): SessionClient {
@@ -107,9 +23,7 @@ export function createHttpSessionClient(
},
async getSessionHistory({ sessionId, cwd }) {
- return apiGet(
- `/api/sessions/${sessionId}/history?cwd=${encodeURIComponent(cwd)}`
- );
+ return apiGet(`/api/sessions/${sessionId}/history?cwd=${encodeURIComponent(cwd)}`);
},
async deleteSession({ sessionId, cwd }) {
@@ -147,7 +61,11 @@ export function createHttpSessionClient(
},
async revertCheckpointFiles({ sessionId, cwd, files }) {
- return apiPost(`/api/sessions/${sessionId}/checkpoints/latest/revert-files`, { cwd, files });
+ const res = await apiPost<{ ok: boolean; result: import('../../checkpoint/types.js').CodeRollbackResult }>(
+ `/api/sessions/${sessionId}/checkpoints/latest/revert-files`,
+ { cwd, files }
+ );
+ return res.result;
},
async previewRollbackDiff({ sessionId, cwd, throughTurnId }) {
@@ -157,7 +75,11 @@ export function createHttpSessionClient(
},
async rollbackCodeToTurn({ sessionId, cwd, throughTurnId }) {
- return apiPost(`/api/sessions/${sessionId}/rollback-code-to-turn`, { cwd, throughTurnId });
+ const res = await apiPost<{ ok: boolean; result: import('../../checkpoint/types.js').CodeRollbackResult }>(
+ `/api/sessions/${sessionId}/rollback-code-to-turn`,
+ { cwd, throughTurnId }
+ );
+ return res.result;
},
async rollbackContext({ sessionId, cwd, throughTurnId }) {
@@ -168,16 +90,10 @@ 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 });
},
};
}
+
+export type { SessionClient };
diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts
index 5768be68..e06ef459 100644
--- a/packages/codingcode/src/client/http/settings.ts
+++ b/packages/codingcode/src/client/http/settings.ts
@@ -1,44 +1,8 @@
import type { PermissionMode } from '../../approval/types.js';
-import type { McpServerConfig, McpStatus } from '../../mcp/types.js';
-import type { UserHookConfig } from '../../hooks/types.js';
+import type { McpStatus } from '../../mcp/types.js';
+import type { SettingsClient } from '../contracts.js';
import type { createRequestHelpers } from './request.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 }>;
- getMcpStatus(input: { cwd: string }): Promise;
- setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise;
- resetMcpDisabled(body: { name: string; cwd: string }): Promise;
- createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise;
- updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise;
- deleteMcpServer(input: { cwd: string; name: string }): Promise;
- listSkills(): Promise>;
- listHooks(input: { cwd: string }): Promise;
- createHook(input: { cwd: string; hook: UserHookConfig }): Promise;
- updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise;
- deleteHook(input: { cwd: string; name: string }): Promise;
- setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise;
- resetHookDisabled(body: { name: string; cwd: string }): Promise;
- getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise;
- setGlobalPermissionMode(input: {
- sessionId: string;
- cwd: string;
- mode: PermissionMode;
- }): Promise;
-}
-
export function createHttpSettingsClient(
request: ReturnType
): SettingsClient {
@@ -74,22 +38,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)}`);
},
@@ -171,3 +119,5 @@ export function createHttpSettingsClient(
},
};
}
+
+export type { SettingsClient };
diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts
deleted file mode 100644
index 63756f83..00000000
--- a/packages/codingcode/src/client/types.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-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 }
- | { type: 'text'; text: string; messageId?: number }
- | { type: 'message'; id: number; content: string; partial: false }
- | {
- type: 'approval_request';
- id: string;
- 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;
-}
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..e11f151f 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 { 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 estimateFor = (s: PayloadState): number =>
+ estimateTokens(buildContextMessages(s.visible, s.compactedTurnIds));
- const didCompact = applyOldTurnCompaction(
- visible,
- currentTurnId,
- preEstimate,
- contextWindow,
- jsonlPath
- );
-
- 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