c0de-agent 1.9.0 → 1.11.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 (109) hide show
  1. package/README.md +25 -8
  2. package/dist/cli/commands/acp.js +5 -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 +10 -0
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +15 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/serve.d.ts +1 -0
  11. package/dist/cli/commands/serve.js +4 -1
  12. package/dist/cli/commands/sessions.d.ts +10 -0
  13. package/dist/cli/commands/sessions.js +35 -0
  14. package/dist/cli/commands/update.js +18 -10
  15. package/dist/cli/deps.d.ts +7 -5
  16. package/dist/cli/deps.js +23 -7
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +96 -37
  19. package/dist/cli/modes/print.d.ts +3 -1
  20. package/dist/cli/modes/print.js +31 -4
  21. package/dist/core/agent.js +3 -4
  22. package/dist/core/config-path.d.ts +9 -0
  23. package/dist/core/config-path.js +48 -0
  24. package/dist/core/config.d.ts +27 -2
  25. package/dist/core/config.js +83 -13
  26. package/dist/core/index.d.ts +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/loop.js +14 -4
  29. package/dist/core/slash.js +92 -30
  30. package/dist/core/title.js +15 -0
  31. package/dist/core/types.d.ts +2 -0
  32. package/dist/core/workflows/discovery.d.ts +1 -1
  33. package/dist/core/workflows/index.d.ts +1 -1
  34. package/dist/db/client.d.ts +4 -0
  35. package/dist/db/client.js +5 -0
  36. package/dist/db/schema.d.ts +51 -0
  37. package/dist/db/schema.js +7 -1
  38. package/dist/server/agent-manager.d.ts +20 -0
  39. package/dist/server/agent-manager.js +63 -11
  40. package/dist/server/app.js +12 -1
  41. package/dist/server/auth-manager.d.ts +79 -0
  42. package/dist/server/auth-manager.js +270 -0
  43. package/dist/server/context.js +3 -0
  44. package/dist/server/middleware/auth.d.ts +4 -3
  45. package/dist/server/middleware/auth.js +5 -5
  46. package/dist/server/middleware/cors.d.ts +11 -2
  47. package/dist/server/middleware/cors.js +15 -12
  48. package/dist/server/permission/interactive.d.ts +2 -0
  49. package/dist/server/permission/interactive.js +5 -1
  50. package/dist/server/permission/store.d.ts +4 -0
  51. package/dist/server/permission/store.js +1 -0
  52. package/dist/server/routes/auth.d.ts +4 -0
  53. package/dist/server/routes/auth.js +94 -0
  54. package/dist/server/routes/chat.d.ts +6 -1
  55. package/dist/server/routes/chat.js +294 -153
  56. package/dist/server/routes/config.js +60 -8
  57. package/dist/server/routes/permissions.d.ts +4 -1
  58. package/dist/server/routes/permissions.js +27 -4
  59. package/dist/server/routes/project.js +56 -2
  60. package/dist/server/routes/session.js +37 -9
  61. package/dist/server/routes/terminal.js +29 -2
  62. package/dist/server/routes/update.d.ts +1 -0
  63. package/dist/server/routes/update.js +33 -4
  64. package/dist/server/routes/workflows.js +2 -2
  65. package/dist/server/server.d.ts +24 -1
  66. package/dist/server/server.js +149 -29
  67. package/dist/server/types.d.ts +17 -2
  68. package/dist/session/archive.d.ts +1 -1
  69. package/dist/session/branch.d.ts +11 -2
  70. package/dist/session/branch.js +107 -31
  71. package/dist/session/compaction.js +1 -0
  72. package/dist/session/context.d.ts +3 -2
  73. package/dist/session/context.js +36 -3
  74. package/dist/session/index.d.ts +1 -1
  75. package/dist/session/index.js +1 -1
  76. package/dist/session/session.d.ts +25 -5
  77. package/dist/session/session.js +129 -10
  78. package/dist/session/snapshot.d.ts +9 -3
  79. package/dist/session/snapshot.js +45 -4
  80. package/dist/session/squash.js +1 -0
  81. package/dist/session/types.d.ts +3 -1
  82. package/dist/shared/types/agent.d.ts +11 -0
  83. package/dist/shared/types/config.d.ts +12 -6
  84. package/dist/shared/types/message.d.ts +6 -2
  85. package/dist/shared/utils/path.d.ts +2 -3
  86. package/dist/shared/utils/path.js +20 -4
  87. package/dist/tools/builtin/todo.js +5 -4
  88. package/dist/tools/index.d.ts +14 -3
  89. package/dist/tools/index.js +35 -14
  90. package/dist/update/index.d.ts +2 -2
  91. package/dist/update/index.js +2 -2
  92. package/dist/update/ipc.d.ts +38 -5
  93. package/dist/update/ipc.js +56 -7
  94. package/dist/update/snapshot.d.ts +4 -0
  95. package/dist/update/snapshot.js +8 -0
  96. package/dist/update/updater.d.ts +61 -10
  97. package/dist/update/updater.js +236 -25
  98. package/dist/update/version.d.ts +6 -1
  99. package/dist/update/version.js +18 -3
  100. package/drizzle/0005_snapshot_mtime.sql +1 -0
  101. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  102. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  103. package/drizzle/meta/0005_snapshot.json +855 -0
  104. package/drizzle/meta/0006_snapshot.json +867 -0
  105. package/drizzle/meta/0007_snapshot.json +869 -0
  106. package/drizzle/meta/_journal.json +21 -0
  107. package/package.json +1 -7
  108. package/dist/mcp/index.d.ts +0 -1
  109. package/dist/mcp/index.js +0 -3
