c0de-agent 1.10.0 → 1.12.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 (124) hide show
  1. package/README.md +28 -6
  2. package/dist/cli/commands/acp.js +3 -3
  3. package/dist/cli/commands/auth.d.ts +17 -0
  4. package/dist/cli/commands/auth.js +73 -0
  5. package/dist/cli/commands/chat.js +95 -1
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +25 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/sessions.d.ts +10 -0
  11. package/dist/cli/commands/sessions.js +94 -0
  12. package/dist/cli/commands/update.js +18 -10
  13. package/dist/cli/index.js +51 -4
  14. package/dist/cli/modes/print.js +19 -2
  15. package/dist/core/compact.d.ts +2 -0
  16. package/dist/core/compact.js +1 -0
  17. package/dist/core/config-path.d.ts +9 -0
  18. package/dist/core/config-path.js +48 -0
  19. package/dist/core/config.d.ts +20 -3
  20. package/dist/core/config.js +103 -14
  21. package/dist/core/index.d.ts +1 -1
  22. package/dist/core/index.js +1 -1
  23. package/dist/core/loop/compaction.d.ts +2 -1
  24. package/dist/core/loop/compaction.js +19 -6
  25. package/dist/core/loop/stream-collect.js +9 -1
  26. package/dist/core/loop/subagent.js +23 -5
  27. package/dist/core/loop.js +13 -3
  28. package/dist/core/redact.d.ts +11 -0
  29. package/dist/core/redact.js +49 -0
  30. package/dist/core/slash.js +167 -63
  31. package/dist/core/title.js +22 -1
  32. package/dist/core/types.d.ts +4 -0
  33. package/dist/db/client.d.ts +4 -0
  34. package/dist/db/client.js +5 -0
  35. package/dist/db/schema.d.ts +51 -0
  36. package/dist/db/schema.js +6 -0
  37. package/dist/kanban/index.d.ts +1 -1
  38. package/dist/kanban/index.js +1 -1
  39. package/dist/kanban/store.d.ts +5 -1
  40. package/dist/kanban/store.js +76 -2
  41. package/dist/llm/index.d.ts +1 -1
  42. package/dist/llm/index.js +1 -1
  43. package/dist/llm/provider.js +1 -1
  44. package/dist/llm/retry.d.ts +5 -2
  45. package/dist/llm/retry.js +6 -5
  46. package/dist/llm/routing.d.ts +24 -1
  47. package/dist/llm/routing.js +29 -1
  48. package/dist/project/index.d.ts +1 -1
  49. package/dist/project/index.js +1 -1
  50. package/dist/project/project.d.ts +14 -0
  51. package/dist/project/project.js +77 -1
  52. package/dist/server/agent-manager.d.ts +14 -0
  53. package/dist/server/agent-manager.js +41 -0
  54. package/dist/server/app.js +12 -2
  55. package/dist/server/auth-manager.d.ts +85 -0
  56. package/dist/server/auth-manager.js +289 -0
  57. package/dist/server/context.js +4 -0
  58. package/dist/server/middleware/auth.d.ts +4 -3
  59. package/dist/server/middleware/auth.js +5 -5
  60. package/dist/server/permission/interactive.d.ts +7 -2
  61. package/dist/server/permission/interactive.js +25 -5
  62. package/dist/server/permission/store.d.ts +15 -1
  63. package/dist/server/permission/store.js +31 -2
  64. package/dist/server/registry-config.d.ts +14 -0
  65. package/dist/server/registry-config.js +44 -0
  66. package/dist/server/routes/auth.d.ts +4 -0
  67. package/dist/server/routes/auth.js +124 -0
  68. package/dist/server/routes/chat.d.ts +18 -1
  69. package/dist/server/routes/chat.js +295 -37
  70. package/dist/server/routes/commands.d.ts +4 -2
  71. package/dist/server/routes/commands.js +10 -3
  72. package/dist/server/routes/config.js +143 -15
  73. package/dist/server/routes/files.js +22 -4
  74. package/dist/server/routes/kanban.js +68 -6
  75. package/dist/server/routes/permissions.d.ts +0 -1
  76. package/dist/server/routes/permissions.js +153 -4
  77. package/dist/server/routes/project.js +106 -3
  78. package/dist/server/routes/provider.js +48 -3
  79. package/dist/server/routes/session.js +319 -19
  80. package/dist/server/routes/terminal.js +17 -2
  81. package/dist/server/routes/update.d.ts +11 -5
  82. package/dist/server/routes/update.js +98 -16
  83. package/dist/server/server.d.ts +4 -12
  84. package/dist/server/server.js +235 -100
  85. package/dist/server/types.d.ts +15 -1
  86. package/dist/session/archive.d.ts +4 -2
  87. package/dist/session/archive.js +13 -1
  88. package/dist/session/branch.d.ts +1 -0
  89. package/dist/session/branch.js +32 -7
  90. package/dist/session/import.d.ts +44 -0
  91. package/dist/session/import.js +83 -0
  92. package/dist/session/index.d.ts +1 -1
  93. package/dist/session/index.js +1 -1
  94. package/dist/session/jobs.d.ts +12 -0
  95. package/dist/session/jobs.js +53 -0
  96. package/dist/session/message.d.ts +7 -1
  97. package/dist/session/message.js +21 -1
  98. package/dist/session/session.d.ts +143 -6
  99. package/dist/session/session.js +561 -11
  100. package/dist/session/snapshot.d.ts +5 -2
  101. package/dist/session/snapshot.js +10 -2
  102. package/dist/session/types.d.ts +12 -2
  103. package/dist/shared/types/agent.d.ts +22 -0
  104. package/dist/shared/types/config.d.ts +10 -1
  105. package/dist/shared/types/kanban.d.ts +13 -0
  106. package/dist/shared/types/message.d.ts +15 -2
  107. package/dist/tools/index.js +2 -3
  108. package/dist/tools/websearch/index.js +6 -3
  109. package/dist/update/index.d.ts +3 -3
  110. package/dist/update/index.js +2 -2
  111. package/dist/update/ipc.d.ts +19 -2
  112. package/dist/update/ipc.js +43 -6
  113. package/dist/update/snapshot.d.ts +5 -0
  114. package/dist/update/snapshot.js +10 -0
  115. package/dist/update/updater.d.ts +69 -8
  116. package/dist/update/updater.js +263 -32
  117. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  118. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  119. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  120. package/drizzle/meta/0006_snapshot.json +867 -0
  121. package/drizzle/meta/0007_snapshot.json +869 -0
  122. package/drizzle/meta/0008_snapshot.json +875 -0
  123. package/drizzle/meta/_journal.json +21 -0
  124. package/package.json +41 -42
