Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ LLM_BASE_URL=
# 要操作本仓时显式写成仓根。pnpm dev:api 在沙箱存在时默认用沙箱。
GIT_REPO=

# 插件目录。每个子目录放一个与目录同名的二进制。未设则不拉插件进程。
# 示例:编译 plugin/redact 后设 PLUGIN_DIR=<仓根>/plugin
# PLUGIN_DIR=

# 单次插件 RPC 超时(默认 10s)
# PLUGIN_RPC_TIMEOUT=10s

# 本机 Codex CLI 可执行文件(默认 codex)。未安装时主服务仍可启动,/codex/status 会说明原因。
CODEX_BIN=codex

11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,20 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
cache-dependency-path: |
server/go.sum
plugin/example/go.sum
plugin/redact/go.sum

- name: Lint
run: |
test -z "$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; }
test -z "$(gofmt -l . ../plugin)" || { echo "gofmt needed:"; gofmt -l . ../plugin; exit 1; }
go vet ./...

- name: Test
run: go test ./...
run: |
go test ./...
(cd ../plugin/redact && go test .)

- name: Codex coverage
run: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*.db-shm
*.db-wal
tmp/
/plugin/*/*
!/plugin/*/*.*
.DS_Store
node_modules
.pnpm-store
Expand Down
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字
- 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。
- Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。
- 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。
- Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。
- Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。六个口的信封放在 `pkg/agent/seam`。
- 插件 SDK 与 proto 放在 `server/pkg/plugin`;宿主放在 `server/internal/pluginhost`。两者都不进 `pkg/agent`,也不知道主循环内部状态机。可装载的插件放在仓根 `plugin/`(子目录名即插件名)。`plugin/example` 是作者拷贝模板:不订阅、不改正文、不换向、不登记方法。不进 `pkg/plugin`。插件共享参数用 `PluginContext`,不进模型、不复用 Hidden。
- Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。
- Codex 协议与领域类型放在 `server/pkg/codex`:看板的子模块,JSONL 客户端给 `internal/codex` 调用;不查库、不 spawn CLI。不进 `pkg/agent`。
- 进程内事件总线放在 `server/internal/events`。
Expand Down Expand Up @@ -42,4 +43,10 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字
- 前端三层不得反依赖:`core` 不依赖 React / Next / DOM / `process.env`;`ui` 不依赖 `core`;`views` 不 import `next/*`;`apps/web` 只做路由与平台装配。
- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。

## 注释规则

- 每个函数、方法正上方必须有一行注释,写清它做什么。Go 用文档注释(`// Name ...`),TypeScript 同等要求。不要用注释复述函数名或参数列表。
- 结构体 / 接口里,单看字段名读不懂含义或取值约定的属性必须加字段注释;`ID`、`Name`、`Content` 这类自明字段不必硬加。
- 改现有代码时顺手补上缺的注释;sqlc / proto 生成文件不要手改。

当需求变更没有明显的代码归属时,先依据 `docs/architecture.md` 对其分类,再开始编写代码。
34 changes: 33 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ CodeDock/
│ ├── ui/ # 无业务语义;components / lib / styles,不要 src/
│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/),不要 src/
├── docs/
├── plugin/example/ # 插件拷贝模板;不改正文、不换向
├── plugin/redact/ # 脱敏插件;PLUGIN_DIR 指到 plugin/
├── data/ # 运行时文件(sqlite 等),gitignore
├── server/
│ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配
Expand All @@ -66,12 +68,15 @@ CodeDock/
│ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_*
│ │ ├── codex/ # 本机 app-server 生命周期与内存排队/问票/SSE
│ │ ├── events/ # 进程内事件总线
│ │ ├── pluginhost/ # go-plugin 宿主:拉进程、Dispatch、Host 白名单
│ │ ├── config/
│ │ ├── logger/
│ │ ├── errors/
│ │ └── util/
│ ├── pkg/
│ │ ├── agent/ # 全部通用无状态逻辑,含模型调用与 Tool 抽象
│ │ │ └── seam/ # Envelope / Dispatcher / 六个口的类型常量
│ │ ├── plugin/ # 插件 SDK 与 proto;作者只 import 这个包
│ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用
│ │ ├── codex/ # 看板的 Codex 子模块:协议客户端与领域类型
│ │ └── db/ # Client 与 sqlc 生成代码
Expand All @@ -90,6 +95,7 @@ cmd/server
-> internal/codex
-> internal/agent
-> internal/events
-> internal/pluginhost
-> pkg/db

