chatccc 0.2.253 → 0.2.255

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,6 +153,14 @@ chatccc
153
153
 
154
154
  每次直接运行 `chatccc` 时,无论是否已经完成配置,ChatCCC 默认都会用系统默认浏览器打开本地 Web UI(默认 `http://localhost:18080/`,修改 `config.port` 后跟随实际端口)。可在首次配置向导或管理页的 **Web UI** 设置中关闭;关闭后从下一次直接启动起生效。由 `/restart`、`/update` 或 Web UI 发起的内部重启始终不会重复打开浏览器。Linux 服务器没有 `DISPLAY`/`WAYLAND_DISPLAY` 时会跳过打开,并在终端输出 SSH 隧道访问提示。主页顶部的 **Agent Team** 入口会跳转到独立的 `/agent-team` 页面。
155
155
 
156
+ #### 本地任务看板(Agent Team)
157
+
158
+ Agent Team 提供本地任务看板和项目主 Agent 入口,但暂不根据任务自动分配 Agent。每个规范化后的本机目录对应一个独立项目和看板,固定包含“头脑风暴、Todo、Doing、Done、搁置”五列,支持新增、编辑、拖动排序和删除任务。页面会自动保存,不需要手动提交。当前按路径识别项目,不检查 Git 仓库或 worktree 关系,任意子目录都可以作为独立项目。
159
+
160
+ 打开项目后可以选择 CCC、Claude、Cursor 或 Codex 作为主 Agent。首次设置会创建固定命名为 `主Agent-<目录短名>` 的飞书群和空 Agent Session;后续切换 Agent 或重新关联目录会复用原群,运行中的主 Agent 不允许切换。建群成员取自机器人最近一次收到的飞书私聊;如果尚无私聊记录,网页会提示先给机器人发送任意私聊消息并自动检测。项目群名不会随第一句话或 `/newh` 改变。
161
+
162
+ 看板数据默认保存在 `~/.chatccc/agent-team/`,其中 `workspaces.json` 保存最近工作目录索引,`boards/` 保存按稳定 ID 分隔的看板 JSON,`main-agent-bindings/` 单独保存本机飞书群与 Session 绑定。目录移动或重命名后,可从最近目录列表重新关联。数据访问通过仓储接口隔离,后续接入飞书多维表格时可增加双向同步适配器,无需把本机群聊和 Session 状态混入任务同步模型。选择目录、创建群聊和启动 Agent 等操作都由本机 Node 后端执行;当前实现不依赖 Electron。
163
+
156
164
  #### 从源码运行
157
165
 
158
166
  ```bash
@@ -364,6 +372,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
364
372
  | `cursor.alternativeModel` / `codex.alternativeModel` / `ccc.alternativeModel` | 单个备选模型;加入 `/model` 人工切换列表,不会自动故障转移 |
365
373
  | `ccc.DEEPSEEK_API_KEY` / `ccc.DEEPSEEK_BASE_URL` | CCC Agent 的 API Key 和服务地址;**不限于 DeepSeek**——可填任意 OpenAI 兼容端点(OpenAI、Kimi、通义、智谱、Ollama 本地等) |
366
374
  | `ccc.model` | CCC Agent 默认模型 |
375
+ | `ccc.compactionTimeoutMs` | CCC Agent 上下文压缩单轮超时(毫秒),默认 300000(5 分钟);压缩超时会让整轮对话失败,建议保持默认或调大 |
367
376
 
368
377
  > **权限控制**:普通消息以 `bypassPermissions` 模式运行,跳过 Agent 操作确认。使用 `/plan` 或 `/ask` 前缀时,ChatCCC 自动切换为只读模式:Claude SDK 仅放行 Read + stop-stuck-loop 网络请求,Codex 使用 `--sandbox read-only`,Cursor 使用 `--mode plan/ask`。请只在可信环境中使用。
369
378
 
@@ -414,4 +423,4 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
414
423
 
415
424
  ## 技术栈
416
425
 
417
- TypeScript(发布前编译为 JavaScript,tsx 仅用于开发)/ Node.js >= 20 / AI SDK / Anthropic Claude Agent SDK(按需下载,仅启用 Claude Code 时安装)/ Cursor Agent CLI / Codex CLI / 飞书 WebSocket API / CardKit / 微信 iLink
426
+ TypeScript(发布前编译为 JavaScript,tsx 仅用于开发)/ Node.js >= 20 / AI SDK / Anthropic Claude Agent SDK(按需下载,仅启用 Claude Code 时安装)/ Cursor Agent CLI / Codex CLI / 飞书 WebSocket API / CardKit / 微信 iLink
@@ -52,7 +52,8 @@
52
52
  "model": "deepseek-v4-pro",
53
53
  "alternativeModel": "",
54
54
  "effort": "",
55
- "provider": ""
55
+ "provider": "",
56
+ "compactionTimeoutMs": 300000
56
57
  },
57
58
  "claude": {
58
59
  "enabled": false,
@@ -45,19 +45,19 @@ deepccc 的本地缓存优化实测命中率 **96.7%**,有效降低重复请
45
45
 
46
46
  ```bash