@@ -5,6 +5,13 @@ import { DEFAULT_KANBAN_COLUMNS } from '../shared/types/kanban.js';
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
+ /** 列内仍有卡片时禁止删除该列(否则卡片静默不可见)。 */
9
+ class KanbanColumnInUseError extends Error {
10
+ constructor(columnNames, cardCount) {
11
+ super(`列 [${columnNames.join(', ')}] 中仍有 ${cardCount} 张卡片,无法删除。` +
12
+ '请先移动或删除这些卡片。');
13
+ }
14
+ }
8
15
  // ── Row → API mappers ──────────────────────────────────────
9
16
  function rowToBoard(row) {
10
17
  return {
@@ -140,7 +147,39 @@ function createKanbanStore(handle, projectId) {
140
147
  await db.delete(kanbanCards).where(eq(kanbanCards.id, id));
141
148
  },
142
149
  async updateBoard(patch) {
143
- await getOrCreateBoardId();
150
+ const boardId = await getOrCreateBoardId();
151
+ // P1-4:删除列前校验该列内是否有卡片;删除标签前把悬空 labelId 从卡片上清掉。
152
+ if (patch.columns !== undefined) {
153
+ const cards = await db
154
+ .select({ columnId: kanbanCards.columnId })
155
+ .from(kanbanCards)
156
+ .where(eq(kanbanCards.boardId, boardId));
157
+ const newColumnIds = new Set(patch.columns.map((c) => c.id));
158
+ const removedWithCards = new Map();
159
+ for (const card of cards) {
160
+ if (!newColumnIds.has(card.columnId)) {
161
+ removedWithCards.set(card.columnId, (removedWithCards.get(card.columnId) ?? 0) + 1);
162
+ }
163
+ }
164
+ if (removedWithCards.size > 0) {
165
+ const total = Array.from(removedWithCards.values()).reduce((a, b) => a + b, 0);
166
+ throw new KanbanColumnInUseError(Array.from(removedWithCards.keys()), total);
167
+ }
168
+ }
169
+ if (patch.labels !== undefined) {
170
+ const labelIds = new Set(patch.labels.map((l) => l.id));
171
+ const cards = await db
172
+ .select({ id: kanbanCards.id, labels: kanbanCards.labels })
173
+ .from(kanbanCards)
174
+ .where(eq(kanbanCards.boardId, boardId));
175
+ for (const card of cards) {
176
+ const current = (card.labels ?? []);
177
+ const kept = current.filter((lid) => labelIds.has(lid));
178
+ if (kept.length !== current.length) {
179
+ await db.update(kanbanCards).set({ labels: kept }).where(eq(kanbanCards.id, card.id));
180
+ }
181
+ }
182
+ }
144
183
  const [boardRow] = await db
145
184
  .update(kanbanBoards)
146
185
  .set({
@@ -153,6 +192,41 @@ function createKanbanStore(handle, projectId) {
153
192
  const row = boardRow;
154
193
  return rowToBoard(row);
155
194
  },
195
+ /** 整板替换(导入):board 配置 + 卡片在单事务内重建;中途失败整体回滚,
196
+ * 不残留「配置已换、卡片半新半旧」的混合状态。 */
197
+ async replaceBoard(input) {
198
+ const boardId = await getOrCreateBoardId();
199
+ await db.transaction(async (tx) => {
200
+ await tx.delete(kanbanCards).where(eq(kanbanCards.boardId, boardId));
201
+ await tx
202
+ .update(kanbanBoards)
203
+ .set({ columns: input.columns, labels: input.labels, updatedAt: new Date() })
204
+ .where(eq(kanbanBoards.id, boardId));
205
+ if (input.cards.length > 0) {
206
+ await tx.insert(kanbanCards).values(input.cards.map((c) => ({
207
+ boardId,
208
+ title: c.title,
209
+ description: c.description ?? null,
210
+ columnId: c.columnId,
211
+ priority: c.priority,
212
+ position: c.position,
213
+ labels: c.labels,
214
+ })));
215
+ }
216
+ });
217
+ const boardRow = await db
218
+ .select()
219
+ .from(kanbanBoards)
220
+ .where(eq(kanbanBoards.id, boardId))
221
+ .limit(1);
222
+ const board = boardRow[0];
223
+ const cards = await db
224
+ .select()
225
+ .from(kanbanCards)
226
+ .where(eq(kanbanCards.boardId, boardId))
227
+ .orderBy(asc(kanbanCards.columnId), asc(kanbanCards.position));
228
+ return { ...rowToBoard(board), cards: cards.map(rowToCard) };
229
+ },
156
230
  };
157
231
  }
158
- export { createKanbanStore };
232
+ export { createKanbanStore, KanbanColumnInUseError };
@@ -8,6 +8,6 @@ export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overri
8
8
  export type { Retryable, RetryOptions } from './retry.js';
9
9
  export { delay, retryable, withRetry } from './retry.js';
10
10
  export type { FallbackChain } from './routing.js';
11
- export { runWithFallback, shouldFallOver } from './routing.js';
11
+ export { buildFallbackChain, runWithFallback, shouldFallOver } from './routing.js';
12
12
  export * from './schema/index.js';
13
13
  export { estimateTokens } from './token.js';
package/dist/llm/index.js CHANGED
@@ -3,6 +3,6 @@ export { buildInternalRequest, chat, chatStream, toInternalMessage, toStreamChun
3
3
  export { isContextOverflow, isContextOverflowFailure } from './provider-error.js';
4
4
  export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overrideToCapabilities, registerProvider, resolveModelByRole, resolveRoute, setRole, } from './registry.js';
5
5
  export { delay, retryable, withRetry } from './retry.js';
6
- export { runWithFallback, shouldFallOver } from './routing.js';
6
+ export { buildFallbackChain, runWithFallback, shouldFallOver } from './routing.js';
7
7
  export * from './schema/index.js';
8
8
  export { estimateTokens } from './token.js';
@@ -135,7 +135,7 @@ const streamTarget = async function* (ctx, request, target, chain) {
135
135
  headers: { authorization: `Bearer ${authHeader}`, ...resolved.route.headers() },
136
136
  signal: ctx.signal,
137
137
  fetchImpl: ctx.fetchImpl,
138
- }), { maxRetries: chain.maxRetries, sleep: chain.sleep });
138
+ }), { maxRetries: chain.maxRetries, sleep: chain.sleep, initialDelayMs: chain.retryDelay });
139
139
  let state = initialStepState();