@@ -7,9 +7,42 @@ type HandoffServer = {
7
7
  port: number;
8
8
  close: () => Promise<void>;
9
9
  };
10
- /** 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff(序列化 + 退出)。 */
11
- declare function createHandoffServer(onHandoff: () => Promise<void>): Promise<HandoffServer>;
12
- /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛错。 */
13
- declare function requestHandoff(port: number, host?: string): Promise<void>;
14
- export type { HandoffServer };
10
+ type HandoffOptions = {
11
+ /**
12
+ * 期望的 Bearer token。设置后 /handoff 必须携带匹配的 Authorization 头,
13
+ * 否则 401——防止任意本地进程用 POST /handoff 杀掉服务。
14
+ * 旧实例与新实例读同一 auth-token 文件(或同一 config token),天然匹配。
15
+ */
16
+ expectedToken?: string;
17
+ /**
18
+ * 自定义校验函数(P2-16:token 轮换后新旧实例 bootstrap 可能不同,
19
+ * 用 authManager.verifyHandoff 接受当前/历史 bootstrap 与设备 token)。
20
+ * 提供时优先于 expectedToken。
21
+ */
22
+ verify?: (token: string | undefined) => boolean;
23
+ /**
24
+ * 响应发出后退出进程(旧实例完整让渡:主服务与资源已在 onHandoff 中关闭)。
25
+ * 退出延迟 250ms 保证 HTTP 响应刷出;不设时仅响应不退出(测试用)。
26
+ */
27
+ exitAfterResponse?: boolean;
28
+ };
29
+ /**
30
+ * requestHandoff 抛出的可区分错误(调用方据此分流处理):
31
+ * - kind='connect':连接层失败(ECONNREFUSED 等)——旧实例未启 handoff 端点,
32
+ * 调用方应直接尝试绑端口;
33
+ * - kind='http':收到 HTTP 响应但非 2xx(status 为状态码)——如 401 token
34
+ * 不匹配、500 onHandoff 失败,旧实例存在但拒绝/未能让渡。
35
+ */
36
+ type HandoffError = Error & {
37
+ kind: 'connect' | 'http';
38
+ status?: number;
39
+ };
40
+ /**
41
+ * 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff
42
+ * (关闭主服务与资源),响应 200;exitAfterResponse 时随后退出进程。
43
+ */
44
+ declare function createHandoffServer(onHandoff: () => Promise<void>, opts?: HandoffOptions): Promise<HandoffServer>;
45
+ /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛可区分的 HandoffError。 */
46
+ declare function requestHandoff(port: number, host?: string, token?: string): Promise<void>;
47
+ export type { HandoffError, HandoffOptions, HandoffServer };
15
48
  export { createHandoffServer, requestHandoff };
@@ -1,17 +1,48 @@
1
+ import { timingSafeEqual } from 'node:crypto';
1
2
  import { createServer } from 'node:http';