internal/handler
Expand Down Expand Up @@ -127,10 +133,26 @@ internal/agent/tools
-> pkg/db/sqlite.Queries
不 import 父包 internal/agent

internal/pluginhost
-> pkg/plugin
-> pkg/agent / pkg/agent/seam / pkg/agent/tool
-> internal/agent/memory
-> pkg/db/sqlite.Queries
-> internal/events
不 import 父包 internal/agent

pkg/plugin
-> pkg/agent / pkg/agent/seam / pkg/agent/tool
-> pkg/plugin/proto
不依赖 handler、internal、sqlc
插件作者只 import 这个包

pkg/agent
不依赖 handler、internal、sqlc
不持有包级状态,不查库
不知道 gRPC / go-plugin
Tool 包只含接口、Registry、Dispatch,不含具体工具定义
seam 是叶子包:信封与六个口,agent 与 tool 都能 import

pkg/git
不依赖 handler、internal、sqlc
Expand Down Expand Up @@ -217,6 +239,16 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi

每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。发给模型的是注册表全量工具;`Profile.Tools.Names` 是可执行绑定。模式规则由 `Build` 注入一条 developer 消息;发给网关时紧跟底座 system,不改底座正文。审批流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo / 已批准都不能抬)→ Agent `Effects` → `approval`(manual/auto/yolo);每层只审上一层的 `ask`。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。

### 插件

主循环在六个口把当前数据递给 `seam.Dispatcher`:`agent/input`、`agent/pre-step`、`agent/request`、`llm/stream`、`tools/pre-execute`、`tools/post-execute`。没有 Dispatcher 时原样通过。作者侧每个口是一对入参/回包结构体(`OnAgentInput` 的 `AgentInput` / `AgentInputResult` 等),换向是回包字段,不解信封。

多个插件按子目录名排序依次改同一份载荷。回包类型不变则继续;类型变成 `input/handled` / `run/blocked` / `tools/denied` / `tools/ask` 则换方向。同一条链上问过的插件记入 `Seen`,不会再问自己。插件之间的参数走 `PluginContext`(信封 `Context`),由宿主按会话/Run 暂存,不进模型、不进消息表。

插件跑在独立进程里,经 go-plugin gRPC 通信。宿主白名单:`Emit`(不能发六个口的同名事件)、`RegisterMethod`、记忆读写、`Complete`、`AppendNotice`。超时或进程挂了:拦截口按否决,只改数据的口保留原样。`assistant.delta` 不发给插件。已批准的工具不再拦一次。换插件二进制要重启服务。未设 `PLUGIN_DIR` 不拉进程。

详见 [plugin.md](plugin.md)。

### `pkg/git`

无状态 Git CLI:`Open` / `Status`(`SiteState` 整局)/ Diff / 图 / 暂存提交 / reset / revert / 推拉 / remote / 分支 / worktree / `stash create` 副本 / 冲突读写。不进 `pkg/agent`,不写 HTTP 或产品流程。Workspace / Branch / Undo / 说明 / Agent 快照的产品组合在 Handler。
Expand Down Expand Up @@ -296,7 +328,7 @@ Worker

## 配置

`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。`CODEX_BIN` 为本机 Codex CLI(默认 `codex`)。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。
`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。`PLUGIN_DIR` 指向插件根目录,未设则不拉插件进程;`PLUGIN_RPC_TIMEOUT` 默认 `10s`。`CODEX_BIN` 为本机 Codex CLI(默认 `codex`)。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。

HTTP 出站领域对象使用 snake_case JSON。Router 只对本地回环 Origin 放行 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。

Expand Down
76 changes: 76 additions & 0 deletions docs/plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 编写 CodeDock 插件

外来程序可以在对话的六个口上改数据或换方向,也可以给模型登记方法。插件跑在独立进程里,通过 gRPC 和宿主说话。

没有 `PLUGIN_DIR` 时,服务不拉任何插件,主循环与现在完全一样。

## 从 example 模板开始

`plugin/example` 是作者拷贝的模板:不订阅、不改正文、不换向、不登记方法。装进 `PLUGIN_DIR` 也不会改对话。复制本目录,改 `go.mod` 模块名、`Manifest.Name` 和策略,再在 `Bootstrap` 里填 `Subscriptions`。作者只 import `codedock/pkg/plugin`。`go.mod` 用 `replace` 指到本仓 `server/`。

