c0de-agent 1.12.0 → 1.13.0

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.
Files changed (142) hide show
  1. package/README.md +51 -23
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +70 -6
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +46 -0
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +51 -4
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +92 -12
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +71 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/config.d.ts +43 -1
  24. package/dist/core/config.js +163 -14
  25. package/dist/core/loop/segment.d.ts +3 -1
  26. package/dist/core/loop/segment.js +24 -1
  27. package/dist/core/loop/subagent.js +65 -32
  28. package/dist/core/loop.d.ts +7 -0
  29. package/dist/core/loop.js +90 -6
  30. package/dist/core/slash.d.ts +10 -0
  31. package/dist/core/slash.js +150 -20
  32. package/dist/core/types.d.ts +20 -1
  33. package/dist/core/workflows/context.js +44 -4
  34. package/dist/core/workflows/discovery.d.ts +2 -2
  35. package/dist/core/workflows/discovery.js +5 -7
  36. package/dist/core/workflows/index.d.ts +2 -1
  37. package/dist/core/workflows/index.js +2 -1
  38. package/dist/core/workflows/registry.d.ts +13 -3
  39. package/dist/core/workflows/registry.js +14 -9
  40. package/dist/core/workflows/resolve.d.ts +22 -0
  41. package/dist/core/workflows/resolve.js +23 -0
  42. package/dist/core/workflows/runtime.d.ts +6 -1
  43. package/dist/core/workflows/runtime.js +19 -2
  44. package/dist/db/schema.d.ts +356 -1
  45. package/dist/db/schema.js +57 -3
  46. package/dist/kanban/index.d.ts +2 -1
  47. package/dist/kanban/index.js +1 -1
  48. package/dist/kanban/store.d.ts +84 -2
  49. package/dist/kanban/store.js +358 -25
  50. package/dist/llm/registry.d.ts +7 -1
  51. package/dist/llm/registry.js +7 -1
  52. package/dist/plugins/init.d.ts +7 -0
  53. package/dist/plugins/init.js +22 -1
  54. package/dist/plugins/loader.d.ts +7 -1
  55. package/dist/plugins/loader.js +12 -4
  56. package/dist/project/index.d.ts +1 -1
  57. package/dist/project/index.js +1 -1
  58. package/dist/project/project.d.ts +23 -0
  59. package/dist/project/project.js +65 -0
  60. package/dist/project/trust.d.ts +65 -0
  61. package/dist/project/trust.js +328 -0
  62. package/dist/server/agent-manager.d.ts +6 -2
  63. package/dist/server/agent-manager.js +4 -2
  64. package/dist/server/app.js +45 -30
  65. package/dist/server/auth-manager.d.ts +15 -4
  66. package/dist/server/auth-manager.js +41 -24
  67. package/dist/server/context.js +11 -1
  68. package/dist/server/index.d.ts +1 -1
  69. package/dist/server/index.js +1 -1
  70. package/dist/server/middleware/auth.d.ts +11 -1
  71. package/dist/server/middleware/auth.js +25 -3
  72. package/dist/server/permission/interactive.d.ts +5 -0
  73. package/dist/server/permission/interactive.js +4 -1
  74. package/dist/server/permission/store.d.ts +2 -0
  75. package/dist/server/permission/store.js +8 -0
  76. package/dist/server/routes/auth.js +35 -9
  77. package/dist/server/routes/chat.d.ts +16 -7
  78. package/dist/server/routes/chat.js +483 -28
  79. package/dist/server/routes/commands.d.ts +2 -2
  80. package/dist/server/routes/commands.js +4 -6
  81. package/dist/server/routes/config.js +81 -4
  82. package/dist/server/routes/files.js +120 -24
  83. package/dist/server/routes/kanban.js +152 -22
  84. package/dist/server/routes/permissions.js +7 -0
  85. package/dist/server/routes/project.js +76 -22
  86. package/dist/server/routes/provider.js +6 -0
  87. package/dist/server/routes/session.js +177 -13
  88. package/dist/server/routes/tool.js +24 -5
  89. package/dist/server/routes/update.js +58 -2
  90. package/dist/server/routes/usage.d.ts +4 -0
  91. package/dist/server/routes/usage.js +133 -0
  92. package/dist/server/routes/workflows.js +476 -87
  93. package/dist/server/server.d.ts +12 -1
  94. package/dist/server/server.js +194 -33
  95. package/dist/server/terminal/pty-manager.d.ts +12 -0
  96. package/dist/server/terminal/pty-manager.js +63 -7
  97. package/dist/server/types.d.ts +9 -0
  98. package/dist/session/branch.d.ts +2 -1
  99. package/dist/session/branch.js +75 -10
  100. package/dist/session/context.js +28 -2
  101. package/dist/session/index.d.ts +1 -1
  102. package/dist/session/index.js +1 -1
  103. package/dist/session/session.d.ts +67 -10
  104. package/dist/session/session.js +180 -67
  105. package/dist/session/types.d.ts +2 -0
  106. package/dist/session/usage.d.ts +62 -0
  107. package/dist/session/usage.js +194 -0
  108. package/dist/shared/types/agent.d.ts +19 -3
  109. package/dist/shared/types/config.d.ts +59 -4
  110. package/dist/shared/types/kanban.d.ts +4 -2
  111. package/dist/shared/types/message.d.ts +14 -0
  112. package/dist/tools/builtin/kanban.js +13 -2
  113. package/dist/tools/index.d.ts +14 -3
  114. package/dist/tools/index.js +23 -5
  115. package/dist/tools/websearch/index.d.ts +5 -1
  116. package/dist/tools/websearch/index.js +40 -4
  117. package/dist/tools/websearch/providers/brave.js +2 -2
  118. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  119. package/dist/tools/websearch/providers/tavily.js +2 -2
  120. package/dist/tools/websearch/types.d.ts +5 -1
  121. package/dist/tools/websearch/types.js +7 -1
  122. package/dist/update/scheduler.js +5 -2
  123. package/dist/update/snapshot.d.ts +17 -3
  124. package/dist/update/snapshot.js +3 -2
  125. package/dist/update/updater.d.ts +2 -0
  126. package/dist/update/updater.js +2 -1
  127. package/dist/update/version.d.ts +3 -0
  128. package/dist/update/version.js +9 -3
  129. package/drizzle/0010_usage_events_ledger.sql +17 -0
  130. package/drizzle/0011_slow_slapstick.sql +12 -0
  131. package/drizzle/0012_chief_timeslip.sql +1 -0
  132. package/drizzle/0013_swift_zeigeist.sql +1 -0
  133. package/drizzle/0014_flimsy_legion.sql +1 -0
  134. package/drizzle/meta/0010_snapshot.json +1010 -0
  135. package/drizzle/meta/0011_snapshot.json +1055 -0
  136. package/drizzle/meta/0012_snapshot.json +1062 -0
  137. package/drizzle/meta/0013_snapshot.json +1068 -0
  138. package/drizzle/meta/0014_snapshot.json +1075 -0
  139. package/drizzle/meta/_journal.json +35 -0
  140. package/package.json +24 -23
  141. package/dist/core/agents/discovery.d.ts +0 -6
  142. package/dist/core/agents/discovery.js +0 -96
