c0de-agent 1.11.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 (177) hide show
  1. package/README.md +63 -16
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +151 -1
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +63 -3
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +109 -3
  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 +110 -11
  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 +78 -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/compact.d.ts +2 -0
  24. package/dist/core/compact.js +1 -0
  25. package/dist/core/config.d.ts +53 -2
  26. package/dist/core/config.js +235 -19
  27. package/dist/core/loop/compaction.d.ts +2 -1
  28. package/dist/core/loop/compaction.js +19 -6
  29. package/dist/core/loop/segment.d.ts +3 -1
  30. package/dist/core/loop/segment.js +24 -1
  31. package/dist/core/loop/stream-collect.js +9 -1
  32. package/dist/core/loop/subagent.js +88 -37
  33. package/dist/core/loop.d.ts +7 -0
  34. package/dist/core/loop.js +90 -6
  35. package/dist/core/redact.d.ts +11 -0
  36. package/dist/core/redact.js +49 -0
  37. package/dist/core/slash.d.ts +10 -0
  38. package/dist/core/slash.js +237 -65
  39. package/dist/core/title.js +7 -1
  40. package/dist/core/types.d.ts +22 -1
  41. package/dist/core/workflows/context.js +44 -4
  42. package/dist/core/workflows/discovery.d.ts +2 -2
  43. package/dist/core/workflows/discovery.js +5 -7
  44. package/dist/core/workflows/index.d.ts +2 -1
  45. package/dist/core/workflows/index.js +2 -1
  46. package/dist/core/workflows/registry.d.ts +13 -3
  47. package/dist/core/workflows/registry.js +14 -9
  48. package/dist/core/workflows/resolve.d.ts +22 -0
  49. package/dist/core/workflows/resolve.js +23 -0
  50. package/dist/core/workflows/runtime.d.ts +6 -1
  51. package/dist/core/workflows/runtime.js +19 -2
  52. package/dist/db/schema.d.ts +373 -1
  53. package/dist/db/schema.js +60 -4
  54. package/dist/kanban/index.d.ts +2 -1
  55. package/dist/kanban/index.js +1 -1
  56. package/dist/kanban/store.d.ts +87 -1
  57. package/dist/kanban/store.js +429 -22
  58. package/dist/llm/index.d.ts +1 -1
  59. package/dist/llm/index.js +1 -1
  60. package/dist/llm/provider.js +1 -1
  61. package/dist/llm/registry.d.ts +7 -1
  62. package/dist/llm/registry.js +7 -1
  63. package/dist/llm/retry.d.ts +5 -2
  64. package/dist/llm/retry.js +6 -5
  65. package/dist/llm/routing.d.ts +24 -1
  66. package/dist/llm/routing.js +29 -1
  67. package/dist/plugins/init.d.ts +7 -0
  68. package/dist/plugins/init.js +22 -1
  69. package/dist/plugins/loader.d.ts +7 -1
  70. package/dist/plugins/loader.js +12 -4
  71. package/dist/project/index.d.ts +1 -1
  72. package/dist/project/index.js +1 -1
  73. package/dist/project/project.d.ts +37 -0
  74. package/dist/project/project.js +142 -1
  75. package/dist/project/trust.d.ts +65 -0
  76. package/dist/project/trust.js +328 -0
  77. package/dist/server/agent-manager.d.ts +15 -2
  78. package/dist/server/agent-manager.js +18 -3
  79. package/dist/server/app.js +45 -30
  80. package/dist/server/auth-manager.d.ts +26 -9
  81. package/dist/server/auth-manager.js +78 -42
  82. package/dist/server/context.js +14 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/index.js +1 -1
  85. package/dist/server/middleware/auth.d.ts +11 -1
  86. package/dist/server/middleware/auth.js +25 -3
  87. package/dist/server/permission/interactive.d.ts +11 -3
  88. package/dist/server/permission/interactive.js +23 -4
  89. package/dist/server/permission/store.d.ts +14 -2
  90. package/dist/server/permission/store.js +39 -3
  91. package/dist/server/registry-config.d.ts +14 -0
  92. package/dist/server/registry-config.js +44 -0
  93. package/dist/server/routes/auth.js +66 -10
  94. package/dist/server/routes/chat.d.ts +21 -0
  95. package/dist/server/routes/chat.js +685 -52
  96. package/dist/server/routes/commands.d.ts +4 -2
  97. package/dist/server/routes/commands.js +9 -4
  98. package/dist/server/routes/config.js +180 -23
  99. package/dist/server/routes/files.js +142 -28
  100. package/dist/server/routes/kanban.js +218 -26
  101. package/dist/server/routes/permissions.d.ts +0 -4
  102. package/dist/server/routes/permissions.js +140 -7
  103. package/dist/server/routes/project.js +132 -29
  104. package/dist/server/routes/provider.js +54 -3
  105. package/dist/server/routes/session.js +463 -23
  106. package/dist/server/routes/tool.js +24 -5
  107. package/dist/server/routes/update.d.ts +11 -5
  108. package/dist/server/routes/update.js +145 -29
  109. package/dist/server/routes/usage.d.ts +4 -0
  110. package/dist/server/routes/usage.js +133 -0
  111. package/dist/server/routes/workflows.js +476 -87
  112. package/dist/server/server.d.ts +16 -13
  113. package/dist/server/server.js +364 -107
  114. package/dist/server/terminal/pty-manager.d.ts +12 -0
  115. package/dist/server/terminal/pty-manager.js +63 -7
  116. package/dist/server/types.d.ts +16 -0
  117. package/dist/session/archive.d.ts +3 -1
  118. package/dist/session/archive.js +13 -1
  119. package/dist/session/branch.d.ts +2 -1
  120. package/dist/session/branch.js +92 -9
  121. package/dist/session/context.js +28 -2
  122. package/dist/session/import.d.ts +44 -0
  123. package/dist/session/import.js +83 -0
  124. package/dist/session/index.d.ts +1 -1
  125. package/dist/session/index.js +1 -1
  126. package/dist/session/jobs.d.ts +12 -0
  127. package/dist/session/jobs.js +53 -0
  128. package/dist/session/message.d.ts +7 -1
  129. package/dist/session/message.js +21 -1
  130. package/dist/session/session.d.ts +186 -12
  131. package/dist/session/session.js +577 -33
  132. package/dist/session/snapshot.d.ts +5 -2
  133. package/dist/session/snapshot.js +10 -2
  134. package/dist/session/types.d.ts +13 -1
  135. package/dist/session/usage.d.ts +62 -0
  136. package/dist/session/usage.js +194 -0
  137. package/dist/shared/types/agent.d.ts +29 -2
  138. package/dist/shared/types/config.d.ts +64 -5
  139. package/dist/shared/types/kanban.d.ts +17 -2
  140. package/dist/shared/types/message.d.ts +23 -0
  141. package/dist/tools/builtin/kanban.js +13 -2
  142. package/dist/tools/index.d.ts +14 -3
  143. package/dist/tools/index.js +23 -5
  144. package/dist/tools/websearch/index.d.ts +5 -1
  145. package/dist/tools/websearch/index.js +46 -7
  146. package/dist/tools/websearch/providers/brave.js +2 -2
  147. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  148. package/dist/tools/websearch/providers/tavily.js +2 -2
  149. package/dist/tools/websearch/types.d.ts +5 -1
  150. package/dist/tools/websearch/types.js +7 -1
  151. package/dist/update/index.d.ts +3 -3
  152. package/dist/update/index.js +2 -2
  153. package/dist/update/ipc.d.ts +13 -2
  154. package/dist/update/ipc.js +36 -2
  155. package/dist/update/scheduler.js +5 -2
  156. package/dist/update/snapshot.d.ts +18 -3
  157. package/dist/update/snapshot.js +5 -2
  158. package/dist/update/updater.d.ts +17 -1
  159. package/dist/update/updater.js +47 -18
  160. package/dist/update/version.d.ts +3 -0
  161. package/dist/update/version.js +9 -3
  162. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  163. package/drizzle/0010_usage_events_ledger.sql +17 -0
  164. package/drizzle/0011_slow_slapstick.sql +12 -0
  165. package/drizzle/0012_chief_timeslip.sql +1 -0
  166. package/drizzle/0013_swift_zeigeist.sql +1 -0
  167. package/drizzle/0014_flimsy_legion.sql +1 -0
  168. package/drizzle/meta/0008_snapshot.json +875 -0
  169. package/drizzle/meta/0010_snapshot.json +1010 -0
  170. package/drizzle/meta/0011_snapshot.json +1055 -0
  171. package/drizzle/meta/0012_snapshot.json +1062 -0
  172. package/drizzle/meta/0013_snapshot.json +1068 -0
  173. package/drizzle/meta/0014_snapshot.json +1075 -0
  174. package/drizzle/meta/_journal.json +42 -0
  175. package/package.json +42 -42
  176. package/dist/core/agents/discovery.d.ts +0 -6
  177. package/dist/core/agents/discovery.js +0 -96