2
- /** 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff(序列化 + 退出)。 */
3
- function createHandoffServer(onHandoff) {
3
+ /**
4
+ * 恒时比较 Authorization 头与期望值:字符串直接比较可被时序攻击逐字符探测。
5
+ * 长度不等直接 false——timingSafeEqual 要求等长 buffer,先比较长度本身
6
+ * 不泄露内容信息。
7
+ */
8
+ function authHeaderMatches(actual, expected) {
9
+ if (typeof actual !== 'string' || actual.length !== expected.length)
10
+ return false;
11
+ return timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
12
+ }
13
+ /**
14
+ * 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff
15
+ * (关闭主服务与资源),响应 200;exitAfterResponse 时随后退出进程。
16
+ */
17
+ function createHandoffServer(onHandoff, opts = {}) {
4
18
  return new Promise((resolve, reject) => {
19
+ const expected = opts.expectedToken && opts.expectedToken.length > 0 ? `Bearer ${opts.expectedToken}` : '';
5
20
  const handler = async (req, res) => {
6
21
  if (req.method === 'POST' && req.url === '/handoff') {
22
+ const authorized = opts.verify
23
+ ? opts.verify(req.headers.authorization?.replace(/^Bearer\s+/i, ''))
24
+ : expected.length > 0
25
+ ? authHeaderMatches(req.headers.authorization, expected)
26
+ : true;
27
+ if (!authorized) {
28
+ res.writeHead(401, { 'content-type': 'application/json' });
29
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
30
+ return;
31
+ }
7
32
  try {
8
33
  await onHandoff();
9
34
  res.writeHead(200, { 'content-type': 'application/json' });
10
35
  res.end(JSON.stringify({ ok: true }));
36
+ // 仅 200 才调度退出:500 时 exit(0) 会掩盖让渡失败——保留进程供
37
+ // 诊断/重试,由下方错误日志暴露 onHandoff 失败原因。
38
+ if (opts.exitAfterResponse) {
39
+ setTimeout(() => process.exit(0), 250);
40
+ }
11
41
  }
12
42
  catch (error) {
13
43
  res.writeHead(500, { 'content-type': 'application/json' });
14
44
  res.end(JSON.stringify({ ok: false, error: String(error) }));
45
+ console.error('[handoff] onHandoff failed, keeping process alive:', error);
15
46
  }
16
47
  return;
17
48
  }
@@ -32,10 +63,28 @@ function createHandoffServer(onHandoff) {
32
63
  });
33
64
  });
34
65
  }
35
- /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛错。 */
36
- async function requestHandoff(port, host = '127.0.0.1') {
37
- const res = await fetch(`http://${host}:${port}/handoff`, { method: 'POST' });
38
- if (!res.ok)
39
- throw new Error(`handoff failed: HTTP ${res.status}`);
66
+ /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛可区分的 HandoffError。 */
67
+ async function requestHandoff(port, host = '127.0.0.1', token) {
68
+ let res;
69
+ try {
70
+ res = await fetch(`http://${host}:${port}/handoff`, {
71
+ method: 'POST',
72
+ ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
73
+ });
74
+ }
75
+ catch (err) {
76
+ // 连接层失败(ECONNREFUSED 等):旧实例无 handoff 端点。抛 kind='connect'
77
+ // 让调用方(requestHandoffWithRetry)区分处理——直接尝试绑端口。
78
+ const error = new Error(`handoff connect failed: ${err instanceof Error ? err.message : String(err)}`);
79
+ error.kind = 'connect';
80
+ throw error;
81
+ }
82
+ if (!res.ok) {
83
+ // 收到 HTTP 响应但非 2xx(如 401 token 不匹配、500 onHandoff 失败)。
84
+ const error = new Error(`handoff failed: HTTP ${res.status}`);
85
+ error.kind = 'http';
86
+ error.status = res.status;
87
+ throw error;
88
+ }
40
89
  }
41
90
  export { createHandoffServer, requestHandoff };
@@ -7,6 +7,10 @@ type SerializedSession = {
7
7
  projectId: string | null;
8
8
  branchPoint: number | null;
9
9
  metadata: unknown;
10
+ agentType: string | null;
11
+ worktreePath: string | null;
12
+ source: string | null;
13
+ deletedAt: number | null;
10
14
  createdAt: number;
11
15
  updatedAt: number;
12
16
  };
@@ -15,6 +15,10 @@ function toSerializedSession(row) {
15
15
  projectId: row.projectId,
16
16
  branchPoint: row.branchPoint,
17
17
  metadata: row.metadata,
18
+ agentType: row.agentType,
19
+ worktreePath: row.worktreePath,
20
+ source: row.source,
21
+ deletedAt: row.deletedAt ? toDateMs(row.deletedAt) : null,
18
22
  createdAt: toDateMs(row.createdAt),
19
23
  updatedAt: toDateMs(row.updatedAt),
20
24
  };
@@ -75,6 +79,10 @@ async function restoreSessions(handle, snapshot) {
75
79
  projectId: s.projectId,
76
80
  branchPoint: s.branchPoint,
77
81
  metadata: s.metadata,
82
+ agentType: s.agentType,
83
+ worktreePath: s.worktreePath,
84
+ source: s.source,
85
+ deletedAt: s.deletedAt != null ? new Date(s.deletedAt) : null,
78
86
  createdAt: new Date(s.createdAt),
79
87
  updatedAt: new Date(s.updatedAt),
80
88
  })
@@ -3,6 +3,7 @@ import type { SessionSnapshot } from './snapshot.js';
3
3
  type HotUpdateResult = {
4
4
  _tag: 'success';
5
5
  snapshotPath: string;
6
+ installMethod: string;
6
7
  } | {
7
8
  _tag: 'install_failed';
8
9
  error: string;
@@ -11,16 +12,34 @@ type HotUpdateResult = {
11
12
  _tag: 'spawn_failed';
12
13
  error: string;
13
14
  snapshotPath: string;
15
+ } | {
16
+ _tag: 'manual_install_required';
17
+ error: string;
18
+ snapshotPath: string;
19
+ command: string;
20
+ };
21
+ /** 仅安装结果(无活跃 serve 的 CLI `c0de update --apply` 使用;不 spawn 新实例)。 */
22
+ type InstallOnlyResult = {
23
+ _tag: 'success';
24
+ installMethod: string;
25
+ } | {
26
+ _tag: 'install_failed';
27
+ error: string;
28
+ } | {
29
+ _tag: 'manual_install_required';
30
+ error: string;
31
+ command: string;
14
32
  };
15
33
  /**
16
34
  * 启动新实例的函数签名。
17
35
  * @param snapshotPath 快照文件路径(旧实例已写入)
18
36
  * @param argv 传给新进程的完整参数(含 restore flag + 可选 handoff flag)
37
+ * @param opts 热更新选项(authToken 等经环境变量传递给新实例)
19
38
  */
20
- type SpawnFn = (snapshotPath: string, argv: string[]) => Promise<void>;
39
+ type SpawnFn = (snapshotPath: string, argv: string[], opts?: HotUpdateOptions) => Promise<void>;
21
40
  type HotUpdateOptions = {
22
- /** 自更新安装函数;默认 `npm install -g <pkg>`。 */
23
- installFn?: (pkg: string) => Promise<void>;
41
+ /** 自更新安装函数;默认按检测到的安装方式(npm/pnpm)执行。 */
42
+ installFn?: (pkg: string, method: InstallMethod) => Promise<void>;
24
43
  /** 启动新实例的函数;默认 detached spawn 当前进程入口。 */
25
44
  spawnNewInstanceFn?: SpawnFn;
26
45
  packageName?: string;
@@ -30,19 +49,51 @@ type HotUpdateOptions = {
30
49
  restoreFlag?: string;
31
50
  /** 旧实例 handoff 端口;提供时新实例启动后请求旧实例 graceful shutdown。 */
32
51
  handoffPort?: number;
52
+ /** 旧实例主服务端口;新实例 --port 复用(保证接管同一地址)。 */
53
+ port?: number;
54
+ /** 传给新实例的认证 token(经环境变量 C0DE_AUTH_TOKEN),保证新旧实例握手同 token。 */
55
+ authToken?: string;
56
+ /** 手动安装等待程序文件变更的超时(ms);默认 10 分钟,超时放弃等待。 */
57
+ manualWaitTimeoutMs?: number;
58
+ };
59
+ /** 安装方式(P0-2:识别当前进程安装器;unknown = 不自动安装,提示手动更新)。 */
60
+ type InstallMethod = {
61
+ kind: 'npm';
62
+ } | {
63
+ kind: 'pnpm';
64
+ } | {
65
+ kind: 'unknown';
66
+ hint: string;
33
67
  };
34
68
  /**
35
- * 执行热更新(spec §18.1-18.2 流程):
69
+ * 识别当前进程的安装方式:
70
+ * - 入口脚本 realpath 含 node_modules/.pnpm → pnpm
71
+ * - 含 node_modules(非 .pnpm)→ npm
72
+ * - 否则 unknown(npx 缓存、源码 dev、其他)→ 不自动安装
73
+ * 检测不到时返回 unknown,由上层提示用户手动安装。
74
+ */
75
+ declare function detectInstallMethod(): InstallMethod;
76
+ /** 手动安装提示命令(unknown 安装方式时返回给前端展示)。 */
77
+ declare function manualInstallCommand(pkg: string): string;
78
+ /**
79
+ * 执行热更新(spec §18.1-18.2 + P0-2 修订流程):
36
80
  * 1. 序列化当前会话状态到快照文件
37
- * 2. 全局自更新(npm install -g)
38
- * 3. detached 启动新版本实例,传入快照路径(新实例 restore + 端口接管)
81
+ * 2. 识别当前进程安装方式(npm/pnpm/unknown)
82
+ * 3. 已知方式 → 自动安装;unknown → 不安装,等待用户手动安装
83
+ * 4. watch 当前执行程序文件变更 → 触发滚动切换
84
+ * (detached 启动新实例 + restore 快照 + handoff 端口接管)
39
85
  *
40
86
  * 注:旧实例的 graceful shutdown / 端口让渡由新实例启动后通过 IPC 协调
41
- * (见 ipc.ts),本函数只负责"序列化 + 安装 + 接力"。若提供 handoffPort,
42
- * argv 会带上 --handoff-port,新实例据此 requestHandoff 通知旧实例退出。
87
+ * (见 ipc.ts),本函数只负责"序列化 + 安装 + 等待程序变更 + 接力"。
43
88
  */
44
89
  declare function performHotUpdate(snapshot: SessionSnapshot, opts?: HotUpdateOptions): Promise<HotUpdateResult>;
90
+ /**
91
+ * 仅安装新版本(P2-7:无活跃 serve 的 `c0de update --apply`)。
92
+ * 不序列化快照、不 spawn `serve`——此前独立 CLI 执行会拉起一个用户未请求的常驻服务。
93
+ * 运行中的 serve 由 `/api/update/apply` 走 performHotUpdate 热更新路径。
94
+ */
95
+ declare function performInstall(opts?: HotUpdateOptions): Promise<InstallOnlyResult>;
45
96
  /** 清理快照临时目录(热更新完成或放弃后调用)。 */
46
97
  declare function cleanupSnapshot(snapshotPath: string): Promise<void>;
47
- export type { HotUpdateOptions, HotUpdateResult, SpawnFn };
48
- export { cleanupSnapshot, performHotUpdate };
98
+ export type { HotUpdateOptions, HotUpdateResult, InstallMethod, InstallOnlyResult, SpawnFn };
99
+ export { cleanupSnapshot, detectInstallMethod, manualInstallCommand, performHotUpdate, performInstall, };
@@ -1,28 +1,166 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { realpathSync, statSync, watch } from 'node:fs';
2
3
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
+ import { basename, dirname, join } from 'node:path';
5
6
  const DEFAULT_PACKAGE = 'c0de-agent';
6
- /** 默认自更新:npm install -g <pkg>。 */
7
- function defaultInstall(pkg) {
7
+ const DEFAULT_MANUAL_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
8
+ /**
9
+ * 识别当前进程的安装方式:
10
+ * - 入口脚本 realpath 含 node_modules/.pnpm → pnpm
11
+ * - 含 node_modules(非 .pnpm)→ npm
12
+ * - 否则 unknown(npx 缓存、源码 dev、其他)→ 不自动安装
13
+ * 检测不到时返回 unknown,由上层提示用户手动安装。
14
+ */
15
+ function detectInstallMethod() {
16
+ const entry = process.argv[1];
17
+ if (!entry)
18
+ return { kind: 'unknown', hint: '无法确定入口脚本' };
19
+ let real = entry;
20
+ try {
21
+ real = realpathSync(entry);
22
+ }
23
+ catch {
24
+ // 入口不存在/不可解析:尝试保留原始路径判断
25
+ }
26
+ const p = real.replace(/\\/g, '/');
27
+ if (p.includes('/.pnpm/'))
28
+ return { kind: 'pnpm' };
29
+ if (p.includes('/node_modules/'))
30
+ return { kind: 'npm' };
31
+ if (p.includes('/_npx/')) {
32
+ return { kind: 'unknown', hint: '通过 npx 临时缓存运行,无法自动更新' };
33
+ }
34
+ return { kind: 'unknown', hint: `无法识别安装方式(入口:${basename(entry)})` };
35
+ }
36
+ /** 默认自更新:按检测到的安装方式执行。unknown 时抛错(上层转 manual_install_required)。 */
37
+ function defaultInstall(pkg, method) {
8
38
  return new Promise((resolve, reject) => {
9
- const child = spawn('npm', ['install', '-g', pkg], { stdio: 'ignore' });
39
+ const cmd = method.kind === 'pnpm' ? 'pnpm' : 'npm';
40
+ const args = method.kind === 'pnpm' ? ['add', '-g', pkg] : ['install', '-g', pkg];
41
+ const child = spawn(cmd, args, { stdio: 'ignore' });
10
42
  child.on('error', reject);
11
43
  child.on('exit', (code) => {
12
44
  if (code === 0)
13
45
  resolve();
14
46
  else
15
- reject(new Error(`npm install -g ${pkg} exited with ${code}`));
47
+ reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`));
16
48
  });
17
49
  });
18
50
  }
19
- /** 默认启动新实例:detached spawn,argv 全部透传给新进程。 */
20
- function defaultSpawn(_snapshotPath, argv) {
51
+ /** 手动安装提示命令(unknown 安装方式时返回给前端展示)。 */
52
+ function manualInstallCommand(pkg) {
53
+ return `npm install -g ${pkg} # 或 pnpm add -g ${pkg}`;
54
+ }
55
+ function statFingerprint(path) {
56
+ try {
57
+ const s = statSync(path);
58
+ return { size: s.size, mtimeMs: s.mtimeMs };
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** 采集当前执行程序文件(argv[1] 及 realpath)的 stat 基线。 */
65
+ function captureProgramBaseline() {
66
+ const entry = process.argv[1];
67
+ if (!entry)
68
+ return null;
69
+ const paths = new Set([entry]);
70
+ try {
71
+ paths.add(realpathSync(entry));
72
+ }
73
+ catch {
74
+ // 入口不存在时仅记录原始路径
75
+ }
76
+ const baseline = new Map();
77
+ for (const p of paths)
78
+ baseline.set(p, statFingerprint(p));
79
+ return baseline;
80
+ }
81
+ /**
82
+ * watch 当前执行程序文件(argv[1] 及其 realpath 所在目录)变更。
83
+ * 返回 Promise:文件内容变更(size/mtime 变化)后 resolve。
84
+ * 采用目录 fs.watch + 1s 轮询 stat 双保险(npm 原子替换文件时 watch 事件可能丢失)。
85
+ * baseline 由调用方在「变更发生前」采集(安装前),否则安装已改写文件导致永远等不到变化。
86
+ * 超时(timeoutMs)或可注入的 cancel 信号触发时 reject。
87
+ */
88
+ function waitForProgramChange(timeoutMs, baseline, signal, pollMs = 1000) {
89
+ return new Promise((resolve, reject) => {
90
+ const entry = process.argv[1];
91
+ if (!entry) {
92
+ reject(new Error('无法确定程序入口文件'));
93
+ return;
94
+ }
95
+ const paths = new Set([entry]);
96
+ try {
97
+ paths.add(realpathSync(entry));
98
+ }
99
+ catch {
100
+ // 入口不存在时仅 watch 原始路径目录
101
+ }
102
+ let settled = false;
103
+ let timer = null;
104
+ let interval = null;
105
+ const watchers = [];
106
+ const finish = (fn) => {
107
+ if (settled)
108
+ return;
109
+ settled = true;
110
+ if (timer)
111
+ clearTimeout(timer);
112
+ if (interval)
113
+ clearInterval(interval);
114
+ for (const w of watchers)
115
+ w.close();
116
+ fn();
117
+ };
118
+ const changed = () => {
119
+ for (const [p, before] of baseline) {
120
+ const after = statFingerprint(p);
121
+ if (before === null) {
122
+ if (after !== null)
123
+ return true;
124
+ continue;
125
+ }
126
+ if (after === null || after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
127
+ return true;
128
+ }
129
+ }
130
+ return false;
131
+ };
132
+ const onAbort = () => finish(() => reject(new Error('waiting aborted')));
133
+ signal?.addEventListener('abort', onAbort, { once: true });
134
+ timer = setTimeout(() => finish(() => reject(new Error('等待程序文件变更超时'))), timeoutMs);
135
+ interval = setInterval(() => {
136
+ if (changed())
137
+ finish(() => resolve());
138
+ }, pollMs);
139
+ for (const p of paths) {
140
+ try {
141
+ const dir = dirname(p);
142
+ watchers.push(watch(dir, () => {
143
+ if (changed())
144
+ finish(() => resolve());
145
+ }));
146
+ }
147
+ catch {
148
+ // 目录不可 watch:依赖轮询兜底
149
+ }
150
+ }
151
+ });
152
+ }
153
+ /** 默认启动新实例:detached spawn CLI bin,argv = serve <restore> <handoff>。
154
+ * 必须以 `serve` 子命令开头(CLI dispatch 按首参选命令);
155
+ * --port 复用旧实例端口,--handoff-port 让新实例启动后请求旧实例退出。
156
+ * 认证 token 经环境变量传递(不落 argv,避免 ps 泄漏)。 */
157
+ function defaultSpawn(_snapshotPath, argv, opts) {
21
158
  return new Promise((resolve, reject) => {
22
159
  try {
23
160
  const child = spawn(process.argv0, [process.argv[1] ?? '', ...argv], {
24
161
  detached: true,
25
162
  stdio: 'ignore',
163
+ ...(opts?.authToken ? { env: { ...process.env, C0DE_AUTH_TOKEN: opts.authToken } } : {}),
26
164
  });
27
165
  child.unref();
28
166
  resolve();
@@ -32,52 +170,125 @@ function defaultSpawn(_snapshotPath, argv) {
32
170
  }
33
171
  });
34
172
  }
173
+ /** 组装新实例 argv(serve --restore <snapshot> [--port] [--handoff-port])。 */
174
+ function buildSpawnArgv(snapshotPath, opts) {
175
+ const restoreFlag = opts.restoreFlag ?? '--restore';
176
+ const argv = [
177
+ 'serve',
178
+ restoreFlag,
179
+ snapshotPath,
180
+ ...(opts.port !== undefined ? ['--port', String(opts.port)] : []),
181
+ ];
182
+ if (opts.handoffPort !== undefined) {
183
+ argv.push('--handoff-port', String(opts.handoffPort));
184
+ }
185
+ return argv;
186
+ }
35
187
  /**
36
- * 执行热更新(spec §18.1-18.2 流程):
188
+ * 执行热更新(spec §18.1-18.2 + P0-2 修订流程):
37
189
  * 1. 序列化当前会话状态到快照文件
38
- * 2. 全局自更新(npm install -g)
39
- * 3. detached 启动新版本实例,传入快照路径(新实例 restore + 端口接管)
190
+ * 2. 识别当前进程安装方式(npm/pnpm/unknown)
191
+ * 3. 已知方式 → 自动安装;unknown → 不安装,等待用户手动安装
192
+ * 4. watch 当前执行程序文件变更 → 触发滚动切换
193
+ * (detached 启动新实例 + restore 快照 + handoff 端口接管)
40
194
  *
41
195
  * 注:旧实例的 graceful shutdown / 端口让渡由新实例启动后通过 IPC 协调
42
- * (见 ipc.ts),本函数只负责"序列化 + 安装 + 接力"。若提供 handoffPort,
43
- * argv 会带上 --handoff-port,新实例据此 requestHandoff 通知旧实例退出。
196
+ * (见 ipc.ts),本函数只负责"序列化 + 安装 + 等待程序变更 + 接力"。
44
197
  */
45
198
  async function performHotUpdate(snapshot, opts = {}) {
46
199
  const pkg = opts.packageName ?? DEFAULT_PACKAGE;
47
200
  const dir = await mkdtemp(join(tmpdir(), 'c0de-update-'));
48
201
  const snapshotPath = opts.snapshotPath ?? join(dir, 'snapshot.json');
49
202
  await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf8');
50
- const install = opts.installFn ?? defaultInstall;
203
+ // 识别安装方式
204
+ const method = detectInstallMethod();
205
+ // 安装前采集程序文件基线(watch 依赖此判断「变更已发生」)
206
+ const baseline = captureProgramBaseline();
207
+ // 已知安装方式:自动安装;unknown/安装失败 → 等用户手动安装。
208
+ let installReason = null;
209
+ if (method.kind !== 'unknown') {
210
+ const install = opts.installFn ?? defaultInstall;
211
+ try {
212
+ await install(pkg, method);
213
+ }
214
+ catch (error) {
215
+ // 已知安装方式安装失败:立即返回(不进入 10 分钟等待),快照已落盘可恢复。
216
+ // 用户可用 c0de update --apply 重试或手动安装后重启。
217
+ return {
218
+ _tag: 'install_failed',
219
+ error: error instanceof Error ? error.message : String(error),
220
+ snapshotPath,
221
+ };
222
+ }
223
+ }
224
+ else {
225
+ installReason = `${method.hint},请手动执行 ${manualInstallCommand(pkg)} 完成更新`;
226
+ }
227
+ if (installReason !== null) {
228
+ if (!baseline) {
229
+ return {
230
+ _tag: 'manual_install_required',
231
+ error: '无法确定程序入口文件,无法监听更新',
232
+ snapshotPath,
233
+ command: manualInstallCommand(pkg),
234
+ };
235
+ }
236
+ try {
237
+ await waitForProgramChange(opts.manualWaitTimeoutMs ?? DEFAULT_MANUAL_WAIT_TIMEOUT_MS, baseline);
238
+ }
239
+ catch (error) {
240
+ return {
241
+ _tag: 'manual_install_required',
242
+ error: error instanceof Error ? error.message : String(error),
243
+ snapshotPath,
244
+ command: manualInstallCommand(pkg),
245
+ };
246
+ }
247
+ }
248
+ // 程序文件已变更(自动安装落盘或用户手动安装完成)→ 滚动切换
249
+ const spawnNew = opts.spawnNewInstanceFn ?? defaultSpawn;
250
+ const argv = buildSpawnArgv(snapshotPath, opts);
51
251
  try {
52
- await install(pkg);
252
+ await spawnNew(snapshotPath, argv, opts);
53
253
  }
54
254
  catch (error) {
55
255
  return {
56
- _tag: 'install_failed',
256
+ _tag: 'spawn_failed',
57
257
  error: error instanceof Error ? error.message : String(error),
58
258
  snapshotPath,
59
259
  };
60
260
  }
61
- const restoreFlag = opts.restoreFlag ?? '--restore';
62
- const argv = [restoreFlag, snapshotPath];
63
- if (opts.handoffPort !== undefined) {
64
- argv.push('--handoff-port', String(opts.handoffPort));
261
+ return { _tag: 'success', snapshotPath, installMethod: method.kind };
262
+ }
263
+ /**
264
+ * 仅安装新版本(P2-7:无活跃 serve 的 `c0de update --apply`)。
265
+ * 不序列化快照、不 spawn `serve`——此前独立 CLI 执行会拉起一个用户未请求的常驻服务。
266
+ * 运行中的 serve 由 `/api/update/apply` 走 performHotUpdate 热更新路径。
267
+ */
268
+ async function performInstall(opts = {}) {
269
+ const pkg = opts.packageName ?? DEFAULT_PACKAGE;
270
+ const method = detectInstallMethod();
271
+ if (method.kind === 'unknown') {
272
+ return {
273
+ _tag: 'manual_install_required',
274
+ error: method.hint,
275
+ command: manualInstallCommand(pkg),
276
+ };
65
277
  }
66
- const spawnNew = opts.spawnNewInstanceFn ?? defaultSpawn;
278
+ const install = opts.installFn ?? defaultInstall;
67
279
  try {
68
- await spawnNew(snapshotPath, argv);
280
+ await install(pkg, method);
69
281
  }
70
282
  catch (error) {
71
283
  return {
72
- _tag: 'spawn_failed',
284
+ _tag: 'install_failed',
73
285
  error: error instanceof Error ? error.message : String(error),
74
- snapshotPath,
75
286
  };
76
287
  }
77
- return { _tag: 'success', snapshotPath };
288
+ return { _tag: 'success', installMethod: method.kind };
78
289
  }
79
290
  /** 清理快照临时目录(热更新完成或放弃后调用)。 */
80
291
  async function cleanupSnapshot(snapshotPath) {
81
292
  await rm(join(snapshotPath, '..'), { recursive: true, force: true }).catch(() => { });
82
293
  }
83
- export { cleanupSnapshot, performHotUpdate };
294
+ export { cleanupSnapshot, detectInstallMethod, manualInstallCommand, performHotUpdate, performInstall, };
@@ -10,9 +10,14 @@ type CheckOptions = {
10
10
  currentVersion?: string;
11
11
  registryUrl?: string;
12
12
  };
13
+ /**
14
+ * 读取当前安装版本(package.json 的 version 字段)。
15
+ * src/dev 与 dist 两种布局下 `../../package.json` 都指向包根;读取失败回退常量。
16
+ */
17
+ declare function getCurrentVersion(): string;
13
18
  /** 语义化版本比较:a < b → -1,a == b → 0,a > b → 1。忽略前导 v 和 prerelease。 */
14
19
  declare function compareSemver(a: string, b: string): number;
15
20
  /** 查询 npm registry 判断是否有新版本。网络/解析失败时返回 hasUpdate:false,绝不抛错。 */
16
21
  declare function checkForUpdate(opts?: CheckOptions): Promise<UpdateCheckResult>;
17
22
  export type { CheckOptions, UpdateCheckResult };
18
- export { checkForUpdate, compareSemver };
23
+ export { checkForUpdate, compareSemver, getCurrentVersion };
@@ -1,7 +1,22 @@
1
- // 版本检查(spec §18.1):查询 npm registry 比对当前版本。
1
+ import { readFileSync } from 'node:fs';
2
2
  const DEFAULT_PACKAGE = 'c0de-agent';
3
3
  const DEFAULT_VERSION = '0.1.0';
4
4
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
5
+ /**
6
+ * 读取当前安装版本(package.json 的 version 字段)。
7
+ * src/dev 与 dist 两种布局下 `../../package.json` 都指向包根;读取失败回退常量。
8
+ */
9
+ function getCurrentVersion() {
10
+ try {
11
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'));
12
+ if (typeof pkg.version === 'string' && pkg.version.length > 0)
13
+ return pkg.version;
14
+ }
15
+ catch {
16
+ // 打包布局异常:回退保守常量
17
+ }
18
+ return DEFAULT_VERSION;
19
+ }
5
20
  /** 取 semver 的数值核心(去前导 v 与 prerelease)。 */
6
21
  function semverCore(v) {
7
22
  return (v.replace(/^v/, '').split('-')[0] ?? '').split('.').map(Number);
@@ -23,7 +38,7 @@ function compareSemver(a, b) {
23
38
  /** 查询 npm registry 判断是否有新版本。网络/解析失败时返回 hasUpdate:false,绝不抛错。 */
24
39
  async function checkForUpdate(opts = {}) {
25
40
  const pkg = opts.packageName ?? DEFAULT_PACKAGE;
26
- const current = opts.currentVersion ?? DEFAULT_VERSION;
41
+ const current = opts.currentVersion ?? getCurrentVersion();
27
42
  const registry = opts.registryUrl ?? DEFAULT_REGISTRY;
28
43
  const fetchImpl = opts.fetchImpl ?? fetch;
29
44
  const result = {
@@ -46,4 +61,4 @@ async function checkForUpdate(opts = {}) {
46
61
  }
47
62
  return result;
48
63
  }
49
- export { checkForUpdate, compareSemver };
64
+ export { checkForUpdate, compareSemver, getCurrentVersion };
@@ -0,0 +1 @@
1
+ ALTER TABLE "file_snapshots" ADD COLUMN "mtime_ms" bigint;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "sessions" ADD COLUMN "source" text;--> statement-breakpoint
2
+ ALTER TABLE "sessions" ADD COLUMN "deleted_at" timestamp with time zone;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "sessions" ALTER COLUMN "source" SET DEFAULT null;--> statement-breakpoint
2
+ ALTER TABLE "sessions" ALTER COLUMN "deleted_at" SET DEFAULT null;