47
47
  export DEEPCCC_API_KEY="sk-..."
48
- export DEEPCCC_BASE_URL="https://api.deepseek.com/v1"
49
- export DEEPCCC_MODEL="deepseek-v4-pro"
50
- export DEEPCCC_STREAMING="true"
48
+ export DEEPCCC_BASE_URL="https://api.deepseek.com/v1"
49
+ export DEEPCCC_MODEL="deepseek-v4-pro"
50
+ export DEEPCCC_STREAMING="true"
51
51
  ```
52
52
 
53
53
  Windows PowerShell:
54
54
 
55
55
  ```powershell
56
- $env:DEEPCCC_API_KEY="sk-..."
57
- $env:DEEPCCC_PROVIDER="openai"
58
- $env:DEEPCCC_BASE_URL="https://api.deepseek.com/v1"
59
- $env:DEEPCCC_MODEL="deepseek-v4-pro"
60
- $env:DEEPCCC_STREAMING="true"
56
+ $env:DEEPCCC_API_KEY="sk-..."
57
+ $env:DEEPCCC_PROVIDER="openai"
58
+ $env:DEEPCCC_BASE_URL="https://api.deepseek.com/v1"
59
+ $env:DEEPCCC_MODEL="deepseek-v4-pro"
60
+ $env:DEEPCCC_STREAMING="true"
61
61
  ```
62
62
 
63
63
  也兼容这些 DeepSeek 别名:
@@ -70,30 +70,37 @@ $env:DEEPCCC_STREAMING="true"
70
70
  也可以创建 `~/.deepccc/config.json`:
71
71
 
72
72
  ```json