@@ -66,16 +66,34 @@ export function createDefaultRegistry(config = DEFAULT_CONFIG) {
66
66
  return reg;
67
67
  }
68
68
  /**
69
- * 解析本轮启用的工具名列表(P1-1 修复:config.tools.enabled/disabled 双入口统一生效)。
69
+ * 解析本轮启用的工具名列表(config.tools.enabled/disabled 双入口统一生效)。
70
70
  *
71
- * - explicit(前端显式选择)非 undefined → 以其为准,但过滤掉 disabled 与未注册名。
72
- * - 否则 enabled 非空 → enabled ∩ registered;enabled 空 → 全部 registered(空=全部)。
71
+ * 语义(P1-1 修复「空=全部」安全陷阱 + P2 交集收紧):
72
+ * - explicit(前端显式选择)非 undefined → 与 config.tools.enabled 取交集——
73
+ * enabled 含 '*' 或未配置时视为无上限(explicit 原样生效)。此前 explicit 完全
74
+ * 覆盖 enabled:config.tools.enabled=[] 的 fail-closed 语义可被请求级 tools
75
+ * 静默绕过,设置页「禁用全部」与实际行为矛盾。
76
+ * - 否则 config.tools.enabled:含 '*'(通配)→ 全部注册工具;
77
+ * 空数组 → 无工具(fail-closed);否则 → 显式名单。
73
78
  * - disabled 恒过滤(registry 已排除,此处兜底)。
74
79
  */
75
80
  export function resolveEnabledToolNames(registry, config, explicit) {
76
81
  const all = listTools(registry).map((t) => t.name);
77
82
  const disabled = new Set(config.tools?.disabled ?? []);
78
- const base = explicit ??
79
- (config.tools?.enabled && config.tools.enabled.length > 0 ? config.tools.enabled : all);
83
+ const enabled = config.tools?.enabled;
84
+ const enabledIsWildcard = enabled === undefined || (enabled.length > 0 && enabled.includes('*'));
85
+ const base = explicit !== undefined
86
+ ? enabledIsWildcard
87
+ ? explicit
88
+ : explicit.filter((n) => enabled.includes(n))
89
+ : enabledIsWildcard
90
+ ? all
91
+ : (enabled ?? []);
80
92
  return base.filter((n) => all.includes(n) && !disabled.has(n));
81
93
  }