@@ -1,15 +1,51 @@
1
- import { and, asc, eq, max } from 'drizzle-orm';
1
+ import { and, asc, eq, inArray, isNotNull, isNull, max, sql } from 'drizzle-orm';
2
2
  import { kanbanBoards, kanbanCards } from '../db/schema.js';
3
3
  import { DEFAULT_KANBAN_COLUMNS } from '../shared/types/kanban.js';
4
4
  /** Default column when none is specified. */
5
5
  const DEFAULT_COLUMN_ID = 'todo';
6
6
  /** Position increment — large gap avoids frequent re-indexing on reorder. */
7
7
  const POSITION_GAP = 1000;
8
+ /** 合法优先级枚举(运行时校验用——REST/tool 层输入未经 TS 约束)。 */
9
+ const VALID_PRIORITIES = new Set(['high', 'medium', 'low']);
8
10
  /** 列内仍有卡片时禁止删除该列(否则卡片静默不可见)。 */
9
11
  class KanbanColumnInUseError extends Error {
10
- constructor(columnNames, cardCount) {
12
+ constructor(columnNames, cardCount, cardIds = []) {
13
+ const idHint = cardIds.length > 0
14
+ ? `(示例卡片 id:${cardIds.slice(0, 5).join('、')}${cardIds.length > 5 ? ' 等' : ''},可直接按 id 删除这些卡片)`
15
+ : '';
11
16
  super(`列 [${columnNames.join(', ')}] 中仍有 ${cardCount} 张卡片,无法删除。` +
12
- '请先移动或删除这些卡片。');
17
+ `请先移动或删除这些卡片。${idHint}`);
18
+ }
19
+ }
20
+ /**
21
+ * 目标列不存在(add/move 引用未配置的列)——与 import/删列的悬空列防护同口径:
22
+ * 此前运行时 add/move 不校验,LLM 幻觉列名写入后卡片静默不可见,且幽灵卡片
23
+ * 会反过来冻结列配置删除(KanbanColumnInUseError 死锁)。
24
+ * 错误信息回传可用列清单,供 LLM 自纠而非再猜。
25
+ */
26
+ class KanbanColumnNotFoundError extends Error {
27
+ columnId;
28
+ constructor(columnId, availableColumns, op) {
29
+ const avail = availableColumns.length > 0 ? availableColumns.join('、') : '(看板无列)';
30
+ super(`列 "${columnId}" 不存在,无法${op === 'add' ? '创建' : '移动'}卡片。` +
31
+ `可用列:${avail}。请改用其中一个列 id。`);
32
+ this.columnId = columnId;
33
+ }
34
+ }
35
+ /** 非法优先级(REST PATCH / kanbanTool 透传未校验值)。 */
36
+ class KanbanInvalidPriorityError extends Error {
37
+ value;
38
+ constructor(value) {
39
+ super(`无效优先级 "${value}",允许值:high、medium、low`);
40
+ this.value = value;
41
+ }
42
+ }
43
+ /** 卡片不存在(update/move 目标 id 无效)。 */
44
+ class KanbanCardNotFoundError extends Error {
45
+ cardId;
46
+ constructor(cardId) {
47
+ super(`Kanban card not found: ${cardId}`);
48
+ this.cardId = cardId;
13
49
  }
14
50
  }
15
51
  // ── Row → API mappers ──────────────────────────────────────
@@ -57,9 +93,14 @@ function createKanbanStore(handle, projectId) {
57
93
  const [row] = await db
58
94
  .select({ id: kanbanBoards.id })
59
95
  .from(kanbanBoards)
60
- .where(eq(kanbanBoards.projectId, projectId))
96
+ .where(and(eq(kanbanBoards.projectId, projectId), isNull(kanbanBoards.deletedAt)))
61
97
  .limit(1);
62
- // 行一定存在:上面 insert + onConflictDoNothing 保证了 projectId 对应的行已创建
98
+ // 正常路径行一定存在(insert + onConflictDoNothing 已保证)。防御性兜底:
99
+ // 唯一 projectId 槽位被软删行占用等异常态下不再返回 undefined id
100
+ //(后续查询会静默查空、上层误判「板不存在」),显式抛错暴露问题。
101
+ if (!row) {
102
+ throw new Error(`Kanban board for project ${projectId} missing after ensure-insert (unique slot likely held by a soft-deleted row)`);
103
+ }
63
104
  return row.id;
64
105
  }