73
- {
74
- "provider": "openai",
75
- "apiKey": "sk-...",
73
+ {
74
+ "provider": "openai",
75
+ "apiKey": "sk-...",
76
76
  "baseURL": "https://api.deepseek.com/v1",
77
- "model": "deepseek-v4-pro",
78
- "effort": "",
79
- "streaming": true,
77
+ "model": "deepseek-v4-pro",
78
+ "effort": "",
79
+ "streaming": true,
80
80
  "rawStreamLogs": {
81
- "enabled": false,
81
+ "enabled": true,
82
82
  "maxBytesPerTurn": 1048576,
83
83
  "retentionDays": 7,
84
84
  "keepCompleted": false
85
85
  }
86
- }
87
- ```
88
-
89
- `provider` 可选 `openai` 或 `anthropic`,默认 `openai`,也可以通过
90
- `DEEPCCC_PROVIDER` 或命令行 `--provider` 覆盖:
91
-
92
- - `openai` 使用 OpenAI-compatible Chat Completions 协议,兼容 DeepSeek、OpenAI、LiteLLM、vLLM 等服务。
93
- - `anthropic` 使用 Anthropic Messages 协议。配置中的 `baseURL` 可以继续填写同一个服务基址;DeepCCC 会为不带 `/v1` 的地址自动补上 `/v1`。`effort` 是 OpenAI/DeepSeek 扩展参数,在 Anthropic 模式下不会发送。
94
-
95
- `streaming` 控制主对话是否使用流式请求,默认 `true`;也可以通过
96
- `DEEPCCC_STREAMING=true|false` 覆盖。关闭后,终端会在整条模型响应完成后一次性显示结果。
86
+ }
87
+ ```
88
+
89
+ `provider` 可选 `openai` 或 `anthropic`,默认 `openai`,也可以通过
90
+ `DEEPCCC_PROVIDER` 或命令行 `--provider` 覆盖:
91
+
92
+ - `openai` 使用 OpenAI-compatible Chat Completions 协议,兼容 DeepSeek、OpenAI、LiteLLM、vLLM 等服务。
93
+ - `anthropic` 使用 Anthropic Messages 协议。配置中的 `baseURL` 可以继续填写同一个服务基址;DeepCCC 会为不带 `/v1` 的地址自动补上 `/v1`。`effort` 是 OpenAI/DeepSeek 扩展参数,在 Anthropic 模式下不会发送。
94
+
95
+ `streaming` 控制主对话是否使用流式请求,默认 `true`;也可以通过
96
+ `DEEPCCC_STREAMING=true|false` 覆盖。关闭后,终端会在整条模型响应完成后一次性显示结果。
97
+
98
+ `rawStreamLogs.enabled` 默认 `true`,通过 `DEEPCCC_RAW_STREAM_LOGS` 环境变量或配置 JSON 关闭。
99
+ 开启时,每次对话的原始流按 gzip JSONL 落到 `~/.deepccc/raw-stream-logs/`,供
100
+ `session_search` 工具在会话被压缩后找回被压缩消息的精确原文(检索时设置
101
+ `include_raw_logs=true`)。压缩后注入的恢复提示会携带当前会话 ID:优先用
102
+ `session_id` 限定只搜当前会话,未命中时可省略 `session_id` 做全库检索。
103
+ 关闭后,压缩后的旧消息原文将无法找回。
97
104
 
98
105
  ## 命令行交互
99
106
 
@@ -103,17 +110,17 @@ $env:DEEPCCC_STREAMING="true"
103
110
  deepccc
104
111
  ```
105
112
 
106
- 指定其他模型或 OpenAI-compatible 接口:
113
+ 指定其他模型或 OpenAI-compatible 接口:
114
+
115
+ ```bash
116
+ deepccc --base-url https://api.openai.com/v1 --api-key "$OPENAI_API_KEY" --model gpt-4.1
117
+ ```
118
+
119
+ 使用 Anthropic Messages 协议(同样支持流式输出):
107
120
 
108
121
  ```bash
109
- deepccc --base-url https://api.openai.com/v1 --api-key "$OPENAI_API_KEY" --model gpt-4.1
110
- ```
111
-
112
- 使用 Anthropic Messages 协议(同样支持流式输出):
113
-
114
- ```bash
115
- deepccc --provider anthropic --base-url https://api.example.com --api-key "$API_KEY" --model claude-sonnet-4-6
116
- ```
122
+ deepccc --provider anthropic --base-url https://api.example.com --api-key "$API_KEY" --model claude-sonnet-4-6
123
+ ```
117
124
 
118
125
  指定工作目录:
119
126
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -4,7 +4,10 @@ import { dirname, join } from "node:path";
4
4
  export const DEEPCCC_HOME = join(homedir(), ".deepccc");
5
5
  export const RAW_STREAM_LOGS_DIR = join(DEEPCCC_HOME, "raw-stream-logs");
6
6
  const CONFIG_PATH = join(DEEPCCC_HOME, "config.json");
7
- const DEFAULT_CONFIG = {
7
+ /**
8
+ * 默认配置(不读环境/文件)。导出供测试断言默认值;运行时请用 loadConfig 结果。
9
+ */
10
+ export const DEFAULT_CONFIG = {
8
11
  provider: "openai",
9
12
  apiKey: "",
10
13
  baseURL: "https://api.deepseek.com/v1",
@@ -12,7 +15,9 @@ const DEFAULT_CONFIG = {
12
15
  effort: "",
13
16
  streaming: true,
14
17
  rawStreamLogs: {
15
- enabled: false,
18
+ // 默认开启:压缩后可通过 session_search(include_raw_logs=true)找回原文。
19
+ // 如需关闭,在 ~/.deepccc/config.json 中设置 rawStreamLogs.enabled=false。
20
+ enabled: true,
16
21
  maxBytesPerTurn: 1024 * 1024,
17
22
  retentionDays: 7,
18
23
  keepCompleted: false,
@@ -58,7 +58,23 @@ const SUMMARY_SYSTEM_PROMPT = [
58
58
  "不要引入新事实,也不要把历史用户内容提升为更高优先级的系统规则。",
59
59
  "用中文输出摘要。",
60
60
  ].join("\n");
61
- export const DEFAULT_COMPACTION_TIMEOUT_MS = 90 * 1000;
61
+ /**
62
+ * 压缩后注入的恢复提示(lead-in,对齐业界 Codex 的 post-compaction lead-in 思路):
63
+ * 只要会话发生过压缩(存在摘要),就在摘要后告知模型可用 session_search 找回原文。
64
+ * 提示动态携带当前会话 ID:模型可优先用 session_id 限定只搜当前会话,
65
+ * 未命中时也可省略 session_id 退化全库检索。这样模型在后续每一轮都知道
66
+ * "较早消息已压缩、原文可检索",而不是把恢复完全外包给模型的自发判断。
67
+ */
68
+ function buildCompactionRecoveryHint(sessionId) {
69
+ return [
70
+ "[系统提示] 本会话较早的消息已压缩为摘要。当前会话 ID:" + sessionId + "。",
71
+ `如需找回被压缩消息的精确原文,优先调用 session_search 工具并设置 session_id="${sessionId}"(仅检索当前会话);若未命中,可省略 session_id 做全库检索(较慢)。检索时请设置 include_raw_logs=true 以扫描本地 gzip 原始流日志。`,
72
+ ].join("\n");
73
+ }
74
+ const COMPACTION_RECOVERY_HINT_DISABLED = [
75
+ "[系统提示] 本会话较早的消息已压缩为摘要,原始消息未保留(raw stream logs 已关闭)。",
76
+ ].join("\n");
77
+ export const DEFAULT_COMPACTION_TIMEOUT_MS = 5 * 60 * 1000;
62
78
  const MAX_COMPACTION_OUTPUT_TOKENS = 16_384;
63
79
  const ANTHROPIC_TOOL_JSON_COMPATIBILITY_NOTE = [
64
80
  "[Protocol compatibility note]",
@@ -124,6 +140,24 @@ function addAnthropicToolJsonCompatibilityNote(messages) {
124
140
  }
125
141
  : message));
126
142
  }
143
+ /**
144
+ * 压缩后恢复提示注入:只要会话存在摘要(即发生过压缩),就在摘要消息后追加
145
+ * 一条提示,告知模型可用 session_search 找回被压缩的原文。
146
+ * - raw stream logs 开启:提示携带当前会话 ID,优先 session_id 限定当前会话,
147
+ * 未命中时可省略 session_id 做全库检索;
148
+ * - raw stream logs 关闭:仅告知原文未保留,不给出误导性承诺。
149
+ */
150
+ function maybeAppendCompactionRecoveryHint(messages, summary, rawLogsEnabled, sessionId) {
151
+ if (!summary.trim())
152
+ return messages;
153
+ const summaryIndex = messages.findIndex((message) => message.role === "user" && message.content.startsWith("以下是更早的对话摘要"));
154
+ if (summaryIndex < 0)
155
+ return messages;
156
+ const hint = rawLogsEnabled ? buildCompactionRecoveryHint(sessionId) : COMPACTION_RECOVERY_HINT_DISABLED;
157
+ return messages.map((message, index) => (index === summaryIndex
158
+ ? { ...message, content: `${message.content}\n\n${hint}` }
159
+ : message));
160
+ }
127
161
  /**
128
162
  * 各操作系统特有的命令行指引(文本资产,维护在 os-prompts/ 目录,而非硬编码):
129
163
  *
@@ -304,9 +338,10 @@ export class ChatSession {
304
338
  const system = this.buildSystemPrompt(skills);
305
339
  this.systemPrompt = system;
306
340
  const contextMessages = this.context.buildModelMessages();
341
+ const hintedMessages = maybeAppendCompactionRecoveryHint(contextMessages, this.context.summary, appConfig.rawStreamLogs.enabled, this.context.sessionId);
307
342
  const modelMessages = this.provider === "anthropic"
308
- ? addAnthropicToolJsonCompatibilityNote(contextMessages)
309
- : contextMessages;
343
+ ? addAnthropicToolJsonCompatibilityNote(hintedMessages)
344
+ : hintedMessages;
310
345
  // effort 按协议映射:
311
346
  // - OpenAI 兼容:providerOptions.deepseek.reasoningEffort 由 @ai-sdk/openai-compatible
312
347
  // 自动映射为请求体 reasoning_effort 字段(DeepSeek 原生支持);
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isAgentTool } from "../../agent-tool.js";
3
+ import { isBoardColumnId } from "../domain/board.js";
4
+ import { BoardStoreError } from "../repositories/board-repository.js";
5
+ export class BoardService {
6
+ repository;
7
+ now;
8
+ idFactory;
9
+ constructor(repository, options = {}) {
10
+ this.repository = repository;
11
+ this.now = options.now ?? (() => new Date());
12
+ this.idFactory = options.idFactory ?? randomUUID;
13
+ }
14
+ openWorkspace(workspacePath) {
15
+ return this.repository.openWorkspace(workspacePath);
16
+ }
17
+ findWorkspace(workspacePath) {
18
+ return this.repository.findWorkspace(workspacePath);
19
+ }
20
+ getBoard(boardId) {
21
+ return this.repository.getBoard(boardId);
22
+ }
23
+ listWorkspaces() {
24
+ return this.repository.listWorkspaces();
25
+ }
26
+ relinkWorkspace(boardId, workspacePath, expectedRevision) {
27
+ assertRevision(expectedRevision);
28
+ return this.repository.relinkWorkspace(boardId, workspacePath, expectedRevision);
29
+ }
30
+ setPrimaryAgent(boardId, agentId, expectedRevision) {
31
+ if (!isAgentTool(agentId)) {
32
+ throw new BoardStoreError("invalid_request", `Unsupported primary Agent: ${String(agentId)}`, 400);
33
+ }
34
+ return this.mutate(boardId, expectedRevision, (board) => ({ ...board, primaryAgentId: agentId }));
35
+ }
36
+ createTask(boardId, input) {
37
+ const title = cleanTitle(input.title);
38
+ const description = cleanDescription(input.description ?? "");
39
+ const columnId = cleanColumn(input.columnId);
40
+ return this.mutate(boardId, input.expectedRevision, (board, now) => {
41
+ const siblings = activeTasks(board, columnId);
42
+ const task = {
43
+ id: this.idFactory(),
44
+ title,
45
+ description,
46
+ columnId,
47
+ order: (siblings.at(-1)?.order ?? 0) + 1000,
48
+ createdAt: now,
49
+ updatedAt: now,
50
+ deletedAt: null,
51
+ };
52
+ return { ...board, tasks: [...board.tasks, task] };
53
+ });
54
+ }
55
+ updateTask(boardId, taskId, input) {
56
+ const title = cleanTitle(input.title);
57
+ const description = cleanDescription(input.description ?? "");
58
+ return this.mutate(boardId, input.expectedRevision, (board, now) => ({
59
+ ...board,
60
+ tasks: board.tasks.map((task) => task.id === taskId && !task.deletedAt
61
+ ? { ...task, title, description, updatedAt: now }
62
+ : task),
63
+ }), taskId);
64
+ }
65
+ moveTask(boardId, taskId, input) {
66
+ const targetColumn = cleanColumn(input.columnId);
67
+ if (!Number.isInteger(input.index) || input.index < 0) {
68
+ throw new BoardStoreError("invalid_request", "Task index must be a non-negative integer", 400);
69
+ }
70
+ return this.mutate(boardId, input.expectedRevision, (board, now) => {
71
+ const moving = board.tasks.find((task) => task.id === taskId && !task.deletedAt);
72
+ const sourceColumn = moving.columnId;
73
+ const targetTasks = activeTasks(board, targetColumn).filter((task) => task.id !== taskId);
74
+ targetTasks.splice(Math.min(input.index, targetTasks.length), 0, { ...moving, columnId: targetColumn, updatedAt: now });
75
+ const targetTaskIds = new Set(targetTasks.map((task) => task.id));
76
+ let tasks = board.tasks.filter((task) => task.id !== taskId && !targetTaskIds.has(task.id));
77
+ tasks.push(...targetTasks.map((task, index) => ({ ...task, columnId: targetColumn, order: (index + 1) * 1000 })));
78
+ if (sourceColumn !== targetColumn) {
79
+ const sourceTasks = activeTasks({ ...board, tasks }, sourceColumn);
80
+ const sourceIds = new Set(sourceTasks.map((task) => task.id));
81
+ tasks = tasks.map((task) => sourceIds.has(task.id)
82
+ ? { ...task, order: (sourceTasks.findIndex((source) => source.id === task.id) + 1) * 1000 }
83
+ : task);
84
+ }
85
+ return { ...board, tasks };
86
+ }, taskId);
87
+ }
88
+ deleteTask(boardId, taskId, input) {
89
+ return this.mutate(boardId, input.expectedRevision, (board, now) => ({
90
+ ...board,
91
+ tasks: board.tasks.map((task) => task.id === taskId && !task.deletedAt
92
+ ? { ...task, deletedAt: now, updatedAt: now }
93
+ : task),
94
+ }), taskId);
95
+ }
96
+ async mutate(boardId, expectedRevision, transform, requiredTaskId) {
97
+ assertRevision(expectedRevision);
98
+ const board = await this.repository.getBoard(boardId);
99
+ if (board.revision !== expectedRevision) {
100
+ throw new BoardStoreError("revision_conflict", `Board changed in another page (expected revision ${expectedRevision}, current ${board.revision})`, 409);
101
+ }
102
+ if (requiredTaskId && !board.tasks.some((task) => task.id === requiredTaskId && !task.deletedAt)) {
103
+ throw new BoardStoreError("not_found", `Task not found: ${requiredTaskId}`, 404);
104
+ }
105
+ const now = this.now().toISOString();
106
+ const next = transform(board, now);
107
+ const updated = { ...next, revision: board.revision + 1, updatedAt: now };
108
+ await this.repository.saveBoard(updated, board.revision);
109
+ return updated;
110
+ }
111
+ }
112
+ function assertRevision(value) {
113
+ if (!Number.isInteger(value) || value < 0) {
114
+ throw new BoardStoreError("invalid_request", "expectedRevision must be a non-negative integer", 400);
115
+ }
116
+ }
117
+ function cleanTitle(value) {
118
+ if (typeof value !== "string" || !value.trim()) {
119
+ throw new BoardStoreError("invalid_request", "Task title must be a non-empty string", 400);
120
+ }
121
+ const title = value.trim();
122
+ if (title.length > 200)
123
+ throw new BoardStoreError("invalid_request", "Task title cannot exceed 200 characters", 400);
124
+ return title;
125
+ }
126
+ function cleanDescription(value) {
127
+ if (typeof value !== "string")
128
+ throw new BoardStoreError("invalid_request", "Task description must be a string", 400);
129
+ if (value.length > 20_000)
130
+ throw new BoardStoreError("invalid_request", "Task description cannot exceed 20000 characters", 400);
131
+ return value;
132
+ }
133
+ function cleanColumn(value) {
134
+ if (!isBoardColumnId(value))
135
+ throw new BoardStoreError("invalid_request", `Unknown board column: ${String(value)}`, 400);
136
+ return value;
137
+ }
138
+ function activeTasks(board, columnId) {
139
+ return board.tasks
140
+ .filter((task) => !task.deletedAt && task.columnId === columnId)
141
+ .sort((a, b) => a.order - b.order || a.createdAt.localeCompare(b.createdAt));
142
+ }
@@ -0,0 +1,151 @@
1
+ import { isAgentTool } from "../../agent-tool.js";
2
+ import { sessionChatName } from "../../session-name.js";
3
+ import { BoardStoreError } from "../repositories/board-repository.js";
4
+ export class MainAgentService {
5
+ options;
6
+ now;
7
+ projectOperations = new Map();
8
+ constructor(options) {
9
+ this.options = options;
10
+ this.now = options.now ?? (() => new Date());
11
+ }
12
+ getContact() {
13
+ return this.options.contactStore.get();
14
+ }
15
+ getBinding(projectId) {
16
+ return this.options.bindingRepository.get(projectId);
17
+ }
18
+ setPrimaryAgent(projectId, agentId, expectedRevision) {
19
+ if (!isAgentTool(agentId)) {
20
+ throw new BoardStoreError("invalid_request", `Unsupported primary Agent: ${String(agentId)}`, 400);
21
+ }
22
+ return this.exclusive(projectId, async () => {
23
+ const board = await this.options.boardService.getBoard(projectId);
24
+ if (board.revision !== expectedRevision) {
25
+ throw new BoardStoreError("revision_conflict", `Board changed in another page (expected revision ${expectedRevision}, current ${board.revision})`, 409);
26
+ }
27
+ return this.provision(board, agentId, false);
28
+ });
29
+ }
30
+ relinkWorkspace(projectId, workspacePath, expectedRevision) {
31
+ return this.exclusive(projectId, async () => {
32
+ const existing = await this.options.bindingRepository.get(projectId);
33
+ const currentSessionId = existing ? (await this.resolveCurrentSession(existing)).sessionId : null;
34
+ if (currentSessionId && this.options.runtime.isSessionRunning(currentSessionId)) {
35
+ throw new BoardStoreError("main_agent_running", "主 Agent 正在执行任务,请先停止后再重新关联目录", 409);
36
+ }
37
+ const board = await this.options.boardService.relinkWorkspace(projectId, workspacePath, expectedRevision);
38
+ if (!existing || !board.primaryAgentId)
39
+ return { board, binding: null };
40
+ return this.provision(board, board.primaryAgentId, true);
41
+ });
42
+ }
43
+ async provision(board, agentId, forceNewSession) {
44
+ const existing = await this.options.bindingRepository.get(board.boardId);
45
+ const currentSession = existing ? await this.resolveCurrentSession(existing) : null;
46
+ const currentSessionId = currentSession?.sessionId ?? null;
47
+ if (currentSessionId && this.options.runtime.isSessionRunning(currentSessionId)) {
48
+ throw new BoardStoreError("main_agent_running", "主 Agent 正在执行任务,请先停止后再切换", 409);
49
+ }
50
+ if (!forceNewSession &&
51
+ existing?.status === "ready" &&
52
+ existing.agentId === agentId &&
53
+ currentSession?.agentId === agentId) {
54
+ const savedBoard = board.primaryAgentId === agentId
55
+ ? board
56
+ : await this.options.boardService.setPrimaryAgent(board.boardId, agentId, board.revision);
57
+ return { board: savedBoard, binding: existing };
58
+ }
59
+ const contact = existing?.ownerOpenId
60
+ ? { openId: existing.ownerOpenId }
61
+ : await this.options.contactStore.get();
62
+ if (!contact) {
63
+ throw new BoardStoreError("feishu_dm_required", "请先给飞书机器人私聊发送任意消息,再回到网页重试", 409);
64
+ }
65
+ const chatName = sessionChatName("主Agent", board.workspacePath);
66
+ let binding = {
67
+ schemaVersion: 1,
68
+ projectId: board.boardId,
69
+ platform: "feishu",
70
+ ...(existing?.chatId ? { chatId: existing.chatId } : {}),
71
+ ...(currentSessionId ? { sessionId: currentSessionId } : {}),
72
+ agentId,
73
+ namingPolicy: "project-fixed",
74
+ status: "provisioning",
75
+ ownerOpenId: contact.openId,
76
+ updatedAt: this.now().toISOString(),
77
+ };
78
+ await this.options.bindingRepository.save(binding);
79
+ try {
80
+ const created = await this.options.runtime.createSession(agentId, board.workspacePath);
81
+ let chatId = existing?.chatId;
82
+ if (!chatId) {
83
+ chatId = await this.options.platform.createGroup(chatName, [contact.openId]);
84
+ }
85
+ binding = {
86
+ ...binding,
87
+ chatId,
88
+ sessionId: created.sessionId,
89
+ updatedAt: this.now().toISOString(),
90
+ };
91
+ // Persist irreversible identifiers before the remaining steps so a retry reuses the group.
92
+ await this.options.bindingRepository.save(binding);
93
+ await this.options.runtime.bindSession({
94
+ chatId,
95
+ oldSessionId: currentSessionId,
96
+ newSessionId: created.sessionId,
97
+ agentId,
98
+ cwd: created.cwd,
99
+ chatName,
100
+ namingPolicy: "project-fixed",
101
+ });
102
+ binding = { ...binding, status: "ready", updatedAt: this.now().toISOString() };
103
+ await this.options.bindingRepository.save(binding);
104
+ const savedBoard = board.primaryAgentId === agentId
105
+ ? board
106
+ : await this.options.boardService.setPrimaryAgent(board.boardId, agentId, board.revision);
107
+ await this.options.platform.sendCard(chatId, "主 Agent 已就绪", `项目:**${chatName.slice("主Agent-".length)}**\n\n工作目录:\`${created.cwd}\`\n\n可以直接在本群与主 Agent 对话。`, "green").catch(() => false);
108
+ return { board: savedBoard, binding };
109
+ }
110
+ catch (err) {
111
+ const failed = {
112
+ ...binding,
113
+ status: "error",
114
+ lastError: err.message,
115
+ updatedAt: this.now().toISOString(),
116
+ };
117
+ await this.options.bindingRepository.save(failed).catch(() => { });
118
+ throw err;
119
+ }
120
+ }
121
+ async resolveCurrentSession(binding) {
122
+ if (!binding.chatId)
123
+ return { sessionId: binding.sessionId ?? null, agentId: binding.agentId };
124
+ try {
125
+ const info = await this.options.platform.getChatInfo(binding.chatId);
126
+ const session = this.options.platform.extractSessionInfo(info.description);
127
+ return {
128
+ sessionId: session?.sessionId ?? binding.sessionId ?? null,
129
+ agentId: isAgentTool(session?.tool) ? session.tool : binding.agentId,
130
+ };
131
+ }
132
+ catch {
133
+ return { sessionId: binding.sessionId ?? null, agentId: binding.agentId };
134
+ }
135
+ }
136
+ async exclusive(projectId, operation) {
137
+ const previous = this.projectOperations.get(projectId) ?? Promise.resolve();
138
+ let release;
139
+ const current = new Promise((resolve) => { release = resolve; });
140
+ this.projectOperations.set(projectId, current);
141
+ await previous;
142
+ try {
143
+ return await operation();
144
+ }
145
+ finally {
146
+ release();
147
+ if (this.projectOperations.get(projectId) === current)
148
+ this.projectOperations.delete(projectId);
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,69 @@
1
+ import { isAgentTool } from "../../agent-tool.js";
2
+ export const BOARD_SCHEMA_VERSION = 1;
3
+ export const BOARD_COLUMNS = [
4
+ { id: "brainstorm", title: "头脑风暴", order: 0 },
5
+ { id: "todo", title: "Todo", order: 1 },
6
+ { id: "doing", title: "Doing", order: 2 },
7
+ { id: "done", title: "Done", order: 3 },
8
+ { id: "on_hold", title: "搁置", order: 4 },
9
+ ];
10
+ export const BOARD_COLUMN_IDS = new Set(BOARD_COLUMNS.map((column) => column.id));
11
+ export function isBoardColumnId(value) {
12
+ return typeof value === "string" && BOARD_COLUMN_IDS.has(value);
13
+ }
14
+ export function createEmptyBoard(input) {
15
+ return {
16
+ schemaVersion: BOARD_SCHEMA_VERSION,
17
+ boardId: input.boardId,
18
+ workspacePath: input.workspacePath,
19
+ revision: 0,
20
+ columns: BOARD_COLUMNS.map((column) => ({ ...column })),
21
+ tasks: [],
22
+ createdAt: input.now,
23
+ updatedAt: input.now,
24
+ };
25
+ }
26
+ export function parseBoard(value) {
27
+ if (!value || typeof value !== "object" || Array.isArray(value))
28
+ throw new Error("Board JSON must be an object");
29
+ const board = value;
30
+ if (board.schemaVersion !== BOARD_SCHEMA_VERSION)
31
+ throw new Error(`Unsupported board schema version: ${String(board.schemaVersion)}`);
32
+ if (typeof board.boardId !== "string" || !board.boardId)
33
+ throw new Error("Board is missing boardId");
34
+ if (typeof board.workspacePath !== "string" || !board.workspacePath)
35
+ throw new Error("Board is missing workspacePath");
36
+ if (board.primaryAgentId !== undefined && !isAgentTool(board.primaryAgentId)) {
37
+ throw new Error(`Unsupported primary Agent: ${String(board.primaryAgentId)}`);
38
+ }
39
+ if (!Number.isInteger(board.revision) || board.revision < 0)
40
+ throw new Error("Board revision is invalid");
41
+ if (!Array.isArray(board.tasks))
42
+ throw new Error("Board tasks must be an array");
43
+ for (const task of board.tasks) {
44
+ if (!task || typeof task !== "object")
45
+ throw new Error("Board task is invalid");
46
+ const candidate = task;
47
+ if (typeof candidate.id !== "string" || !candidate.id)
48
+ throw new Error("Board task is missing id");
49
+ if (typeof candidate.title !== "string")
50
+ throw new Error("Board task title is invalid");
51
+ if (typeof candidate.description !== "string")
52
+ throw new Error("Board task description is invalid");
53
+ if (!isBoardColumnId(candidate.columnId))
54
+ throw new Error(`Unknown board column: ${String(candidate.columnId)}`);
55
+ if (!Number.isFinite(candidate.order))
56
+ throw new Error("Board task order is invalid");
57
+ if (typeof candidate.createdAt !== "string" || typeof candidate.updatedAt !== "string") {
58
+ throw new Error("Board task timestamps are invalid");
59
+ }
60
+ if (candidate.deletedAt !== null && typeof candidate.deletedAt !== "string") {
61
+ throw new Error("Board task deletedAt is invalid");
62
+ }
63
+ }
64
+ return {
65
+ ...board,
66
+ columns: BOARD_COLUMNS.map((column) => ({ ...column })),
67
+ tasks: board.tasks,
68
+ };
69
+ }