140
140
  for await (const frame of sseFraming(stream)) {
141
141
  const chunk = parseChunk(resolved.route.id, frame);
@@ -4,9 +4,10 @@ declare const RETRY_MAX_DELAY_NO_HEADERS = 30000;
4
4
  declare const RETRY_MAX_DELAY = 2147483647;
5
5
  /**
6
6
  * Compute the delay before the next retry attempt (ms).
7
- * Honors retry-after / retry-after-ms headers when present, else exponential backoff.
7
+ * Honors retry-after / retry-after-ms headers when present, else exponential backoff
8
+ * from the given base delay (defaults to RETRY_INITIAL_DELAY).
8
9
  */
9
- declare const delay: (attempt: number, error?: unknown) => number;
10
+ declare const delay: (attempt: number, error?: unknown, initialDelayMs?: number) => number;
10
11
  /** A normalized, retryable error descriptor for the session layer. */
11
12
  type Retryable = {
12
13
  message: string;
@@ -29,6 +30,8 @@ type RetryOptions = {
29
30
  delayMs: number;
30
31
  error: unknown;
31
32
  }) => void;
33
+ /** Base delay for exponential backoff (default RETRY_INITIAL_DELAY). */
34
+ initialDelayMs?: number;
32
35
  };
33
36
  /**
34
37
  * Run an async operation with retry. Retries only on retryable LLM errors.
package/dist/llm/retry.js CHANGED
@@ -17,9 +17,10 @@ const errorHeaders = (error) => {
17
17
  const capDelay = (ms) => Math.min(ms, RETRY_MAX_DELAY);
18
18
  /**
19
19
  * Compute the delay before the next retry attempt (ms).
20
- * Honors retry-after / retry-after-ms headers when present, else exponential backoff.
20
+ * Honors retry-after / retry-after-ms headers when present, else exponential backoff
21
+ * from the given base delay (defaults to RETRY_INITIAL_DELAY).
21
22
  */