可装载的插件放在仓根 `plugin/`,子目录名即插件名。`plugin/redact` 是脱敏插件:不拦工具,只在 input / request / post-execute 把秘密换成占位符。六个口要哪些字段,以 `codedock/pkg/plugin` 里的结构体为准(`AgentInput`、`AgentInputResult` 等),跳进类型就能看到。

本地编译后:

```sh
(cd plugin/example && go build -o example .)
(cd plugin/redact && go build -o redact .)
PLUGIN_DIR=$PWD/plugin pnpm run dev
```

正文或工具回包含 `AKIA…` / `API_KEY=…` 时,落库和发给模型的是占位符。

目录约定:

```text
plugin/ # PLUGIN_DIR 指这里
example/
example # 拷贝模板,不改对话
redact/
redact # 与子目录同名的二进制
```

## 六个口

每个口是一对 SDK 结构体,实现对应方法即可。没实现的口原样通过。字段含义写在结构体上。

| 口 | 方法 | 入参 | 回包 |
| --- | --- | --- | --- |
| `agent/input` | `OnAgentInput` | `AgentInput`(正文、模式) | `AgentInputResult`;`Handle()` 不建 Run |
| `agent/pre-step` | `OnAgentPreStep` | `AgentPreStep`(系统提示、隐藏消息) | `AgentPreStepResult`;`Block()` 取消本轮 |
| `agent/request` | `OnAgentRequest` | `AgentRequest` | `AgentRequestResult`;只能 `Reply()` |
| `llm/stream` | `OnLLMStream` | `LLMStream`(请求头、请求体) | `LLMStreamResult`;只能 `Reply()`;fake 模型不插 |
| `tools/pre-execute` | `OnToolPreExecute` | `ToolPreExecute`(工具调用) | `ToolPreExecuteResult`;`Deny()` 当失败;`AskApproval()` 进审批 |
| `tools/post-execute` | `OnToolPostExecute` | `ToolPostExecute`(工具结果) | `ToolPostExecuteResult`;只能 `Reply()` |

账本通知走 `OnLedgerNotify`,没有换向。多个插件按子目录名排序,后一个看到前一个改完的结果。同一条链上问过的插件记在 `Seen` 里,不会再问自己。

跨口、跨插件传参数用各口上的 `Context`(`Set("example.xxx", v)` / `Get`)。这是宿主暂存的 JSON 对象,不进模型、不进消息表、不换向。键建议 `插件名.字段`。`agent/input` 时按会话挂;建 Run 后迁到该 Run。`Handle()` 或 Run 终态会清掉。上限 8KB,超了保留上一份。进程重启即丢。不要把协议塞进 `Hidden`。

隐藏提示用 `sdk.HiddenText("...")` 加进 `AgentPreStep.Hidden`。已批准但还没执行的工具不再走 `OnToolPreExecute`。流式增量 `assistant.delta` 不发给插件。

## Host 白名单

`Bootstrap` 拿到的 `Host` 只能做这些事:

- `Emit`:另发一条与当前口无关的事件。不能发六个口的同名事件。
- `RegisterMethod`:给模型加方法。不能覆盖 `ping`、`memory_read`、`memory_write`、`memory_search`。
- `MemoryGet` / `MemoryUpsert`:按会话读写一篇专题记忆。
- `Complete`:自己打一次模型,不进当前助手流。
- `AppendNotice`:写一条用户看得见的 system 消息(本期只落库,不实时推送)。

## 失败与超时

`PLUGIN_RPC_TIMEOUT` 默认 `10s`。超时或进程挂了:

- `agent/input`、`agent/pre-step`、`tools/pre-execute` 按否决处理
- `agent/request`、`llm/stream`、`tools/post-execute` 保留原数据

进程崩了不会自动拉起。换二进制要重启服务。

## 环境变量