94
+ /**
95
+ * 依赖「会话绑定项目」的工具:其运行时依赖(如 kanban 的 kanbanStore)由宿主按
96
+ * session.projectId 注入——无项目会话(CLI print 未注册 cwd 等)调用必失败,
97
+ * 只会浪费一轮工具调用。会话无 projectId 时从工具集中剔除。
98
+ */
99
+ export const PROJECT_BOUND_TOOLS = new Set(['kanban']);
@@ -10,7 +10,11 @@ export declare function resolveProvider(preference: 'auto' | WebSearchProviderId
10
10
  export declare function formatForLLM(response: WebSearchResponse): string;
11
11
  /** 测试钩子:覆盖默认 fetch(undici ProxyAgent 包装版)。 */
12
12
  export declare function setFetchOverride(f: typeof fetch | undefined): void;
13
- /** 工具入口:解析 provider → search → 返回 WebSearchResponse。 */
13
+ /** 工具入口:解析 provider → search → 返回 WebSearchResponse。
14
+ * 'auto' 模式带运行时降级链:候选后端按 key 可用性排列(tavily > brave >
15
+ * duckduckgo),当前后端因鉴权/限流/服务端/网络错误失败时依次降级——
16
+ * 此前 auto 只按 key **存在性**选一次,key 失效/过期即整体失败。
17
+ * 显式指定后端时尊重用户选择,单后端不降级。abort 立即上抛,不沿链继续。 */
14
18
  export declare function runWebSearch(input: {
15
19
  query: string;
16
20
  numResults?: number;
@@ -1,3 +1,4 @@
1
+ import { decryptSecret } from '../../core/secret.js';
1
2
  import { createFetch } from './fetch.js';
2
3
  import { braveProvider } from './providers/brave.js';
3
4
  import { duckduckgoProvider } from './providers/duckduckgo.js';
@@ -8,11 +9,13 @@ const PROVIDERS = {
8
9
  tavily: tavilyProvider,
9
10
  brave: braveProvider,
10
11
  };
11
- /** 环境变量名 → config key 字段映射。环境变量优先于 config。 */
12
+ /** 环境变量名 → config key 字段映射。环境变量优先于 config。
13
+ * config 值落盘时已加密(enc: 前缀),此处解密后使用(明文兼容透传)。 */
12
14
  function resolveKeys(config) {
15
+ const decrypt = (v) => (v ? decryptSecret(v) : v);
13
16
  return {
14
- tavily: process.env.TAVILY_API_KEY ?? config.tavilyApiKey,
15
- brave: process.env.BRAVE_API_KEY ?? config.braveApiKey,
17
+ tavily: process.env.TAVILY_API_KEY ?? decrypt(config.tavilyApiKey),
18
+ brave: process.env.BRAVE_API_KEY ?? decrypt(config.braveApiKey),
16
19
  };
17
20
  }
18
21
  /** 按 preference + key 可用性解析目标 provider。auto 优先级:tavily > brave > duckduckgo。 */
@@ -62,17 +65,53 @@ export function setFetchOverride(f) {
62
65
  function currentFetch() {
63
66
  return fetchOverride ?? createFetch();
64
67
  }
65
- /** 工具入口:解析 provider → search → 返回 WebSearchResponse。 */
68
+ /** 运行时降级判定:鉴权失效(key 过期/被撤)、限流、服务端错误可换后端重试;
69
+ * 4xx 参数错误(400 等)换后端无意义;网络层错误(无 status)也降级(该后端连通性
70
+ * 问题,duckduckgo 兜底)。 */
71
+ function isFallbackable(err) {
72
+ const status = err?.status;
73
+ return (typeof status !== 'number' ||
74
+ status >= 500 ||
75
+ status === 401 ||
76
+ status === 403 ||
77
+ status === 429);
78
+ }
79
+ /** 工具入口:解析 provider → search → 返回 WebSearchResponse。
80
+ * 'auto' 模式带运行时降级链:候选后端按 key 可用性排列(tavily > brave >
81
+ * duckduckgo),当前后端因鉴权/限流/服务端/网络错误失败时依次降级——
82
+ * 此前 auto 只按 key **存在性**选一次,key 失效/过期即整体失败。
83
+ * 显式指定后端时尊重用户选择,单后端不降级。abort 立即上抛,不沿链继续。 */
66
84
  export async function runWebSearch(input, config, abort) {
67
85
  const keys = resolveKeys(config);
68
- const provider = resolveProvider(config.provider, keys);
69
- return provider.search({
86
+ const apiKeyFor = (provider) => provider.id === 'tavily' ? keys.tavily : provider.id === 'brave' ? keys.brave : undefined;
87
+ const search = (provider) => provider.search({
70
88
  query: input.query,
71
89
  limit: clampNumResults(input.numResults),
72
90
  recency: input.recency,
73
91
  signal: abort,
74
- apiKey: provider.id === 'tavily' ? keys.tavily : provider.id === 'brave' ? keys.brave : undefined,
92
+ apiKey: apiKeyFor(provider),
75
93
  fetchImpl: currentFetch(),
76
94
  });
95
+ if (config.provider === 'auto') {
96
+ const chain = [tavilyProvider, braveProvider, duckduckgoProvider].filter((p) => p.isAvailable(apiKeyFor(p)));
97
+ let lastError = null;
98
+ for (const provider of chain) {
99
+ try {
100
+ return await search(provider);
101
+ }
102
+ catch (err) {
103
+ if (abort.aborted)
104
+ throw err;
105
+ if (!isFallbackable(err))
106
+ throw err;
107
+ lastError = err;
108
+ }
109
+ }
110
+ if (lastError instanceof Error)
111
+ throw lastError;
112
+ throw new Error(`websearch 无可用后端:${String(lastError)}`);
113
+ }
114
+ const provider = resolveProvider(config.provider, keys);
115
+ return search(provider);
77
116
  }
78
117
  export { clampNumResults, DEFAULT_NUM_RESULTS };
@@ -1,4 +1,4 @@
1
- import { DEFAULT_NUM_RESULTS } from '../types.js';
1
+ import { DEFAULT_NUM_RESULTS, httpStatusError } from '../types.js';
2
2
  const BRAVE_SEARCH_URL = 'https://api.search.brave.com/res/v1/web/search';
3
3
  /** 导出供测试:recency → Brave freshness 代码映射。 */
4
4
  export function recencyToFreshness(recency) {
@@ -39,7 +39,7 @@ export const braveProvider = {
39
39
  });
40
40
  if (!response.ok) {
41
41
  const text = await response.text().catch(() => response.statusText);
42
- throw new Error(`Brave API error (${response.status}): ${text}`);
42
+ throw httpStatusError(`Brave API error (${response.status}): ${text}`, response.status);
43
43
  }
44
44
  const data = (await response.json());
45
45
  const sources = [];
@@ -1,3 +1,4 @@
1
+ import { httpStatusError } from '../types.js';
1
2
  const DUCKDUCKGO_SEARCH_URL = 'https://api.duckduckgo.com/';
2
3
  const AGENT_TOKEN = 'c0de-agent';
3
4
  function cleanText(value) {
@@ -49,7 +50,7 @@ async function callDuckDuckGoSearch(params) {
49
50
  });
50
51
  if (!response.ok) {
51
52
  const text = await response.text().catch(() => response.statusText);
52
- throw new Error(`DuckDuckGo API error (${response.status}): ${text}`);
53
+ throw httpStatusError(`DuckDuckGo API error (${response.status}): ${text}`, response.status);
53
54
  }
54
55
  return (await response.json());
55
56
  }
@@ -1,4 +1,4 @@
1
- import { DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS, MIN_NUM_RESULTS } from '../types.js';
1
+ import { DEFAULT_NUM_RESULTS, httpStatusError, MAX_NUM_RESULTS, MIN_NUM_RESULTS } from '../types.js';
2
2
  const TAVILY_SEARCH_URL = 'https://api.tavily.com/search';
3
3
  function clamp(n) {
4
4
  if (n === undefined || Number.isNaN(n))
@@ -62,7 +62,7 @@ async function callTavilySearch(apiKey, params) {
62
62
  if (!response.ok) {
63
63
  const raw = await response.text().catch(() => response.statusText);
64
64
  const msg = getErrorMessage(safeParse(raw)) ?? (raw.trim() || response.statusText);
65
- throw new Error(`Tavily API error (${response.status}): ${msg}`);
65
+ throw httpStatusError(`Tavily API error (${response.status}): ${msg}`, response.status);
66
66
  }
67
67
  return (await response.json());
68
68
  }
@@ -47,5 +47,9 @@ declare const MAX_NUM_RESULTS = 20;
47
47
  declare const MIN_NUM_RESULTS = 1;
48
48
  /** clamp 结果数到 [MIN, MAX];undefined/NaN 回退到 fallback。 */
49
49
  declare function clampNumResults(n: number | undefined, fallback?: number): number;
50
+ /** 携带 HTTP 状态码的错误(降级链按 401/403/429/5xx 判定是否换后端重试)。 */
51
+ declare function httpStatusError(message: string, status: number): Error & {
52
+ status: number;
53
+ };
50
54
  export type { Recency, WebSearchParams, WebSearchProvider, WebSearchProviderId, WebSearchResponse, WebSearchSource, };
51
- export { clampNumResults, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS, MIN_NUM_RESULTS };
55
+ export { clampNumResults, DEFAULT_NUM_RESULTS, httpStatusError, MAX_NUM_RESULTS, MIN_NUM_RESULTS };
@@ -15,4 +15,10 @@ function clampNumResults(n, fallback = DEFAULT_NUM_RESULTS) {
15
15
  return fallback;
16
16
  return Math.min(MAX_NUM_RESULTS, Math.max(MIN_NUM_RESULTS, Math.trunc(n)));
17
17
  }
18
- export { clampNumResults, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS, MIN_NUM_RESULTS };
18
+ /** 携带 HTTP 状态码的错误(降级链按 401/403/429/5xx 判定是否换后端重试)。 */
19
+ function httpStatusError(message, status) {
20
+ const err = new Error(message);
21
+ err.status = status;
22
+ return err;
23
+ }
24
+ export { clampNumResults, DEFAULT_NUM_RESULTS, httpStatusError, MAX_NUM_RESULTS, MIN_NUM_RESULTS };
@@ -1,10 +1,10 @@
1
1
  export type { HandoffServer } from './ipc.js';
2
- export { createHandoffServer, requestHandoff } from './ipc.js';
2
+ export { confirmHandoff, createHandoffServer, requestHandoff } from './ipc.js';
3
3
  export type { UpdateScheduler, UpdateSchedulerOptions } from './scheduler.js';
4
4
  export { createUpdateScheduler } from './scheduler.js';
5
5
  export type { SerializedEntry, SerializedSession, SessionSnapshot } from './snapshot.js';
6
6
  export { orderSessionsByParent, restoreSessions, serializeSessions } from './snapshot.js';
7
- export type { HotUpdateOptions, HotUpdateResult, SpawnFn } from './updater.js';
8
- export { cleanupSnapshot, performHotUpdate, performInstall } from './updater.js';
7
+ export type { HotUpdateOptions, HotUpdateResult, InstallMethod, InstallOnlyResult, SpawnFn, } from './updater.js';
8
+ export { cleanupSnapshot, manualInstallCommand, performHandoff, performHotUpdate, performInstall, } from './updater.js';
9
9
  export type { UpdateCheckResult } from './version.js';
10
10
  export { checkForUpdate, compareSemver, getCurrentVersion } from './version.js';
@@ -1,6 +1,6 @@
1
1
  // 热更新 + 会话迁移包(spec §18)。
2
- export { createHandoffServer, requestHandoff } from './ipc.js';
2
+ export { confirmHandoff, createHandoffServer, requestHandoff } from './ipc.js';
3
3
  export { createUpdateScheduler } from './scheduler.js';
4
4
  export { orderSessionsByParent, restoreSessions, serializeSessions } from './snapshot.js';
5
- export { cleanupSnapshot, performHotUpdate, performInstall } from './updater.js';
5
+ export { cleanupSnapshot, manualInstallCommand, performHandoff, performHotUpdate, performInstall, } from './updater.js';
6
6
  export { checkForUpdate, compareSemver, getCurrentVersion } from './version.js';
@@ -22,9 +22,14 @@ type HandoffOptions = {
22
22
  verify?: (token: string | undefined) => boolean;
23
23
  /**
24
24
  * 响应发出后退出进程(旧实例完整让渡:主服务与资源已在 onHandoff 中关闭)。
25
- * 退出延迟 250ms 保证 HTTP 响应刷出;不设时仅响应不退出(测试用)。
25
+ * 退出延迟 250ms 保证 HTTP 响应刷出;不设时仅响应不退出(测试用 / 两阶段交接)。
26
26
  */
27
27
  exitAfterResponse?: boolean;
28
+ /**
29
+ * 收到 POST /handoff-confirm 时调用(两阶段交接第二阶段):
30
+ * 新实例绑定端口成功后的确认。调用方在此清理定时器并退出进程。
31
+ */
32
+ onConfirm?: () => void | Promise<void>;
28
33
  };
29
34
  /**
30
35
  * requestHandoff 抛出的可区分错误(调用方据此分流处理):
@@ -44,5 +49,11 @@ type HandoffError = Error & {
44
49
  declare function createHandoffServer(onHandoff: () => Promise<void>, opts?: HandoffOptions): Promise<HandoffServer>;
45
50
  /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛可区分的 HandoffError。 */
46
51
  declare function requestHandoff(port: number, host?: string, token?: string): Promise<void>;
52
+ /**
53
+ * 两阶段交接第二阶段(P3 回滚修复):新实例绑定端口成功后 POST /handoff-confirm,
54
+ * 旧实例收到才退出进程。连接失败/旧实例已退出时静默——确认是尽力而为,
55
+ * 超时兜底由旧实例的复活窗口负责(端口占用状态是最终裁判)。
56
+ */
57
+ declare function confirmHandoff(port: number, host?: string, token?: string): Promise<void>;
47
58
  export type { HandoffError, HandoffOptions, HandoffServer };
48
- export { createHandoffServer, requestHandoff };
59
+ export { confirmHandoff, createHandoffServer, requestHandoff };
@@ -18,7 +18,7 @@ function createHandoffServer(onHandoff, opts = {}) {
18
18
  return new Promise((resolve, reject) => {
19
19
  const expected = opts.expectedToken && opts.expectedToken.length > 0 ? `Bearer ${opts.expectedToken}` : '';
20
20
  const handler = async (req, res) => {
21
- if (req.method === 'POST' && req.url === '/handoff') {
21
+ if (req.method === 'POST' && (req.url === '/handoff' || req.url === '/handoff-confirm')) {
22
22
  const authorized = opts.verify
23
23
  ? opts.verify(req.headers.authorization?.replace(/^Bearer\s+/i, ''))
24
24
  : expected.length > 0
@@ -29,6 +29,24 @@ function createHandoffServer(onHandoff, opts = {}) {
29
29
  res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
30
30
  return;
31
31
  }
32
+ if (req.url === '/handoff-confirm') {
33
+ // 两阶段交接第二阶段:新实例绑定成功后的确认。响应后由 onConfirm
34
+ // 调度退出;不设 onConfirm 时直接退出(与 exitAfterResponse 独立)。
35
+ try {
36
+ res.writeHead(200, { 'content-type': 'application/json' });
37
+ res.end(JSON.stringify({ ok: true }));
38
+ if (opts.onConfirm) {
39
+ await opts.onConfirm();
40
+ }
41
+ else {
42
+ setTimeout(() => process.exit(0), 250);
43
+ }
44
+ }
45
+ catch (error) {
46
+ console.error('[handoff] confirm handler failed:', error);
47
+ }
48
+ return;
49
+ }
32
50
  try {
33
51
  await onHandoff();
34
52
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -87,4 +105,20 @@ async function requestHandoff(port, host = '127.0.0.1', token) {
87
105
  throw error;
88
106
  }
89
107
  }
90
- export { createHandoffServer, requestHandoff };
108
+ /**
109
+ * 两阶段交接第二阶段(P3 回滚修复):新实例绑定端口成功后 POST /handoff-confirm,
110
+ * 旧实例收到才退出进程。连接失败/旧实例已退出时静默——确认是尽力而为,
111
+ * 超时兜底由旧实例的复活窗口负责(端口占用状态是最终裁判)。
112
+ */
113
+ async function confirmHandoff(port, host = '127.0.0.1', token) {
114
+ try {
115
+ await fetch(`http://${host}:${port}/handoff-confirm`, {
116
+ method: 'POST',
117
+ ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
118
+ });
119
+ }
120
+ catch {
121
+ // 旧实例已退出或不可达:无确认对象,忽略。
122
+ }
123
+ }
124
+ export { confirmHandoff, createHandoffServer, requestHandoff };
@@ -1,3 +1,4 @@
1
+ import { getCurrentVersion } from './version.js';
1
2
  const DEFAULT_INTERVAL_MS = 60 * 60 * 1000;
2
3
  const DEFAULT_INITIAL_DELAY_MS = 10_000;
3
4
  function createUpdateScheduler(opts) {
@@ -14,8 +15,10 @@ function createUpdateScheduler(opts) {
14
15
  r = await opts.checkFn();
15
16
  }
16
17
  catch {
17
- // 异常降级:缓存 hasUpdate:false,但不触发 onUpdate(避免误报)。
18
- lastResult = { hasUpdate: false, currentVersion: '0.0.0', latestVersion: '0.0.0' };
18
+ // 异常降级:缓存 hasUpdate:false + checkError,且保留真实当前版本
19
+ //(此前写死 '0.0.0',前端/API 会展示错误版本号)。不触发 onUpdate(避免误报)。
20
+ const v = getCurrentVersion();
21
+ lastResult = { hasUpdate: false, currentVersion: v, latestVersion: v, checkError: true };
19
22
  return lastResult;
20
23
  }
21
24
  lastResult = r;
@@ -11,6 +11,7 @@ type SerializedSession = {
11
11
  worktreePath: string | null;
12
12
  source: string | null;
13
13
  deletedAt: number | null;
14
+ deletedBatchId: string | null;
14
15
  createdAt: number;
15
16
  updatedAt: number;
16
17
  };
@@ -25,6 +26,17 @@ type SerializedEntry = {
25
26
  tokenCount: number;
26
27
  createdAt: number;
27
28
  };
29
+ /** 序列化后的终端元信息(热更新后新实例按原 id 重建 shell)。 */
30
+ type SerializedTerminal = {
31
+ id: string;
32
+ shell: string;
33
+ cwd: string;
34
+ title: string;
35
+ projectId?: string;
36
+ /** P3-7:用户勾选「更新后自动重启」的前台命令(如 `npm run dev`);
37
+ * 新实例重建 shell 后把该命令写入 stdin 重跑。仅显式勾选才携带。 */
38
+ command?: string;
39
+ };
28
40
  /** 热更新迁移快照(spec §18.2)。 */
29
41
  type SessionSnapshot = {
30
42
  version: string;
@@ -32,12 +44,15 @@ type SessionSnapshot = {
32
44
  entries: SerializedEntry[];
33
45
  config: unknown;
34
46
  timestamp: number;
47
+ /** P1:活跃终端元信息——进程无法续命,新实例据此在原位重建 shell(同 id,
48
+ * 前端持久化布局可无感重连)。旧快照缺省视为无终端。 */
49
+ terminals?: SerializedTerminal[];
35
50
  };
36
51
  /** 按 parentId 拓扑序排列 sessions,使父会话先于子会话插入(满足自引用 FK)。 */
37
52
  declare function orderSessionsByParent(list: SerializedSession[]): SerializedSession[];
38
- /** 从 DB 导出所有会话与条目为可序列化快照。 */
39
- declare function serializeSessions(handle: DB, config?: unknown): Promise<SessionSnapshot>;
53
+ /** 从 DB 导出所有会话与条目为可序列化快照。terminals 为活跃终端元信息(可选)。 */
54
+ declare function serializeSessions(handle: DB, config?: unknown, terminals?: SerializedTerminal[]): Promise<SessionSnapshot>;
40
55
  /** 把快照导入 DB(保留原始 id 与时间戳;父会话先插入以满足 FK)。 */
41
56
  declare function restoreSessions(handle: DB, snapshot: SessionSnapshot): Promise<void>;
42
- export type { SerializedEntry, SerializedSession, SessionSnapshot };
57
+ export type { SerializedEntry, SerializedSession, SerializedTerminal, SessionSnapshot };
43
58
  export { orderSessionsByParent, restoreSessions, serializeSessions };
@@ -19,6 +19,7 @@ function toSerializedSession(row) {
19
19
  worktreePath: row.worktreePath,
20
20
  source: row.source,
21
21
  deletedAt: row.deletedAt ? toDateMs(row.deletedAt) : null,
22
+ deletedBatchId: row.deletedBatchId ?? null,
22
23
  createdAt: toDateMs(row.createdAt),
23
24
  updatedAt: toDateMs(row.updatedAt),
24
25
  };
@@ -53,8 +54,8 @@ function orderSessionsByParent(list) {
53
54
  visit(s);
54
55
  return ordered;
55
56
  }
56
- /** 从 DB 导出所有会话与条目为可序列化快照。 */
57
- async function serializeSessions(handle, config) {
57
+ /** 从 DB 导出所有会话与条目为可序列化快照。terminals 为活跃终端元信息(可选)。 */
58
+ async function serializeSessions(handle, config, terminals) {
58
59
  const [sRows, eRows] = await Promise.all([
59
60
  handle.db.select().from(sessions),
60
61
  handle.db.select().from(sessionEntries),
@@ -65,6 +66,7 @@ async function serializeSessions(handle, config) {
65
66
  entries: eRows.map(toSerializedEntry),
66
67
  config: config ?? null,
67
68
  timestamp: Date.now(),
69
+ ...(terminals && terminals.length > 0 ? { terminals } : {}),
68
70
  };
69
71
  }
70
72
  /** 把快照导入 DB(保留原始 id 与时间戳;父会话先插入以满足 FK)。 */
@@ -83,6 +85,7 @@ async function restoreSessions(handle, snapshot) {
83
85
  worktreePath: s.worktreePath,
84
86
  source: s.source,
85
87
  deletedAt: s.deletedAt != null ? new Date(s.deletedAt) : null,
88
+ deletedBatchId: s.deletedBatchId ?? null,
86
89
  createdAt: new Date(s.createdAt),
87
90
  updatedAt: new Date(s.updatedAt),
88
91
  })
@@ -51,6 +51,8 @@ type HotUpdateOptions = {
51
51
  handoffPort?: number;
52
52
  /** 旧实例主服务端口;新实例 --port 复用(保证接管同一地址)。 */
53
53
  port?: number;
54
+ /** 旧实例绑定地址;新实例 --host 复用(保证接管同一地址)。 */
55
+ host?: string;
54
56
  /** 传给新实例的认证 token(经环境变量 C0DE_AUTH_TOKEN),保证新旧实例握手同 token。 */
55
57
  authToken?: string;
56
58
  /** 手动安装等待程序文件变更的超时(ms);默认 10 分钟,超时放弃等待。 */
@@ -75,6 +77,20 @@ type InstallMethod = {
75
77
  declare function detectInstallMethod(): InstallMethod;
76
78
  /** 手动安装提示命令(unknown 安装方式时返回给前端展示)。 */
77
79
  declare function manualInstallCommand(pkg: string): string;
80
+ /**
81
+ * 分阶段热更新的第二阶段(P0 修复:install 与 pause 解耦)。
82
+ *
83
+ * 调用方(/api/update/apply)顺序:
84
+ * 1. checkNow → hasUpdate
85
+ * 2. performInstall(仅安装;失败时**尚未暂停任何会话**,直接 409 可操作错误)
86
+ * 3. 安装成功 → pauseAll(暂停活跃 run 至安全点)
87
+ * 4. serializeSessions → performHandoff(本函数:写快照 + spawn 新实例)
88
+ * 5. spawn 失败 → 调用方 resumeAll 回滚暂停的会话
89
+ *
90
+ * 对比旧流程(pause → 序列化 → performHotUpdate 内含 install):install 失败时
91
+ * 会话已被 pause/forcedAbort 且无回滚路径——更新失败顺带销毁进行中的工作。
92
+ */
93
+ declare function performHandoff(snapshot: SessionSnapshot, method: InstallMethod, opts?: HotUpdateOptions): Promise<HotUpdateResult>;
78
94
  /**
79
95
  * 执行热更新(spec §18.1-18.2 + P0-2 修订流程):
80
96
  * 1. 序列化当前会话状态到快照文件
@@ -96,4 +112,4 @@ declare function performInstall(opts?: HotUpdateOptions): Promise<InstallOnlyRes
96
112
  /** 清理快照临时目录(热更新完成或放弃后调用)。 */
97
113
  declare function cleanupSnapshot(snapshotPath: string): Promise<void>;
98
114
  export type { HotUpdateOptions, HotUpdateResult, InstallMethod, InstallOnlyResult, SpawnFn };
99
- export { cleanupSnapshot, detectInstallMethod, manualInstallCommand, performHotUpdate, performInstall, };
115
+ export { cleanupSnapshot, detectInstallMethod, manualInstallCommand, performHandoff, performHotUpdate, performInstall, };
@@ -170,7 +170,7 @@ function defaultSpawn(_snapshotPath, argv, opts) {
170
170
  }
171
171
  });
172
172
  }
173
- /** 组装新实例 argv(serve --restore <snapshot> [--port] [--handoff-port])。 */
173
+ /** 组装新实例 argv(serve --restore <snapshot> [--port] [--host] [--handoff-port])。 */
174
174
  function buildSpawnArgv(snapshotPath, opts) {
175
175
  const restoreFlag = opts.restoreFlag ?? '--restore';
176
176
  const argv = [
@@ -178,12 +178,55 @@ function buildSpawnArgv(snapshotPath, opts) {
178
178
  restoreFlag,
179
179
  snapshotPath,
180
180
  ...(opts.port !== undefined ? ['--port', String(opts.port)] : []),
181
+ ...(opts.host ? ['--host', opts.host] : []),
181
182
  ];
182
183
  if (opts.handoffPort !== undefined) {
183
184
  argv.push('--handoff-port', String(opts.handoffPort));
184
185
  }
185
186
  return argv;
186
187
  }
188
+ /**
189
+ * 把快照写入临时目录(或 opts.snapshotPath),返回实际路径。
190
+ * 独立成函数供 performHotUpdate(一体流程)与 performHandoff(分阶段流程)共用。
191
+ */
192
+ async function writeSnapshot(snapshot, opts) {
193
+ const snapshotPath = opts.snapshotPath ?? join(await mkdtemp(join(tmpdir(), 'c0de-update-')), 'snapshot.json');
194
+ await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf8');
195
+ return snapshotPath;
196
+ }
197
+ /** 安装完成后的滚动切换:spawn 新实例(restore 快照 + handoff 端口接管由新实例发起)。 */
198
+ async function spawnAfterInstall(snapshotPath, method, opts) {
199
+ const spawnNew = opts.spawnNewInstanceFn ?? defaultSpawn;
200
+ const argv = buildSpawnArgv(snapshotPath, opts);
201
+ try {
202
+ await spawnNew(snapshotPath, argv, opts);
203
+ }
204
+ catch (error) {
205
+ return {
206
+ _tag: 'spawn_failed',
207
+ error: error instanceof Error ? error.message : String(error),
208
+ snapshotPath,
209
+ };
210
+ }
211
+ return { _tag: 'success', snapshotPath, installMethod: method.kind };
212
+ }
213
+ /**
214
+ * 分阶段热更新的第二阶段(P0 修复:install 与 pause 解耦)。
215
+ *
216
+ * 调用方(/api/update/apply)顺序:
217
+ * 1. checkNow → hasUpdate
218
+ * 2. performInstall(仅安装;失败时**尚未暂停任何会话**,直接 409 可操作错误)
219
+ * 3. 安装成功 → pauseAll(暂停活跃 run 至安全点)
220
+ * 4. serializeSessions → performHandoff(本函数:写快照 + spawn 新实例)
221
+ * 5. spawn 失败 → 调用方 resumeAll 回滚暂停的会话
222
+ *
223
+ * 对比旧流程(pause → 序列化 → performHotUpdate 内含 install):install 失败时
224
+ * 会话已被 pause/forcedAbort 且无回滚路径——更新失败顺带销毁进行中的工作。
225
+ */
226
+ async function performHandoff(snapshot, method, opts = {}) {
227
+ const snapshotPath = await writeSnapshot(snapshot, opts);
228
+ return spawnAfterInstall(snapshotPath, method, opts);
229
+ }
187
230
  /**
188
231
  * 执行热更新(spec §18.1-18.2 + P0-2 修订流程):
189
232
  * 1. 序列化当前会话状态到快照文件
@@ -197,9 +240,7 @@ function buildSpawnArgv(snapshotPath, opts) {
197
240
  */
198
241
  async function performHotUpdate(snapshot, opts = {}) {
199
242
  const pkg = opts.packageName ?? DEFAULT_PACKAGE;
200
- const dir = await mkdtemp(join(tmpdir(), 'c0de-update-'));
201
- const snapshotPath = opts.snapshotPath ?? join(dir, 'snapshot.json');
202
- await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf8');
243
+ const snapshotPath = await writeSnapshot(snapshot, opts);
203
244
  // 识别安装方式
204
245
  const method = detectInstallMethod();
205
246
  // 安装前采集程序文件基线(watch 依赖此判断「变更已发生」)
@@ -246,19 +287,7 @@ async function performHotUpdate(snapshot, opts = {}) {
246
287
  }
247
288
  }
248
289
  // 程序文件已变更(自动安装落盘或用户手动安装完成)→ 滚动切换
249
- const spawnNew = opts.spawnNewInstanceFn ?? defaultSpawn;
250
- const argv = buildSpawnArgv(snapshotPath, opts);
251
- try {
252
- await spawnNew(snapshotPath, argv, opts);
253
- }
254
- catch (error) {
255
- return {
256
- _tag: 'spawn_failed',
257
- error: error instanceof Error ? error.message : String(error),
258
- snapshotPath,
259
- };
260
- }
261
- return { _tag: 'success', snapshotPath, installMethod: method.kind };
290
+ return spawnAfterInstall(snapshotPath, method, opts);
262
291
  }
263
292
  /**
264
293
  * 仅安装新版本(P2-7:无活跃 serve 的 `c0de update --apply`)。
@@ -291,4 +320,4 @@ async function performInstall(opts = {}) {
291
320
  async function cleanupSnapshot(snapshotPath) {
292
321
  await rm(join(snapshotPath, '..'), { recursive: true, force: true }).catch(() => { });
293
322
  }
294
- export { cleanupSnapshot, detectInstallMethod, manualInstallCommand, performHotUpdate, performInstall, };
323
+ export { cleanupSnapshot, detectInstallMethod, manualInstallCommand, performHandoff, performHotUpdate, performInstall, };
@@ -3,6 +3,9 @@ type UpdateCheckResult = {
3
3
  currentVersion: string;
4
4
  latestVersion: string;
5
5
  releaseNotes?: string;
6
+ /** P3-8:检查本身失败(网络/registry 异常),与「已是最新」区分——
7
+ * 消费方据此给出「稍后重试」而非误导性的「无更新」。 */
8
+ checkError?: boolean;
6
9
  };
7
10
  type CheckOptions = {
8
11
  fetchImpl?: typeof fetch;
@@ -48,16 +48,22 @@ async function checkForUpdate(opts = {}) {
48
48
  };
49
49
  try {
50
50
  const res = await fetchImpl(`${registry}/${pkg}/latest`);
51
- if (!res.ok)
51
+ if (!res.ok) {
52
+ // 非 200(404/5xx/限流)→ 检查失败而非无更新
53
+ result.checkError = true;
52
54
  return result;
55
+ }
53
56
  const data = (await res.json());
54
- if (!data.version)
57
+ if (!data.version) {
58
+ result.checkError = true;
55
59
  return result;
60
+ }
56
61
  result.latestVersion = data.version;
57
62
  result.hasUpdate = compareSemver(current, data.version) < 0;
58
63
  }
59
64
  catch {
60
- // 离线/网络错误:保守地报告无更新
65
+ // 离线/网络错误:保守地报告无更新,但标记检查失败供上层区分提示
66
+ result.checkError = true;
61
67
  }
62
68
  return result;
63
69
  }
@@ -0,0 +1 @@
1
+ ALTER TABLE "sessions" ADD COLUMN "deleted_batch_id" uuid;
@@ -0,0 +1,17 @@
1
+ CREATE TABLE "usage_events" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "call_id" uuid NOT NULL,
4
+ "session_id" uuid,
5
+ "project_id" text,
6
+ "provider" text NOT NULL,
7
+ "model" text NOT NULL,
8
+ "input_tokens" bigint DEFAULT 0 NOT NULL,
9
+ "output_tokens" bigint DEFAULT 0 NOT NULL,
10
+ "cache_read" bigint DEFAULT 0,
11
+ "cost" real,
12
+ "timestamp" bigint NOT NULL
13
+ );
14
+ --> statement-breakpoint
15
+ CREATE UNIQUE INDEX "uq_usage_events_call" ON "usage_events" USING btree ("call_id");--> statement-breakpoint
16
+ CREATE INDEX "idx_usage_events_project_ts" ON "usage_events" USING btree ("project_id","timestamp");--> statement-breakpoint
17
+ CREATE INDEX "idx_usage_events_ts" ON "usage_events" USING btree ("timestamp");
@@ -0,0 +1,12 @@
1
+ CREATE TABLE "app_meta" (
2
+ "key" text PRIMARY KEY NOT NULL,
3
+ "value" text NOT NULL
4
+ );
5
+ --> statement-breakpoint
6
+ ALTER TABLE "kanban_boards" DROP CONSTRAINT "kanban_boards_project_id_projects_id_fk";
7
+ --> statement-breakpoint
8
+ ALTER TABLE "kanban_boards" ALTER COLUMN "project_id" DROP NOT NULL;--> statement-breakpoint
9
+ ALTER TABLE "kanban_boards" ADD COLUMN "deleted_at" timestamp with time zone DEFAULT null;--> statement-breakpoint
10
+ ALTER TABLE "kanban_boards" ADD COLUMN "deleted_project_name" text;--> statement-breakpoint
11
+ ALTER TABLE "projects" ADD COLUMN "trusted_at" timestamp with time zone DEFAULT null;--> statement-breakpoint
12
+ ALTER TABLE "kanban_boards" ADD CONSTRAINT "kanban_boards_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
@@ -0,0 +1 @@
1
+ ALTER TABLE "kanban_boards" ADD COLUMN "purge_pending_at" timestamp with time zone DEFAULT null;
@@ -0,0 +1 @@
1
+ ALTER TABLE "kanban_boards" ADD COLUMN "deleted_project_worktree" text;
@@ -0,0 +1 @@
1
+ ALTER TABLE "projects" ADD COLUMN "risk_fingerprint" text DEFAULT null;