22
- const delay = (attempt, error) => {
23
+ const delay = (attempt, error, initialDelayMs = RETRY_INITIAL_DELAY) => {
23
24
  const headers = errorHeaders(error);
24
25
  if (headers) {
25
26
  const retryAfterMs = headers['retry-after-ms'];
@@ -37,9 +38,9 @@ const delay = (attempt, error) => {
37
38
  if (!Number.isNaN(parsed) && parsed > 0)
38
39
  return capDelay(Math.ceil(parsed));
39
40
  }
40
- return capDelay(RETRY_INITIAL_DELAY * RETRY_BACKOFF_FACTOR ** (attempt - 1));
41
+ return capDelay(initialDelayMs * RETRY_BACKOFF_FACTOR ** (attempt - 1));
41
42
  }
42
- return capDelay(Math.min(RETRY_INITIAL_DELAY * RETRY_BACKOFF_FACTOR ** (attempt - 1), RETRY_MAX_DELAY_NO_HEADERS));
43
+ return capDelay(Math.min(initialDelayMs * RETRY_BACKOFF_FACTOR ** (attempt - 1), RETRY_MAX_DELAY_NO_HEADERS));
43
44
  };
44
45
  /**
45
46
  * Decide whether a thrown error is retryable. Returns undefined when not retryable
@@ -74,7 +75,7 @@ const withRetry = async (fn, options) => {
74
75
  const fallbackReason = isLLMError(error)
75
76
  ? error.reason
76
77
  : { _tag: 'InvalidRequest', message: '' };
77
- const delayMs = Math.min(reasonRetryAfterMs(fallbackReason) ?? delay(attempt, error), canRetry.policy.maxDelay);
78
+ const delayMs = Math.min(reasonRetryAfterMs(fallbackReason) ?? delay(attempt, error, options.initialDelayMs), canRetry.policy.maxDelay);
78
79
  options.onRetry?.({ attempt, delayMs, error });
79
80
  await sleep(delayMs);
80
81
  }
@@ -32,5 +32,28 @@ declare const runWithFallback: <T>(registry: Registry, chain: FallbackChain, run
32
32
  * errors propagate without trying fallbacks.
33
33
  */
34
34
  declare const shouldFallOver: (error: unknown) => boolean;
35
+ /** 回退目标所需的最小 provider 信息。 */
36
+ type FallbackSourceProvider = {
37
+ name?: string;
38
+ _tag?: string;
39
+ /** 声明了模型清单的 provider 只对清单内的模型回退;未声明视为不支持(保守)。 */
40
+ models?: Record<string, unknown>;
41
+ };
42
+ /**
43
+ * 从 config.fallback 构建回退链(主 chat 循环/压缩/标题等 LLM 调用的接线点)。
44
+ * - enabled=false → undefined(保持内置默认:3 次重试、无跨 provider 回退)。
45
+ * - enabled=true → 按配置 maxRetries/retryDelay 重试,并对「声明了同一模型」的
46
+ * 其他 provider 依次回退。无合格回退目标时 fallbacks 为空——重试参数仍然生效。
47
+ * 回退只接受声明了该模型的 provider:对未声明的 provider 盲目回退会在其 API 处
48
+ * 得到 InvalidRequest 并中断整条链(shouldFallOver 不放过非回退错误)。
49
+ */
50
+ declare function buildFallbackChain(config: {
51
+ providers: FallbackSourceProvider[];
52
+ fallback: {
53
+ enabled: boolean;
54
+ maxRetries: number;
55
+ retryDelay: number;
56
+ };
57
+ }, provider: string, model: string): FallbackChain | undefined;
35
58
  export type { FallbackChain };
36
- export { runWithFallback, shouldFallOver };
59
+ export { buildFallbackChain, runWithFallback, shouldFallOver };
@@ -20,6 +20,7 @@ const runWithFallback = async (registry, chain, run) => {
20
20
  const result = await withRetry(() => run(target.provider, target.model), {
21
21
  maxRetries: chain.maxRetries,
22
22
  sleep: chain.sleep,
23
+ initialDelayMs: chain.retryDelay,
23
24
  });
24
25
  return { result, provider: target.provider, model: target.model };
25
26
  }
@@ -50,4 +51,31 @@ const shouldFallOver = (error) => {
50
51
  return true;
51
52
  return false;
52
53
  };
53
- export { runWithFallback, shouldFallOver };
54
+ /**
55
+ * 从 config.fallback 构建回退链(主 chat 循环/压缩/标题等 LLM 调用的接线点)。
56
+ * - enabled=false → undefined(保持内置默认:3 次重试、无跨 provider 回退)。
57
+ * - enabled=true → 按配置 maxRetries/retryDelay 重试,并对「声明了同一模型」的
58
+ * 其他 provider 依次回退。无合格回退目标时 fallbacks 为空——重试参数仍然生效。
59
+ * 回退只接受声明了该模型的 provider:对未声明的 provider 盲目回退会在其 API 处
60
+ * 得到 InvalidRequest 并中断整条链(shouldFallOver 不放过非回退错误)。
61
+ */
62
+ function buildFallbackChain(config, provider, model) {
63
+ const fb = config.fallback;
64
+ if (!fb.enabled)
65
+ return undefined;
66
+ const fallbacks = config.providers
67
+ .filter((p) => {
68
+ const name = p.name || p._tag;
69
+ if (!name || name === provider)
70
+ return false;
71
+ return p.models !== undefined && model in p.models;
72
+ })
73
+ .map((p) => ({ provider: (p.name || p._tag), model }));
74
+ return {
75
+ primary: { provider, model },
76
+ fallbacks,
77
+ maxRetries: fb.maxRetries,
78
+ retryDelay: fb.retryDelay,
79
+ };
80
+ }
81
+ export { buildFallbackChain, runWithFallback, shouldFallOver };
@@ -1,4 +1,4 @@
1
1
  export type { Project } from './project.js';
2
- export { fromDirectory, getByDirectory, getProject, listProjects, updateProjectName, } from './project.js';
2
+ export { fromDirectory, getByDirectory, getProject, listProjects, relocateProject, 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, updateProjectName, } from './project.js';
1
+ export { fromDirectory, getByDirectory, getProject, listProjects, relocateProject, updateProjectName, } from './project.js';
2
2
  export { resolveProject } from './resolve.js';
@@ -21,3 +21,17 @@ export declare function listProjects(handle: DB): Promise<Project[]>;
21
21
  export declare function getProject(handle: DB, id: string): Promise<Project | null>;
22
22
  export declare function getByDirectory(handle: DB, directory: string): Promise<Project | null>;
23
23
  export declare function updateProjectName(handle: DB, id: string, name: string): Promise<Project | null>;
24
+ /**
25
+ * 项目重新定位(A1:目录被移动/重命名后的恢复通道)。
26
+ *
27
+ * 项目身份 = sha256(worktree),目录移动后旧项目记录的 worktree 失效且不可改 id。
28
+ * 此前唯一恢复路径是「删除项目」:会话打散进孤儿回收站逐条恢复,看板永久丢失。
29
+ * 本函数在单事务内把项目整体迁移到新目录身份(新 id),保住看板与会话归属:
30
+ * 1. 以新目录解析出的 id upsert 项目行(沿用旧名称);
31
+ * 2. 会话 projectId/worktreePath 迁移到新身份;
32
+ * 3. 看板 projectId 迁移(目标为新身份,唯一约束无冲突);
33
+ * 4. 删除旧项目行。
34
+ * 同一 id(如 git 仓库根未变、仅路径符号变化)→ 仅更新 worktree,不换身份。
35
+ * 目标目录已注册为另一项目时抛 TARGET_OCCUPIED(调用方转 409),绝不静默合并。
36
+ */
37
+ export declare function relocateProject(handle: DB, id: string, directory: string): Promise<Project>;
@@ -1,6 +1,6 @@
1
1
  import { basename } from 'node:path';
2
2
  import { eq } from 'drizzle-orm';
3
- import { projects, sessions } from '../db/schema.js';
3
+ import { kanbanBoards, projects, sessions } from '../db/schema.js';
4
4
  import { resolveProject } from './resolve.js';
5
5
  function rowToProject(row) {
6
6
  return {
@@ -85,3 +85,79 @@ export async function updateProjectName(handle, id, name) {
85
85
  .returning();
86
86
  return row ? rowToProject(row) : null;
87
87
  }
88
+ /**
89
+ * 项目重新定位(A1:目录被移动/重命名后的恢复通道)。
90
+ *
91
+ * 项目身份 = sha256(worktree),目录移动后旧项目记录的 worktree 失效且不可改 id。
92
+ * 此前唯一恢复路径是「删除项目」:会话打散进孤儿回收站逐条恢复,看板永久丢失。
93
+ * 本函数在单事务内把项目整体迁移到新目录身份(新 id),保住看板与会话归属:
94
+ * 1. 以新目录解析出的 id upsert 项目行(沿用旧名称);
95
+ * 2. 会话 projectId/worktreePath 迁移到新身份;
96
+ * 3. 看板 projectId 迁移(目标为新身份,唯一约束无冲突);
97
+ * 4. 删除旧项目行。
98
+ * 同一 id(如 git 仓库根未变、仅路径符号变化)→ 仅更新 worktree,不换身份。
99
+ * 目标目录已注册为另一项目时抛 TARGET_OCCUPIED(调用方转 409),绝不静默合并。
100
+ */
101
+ export async function relocateProject(handle, id, directory) {
102
+ const current = await getProject(handle, id);
103
+ if (!current)
104
+ throw new Error('PROJECT_NOT_FOUND');
105
+ const resolved = resolveProject(directory);
106
+ if (resolved.id === id) {
107
+ // 身份不变:仅刷新 worktree 与 git 元数据;会话 worktreePath 同步。
108
+ await handle.db.transaction(async (tx) => {
109
+ await tx
110
+ .update(projects)
111
+ .set({
112
+ worktree: resolved.worktree,
113
+ vcs: resolved.vcs,
114
+ gitRemote: resolved.gitRemote,
115
+ updatedAt: new Date(),
116
+ })
117
+ .where(eq(projects.id, id));
118
+ await tx
119
+ .update(sessions)
120
+ .set({ worktreePath: resolved.worktree })
121
+ .where(eq(sessions.projectId, id));
122
+ });
123
+ }
124
+ else {
125
+ const occupied = await getProject(handle, resolved.id);
126
+ if (occupied) {
127
+ throw new Error(`TARGET_OCCUPIED: 目标目录已注册为项目「${occupied.name ?? '未命名项目'}」`);
128
+ }
129
+ await handle.db.transaction(async (tx) => {
130
+ await tx
131
+ .insert(projects)
132
+ .values({
133
+ id: resolved.id,
134
+ worktree: resolved.worktree,
135
+ vcs: resolved.vcs,
136
+ name: current.name ?? basename(resolved.worktree),
137
+ gitRemote: resolved.gitRemote,
138
+ })
139
+ .onConflictDoUpdate({
140
+ target: projects.id,
141
+ set: {
142
+ worktree: resolved.worktree,
143
+ vcs: resolved.vcs,
144
+ gitRemote: resolved.gitRemote,
145
+ updatedAt: new Date(),
146
+ },
147
+ });
148
+ await tx
149
+ .update(sessions)
150
+ .set({ projectId: resolved.id, worktreePath: resolved.worktree })
151
+ .where(eq(sessions.projectId, id));
152
+ await tx
153
+ .update(kanbanBoards)
154
+ .set({ projectId: resolved.id })
155
+ .where(eq(kanbanBoards.projectId, id));
156
+ await tx.delete(projects).where(eq(projects.id, id));
157
+ });
158
+ }
159
+ const result = await getProject(handle, resolved.id);
160
+ if (!result)
161
+ throw new Error(`Relocate failed for ${directory}`);
162
+ return result;
163
+ }
@@ -36,11 +36,25 @@ type AgentManager = {
36
36
  abort(sessionId: string): boolean;
37
37
  pause(sessionId: string): boolean;
38
38
  resume(sessionId: string): boolean;
39
+ /** 暂停全部活跃 run 并等待到达安全点;超时强制中止(热更新前调用)。
40
+ * 返回 pausedIds:成功暂停的会话 id 列表——调用方在后续步骤(spawn 等)
41
+ * 失败时必须对其逐个 resume 回滚,避免更新失败连带冻结用户对话。 */
42
+ pauseAll(timeoutMs?: number): Promise<{
43
+ paused: number;
44
+ forcedAbort: number;
45
+ pausedIds: string[];
46
+ }>;
39
47
  steer(sessionId: string, message: string): boolean;
40
48
  /** 查询某 session 的所有子 agent run(恢复/展示用)。 */
41
49
  children(parentSessionId: string): ActiveRun[];
42
50
  /** 查询所有后台任务(jobId 非空的 run)。 */
43
51
  backgroundJobs(): ActiveRun[];
52
+ /** 列出全部活跃 run(含子 agent)——热更新影响面展示用。 */
53
+ listActive(): Array<{
54
+ sessionId: string;
55
+ agentType?: string;
56
+ parentSessionId?: string;
57
+ }>;
44
58
  /** 中止所有活跃 run 并清空(dev 热重载重建前调用)。 */
45
59
  dispose(): void;
46
60
  };
@@ -54,6 +54,37 @@ function createAgentManager() {
54
54
  resumeAgent(run.state);
55
55
  return true;
56
56
  },
57
+ /**
58
+ * 请求暂停所有活跃 run,等待其到达安全暂停点(当前原子操作完成后)。
59
+ * 超时后对仍未暂停的 run 强制 abort(热更新不能无限等待)。
60
+ * 返回 { paused: 已暂停数, forcedAbort: 超时中止数 }。
61
+ */
62
+ async pauseAll(timeoutMs = 30_000) {
63
+ const active = Array.from(runs.values())
64
+ .map((slot) => slotRun(slot))
65
+ .filter((r) => r !== undefined && r.state.status._tag === 'running');
66
+ for (const run of active) {
67
+ pauseAgent(run.state);
68
+ }
69
+ const deadline = Date.now() + timeoutMs;
70
+ while (Date.now() < deadline) {
71
+ const stillRunning = active.filter((r) => r.state.status._tag === 'running');
72
+ if (stillRunning.length === 0)
73
+ break;
74
+ await new Promise((res) => setTimeout(res, 100));
75
+ }
76
+ let forcedAbort = 0;
77
+ for (const run of active) {
78
+ if (run.state.status._tag === 'running') {
79
+ abortAgent(run.state);
80
+ forcedAbort += 1;
81
+ }
82
+ }
83
+ const pausedIds = active
84
+ .filter((r) => r.state.status._tag === 'paused')
85
+ .map((r) => r.sessionId);
86
+ return { paused: active.length - forcedAbort, forcedAbort, pausedIds };
87
+ },
57
88
  steer(sessionId, message) {
58
89
  const run = slotRun(runs.get(sessionId));
59
90
  if (!run)
@@ -71,6 +102,16 @@ function createAgentManager() {
71
102
  .map((slot) => slotRun(slot))
72
103
  .filter((r) => r?.jobId !== undefined);
73
104
  },
105
+ listActive() {
106
+ return Array.from(runs.values())
107
+ .map((slot) => slotRun(slot))
108
+ .filter((r) => r !== undefined)
109
+ .map((r) => ({
110
+ sessionId: r.sessionId,
111
+ agentType: r.agentType,
112
+ parentSessionId: r.parentSessionId,
113
+ }));
114
+ },
74
115
  dispose() {
75
116
  // dev 热重载重建前调用:中止所有活跃 run(loop 在 turn/流边界检测 signal
76
117
  // → unwind → 调用方 finally 持久化 + unregister),然后清空 Map。
@@ -7,6 +7,7 @@ import { createAuthMiddleware } from './middleware/auth.js';
7
7
  import { createCORSMiddleware } from './middleware/cors.js';
8
8
  import { errorHandler } from './middleware/error.js';
9
9
  import { createAgentRoute } from './routes/agent.js';
10
+ import { createAuthRoute } from './routes/auth.js';
10
11
  import { createCatalogRoute } from './routes/catalog.js';
11
12
  import { createChatRoute } from './routes/chat.js';
12
13
  import { createCommandsRoute } from './routes/commands.js';
@@ -32,10 +33,19 @@ function createApp(ctx) {
32
33
  // spec §24.2:仅允许本地/可信 origin 跨域读,拒绝外部网页。
33
34
  app.use('*', createCORSMiddleware({ allowedOrigins: ctx.config.security.allowedOrigins }));
34
35
  // spec §24.2:Bearer token 认证(配置了 token 时生效;/api/health 探活放行)。
35
- // token 来源:用户配置 security.token,或启动时自动生成(ctx.authToken)。
36
- app.use('/api/*', createAuthMiddleware(ctx.authToken));
36
+ // P2-16:authManager 存在时按设备 token 校验;认证/配对引导端点公开。
37
+ app.use('/api/*', createAuthMiddleware(ctx.authToken, {
38
+ publicPaths: [
39
+ '/api/health',
40
+ '/api/auth/register',
41
+ '/api/auth/pairing/request',
42
+ '/api/auth/pairing/status',
43
+ ],
44
+ ...(ctx.authManager ? { verify: (t) => ctx.authManager?.verify(t) ?? false } : {}),
45
+ }));
37
46
  // 路由
38
47
  app.route('/api/health', createHealthRoute());
48
+ app.route('/api/auth', createAuthRoute(ctx));
39
49
  app.route('/api/agents', createAgentRoute(ctx));
40
50
  app.route('/api/sessions', createSessionRoute(ctx));
41
51
  app.route('/api/projects', createProjectRoute(ctx));
@@ -0,0 +1,85 @@
1
+ type DeviceRecord = {
2
+ id: string;
3
+ name: string;
4
+ tokenHash: string;
5
+ createdAt: number;
6
+ };
7
+ type PendingPairing = {
8
+ pairingId: string;
9
+ deviceName: string;
10
+ code: string;
11
+ createdAt: number;
12
+ status: 'pending' | 'approved' | 'denied';
13
+ /** 请求来源(尽力而为:x-forwarded-for 首跳,本地回环时为 local)。仅展示 + 软限流,不作身份断言。 */
14
+ source: string;
15
+ deviceToken?: string;
16
+ };
17
+ type AuthManagerOptions = {
18
+ dataDir: string;
19
+ /** 显式配置的静态 token(security.token / C0DE_AUTH_TOKEN);非空 → 静态模式。 */
20
+ staticToken?: string;
21
+ /** bootstrap token 文件路径(默认 dataDir/auth-token)。 */
22
+ tokenFilePath?: string;
23
+ /** 配对码有效期(ms),默认 10 分钟。 */
24
+ pairingTtlMs?: number;
25
+ /** 首设备 bootstrap TTL(ms):>0 时,bootstrap 超过此时长即拒绝首设备注册
26
+ * (需重启 serve 重新生成)。0=不限制。用于缩短「URL 泄漏后被抢先注册」的窗口。 */
27
+ firstDeviceTtlMs?: number;
28
+ now?: () => number;
29
+ };
30
+ export type AuthManager = {
31
+ /** 当前 bootstrap token(静态模式下即 staticToken)。 */
32
+ readonly bootstrap: string | undefined;
33
+ /** API/WS 请求校验:仅设备 token 有效(静态模式下 staticToken 有效)。 */
34
+ verify(token: string | undefined): boolean;
35
+ /** handoff 端点校验:设备 token + 当前 bootstrap(历史 bootstrap 不接受,
36
+ * 否则已泄漏的旧 URL token 仍可借 handoff 强杀本地服务)。 */
37
+ verifyHandoff(token: string | undefined): boolean;
38
+ /** 首次设备注册:校验 bootstrap 后换发设备 token 并轮换 bootstrap。
39
+ * 无设备时首个凭 bootstrap 的请求即视为首设备(免审批),后续请求需配对审批。
40
+ * 返回新设备 token;bootstrap 已失效/已注册过 → 返回 null。 */
41
+ registerFirstDevice(bootstrapToken: string, deviceName: string): Promise<string | null>;
42
+ /** 发起配对请求(未认证):返回 { pairingId, code };静态模式/未启用返回 null。
43
+ * source 为请求来源(IP 等,尽力而为),用于审批展示与按来源限流。 */
44
+ requestPairing(deviceName: string, source?: string): {
45
+ pairingId: string;
46
+ code: string;
47
+ } | null;
48
+ /** 查询配对状态(未认证):pending / approved(含 token) / denied / not_found。 */
49
+ pairingStatus(pairingId: string): {
50
+ status: 'pending';
51
+ } | {
52
+ status: 'approved';
53
+ deviceToken: string;
54
+ } | {
55
+ status: 'denied';
56
+ } | {
57
+ status: 'not_found';
58
+ };
59
+ /** 列出待审批配对(需已认证设备调用)。 */
60
+ listPairings(): Array<{
61
+ pairingId: string;
62
+ deviceName: string;
63
+ code: string;
64
+ createdAt: number;
65
+ source: string;
66
+ }>;
67
+ /** 审批配对(需已认证设备调用):通过后为请求设备签发 token。 */
68
+ approvePairing(pairingId: string): boolean;
69
+ /** 拒绝配对。 */
70
+ denyPairing(pairingId: string): boolean;
71
+ /** 列出已授权设备(id/name/createdAt)。 */
72
+ listDevices(): Array<{
73
+ id: string;
74
+ name: string;
75
+ createdAt: number;
76
+ }>;
77
+ /** 撤销某设备:立即从内存移除并落盘;返回是否成功。 */
78
+ revokeDevice(id: string): boolean;
79
+ /** 持久化设备注册表到磁盘(fire-and-forget 调用)。 */
80
+ persist(): void;
81
+ /** 关闭 devices.json 热重载 watcher(server dispose 时调用)。 */
82
+ dispose(): void;
83
+ };
84
+ export declare function createAuthManager(opts: AuthManagerOptions): AuthManager;
85
+ export type { DeviceRecord, PendingPairing };