65
106
  /** Max position in a column (0 if empty). */
@@ -70,7 +111,45 @@ function createKanbanStore(handle, projectId) {
70
111
  .where(and(eq(kanbanCards.boardId, boardId), eq(kanbanCards.columnId, columnId)));
71
112
  return row?.m ?? 0;
72
113
  }
114
+ /** 当前板的列配置(add/move 的悬空列校验用)。 */
115
+ async function boardColumns(boardId) {
116
+ const [row] = await db
117
+ .select({ columns: kanbanBoards.columns })
118
+ .from(kanbanBoards)
119
+ .where(eq(kanbanBoards.id, boardId))
120
+ .limit(1);
121
+ return (row?.columns ?? []);
122
+ }
123
+ /** 校验列存在;不存在抛 KanbanColumnNotFoundError(消息含可用列清单)。 */
124
+ function assertColumn(columns, columnId, op) {
125
+ if (!columns.some((c) => c.id === columnId)) {
126
+ throw new KanbanColumnNotFoundError(columnId, columns.map((c) => c.id), op);
127
+ }
128
+ }
129
+ /** 校验优先级合法;非法抛 KanbanInvalidPriorityError。 */
130
+ function assertPriority(priority) {
131
+ if (priority !== undefined && !VALID_PRIORITIES.has(priority)) {
132
+ throw new KanbanInvalidPriorityError(String(priority));
133
+ }
134
+ }
73
135
  return {
136
+ /** 只读获取板(不创建):导出等纯读路径用——此前导出经 getBoard 懒建板,
137
+ * 「导出」一个从未打开过看板的项目会凭空产生空板行。无板返回 null。 */
138
+ async peekBoard() {
139
+ const [boardRow] = await db
140
+ .select()
141
+ .from(kanbanBoards)
142
+ .where(and(eq(kanbanBoards.projectId, projectId), isNull(kanbanBoards.deletedAt)))
143
+ .limit(1);
144
+ if (!boardRow)
145
+ return null;
146
+ const cards = await db
147
+ .select()
148
+ .from(kanbanCards)
149
+ .where(eq(kanbanCards.boardId, boardRow.id))
150
+ .orderBy(asc(kanbanCards.columnId), asc(kanbanCards.position));
151
+ return { ...rowToBoard(boardRow), cards: cards.map(rowToCard) };
152
+ },
74
153
  async getBoard() {
75
154
  const boardId = await getOrCreateBoardId();
76
155
  const [boardRow] = await db
@@ -88,7 +167,12 @@ function createKanbanStore(handle, projectId) {
88
167
  },
89
168
  async addCard(input) {
90
169
  const boardId = await getOrCreateBoardId();
91
- const columnId = input.columnId ?? DEFAULT_COLUMN_ID;
170
+ const columns = await boardColumns(boardId);
171
+ // 默认列 = 板上第一列(而非硬编码 'todo'):todo 列被删除/重排后
172
+ // 硬编码默认会让无参 addCard 静默落入不存在的列(卡片不可见)。
173
+ const columnId = input.columnId ?? columns[0]?.id ?? DEFAULT_COLUMN_ID;
174
+ assertColumn(columns, columnId, 'add');
175
+ assertPriority(input.priority);
92
176
  const position = (await maxPos(boardId, columnId)) + POSITION_GAP;
93
177
  const [cardRow] = await db
94
178
  .insert(kanbanCards)
@@ -106,6 +190,7 @@ function createKanbanStore(handle, projectId) {
106
190
  return rowToCard(row);
107
191
  },
108
192
  async updateCard(id, patch) {
193
+ assertPriority(patch.priority);
109
194
  const [row] = await db
110
195
  .update(kanbanCards)
111
196
  .set({
@@ -118,30 +203,29 @@ function createKanbanStore(handle, projectId) {
118
203
  .where(eq(kanbanCards.id, id))
119
204
  .returning();
120
205
  if (!row)
121
- throw new Error(`Kanban card not found: ${id}`);
206
+ throw new KanbanCardNotFoundError(id);
122
207
  return rowToCard(row);
123
208
  },
124
209
  async moveCard(id, columnId, position) {
210
+ const [card] = await db
211
+ .select({ boardId: kanbanCards.boardId })
212
+ .from(kanbanCards)
213
+ .where(eq(kanbanCards.id, id))
214
+ .limit(1);
215
+ if (!card)
216
+ throw new KanbanCardNotFoundError(id);
217
+ // 悬空列校验:目标列必须存在于当前列配置,否则卡片静默不可见。
218
+ const columns = await boardColumns(card.boardId);
219
+ assertColumn(columns, columnId, 'move');
125
220
  // If no explicit position, append to end of target column.
126
- let newPos = position;
127
- if (newPos === undefined) {
128
- const [card] = await db
129
- .select({ boardId: kanbanCards.boardId })
130
- .from(kanbanCards)
131
- .where(eq(kanbanCards.id, id))
132
- .limit(1);
133
- if (!card)
134
- throw new Error(`Kanban card not found: ${id}`);
135
- newPos = (await maxPos(card.boardId, columnId)) + POSITION_GAP;
136
- }
221
+ const newPos = position ?? (await maxPos(card.boardId, columnId)) + POSITION_GAP;
137
222
  const [row] = await db
138
223
  .update(kanbanCards)
139
224
  .set({ columnId, position: newPos, updatedAt: new Date() })
140
225
  .where(eq(kanbanCards.id, id))
141
226
  .returning();
142
- if (!row)
143
- throw new Error(`Kanban card not found: ${id}`);
144
- return rowToCard(row);
227
+ const moved = row;
228
+ return rowToCard(moved);
145
229
  },
146
230
  async deleteCard(id) {
147
231
  await db.delete(kanbanCards).where(eq(kanbanCards.id, id));
@@ -151,19 +235,21 @@ function createKanbanStore(handle, projectId) {
151
235
  // P1-4:删除列前校验该列内是否有卡片;删除标签前把悬空 labelId 从卡片上清掉。
152
236
  if (patch.columns !== undefined) {
153
237
  const cards = await db
154
- .select({ columnId: kanbanCards.columnId })
238
+ .select({ id: kanbanCards.id, columnId: kanbanCards.columnId })
155
239
  .from(kanbanCards)
156
240
  .where(eq(kanbanCards.boardId, boardId));
157
241
  const newColumnIds = new Set(patch.columns.map((c) => c.id));
158
242
  const removedWithCards = new Map();
243
+ const orphanCardIds = [];
159
244
  for (const card of cards) {
160
245
  if (!newColumnIds.has(card.columnId)) {
161
246
  removedWithCards.set(card.columnId, (removedWithCards.get(card.columnId) ?? 0) + 1);
247
+ orphanCardIds.push(card.id);
162
248
  }
163
249
  }
164
250
  if (removedWithCards.size > 0) {
165
251
  const total = Array.from(removedWithCards.values()).reduce((a, b) => a + b, 0);
166
- throw new KanbanColumnInUseError(Array.from(removedWithCards.keys()), total);
252
+ throw new KanbanColumnInUseError(Array.from(removedWithCards.keys()), total, orphanCardIds);
167
253
  }
168
254
  }
169
255
  if (patch.labels !== undefined) {
@@ -187,7 +273,7 @@ function createKanbanStore(handle, projectId) {
187
273
  ...(patch.labels !== undefined && { labels: patch.labels }),
188
274
  updatedAt: new Date(),
189
275
  })
190
- .where(eq(kanbanBoards.projectId, projectId))
276
+ .where(and(eq(kanbanBoards.projectId, projectId), isNull(kanbanBoards.deletedAt)))
191
277
  .returning();
192
278
  const row = boardRow;
193
279
  return rowToBoard(row);
@@ -229,4 +315,251 @@ function createKanbanStore(handle, projectId) {
229
315
  },
230
316
  };
231
317
  }
232
- export { createKanbanStore, KanbanColumnInUseError };
318
+ export { createKanbanStore, KanbanCardNotFoundError, KanbanColumnInUseError, KanbanColumnNotFoundError, KanbanInvalidPriorityError, };
319
+ /** 把活动看板软删除进回收站(记录原项目名与工作目录)。项目行删除后 FK 将 projectId 置 null。 */
320
+ export async function softDeleteKanbanBoard(handle, projectId, projectName, worktree) {
321
+ const rows = await handle.db
322
+ .update(kanbanBoards)
323
+ .set({
324
+ deletedAt: new Date(),
325
+ deletedProjectName: projectName ?? undefined,
326
+ deletedProjectWorktree: worktree ?? undefined,
327
+ // 恢复后重删的看板须重新经历「到期 → 宽限」周期,宽限标记不跨删除周期生效。
328
+ purgePendingAt: null,
329
+ updatedAt: new Date(),
330
+ })
331
+ .where(and(eq(kanbanBoards.projectId, projectId), isNull(kanbanBoards.deletedAt)))
332
+ .returning({ id: kanbanBoards.id });
333
+ return rows.length > 0;
334
+ }
335
+ /** 行 + 卡片数 → 回收站条目(时间戳统一转 ms,兼容 Date/null/字符串)。 */
336
+ function toDeletedBoard(b, cardCount) {
337
+ const toMs = (d) => {
338
+ if (d == null)
339
+ return null;
340
+ const t = d instanceof Date ? d.getTime() : new Date(d).getTime();
341
+ return Number.isFinite(t) ? t : null;
342
+ };
343
+ return {
344
+ id: b.id,
345
+ projectName: b.deletedProjectName ?? '未知项目',
346
+ cardCount,
347
+ deletedAt: toMs(b.deletedAt) ?? 0,
348
+ purgePendingAt: toMs(b.purgePendingAt),
349
+ deletedProjectWorktree: b.deletedProjectWorktree ?? null,
350
+ };
351
+ }
352
+ /** 回收站看板列表(含卡片数)。 */
353
+ export async function listDeletedKanbanBoards(handle) {
354
+ const boards = await handle.db
355
+ .select()
356
+ .from(kanbanBoards)
357
+ .where(isNotNull(kanbanBoards.deletedAt))
358
+ .orderBy(asc(kanbanBoards.deletedAt));
359
+ if (boards.length === 0)
360
+ return [];
361
+ const counts = await handle.db
362
+ .select({ boardId: kanbanCards.boardId, n: sql `count(*)::int` })
363
+ .from(kanbanCards)
364
+ .where(inArray(kanbanCards.boardId, boards.map((b) => b.id)))
365
+ .groupBy(kanbanCards.boardId);
366
+ const countMap = new Map(counts.map((c) => [c.boardId, c.n]));
367
+ return boards.map((b) => toDeletedBoard(b, countMap.get(b.id) ?? 0));
368
+ }
369
+ /** 按 id 取单个回收站看板(供「重建原项目」恢复读取原工作目录)。 */
370
+ export async function getDeletedKanbanBoard(handle, boardId) {
371
+ const [b] = await handle.db
372
+ .select()
373
+ .from(kanbanBoards)
374
+ .where(and(eq(kanbanBoards.id, boardId), isNotNull(kanbanBoards.deletedAt)))
375
+ .limit(1);
376
+ if (!b)
377
+ return null;
378
+ const counts = await handle.db
379
+ .select({ n: sql `count(*)::int` })
380
+ .from(kanbanCards)
381
+ .where(eq(kanbanCards.boardId, boardId));
382
+ return toDeletedBoard(b, counts[0]?.n ?? 0);
383
+ }
384
+ /** 恢复看板到指定项目:目标项目已有活动看板 → 409 语义(不覆盖用户现有看板)。
385
+ * P2 修复:检查与更新放进单事务,并发下撞 uq_kanban_boards_project 唯一索引时
386
+ * 降级为 TARGET_HAS_BOARD(此前抛 PG 错误 500)。 */
387
+ export async function restoreKanbanBoard(handle, boardId, projectId) {
388
+ try {
389
+ return await handle.db.transaction(async (tx) => {
390
+ const [active] = await tx
391
+ .select({ id: kanbanBoards.id })
392
+ .from(kanbanBoards)
393
+ .where(and(eq(kanbanBoards.projectId, projectId), isNull(kanbanBoards.deletedAt)))
394
+ .limit(1);
395
+ if (active)
396
+ return { ok: false, reason: 'TARGET_HAS_BOARD' };
397
+ const rows = await tx
398
+ .update(kanbanBoards)
399
+ .set({
400
+ projectId,
401
+ deletedAt: null,
402
+ deletedProjectName: null,
403
+ deletedProjectWorktree: null,
404
+ purgePendingAt: null,
405
+ updatedAt: new Date(),
406
+ })
407
+ .where(and(eq(kanbanBoards.id, boardId), isNotNull(kanbanBoards.deletedAt)))
408
+ .returning({ id: kanbanBoards.id });
409
+ if (rows.length === 0)
410
+ return { ok: false, reason: 'BOARD_NOT_FOUND' };
411
+ return { ok: true };
412
+ });
413
+ }
414
+ catch (err) {
415
+ // PG 23505 = unique_violation:并发窗口内目标板被创建。
416
+ if (err.code === '23505') {
417
+ return { ok: false, reason: 'TARGET_HAS_BOARD' };
418
+ }
419
+ throw err;
420
+ }
421
+ }
422
+ /**
423
+ * P1 修复:把回收站看板合并进目标项目的活动看板(TARGET_HAS_BOARD 死胡同的出口)。
424
+ * 此前「目标项目已有看板」时恢复 409,而产品没有删除活动看板的能力,用户无法
425
+ * 把旧项目卡片拿回现有项目——现在 merge 模式下:
426
+ * - 目标无活动看板 → 等价普通恢复(重新归属);
427
+ * - 目标有活动看板 → 追加缺失列,卡片并入对应列末尾(position 重新计算避免
428
+ * 与目标卡片重叠);源卡片引用源板已不存在列时落到目标第一列(保证可见);
429
+ * - 合并成功后源看板物理删除(卡片已迁移,FK cascade 清源卡)。
430
+ * 全程单事务:失败整体回滚,不残留半合并状态。
431
+ */
432
+ export async function mergeKanbanBoard(handle, boardId, projectId) {
433
+ return await handle.db.transaction(async (tx) => {
434
+ const [src] = await tx
435
+ .select({ id: kanbanBoards.id, columns: kanbanBoards.columns })
436
+ .from(kanbanBoards)
437
+ .where(and(eq(kanbanBoards.id, boardId), isNotNull(kanbanBoards.deletedAt)))
438
+ .limit(1);
439
+ if (!src)
440
+ return { ok: false, reason: 'BOARD_NOT_FOUND' };
441
+ const [target] = await tx
442
+ .select({ id: kanbanBoards.id, columns: kanbanBoards.columns })
443
+ .from(kanbanBoards)
444
+ .where(and(eq(kanbanBoards.projectId, projectId), isNull(kanbanBoards.deletedAt)))
445
+ .limit(1);
446
+ // 目标无活动看板 → 重新归属(等价普通恢复,但保持 merge 调用幂等语义)。
447
+ if (!target) {
448
+ const rows = await tx
449
+ .update(kanbanBoards)
450
+ .set({
451
+ projectId,
452
+ deletedAt: null,
453
+ deletedProjectName: null,
454
+ deletedProjectWorktree: null,
455
+ purgePendingAt: null,
456
+ updatedAt: new Date(),
457
+ })
458
+ .where(and(eq(kanbanBoards.id, boardId), isNotNull(kanbanBoards.deletedAt)))
459
+ .returning({ id: kanbanBoards.id });
460
+ if (rows.length === 0)
461
+ return { ok: false, reason: 'BOARD_NOT_FOUND' };
462
+ return { ok: true, mergedColumns: 0, mergedCards: 0 };
463
+ }
464
+ // —— 合并路径 ——
465
+ const srcCols = (src.columns ?? []);
466
+ const targetCols = (target.columns ?? []);
467
+ const targetIds = new Set(targetCols.map((c) => c.id));
468
+ const newCols = srcCols.filter((c) => !targetIds.has(c.id));
469
+ if (newCols.length > 0) {
470
+ await tx
471
+ .update(kanbanBoards)
472
+ .set({ columns: [...targetCols, ...newCols], updatedAt: new Date() })
473
+ .where(eq(kanbanBoards.id, target.id));
474
+ }
475
+ const mergedCols = [...targetCols, ...newCols];
476
+ const fallbackCol = mergedCols[0]?.id ?? DEFAULT_COLUMN_ID;
477
+ const srcCards = await tx
478
+ .select()
479
+ .from(kanbanCards)
480
+ .where(eq(kanbanCards.boardId, boardId))
481
+ .orderBy(asc(kanbanCards.position));
482
+ // 目标各列现有 max(position):合并卡片追加到末尾,避免 position 重叠。
483
+ const targetMax = await tx
484
+ .select({ columnId: kanbanCards.columnId, m: max(kanbanCards.position) })
485
+ .from(kanbanCards)
486
+ .where(eq(kanbanCards.boardId, target.id))
487
+ .groupBy(kanbanCards.columnId);
488
+ const maxMap = new Map(targetMax.map((r) => [r.columnId, r.m ?? 0]));
489
+ const counters = new Map();
490
+ let mergedCards = 0;
491
+ for (const card of srcCards) {
492
+ const colId = mergedCols.some((c) => c.id === card.columnId) ? card.columnId : fallbackCol;
493
+ const idx = counters.get(colId) ?? 0;
494
+ counters.set(colId, idx + 1);
495
+ await tx.insert(kanbanCards).values({
496
+ boardId: target.id,
497
+ title: card.title,
498
+ description: card.description,
499
+ columnId: colId,
500
+ priority: card.priority,
501
+ position: (maxMap.get(colId) ?? 0) + POSITION_GAP * (idx + 1),
502
+ labels: card.labels ?? [],
503
+ });
504
+ mergedCards += 1;
505
+ }
506
+ // 源看板物理删除(卡片已迁移;卡片行经 FK cascade 连带清除)。
507
+ await tx.delete(kanbanBoards).where(eq(kanbanBoards.id, boardId));
508
+ return { ok: true, mergedColumns: newCols.length, mergedCards };
509
+ });
510
+ }
511
+ /** 回收站内彻底删除看板(不可恢复)。 */
512
+ export async function permanentlyDeleteKanbanBoard(handle, boardId) {
513
+ const rows = await handle.db
514
+ .delete(kanbanBoards)
515
+ .where(and(eq(kanbanBoards.id, boardId), isNotNull(kanbanBoards.deletedAt)))
516
+ .returning({ id: kanbanBoards.id });
517
+ return rows.length;
518
+ }
519
+ /**
520
+ * 看板回收站两阶段清理(与会话 purgeDeletedSessions 同策略,杜绝「到期即静默清空」):
521
+ * - 阶段一:软删除超过 retentionMs 且尚未标记 → 写 purgePendingAt(进入宽限期,
522
+ * UI 显示「即将清除」仍可恢复);
523
+ * - 阶段二:purgePendingAt 早于 graceMs 截止 → 物理清除(卡片经 FK cascade 连带删除)。
524
+ * 看板回收站全局单一分组、无「首次查看才起算」语义,保留期自删除时刻起算,稳定可预期。
525
+ */
526
+ export async function purgeDeletedKanbanBoards(handle, retentionMs, graceMs) {
527
+ const retentionCutoff = new Date(Date.now() - retentionMs);
528
+ const graceCutoff = new Date(Date.now() - graceMs);
529
+ const rows = await handle.db
530
+ .select({
531
+ id: kanbanBoards.id,
532
+ deletedAt: kanbanBoards.deletedAt,
533
+ purgePendingAt: kanbanBoards.purgePendingAt,
534
+ })
535
+ .from(kanbanBoards)
536
+ .where(isNotNull(kanbanBoards.deletedAt));
537
+ // —— 阶段一:到期标记(先宽限,不直接清除)——
538
+ let marked = 0;
539
+ for (const r of rows) {
540
+ if (!r.deletedAt)
541
+ continue;
542
+ if (r.purgePendingAt)
543
+ continue;
544
+ if (r.deletedAt.getTime() >= retentionCutoff.getTime())
545
+ continue;
546
+ await handle.db
547
+ .update(kanbanBoards)
548
+ .set({ purgePendingAt: new Date(), updatedAt: new Date() })
549
+ .where(eq(kanbanBoards.id, r.id));
550
+ marked += 1;
551
+ }
552
+ // —— 阶段二:宽限期满物理清除 ——
553
+ const pendingIds = rows
554
+ .filter((r) => r.purgePendingAt && r.purgePendingAt.getTime() < graceCutoff.getTime())
555
+ .map((r) => r.id);
556
+ let deleted = 0;
557
+ if (pendingIds.length > 0) {
558
+ const removed = await handle.db
559
+ .delete(kanbanBoards)
560
+ .where(inArray(kanbanBoards.id, pendingIds))
561
+ .returning({ id: kanbanBoards.id });
562
+ deleted = removed.length;
563
+ }
564
+ return { marked, deleted };
565
+ }
@@ -81,7 +81,13 @@ declare const setRole: (registry: Registry, role: ModelRole, provider: string, m
81
81
  * live request handlers (e.g. config hot-reload).
82
82
  */
83
83
  declare const rebuildRegistry: (registry: Registry, builder: (next: Registry) => void) => void;
84
+ /**
85
+ * 内置价目表版本(H3):记录 builtinCapabilities 最近一次人工核价的日期。
86
+ * LLM 价格频繁变动,前端在用量/成本面板展示此版本提醒「估算可能过期」;
87
+ * 发布新版本时同步核对 builtinCapabilities 并更新此常量(release checklist)。
88
+ */
89
+ declare const PRICE_CATALOG_VERSION = "2026-09-09";
84
90
  /** Default role + a starter catalog of well-known models. */
85
91
  declare const builtinCapabilities: Record<string, Record<string, ModelCapabilities>>;
86
92
  export type { ProviderInput, Registry, ResolveResult, RouteEntry, RouteTable };
87
- export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overrideToCapabilities, rebuildRegistry, registerProvider, resolveModelByRole, resolveRoute, setRole, };
93
+ export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overrideToCapabilities, PRICE_CATALOG_VERSION, rebuildRegistry, registerProvider, resolveModelByRole, resolveRoute, setRole, };
@@ -117,6 +117,12 @@ const rebuildRegistry = (registry, builder) => {
117
117
  builder(next);
118
118
  registry.table = next.table;
119
119
  };
120
+ /**
121
+ * 内置价目表版本(H3):记录 builtinCapabilities 最近一次人工核价的日期。
122
+ * LLM 价格频繁变动,前端在用量/成本面板展示此版本提醒「估算可能过期」;
123
+ * 发布新版本时同步核对 builtinCapabilities 并更新此常量(release checklist)。
124
+ */
125
+ const PRICE_CATALOG_VERSION = '2026-09-09';
120
126
  /** Default role + a starter catalog of well-known models. */
121
127
  const builtinCapabilities = {
122
128
  openai: {
@@ -151,4 +157,4 @@ const builtinCapabilities = {
151
157
  },
152
158
  },
153
159
  };
154
- export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overrideToCapabilities, rebuildRegistry, registerProvider, resolveModelByRole, resolveRoute, setRole, };
160
+ export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overrideToCapabilities, PRICE_CATALOG_VERSION, rebuildRegistry, registerProvider, resolveModelByRole, resolveRoute, setRole, };
@@ -7,6 +7,11 @@ type InitPluginsOptions = {
7
7
  toolRegistry: unknown;
8
8
  /** LLM 注册表;插件通过 registerProvider 往这里注入。 */
9
9
  llmRegistry: unknown;
10
+ /**
11
+ * P0-2:项目已显式信任(projects.trustedAt 非空)时才加载项目 .c0de/plugins。
12
+ * 缺省 false(fail-closed):克隆仓库自带插件不会在 serve/chat 时静默执行。
13
+ */
14
+ projectTrusted?: boolean;
10
15
  };
11
16
  type InitPluginsResult = {
12
17
  pluginRegistry: PluginRegistry;
@@ -17,6 +22,8 @@ type InitPluginsResult = {
17
22
  *
18
23
  * 失败的外部插件被静默跳过(loader 已处理),单个坏插件不影响其余加载。
19
24
  * 内置插件(tool-audit-log / write-guard)始终激活。
25
+ * P0-2:项目插件仅在项目被显式信任后加载(跳过时若目录非空则告警并给出
26
+ * 信任途径);全局插件是用户本机显式放置,始终加载。
20
27
  */
21
28
  declare function initPlugins(opts: InitPluginsOptions): Promise<InitPluginsResult>;
22
29
  export type { InitPluginsOptions, InitPluginsResult };
@@ -1,3 +1,8 @@
1
+ // 插件系统初始化编排(spec §7):创建 hookRunner + pluginRegistry,激活内置插件,
2
+ // 发现并加载外部插件(.c0de/plugins + ~/.c0de/plugins)。供 server bootstrap 与
3
+ // CLI buildAgentDeps 复用——两处启动路径此前都未接入,导致插件框架存在却永不加载。
4
+ import { existsSync, readdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
1
6
  import { registerBuiltinHooks } from './builtin.js';
2
7
  import { createHookRunner } from './hooks.js';
3
8
  import { activatePlugin } from './lifecycle.js';
@@ -8,6 +13,8 @@ import { createPluginRegistry, registerPlugin } from './registry.js';
8
13
  *
9
14
  * 失败的外部插件被静默跳过(loader 已处理),单个坏插件不影响其余加载。
10
15
  * 内置插件(tool-audit-log / write-guard)始终激活。
16
+ * P0-2:项目插件仅在项目被显式信任后加载(跳过时若目录非空则告警并给出
17
+ * 信任途径);全局插件是用户本机显式放置,始终加载。
11
18
  */
12
19
  async function initPlugins(opts) {
13
20
  const hookRunner = createHookRunner();
@@ -20,7 +27,21 @@ async function initPlugins(opts) {
20
27
  // 内置插件先激活:它们注册 tool:before/after hook,对后续外部插件同样生效。
21
28
  await registerBuiltinHooks(pluginRegistry, services);
22
29
  // 发现并加载外部插件(项目 .c0de/plugins 与全局 ~/.c0de/plugins)。
23
- const discovered = await discoverPlugins(opts.cwd);
30
+ const projectPluginsDir = join(opts.cwd, '.c0de', 'plugins');
31
+ if (opts.projectTrusted !== true) {
32
+ try {
33
+ if (existsSync(projectPluginsDir) && readdirSync(projectPluginsDir).length > 0) {
34
+ console.warn(`[plugin] 项目插件目录 ${projectPluginsDir} 存在插件,但项目尚未被信任(或信任后风险配置/插件代码已变更)——已跳过加载。\n` +
35
+ ` 信任/重新确认方式:c0de trust(CLI),或在 Web 界面遇到信任确认时点击「信任项目」;信任后重启 serve 生效。`);
36
+ }
37
+ }
38
+ catch {
39
+ // 目录读取失败不影响启动
40
+ }
41
+ }
42
+ const discovered = await discoverPlugins(opts.cwd, {
43
+ includeProject: opts.projectTrusted === true,
44
+ });
24
45
  for (const { plugin } of discovered) {
25
46
  registerPlugin(pluginRegistry, plugin);
26
47
  await activatePlugin(pluginRegistry, plugin, services);
@@ -8,7 +8,13 @@ type ValidationResult = {
8
8
  };
9
9
  declare function validatePluginModule(mod: unknown): ValidationResult;
10
10
  declare function loadPlugin(path: string): Promise<Plugin>;
11
- declare function discoverPlugins(projectDir: string): Promise<{
11
+ declare function discoverPlugins(projectDir: string,
12
+ /** P0-2:includeProject=false 时跳过项目 .c0de/plugins(未信任项目不加载,
13
+ * 信任后重启生效);全局插件目录(默认 ~/.c0de/plugins,C0DE_CONFIG_DIR 可重定向)
14
+ * 始终加载(用户本机显式放置)。 */
15
+ opts?: {
16
+ includeProject?: boolean;
17
+ }): Promise<{
12
18
  name: string;
13
19
  path: string;
14
20
  plugin: Plugin;
@@ -1,7 +1,7 @@
1
1
  // src/plugins/loader.ts
2
2
  import { existsSync, readdirSync, statSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import { join } from 'node:path';
4
+ import { resolveGlobalConfigDir } from '../core/config.js';
5
5
  function validatePluginModule(mod) {
6
6
  const candidate = mod?.default ?? mod;
7
7
  if (typeof candidate !== 'object' || candidate === null) {
@@ -49,10 +49,18 @@ function scanPluginDir(dir) {
49
49
  }
50
50
  return plugins;
51
51
  }
52
- async function discoverPlugins(projectDir) {
52
+ async function discoverPlugins(projectDir,
53
+ /** P0-2:includeProject=false 时跳过项目 .c0de/plugins(未信任项目不加载,
54
+ * 信任后重启生效);全局插件目录(默认 ~/.c0de/plugins,C0DE_CONFIG_DIR 可重定向)
55
+ * 始终加载(用户本机显式放置)。 */
56
+ opts = {}) {
53
57
  const projectPluginsDir = join(projectDir, '.c0de', 'plugins');
54
- const globalPluginsDir = join(homedir(), '.c0de', 'plugins');
55
- const discovered = [...scanPluginDir(projectPluginsDir), ...scanPluginDir(globalPluginsDir)];
58
+ // 全局插件根(默认 ~/.c0de/plugins,C0DE_CONFIG_DIR 可重定向)。
59
+ const globalPluginsDir = join(resolveGlobalConfigDir(), 'plugins');
60
+ const discovered = [
61
+ ...(opts.includeProject === false ? [] : scanPluginDir(projectPluginsDir)),
62
+ ...scanPluginDir(globalPluginsDir),
63
+ ];
56
64
  const results = [];
57
65
  for (const entry of discovered) {
58
66
  try {
@@ -1,4 +1,4 @@
1
1
  export type { Project } from './project.js';
2
- export { fromDirectory, getByDirectory, getProject, listProjects, relocateProject, updateProjectName, } from './project.js';
2
+ export { enforceProjectTrust, fromDirectory, getByDirectory, getProject, listProjects, relocateProject, trustProject, updateProjectName, } from './project.js';
3
3
  export type { ResolvedProject } from './resolve.js';
4
4
  export { resolveProject } from './resolve.js';
@@ -1,2 +1,2 @@
1
- export { fromDirectory, getByDirectory, getProject, listProjects, relocateProject, updateProjectName, } from './project.js';
1
+ export { enforceProjectTrust, fromDirectory, getByDirectory, getProject, listProjects, relocateProject, trustProject, updateProjectName, } from './project.js';
2
2
  export { resolveProject } from './resolve.js';
@@ -1,4 +1,5 @@
1
1
  import type { DB } from '../db/client.js';
2
+ import type { Config } from '../shared/types/config.js';
2
3
  export type Project = {
3
4
  id: string;
4
5
  worktree: string;
@@ -7,6 +8,10 @@ export type Project = {
7
8
  gitRemote: string | null;
8
9
  createdAt: number;
9
10
  updatedAt: number;
11
+ /** 用户显式信任该项目作用域配置/插件的时间戳;null=未信任(P0-2 信任边界)。 */
12
+ trustedAt: number | null;
13
+ /** 信任时的风险配置指纹;null=历史记录(无指纹,见 schema 注释)。 */
14
+ riskFingerprint: string | null;
10
15
  };
11
16
  /**
12
17
  * 解析目录 + upsert 项目记录。返回 Project。
@@ -21,6 +26,24 @@ export declare function listProjects(handle: DB): Promise<Project[]>;
21
26
  export declare function getProject(handle: DB, id: string): Promise<Project | null>;
22
27
  export declare function getByDirectory(handle: DB, directory: string): Promise<Project | null>;
23
28
  export declare function updateProjectName(handle: DB, id: string, name: string): Promise<Project | null>;
29
+ /**
30
+ * P0-2:显式信任项目——用户在信任确认弹窗中批准项目作用域配置/插件后落盘。
31
+ * 信任是「克隆即信任」防线的落点;未信任项目携带风险配置时聊天入口被门禁拦截。
32
+ * 一次性动作:信任后不再拦截(用户可随时在设置中收回项目级配置)。
33
+ * P1:信任时同时落盘「风险配置指纹」(当前项目作用域的 summarizeProjectRisk
34
+ * 快照)——此后若仓库 git pull 新增 auto 权限/插件/MCP 等风险键,指纹漂移会
35
+ * 重新触发门禁复检,而非永久信任。
36
+ */
37
+ export declare function trustProject(handle: DB, id: string): Promise<Project | null>;
38
+ /**
39
+ * P0 CLI 项目信任门禁:agent 执行路径(c0de chat / c0de acp)在无 Web 确认弹窗的
40
+ * 情况下,若目录项目未信任(或信任后未 config 漂移)且项目作用域/全局权限配置含
41
+ * 风险项,直接抛错引导 `c0de trust <dir>`——与 Web 聊天入口 409 TRUST_REQUIRED 用
42
+ * 同一套 projectTrustNeeded 判定,杜绝「Web 拦、CLI 裸奔」的不对称。
43
+ * 无风险项时不拦;trust/sessions 等非 agent 命令由调用方跳过此门禁。
44
+ * `globalRaw` 参数仅供测试注入确定性全局配置(生产走本机 ~/.c0de/config.json)。
45
+ */
46
+ export declare function enforceProjectTrust(handle: DB, cwd: string, globalRaw?: Partial<Config> | undefined): Promise<void>;
24
47
  /**
25
48
  * 项目重新定位(A1:目录被移动/重命名后的恢复通道)。
26
49
  *