| 变量 | 含义 |
| --- | --- |
| `PLUGIN_DIR` | 插件根目录。每个子目录一个常驻进程。未设则不加载。 |
| `PLUGIN_RPC_TIMEOUT` | 单次 RPC 超时,如 `10s`。 |
8 changes: 8 additions & 0 deletions packages/core/chat/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
applyEvent,
applyLocalCancel,
applyOptimisticUser,
dropOptimisticUser,
decisionsForApproval,
emptyState,
hydrate,
Expand Down Expand Up @@ -508,6 +509,13 @@ test("applyLocalCancel clears the executing run", () => {
assert.equal(optimistic.queued, false);
});

test("dropOptimisticUser removes a handled local bubble", () => {
let state = applyOptimisticUser(emptyState(), { runId: "local:1", text: "/skip" });
assert.equal(state.items.length, 1);
state = dropOptimisticUser(state, "local:1");
assert.equal(state.items.length, 0);
});

test("optimistic user is replaced when run.created arrives", () => {
let state = applyOptimisticUser(emptyState(), { runId: "r1", text: "hi" });
assert.equal(state.items[0]?.kind === "user" && state.items[0].messageId, "pending:r1");
Expand Down
3 changes: 2 additions & 1 deletion packages/core/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ export interface StartRunRequest {

export interface StartRunResponse {
session_id: string;
run_id: string;
run_id?: string;
handled?: boolean;
}

export interface DecideApprovalRequest {
Expand Down
8 changes: 7 additions & 1 deletion packages/views/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { ApprovalMode, Session, TimelineItem, WorkMode } from "@codedock/core/chat";
import type { Session as CodexSession } from "@codedock/core/codex";
import { Button } from "@codedock/ui";
import { useMemo, useState, useEffect, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";

import { CodexPane } from "../codex/codex-pane.tsx";
import { useCodexSessionList } from "../codex/hooks/use-session-list.ts";
Expand Down Expand Up @@ -37,6 +37,7 @@ export type ChatPageProps = {
headerActions?: ReactNode;
};

// ChatPage 组合会话侧栏、时间线与输入条。
export function ChatPage({
sessionId,
engine,
Expand All @@ -62,6 +63,8 @@ export function ChatPage({
const [composerError, setComposerError] = useState<string | null>(null);
const [workspaceDraft, setWorkspaceDraft] = useState("");
const [pickingWorkspace, setPickingWorkspace] = useState(false);

// 上次目录只在本机 localStorage,等 hydration 后再读,避免 SSR 文本对不上。
useEffect(() => {
setWorkspaceDraft(readLastWorkspace());
}, []);
Expand Down Expand Up @@ -308,6 +311,7 @@ export function ChatPage({
);
}

// mergeSessions 把 Agent 与 Codex 会话按更新时间合成侧栏列表,同引擎同 ID 只留更新的一条。
function mergeSessions(agent: Session[], codex: CodexSession[]): SidebarSession[] {
const mapped: SidebarSession[] = [
...agent.map((session) => ({ ...session, engine: "agent" as const })),
Expand All @@ -324,6 +328,7 @@ function mergeSessions(agent: Session[], codex: CodexSession[]): SidebarSession[
return [...seen.values()].sort((left, right) => (left.updated_at < right.updated_at ? 1 : -1));
}

// asSidebarSession 把 Codex 会话收成侧栏条目,目录用 cwd,标题优先 title。
function asSidebarSession(session: CodexSession): SidebarSession {
return {
id: session.id,
Expand All @@ -341,6 +346,7 @@ function asSidebarSession(session: CodexSession): SidebarSession {
};
}

// stampToIso 把秒或毫秒时间戳收成 ISO 字符串,无效值用纪元。
function stampToIso(value?: number): string {
if (!value) {
return new Date(0).toISOString();
Expand Down
12 changes: 11 additions & 1 deletion packages/views/chat/hooks/use-session-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,17 @@ export function useSessionTimeline(sessionId: string | undefined) {
return next;
});
try {
await client.startRun(sessionId, { content, mode, approval });
const started = await client.startRun(sessionId, { content, mode, approval });
if (started.handled) {
setState((current) => {
if (sessionRef.current !== sessionId) {
return current;
}
const next = dropOptimisticUser(current, pendingRunId);
cacheSet(sessionId, next);
return next;
});
}
setRecoverableRunId(null);
setError(null);
} catch (err) {
Expand Down
25 changes: 25 additions & 0 deletions plugin/example/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module codedock-example

go 1.26.5

require codedock v0.0.0

require (
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-plugin v1.8.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/oklog/run v1.1.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)

replace codedock => ../../server
Loading
Loading