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.
- package/README.md +63 -16
- package/dist/cli/commands/acp.js +26 -8
- package/dist/cli/commands/chat.js +151 -1
- package/dist/cli/commands/config.d.ts +2 -0
- package/dist/cli/commands/config.js +63 -3
- package/dist/cli/commands/serve.js +11 -2
- package/dist/cli/commands/sessions.js +109 -3
- package/dist/cli/commands/trust.d.ts +11 -0
- package/dist/cli/commands/trust.js +31 -0
- package/dist/cli/commands/update.js +4 -0
- package/dist/cli/deps.d.ts +9 -6
- package/dist/cli/deps.js +66 -19
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +110 -11
- package/dist/cli/modes/acp.js +39 -8
- package/dist/cli/modes/print.d.ts +3 -0
- package/dist/cli/modes/print.js +78 -8
- package/dist/core/agent.d.ts +1 -1
- package/dist/core/agent.js +9 -4
- package/dist/core/agents/builtin.js +2 -2
- package/dist/core/agents/index.d.ts +0 -1
- package/dist/core/agents/index.js +0 -1
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +235 -19
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/segment.d.ts +3 -1
- package/dist/core/loop/segment.js +24 -1
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +88 -37
- package/dist/core/loop.d.ts +7 -0
- package/dist/core/loop.js +90 -6
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.d.ts +10 -0
- package/dist/core/slash.js +237 -65
- package/dist/core/title.js +7 -1
- package/dist/core/types.d.ts +22 -1
- package/dist/core/workflows/context.js +44 -4
- package/dist/core/workflows/discovery.d.ts +2 -2
- package/dist/core/workflows/discovery.js +5 -7
- package/dist/core/workflows/index.d.ts +2 -1
- package/dist/core/workflows/index.js +2 -1
- package/dist/core/workflows/registry.d.ts +13 -3
- package/dist/core/workflows/registry.js +14 -9
- package/dist/core/workflows/resolve.d.ts +22 -0
- package/dist/core/workflows/resolve.js +23 -0
- package/dist/core/workflows/runtime.d.ts +6 -1
- package/dist/core/workflows/runtime.js +19 -2
- package/dist/db/schema.d.ts +373 -1
- package/dist/db/schema.js +60 -4
- package/dist/kanban/index.d.ts +2 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +87 -1
- package/dist/kanban/store.js +429 -22
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/registry.d.ts +7 -1
- package/dist/llm/registry.js +7 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/plugins/init.d.ts +7 -0
- package/dist/plugins/init.js +22 -1
- package/dist/plugins/loader.d.ts +7 -1
- package/dist/plugins/loader.js +12 -4
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +37 -0
- package/dist/project/project.js +142 -1
- package/dist/project/trust.d.ts +65 -0
- package/dist/project/trust.js +328 -0
- package/dist/server/agent-manager.d.ts +15 -2
- package/dist/server/agent-manager.js +18 -3
- package/dist/server/app.js +45 -30
- package/dist/server/auth-manager.d.ts +26 -9
- package/dist/server/auth-manager.js +78 -42
- package/dist/server/context.js +14 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/middleware/auth.d.ts +11 -1
- package/dist/server/middleware/auth.js +25 -3
- package/dist/server/permission/interactive.d.ts +11 -3
- package/dist/server/permission/interactive.js +23 -4
- package/dist/server/permission/store.d.ts +14 -2
- package/dist/server/permission/store.js +39 -3
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.js +66 -10
- package/dist/server/routes/chat.d.ts +21 -0
- package/dist/server/routes/chat.js +685 -52
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +9 -4
- package/dist/server/routes/config.js +180 -23
- package/dist/server/routes/files.js +142 -28
- package/dist/server/routes/kanban.js +218 -26
- package/dist/server/routes/permissions.d.ts +0 -4
- package/dist/server/routes/permissions.js +140 -7
- package/dist/server/routes/project.js +132 -29
- package/dist/server/routes/provider.js +54 -3
- package/dist/server/routes/session.js +463 -23
- package/dist/server/routes/tool.js +24 -5
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +145 -29
- package/dist/server/routes/usage.d.ts +4 -0
- package/dist/server/routes/usage.js +133 -0
- package/dist/server/routes/workflows.js +476 -87
- package/dist/server/server.d.ts +16 -13
- package/dist/server/server.js +364 -107
- package/dist/server/terminal/pty-manager.d.ts +12 -0
- package/dist/server/terminal/pty-manager.js +63 -7
- package/dist/server/types.d.ts +16 -0
- package/dist/session/archive.d.ts +3 -1
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.d.ts +2 -1
- package/dist/session/branch.js +92 -9
- package/dist/session/context.js +28 -2
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +186 -12
- package/dist/session/session.js +577 -33
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +13 -1
- package/dist/session/usage.d.ts +62 -0
- package/dist/session/usage.js +194 -0
- package/dist/shared/types/agent.d.ts +29 -2
- package/dist/shared/types/config.d.ts +64 -5
- package/dist/shared/types/kanban.d.ts +17 -2
- package/dist/shared/types/message.d.ts +23 -0
- package/dist/tools/builtin/kanban.js +13 -2
- package/dist/tools/index.d.ts +14 -3
- package/dist/tools/index.js +23 -5
- package/dist/tools/websearch/index.d.ts +5 -1
- package/dist/tools/websearch/index.js +46 -7
- package/dist/tools/websearch/providers/brave.js +2 -2
- package/dist/tools/websearch/providers/duckduckgo.js +2 -1
- package/dist/tools/websearch/providers/tavily.js +2 -2
- package/dist/tools/websearch/types.d.ts +5 -1
- package/dist/tools/websearch/types.js +7 -1
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +13 -2
- package/dist/update/ipc.js +36 -2
- package/dist/update/scheduler.js +5 -2
- package/dist/update/snapshot.d.ts +18 -3
- package/dist/update/snapshot.js +5 -2
- package/dist/update/updater.d.ts +17 -1
- package/dist/update/updater.js +47 -18
- package/dist/update/version.d.ts +3 -0
- package/dist/update/version.js +9 -3
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/0010_usage_events_ledger.sql +17 -0
- package/drizzle/0011_slow_slapstick.sql +12 -0
- package/drizzle/0012_chief_timeslip.sql +1 -0
- package/drizzle/0013_swift_zeigeist.sql +1 -0
- package/drizzle/0014_flimsy_legion.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/0010_snapshot.json +1010 -0
- package/drizzle/meta/0011_snapshot.json +1055 -0
- package/drizzle/meta/0012_snapshot.json +1062 -0
- package/drizzle/meta/0013_snapshot.json +1068 -0
- package/drizzle/meta/0014_snapshot.json +1075 -0
- package/drizzle/meta/_journal.json +42 -0
- package/package.json +42 -42
- package/dist/core/agents/discovery.d.ts +0 -6
- package/dist/core/agents/discovery.js +0 -96
package/dist/server/server.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Hono } from 'hono';
|
|
2
2
|
import type { DB } from '../db/client.js';
|
|
3
|
-
import type { Registry } from '../llm/registry.js';
|
|
4
3
|
import type { Config } from '../shared/types/config.js';
|
|
5
4
|
import { checkForUpdate, createHandoffServer } from '../update/index.js';
|
|
5
|
+
import { buildRegistryFromConfig, syncRegistryFromConfig } from './registry-config.js';
|
|
6
6
|
import type { ServerContext } from './types.js';
|
|
7
7
|
type StartServerOptions = {
|
|
8
8
|
port?: number;
|
|
9
|
+
/** 绑定地址(默认 127.0.0.1,仅本机可达)。显式配置非回环地址(0.0.0.0/局域网 IP)
|
|
10
|
+
* 时服务对网络可达,安全仅靠 token + allowedOrigins 保证——serve 命令会打印醒目警告。 */
|
|
11
|
+
host?: string;
|
|
9
12
|
cwd?: string;
|
|
10
13
|
/** 注入已有 DB(测试用)。 */
|
|
11
14
|
db?: DB;
|
|
@@ -31,15 +34,9 @@ type BootstrappedServer = {
|
|
|
31
34
|
ctx: ServerContext;
|
|
32
35
|
close(): Promise<void>;
|
|
33
36
|
};
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
* config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
|
|
38
|
-
* 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
|
|
39
|
-
* 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
|
|
40
|
-
* 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
|
|
41
|
-
*/
|
|
42
|
-
declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
|
|
37
|
+
/** serve 默认端口:避开 node 生态常用端口(3000/4000/5173/8000/8080…),
|
|
38
|
+
* 降低与其他本地服务撞端口的概率。显式 --port 始终优先。 */
|
|
39
|
+
export declare const DEFAULT_SERVE_PORT = 7310;
|
|
43
40
|
/** 解析 PGLite 持久化数据目录:优先 C0DE_DB_DIR,否则全局数据根下 pglite 子目录。
|
|
44
41
|
*
|
|
45
42
|
* 历史上数据库放在 <cwd>/.c0de/pglite(项目级),但项目 DB schema 通过 projectId
|
|
@@ -50,8 +47,9 @@ declare function resolveDbDir(): string;
|
|
|
50
47
|
* 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
|
|
51
48
|
* 2. 用户配置 security.token → 使用。
|
|
52
49
|
* 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
|
|
53
|
-
* 4.
|
|
54
|
-
* 5.
|
|
50
|
+
* 4. 已有已注册设备 → 复用 auth-token 文件(跨重启稳定;设备 token 存 devices.json)。
|
|
51
|
+
* 5. 无已注册设备(首设备待注册态)→ 每次 serve 都重新生成 bootstrap,刷新
|
|
52
|
+
* firstDeviceTtlMs 窗口。此前「复用旧文件」会让过期 bootstrap 无法通过重启恢复。
|
|
55
53
|
*/
|
|
56
54
|
declare function resolveAuthToken(config: Config, dataDir: string): string | undefined;
|
|
57
55
|
/**
|
|
@@ -79,10 +77,15 @@ declare function buildServerContext(db: DB, opts?: StartServerOptions): Promise<
|
|
|
79
77
|
declare function acquireDevDbLock(dataDir: string): void;
|
|
80
78
|
/** Release the dev DB lock (best-effort, stale-detection covers missed calls). */
|
|
81
79
|
declare function releaseDevDbLock(dataDir: string): void;
|
|
80
|
+
/** 读取运行中 serve 的端口(CLI 锁冲突引导用)。文件缺失/损坏 → null。 */
|
|
81
|
+
declare function readServeInfo(dataDir: string): {
|
|
82
|
+
pid: number;
|
|
83
|
+
port: number;
|
|
84
|
+
} | null;
|
|
82
85
|
/** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
|
|
83
86
|
declare function createDevDb(cwd: string): Promise<DB>;
|
|
84
87
|
/** 初始化 DB + 配置 + 注册表,返回 ServerContext + 清理函数(dev 与独立后端共用)。 */
|
|
85
88
|
declare function bootstrapServerContext(opts?: StartServerOptions): Promise<BootstrappedServer>;
|
|
86
89
|
declare function startServer(opts?: StartServerOptions): Promise<RunningServer>;
|
|
87
90
|
export type { BootstrappedServer, RunningServer, StartServerOptions };
|
|
88
|
-
export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
|
|
91
|
+
export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, readServeInfo, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
|
package/dist/server/server.js
CHANGED
|
@@ -7,59 +7,28 @@ import { dirname, join } from 'node:path';
|
|
|
7
7
|
import { serve } from '@hono/node-server';
|
|
8
8
|
import { WebSocketServer } from 'ws';
|
|
9
9
|
import { BUILTIN_AGENTS, createAgentRegistry } from '../core/agents/index.js';
|
|
10
|
-
import { loadConfig } from '../core/config.js';
|
|
11
|
-
import { decryptSecret } from '../core/secret.js';
|
|
10
|
+
import { loadConfig, loadConfigScopes } from '../core/config.js';
|
|
12
11
|
import { createAndPopulateRegistry } from '../core/workflows/index.js';
|
|
13
12
|
import { createDB, migrateDB } from '../db/index.js';
|
|
14
|
-
import {
|
|
13
|
+
import { purgeDeletedKanbanBoards } from '../kanban/index.js';
|
|
15
14
|
import { initPlugins } from '../plugins/index.js';
|
|
16
|
-
import {
|
|
15
|
+
import { getByDirectory } from '../project/index.js';
|
|
16
|
+
import { projectTrustCurrent } from '../project/trust.js';
|
|
17
|
+
import { markDeadBackgroundJobs } from '../session/jobs.js';
|
|
18
|
+
import { purgeDeletedSessions, purgeEmptySessions, purgeTemporarySessions, TRASH_PURGE_GRACE_MS, TRASH_RETENTION_MS, } from '../session/session.js';
|
|
19
|
+
import { backfillUsageEventsOnce } from '../session/usage.js';
|
|
17
20
|
import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
|
|
18
|
-
import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
|
|
21
|
+
import { checkForUpdate, confirmHandoff, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
|
|
19
22
|
import { createAgentManager } from './agent-manager.js';
|
|
20
23
|
import { createApp } from './app.js';
|
|
21
24
|
import { createAuthManager } from './auth-manager.js';
|
|
22
25
|
import { isAllowedOrigin } from './middleware/cors.js';
|
|
23
26
|
import { createPermissionStore } from './permission/store.js';
|
|
27
|
+
import { buildRegistryFromConfig, syncRegistryFromConfig } from './registry-config.js';
|
|
24
28
|
import { PTYManager } from './terminal/pty-manager.js';
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
for (const p of config.providers) {
|
|
29
|
-
registerProviderFromConfig(registry, p);
|
|
30
|
-
}
|
|
31
|
-
return registry;
|
|
32
|
-
}
|
|
33
|
-
function registerProviderFromConfig(registry, p) {
|
|
34
|
-
// 兼容 config.json 中以 _tag 标识 provider 的格式(name 缺失时回退到 _tag)
|
|
35
|
-
const name = p.name || p._tag;
|
|
36
|
-
if (!name || !p.baseURL)
|
|
37
|
-
return;
|
|
38
|
-
// baseURL 已含 /v1 时用 /chat/completions,避免 /v1/v1 双重前缀
|
|
39
|
-
const path = p.baseURL.replace(/\/+$/, '').endsWith('/v1') ? '/chat/completions' : undefined;
|
|
40
|
-
registerProvider(registry, {
|
|
41
|
-
name,
|
|
42
|
-
baseURL: p.baseURL,
|
|
43
|
-
apiKey: p.apiKey ? decryptSecret(p.apiKey) : p.apiKey,
|
|
44
|
-
...(path ? { path } : {}),
|
|
45
|
-
// 传递用户配置的 per-model capabilities(contextWindow 等),
|
|
46
|
-
// 否则 resolveRoute 回退到 DEFAULT_MODEL_CAPABILITIES,可能导致预算过小。
|
|
47
|
-
...(p.models ? { models: overrideToCapabilities(p.models) } : {}),
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
|
|
52
|
-
* 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
|
|
53
|
-
* 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
|
|
54
|
-
* 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
|
|
55
|
-
*/
|
|
56
|
-
function syncRegistryFromConfig(registry, config) {
|
|
57
|
-
rebuildRegistry(registry, (next) => {
|
|
58
|
-
for (const p of config.providers) {
|
|
59
|
-
registerProviderFromConfig(next, p);
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
}
|
|
29
|
+
/** serve 默认端口:避开 node 生态常用端口(3000/4000/5173/8000/8080…),
|
|
30
|
+
* 降低与其他本地服务撞端口的概率。显式 --port 始终优先。 */
|
|
31
|
+
export const DEFAULT_SERVE_PORT = 7310;
|
|
63
32
|
/** 全局数据根目录:XDG_DATA_HOME 优先,否则 ~/.local/share/c0de。
|
|
64
33
|
* 与 opencode (~/.local/share/opencode/)、oh-my-pi (~/.omp/agent/) 同约定——
|
|
65
34
|
* 数据库等运行时数据放全局,项目 .c0de/ 只留配置和扩展。 */
|
|
@@ -84,8 +53,9 @@ function resolveDbDir() {
|
|
|
84
53
|
* 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
|
|
85
54
|
* 2. 用户配置 security.token → 使用。
|
|
86
55
|
* 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
|
|
87
|
-
* 4.
|
|
88
|
-
* 5.
|
|
56
|
+
* 4. 已有已注册设备 → 复用 auth-token 文件(跨重启稳定;设备 token 存 devices.json)。
|
|
57
|
+
* 5. 无已注册设备(首设备待注册态)→ 每次 serve 都重新生成 bootstrap,刷新
|
|
58
|
+
* firstDeviceTtlMs 窗口。此前「复用旧文件」会让过期 bootstrap 无法通过重启恢复。
|
|
89
59
|
*/
|
|
90
60
|
function resolveAuthToken(config, dataDir) {
|
|
91
61
|
if (config.security.authEnabled === false)
|
|
@@ -104,13 +74,16 @@ function resolveAuthToken(config, dataDir) {
|
|
|
104
74
|
}
|
|
105
75
|
return envToken;
|
|
106
76
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
77
|
+
// 已有设备 → 复用 auth-token 文件,保持跨重启稳定(已授权设备经 devices.json 认 token)。
|
|
78
|
+
if (hasRegisteredDevices(dataDir)) {
|
|
79
|
+
try {
|
|
80
|
+
const existing = readFileSync(tokenFile, 'utf-8').trim();
|
|
81
|
+
if (existing)
|
|
82
|
+
return existing;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// 文件不存在,走生成分支
|
|
86
|
+
}
|
|
114
87
|
}
|
|
115
88
|
const generated = randomBytes(24).toString('hex');
|
|
116
89
|
try {
|
|
@@ -122,6 +95,19 @@ function resolveAuthToken(config, dataDir) {
|
|
|
122
95
|
}
|
|
123
96
|
return generated;
|
|
124
97
|
}
|
|
98
|
+
/** dataDir 下是否存在已授权设备(devices.json 非空)。 */
|
|
99
|
+
function hasRegisteredDevices(dataDir) {
|
|
100
|
+
try {
|
|
101
|
+
const path = join(dataDir, 'devices.json');
|
|
102
|
+
if (!existsSync(path))
|
|
103
|
+
return false;
|
|
104
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
105
|
+
return Array.isArray(parsed.devices) && parsed.devices.length > 0;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
125
111
|
/** 只读 dataDir 下已存在的 auth-token(新实例 handoff 握手用;不生成)。 */
|
|
126
112
|
function readAuthTokenFile(dataDir) {
|
|
127
113
|
try {
|
|
@@ -170,23 +156,51 @@ function migrateLegacyPglite(cwd, targetDir) {
|
|
|
170
156
|
*/
|
|
171
157
|
async function buildServerContext(db, opts = {}) {
|
|
172
158
|
const cwd = opts.cwd ?? process.cwd();
|
|
159
|
+
// P1:快照携带的终端元信息——新实例原位重建 shell(同 id)。提前解析出来,
|
|
160
|
+
// ptyManager 创建后立即恢复;旧快照无 terminals 字段时为空。
|
|
161
|
+
let restoredTerminals = [];
|
|
173
162
|
if (opts.restoreFrom) {
|
|
174
163
|
const snapshot = JSON.parse(readFileSync(opts.restoreFrom, 'utf8'));
|
|
175
164
|
await restoreSessions(db, snapshot);
|
|
165
|
+
restoredTerminals = Array.isArray(snapshot.terminals)
|
|
166
|
+
? snapshot.terminals.filter((t) => !!t &&
|
|
167
|
+
typeof t.id === 'string' &&
|
|
168
|
+
typeof t.shell === 'string' &&
|
|
169
|
+
typeof t.cwd === 'string')
|
|
170
|
+
: [];
|
|
176
171
|
}
|
|
177
172
|
const config = await loadConfig(cwd);
|
|
178
173
|
const toolRegistry = createDefaultRegistry(config);
|
|
179
174
|
const llmRegistry = buildRegistryFromConfig(config);
|
|
180
175
|
const urlRegistry = createDefaultURLRegistry();
|
|
176
|
+
// P0-2:项目插件只在项目被显式信任后加载(fail-closed)——克隆仓库自带的
|
|
177
|
+
// .c0de/plugins 不会在首次 serve 时静默执行;c0de trust / Web 信任后重启生效。
|
|
178
|
+
// 未注册项目(首次 serve、尚无项目记录)视为未信任。
|
|
179
|
+
// P0(代码面):已信任但指纹漂移(git pull 改了插件代码/MCP 参数/风险键)→
|
|
180
|
+
// 同样不加载——与聊天门禁同口径,杜绝「先重启、后门禁」窗口里执行漂移代码。
|
|
181
|
+
let projectTrusted = false;
|
|
182
|
+
try {
|
|
183
|
+
const p = await getByDirectory(db, cwd);
|
|
184
|
+
if (p?.trustedAt != null) {
|
|
185
|
+
projectTrusted = projectTrustCurrent(loadConfigScopes(cwd).project, p.trustedAt, p.riskFingerprint, cwd);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// 信任状态查询失败 → 保持未信任(宁可少加载插件)
|
|
190
|
+
}
|
|
181
191
|
const { pluginRegistry, hookRunner } = await initPlugins({
|
|
182
192
|
cwd,
|
|
183
193
|
config,
|
|
184
194
|
toolRegistry,
|
|
185
195
|
llmRegistry,
|
|
196
|
+
projectTrusted,
|
|
186
197
|
});
|
|
187
198
|
// 工作流注册表:三级发现(builtin → global → project),eager 初始化。
|
|
188
199
|
// 此前是惰性 getter 只注册 builtin,导致项目级 .c0de/workflows/*.js 永远不可见。
|
|
189
|
-
|
|
200
|
+
// P0 信任边界:项目级工作流与 .c0de/plugins 同为仓库自带任意代码执行面,
|
|
201
|
+
// 仅在 serve 目录项目当前可信任(projectTrusted)时 dynamic import——与上方
|
|
202
|
+
// 插件加载同口径,杜绝「克隆即执行」。
|
|
203
|
+
const workflowRegistry = await createAndPopulateRegistry(cwd, { projectTrusted });
|
|
190
204
|
const dataDir = resolveDbDir();
|
|
191
205
|
// 先解析/生成 bootstrap token(落盘),再创建 authManager 读取——
|
|
192
206
|
// 首启时文件由 resolveAuthToken 生成,顺序颠倒会导致 bootstrap 读到空值。
|
|
@@ -196,7 +210,38 @@ async function buildServerContext(db, opts = {}) {
|
|
|
196
210
|
...(config.security.token && config.security.token.length > 0
|
|
197
211
|
? { staticToken: config.security.token }
|
|
198
212
|
: {}),
|
|
213
|
+
...(config.security.firstDeviceTtlMs !== undefined
|
|
214
|
+
? { firstDeviceTtlMs: config.security.firstDeviceTtlMs }
|
|
215
|
+
: {}),
|
|
199
216
|
});
|
|
217
|
+
const ptyManager = new PTYManager();
|
|
218
|
+
// P1:热更新恢复——按快照元信息原位重建终端 shell(同 id,前端布局无感重连)。
|
|
219
|
+
// 进程内状态(运行中的 dev server 等)无法续命,但用户在熟悉的布局里得到新 shell,
|
|
220
|
+
// 且 cwd/title/shell 保持一致。恢复失败(极端环境)不阻塞启动。
|
|
221
|
+
for (const t of restoredTerminals) {
|
|
222
|
+
try {
|
|
223
|
+
ptyManager.create({
|
|
224
|
+
id: t.id,
|
|
225
|
+
shell: t.shell,
|
|
226
|
+
cwd: t.cwd,
|
|
227
|
+
title: t.title || undefined,
|
|
228
|
+
...(t.projectId ? { projectId: t.projectId } : {}),
|
|
229
|
+
});
|
|
230
|
+
// P3-7:用户勾选的「更新后自动重启」——重建 shell 后把前台命令写入 stdin 重跑
|
|
231
|
+
// (如 `npm run dev`)。写入由 PTY 缓冲,shell 初始化完成后即执行;失败不阻塞启动。
|
|
232
|
+
if (typeof t.command === 'string' && t.command.length > 0) {
|
|
233
|
+
try {
|
|
234
|
+
ptyManager.write(t.id, `${t.command}\n`);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
console.warn(`[server] 终端命令重跑失败(${t.id}):${err instanceof Error ? err.message : String(err)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
console.warn(`[server] 终端恢复失败(${t.id}):${err instanceof Error ? err.message : String(err)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
200
245
|
const ctx = {
|
|
201
246
|
db,
|
|
202
247
|
config,
|
|
@@ -206,9 +251,21 @@ async function buildServerContext(db, opts = {}) {
|
|
|
206
251
|
hookRunner,
|
|
207
252
|
pluginRegistry,
|
|
208
253
|
agentManager: createAgentManager(),
|
|
209
|
-
|
|
254
|
+
// P3:权限双层超时可配置(config.permission.timeoutMs/expireGraceMs);
|
|
255
|
+
// 缺省回退 store 内置默认(5 分钟 + 25 分钟)。
|
|
256
|
+
permissionStore: createPermissionStore({
|
|
257
|
+
...(config.permission.timeoutMs !== undefined && config.permission.timeoutMs > 0
|
|
258
|
+
? { timeoutMs: config.permission.timeoutMs }
|
|
259
|
+
: {}),
|
|
260
|
+
...(config.permission.expireGraceMs !== undefined && config.permission.expireGraceMs > 0
|
|
261
|
+
? { expireGraceMs: config.permission.expireGraceMs }
|
|
262
|
+
: {}),
|
|
263
|
+
}),
|
|
210
264
|
permissionMode: config.permission.defaultMode,
|
|
211
265
|
sessionPermissionModes: new Map(),
|
|
266
|
+
sessionAlwaysAllow: new Map(),
|
|
267
|
+
workflowBusyBySession: new Map(),
|
|
268
|
+
workflowBusyByScope: new Map(),
|
|
212
269
|
authToken: resolvedToken,
|
|
213
270
|
authManager: config.security.authEnabled === false ? undefined : authManager,
|
|
214
271
|
// Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
|
|
@@ -226,7 +283,7 @@ async function buildServerContext(db, opts = {}) {
|
|
|
226
283
|
initialDelayMs: config.update.initialDelayMs,
|
|
227
284
|
}),
|
|
228
285
|
cwd,
|
|
229
|
-
ptyManager
|
|
286
|
+
ptyManager,
|
|
230
287
|
};
|
|
231
288
|
// handoff server 不在此创建:它需要持有主 HTTP server 引用以触发完整关停,
|
|
232
289
|
// 由 startServer 在 serve() 之后创建(dev 模式走 vite,不创建)。
|
|
@@ -302,6 +359,49 @@ function releaseDevDbLock(dataDir) {
|
|
|
302
359
|
/* best-effort */
|
|
303
360
|
}
|
|
304
361
|
}
|
|
362
|
+
const SERVE_INFO_FILE = 'serve-info.json';
|
|
363
|
+
/** serve 运行信息落盘(port + pid):CLI 在锁冲突(serve 占用持久库)时读取,
|
|
364
|
+
* 引导用户直接打开 Web 界面继续对话,而不是给出无从下手的报错。不落 token。 */
|
|
365
|
+
function writeServeInfo(dataDir, port) {
|
|
366
|
+
try {
|
|
367
|
+
if (!existsSync(dataDir))
|
|
368
|
+
mkdirSync(dataDir, { recursive: true });
|
|
369
|
+
writeFileSync(join(dataDir, SERVE_INFO_FILE), JSON.stringify({ pid: process.pid, port, startedAt: Date.now() }), 'utf8');
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
/* best-effort:仅影响 CLI 引导文案,不影响服务本身 */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** 关闭时清除本进程写入的 serve 信息(pid 匹配才删,防止误删热更新新实例的信息)。 */
|
|
376
|
+
function removeServeInfo(dataDir) {
|
|
377
|
+
try {
|
|
378
|
+
const path = join(dataDir, SERVE_INFO_FILE);
|
|
379
|
+
if (!existsSync(path))
|
|
380
|
+
return;
|
|
381
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
382
|
+
if (parsed.pid === process.pid)
|
|
383
|
+
unlinkSync(path);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* best-effort */
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** 读取运行中 serve 的端口(CLI 锁冲突引导用)。文件缺失/损坏 → null。 */
|
|
390
|
+
function readServeInfo(dataDir) {
|
|
391
|
+
try {
|
|
392
|
+
const path = join(dataDir, SERVE_INFO_FILE);
|
|
393
|
+
if (!existsSync(path))
|
|
394
|
+
return null;
|
|
395
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
396
|
+
if (typeof parsed.pid === 'number' && typeof parsed.port === 'number') {
|
|
397
|
+
return { pid: parsed.pid, port: parsed.port };
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
305
405
|
/** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
|
|
306
406
|
async function createDevDb(cwd) {
|
|
307
407
|
const dataDir = resolveDbDir();
|
|
@@ -346,13 +446,60 @@ async function bootstrapServerContext(opts = {}) {
|
|
|
346
446
|
db = await createDevDb(cwd);
|
|
347
447
|
}
|
|
348
448
|
const { ctx, dispose } = await buildServerContext(db, opts);
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
449
|
+
// P2 成本账本 backfill:升级后首次启动把遗留 segments 补齐进 usage_events
|
|
450
|
+
// (幂等,callId 唯一约束去重;app_meta 标记保证只执行一次——导入会话的
|
|
451
|
+
// segments 已剥离 call id,不产生账本行)。fire-and-forget。
|
|
452
|
+
void backfillUsageEventsOnce(db)
|
|
453
|
+
.then((added) => {
|
|
454
|
+
if (added > 0)
|
|
455
|
+
console.log(`[server] 成本账本补齐:新增 ${added} 条历史调用记录`);
|
|
456
|
+
})
|
|
457
|
+
.catch((err) => {
|
|
458
|
+
console.warn(`[server] 成本账本补齐失败:${err instanceof Error ? err.message : String(err)}`);
|
|
459
|
+
});
|
|
460
|
+
// P2-4 + A3:回收站两阶段清理——启动时清一次 + 每 24h 清一次。
|
|
461
|
+
// 阶段一标记到期条目进入宽限期(UI 可见可恢复),阶段二物理清除宽限期满的条目。
|
|
462
|
+
// fire-and-forget:清理失败不阻塞服务启动;结果落日志可查。
|
|
463
|
+
const runTrashPurge = () => {
|
|
464
|
+
void purgeDeletedSessions(db)
|
|
465
|
+
.then(({ marked, deleted }) => {
|
|
466
|
+
if (marked > 0) {
|
|
467
|
+
console.log(`[server] 回收站清理:${marked} 个会话已到期,进入 ${TRASH_PURGE_GRACE_MS / (24 * 60 * 60 * 1000)} 天宽限期(期间可恢复)`);
|
|
468
|
+
}
|
|
469
|
+
if (deleted > 0) {
|
|
470
|
+
console.log(`[server] 回收站清理:已物理清除 ${deleted} 个超过宽限期的会话(保留期 ${TRASH_RETENTION_MS / (24 * 60 * 60 * 1000)} 天)`);
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
.catch(() => { });
|
|
474
|
+
// P2-5:回收站看板两阶段清理(与会话回收站同策略:到期先标记宽限,期满再物理清除,
|
|
475
|
+
// 杜绝「到期即静默清空」)。
|
|
476
|
+
void purgeDeletedKanbanBoards(db, TRASH_RETENTION_MS, TRASH_PURGE_GRACE_MS)
|
|
477
|
+
.then(({ marked, deleted }) => {
|
|
478
|
+
if (marked > 0) {
|
|
479
|
+
console.log(`[server] 回收站清理:${marked} 个看板已到期,进入 ${TRASH_PURGE_GRACE_MS / (24 * 60 * 60 * 1000)} 天宽限期(期间可恢复)`);
|
|
480
|
+
}
|
|
481
|
+
if (deleted > 0) {
|
|
482
|
+
console.log(`[server] 回收站清理:已物理清除 ${deleted} 个超过宽限期的看板`);
|
|
483
|
+
}
|
|
484
|
+
})
|
|
485
|
+
.catch(() => { });
|
|
486
|
+
};
|
|
487
|
+
runTrashPurge();
|
|
352
488
|
const purgeTimer = setInterval(() => {
|
|
353
|
-
|
|
489
|
+
runTrashPurge();
|
|
490
|
+
// P2:CLI print / 工作流临时会话同样每日清理(30 天保留)
|
|
491
|
+
void purgeTemporarySessions(db).catch(() => { });
|
|
492
|
+
// C4:长期空置的 web 空会话每日清理(7 天保留)
|
|
493
|
+
void purgeEmptySessions(db).catch(() => { });
|
|
354
494
|
}, 24 * 60 * 60 * 1000);
|
|
355
495
|
purgeTimer.unref();
|
|
496
|
+
// P2:启动时同步清一次临时会话(每日定时之外的兜底)
|
|
497
|
+
void purgeTemporarySessions(db).catch(() => { });
|
|
498
|
+
// C4:启动时同步清一次空会话(兜底)
|
|
499
|
+
void purgeEmptySessions(db).catch(() => { });
|
|
500
|
+
// P2:标记崩溃遗留的后台子 agent 任务(lastRun=running 的 subagent 会话)
|
|
501
|
+
// 并给父会话注入失败通知。fire-and-forget:失败不阻塞启动。
|
|
502
|
+
void markDeadBackgroundJobs(db).catch(() => { });
|
|
356
503
|
// P2-15:PGLite WAL 周期刷盘(30s),缩短 kill -9 等突然停机时的已提交数据丢失窗口。
|
|
357
504
|
const syncTimer = db.sync
|
|
358
505
|
? setInterval(() => {
|
|
@@ -367,8 +514,15 @@ async function bootstrapServerContext(opts = {}) {
|
|
|
367
514
|
if (syncTimer)
|
|
368
515
|
clearInterval(syncTimer);
|
|
369
516
|
await dispose();
|
|
370
|
-
if (ownsDb)
|
|
517
|
+
if (ownsDb) {
|
|
371
518
|
await db.close();
|
|
519
|
+
// P0 修复:优雅关闭(含热更新 handoff)时显式释放 .dev.lock。
|
|
520
|
+
// 旧流程依赖新实例的 stale-PID 检测清理锁文件,而旧进程在 handoff
|
|
521
|
+
// 响应后延迟 250ms 才退出——新实例若在该窗口内做锁检查(PID 仍存活)
|
|
522
|
+
// 会抛「Database is locked」崩溃,且旧实例已退出,双输。显式释放后
|
|
523
|
+
// 锁文件在进程存活期就已移除,新实例直接重建锁,无竞态窗口。
|
|
524
|
+
releaseDevDbLock(resolveDbDir());
|
|
525
|
+
}
|
|
372
526
|
},
|
|
373
527
|
};
|
|
374
528
|
}
|
|
@@ -410,24 +564,15 @@ async function requestHandoffWithRetry(port, token, maxAttempts = 30, delayMs =
|
|
|
410
564
|
}
|
|
411
565
|
throw new Error(`handoff: old instance on port ${port} did not release after ${maxAttempts} attempts`);
|
|
412
566
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const app = createApp(ctx);
|
|
423
|
-
// config.update.enabled 时启动后台调度器。
|
|
424
|
-
if (ctx.config.update.enabled)
|
|
425
|
-
ctx.updateScheduler.start();
|
|
426
|
-
const server = serve({ fetch: app.fetch, port });
|
|
427
|
-
ctx.port = port;
|
|
428
|
-
// WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
|
|
429
|
-
// 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
|
|
430
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
567
|
+
/**
|
|
568
|
+
* 两阶段 handoff 的确认窗口(P3 回滚修复):旧实例响应 POST /handoff 后不退出,
|
|
569
|
+
* 等待新实例绑定端口成功后 POST /handoff-confirm 再退。窗口内未收到确认
|
|
570
|
+
* (新实例启动失败)→ 旧实例原地复活重新接管端口,用户工作零损失。
|
|
571
|
+
* 窗口需覆盖新实例 bootstrap(DB 迁移/快照恢复)耗时,取 60s 留裕量。
|
|
572
|
+
*/
|
|
573
|
+
const HANDOFF_CONFIRM_WINDOW_MS = 60_000;
|
|
574
|
+
/** 组装终端 WebSocket upgrade 处理器(bringUp 与复活共用)。 */
|
|
575
|
+
function attachTerminalUpgrade(ctx, wss, server) {
|
|
431
576
|
server.on('upgrade', (req, socket, head) => {
|
|
432
577
|
const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
|
|
433
578
|
const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
|
|
@@ -469,35 +614,147 @@ async function startServer(opts = {}) {
|
|
|
469
614
|
}
|
|
470
615
|
});
|
|
471
616
|
});
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
617
|
+
}
|
|
618
|
+
async function startServer(opts = {}) {
|
|
619
|
+
const port = opts.port ?? DEFAULT_SERVE_PORT;
|
|
620
|
+
// 默认仅绑定回环地址(安全默认:本地工具不应默认暴露局域网/公网)。
|
|
621
|
+
// 需要容器/远程访问时显式 --host 0.0.0.0(serve 命令会打印非回环警告)。
|
|
622
|
+
const host = opts.host ?? '127.0.0.1';
|
|
623
|
+
// spec §18.3:新实例从 --handoff-port 拿到旧实例端口,请求优雅退出后绑端口。
|
|
624
|
+
// 握手 token:updater spawn 时经 C0DE_AUTH_TOKEN 环境变量传入,否则读 auth-token 文件。
|
|
625
|
+
if (opts.handoffPort && !opts.skipHandoff) {
|
|
626
|
+
const pendingToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
|
|
627
|
+
await requestHandoffWithRetry(opts.handoffPort, pendingToken);
|
|
628
|
+
}
|
|
629
|
+
// 两阶段 handoff 状态(旧实例视角):确认窗口定时器与复活互斥锁。
|
|
630
|
+
let confirmTimer = null;
|
|
631
|
+
let reviving = false;
|
|
632
|
+
let handoffServer;
|
|
633
|
+
let currentClose = null;
|
|
634
|
+
// RunningServer 暴露的稳定句柄(复活后更新;app/authToken 每次 bringUp 重建)。
|
|
635
|
+
let currentApp = null;
|
|
636
|
+
let currentAuthToken;
|
|
637
|
+
const clearConfirmTimer = () => {
|
|
638
|
+
if (confirmTimer) {
|
|
639
|
+
clearTimeout(confirmTimer);
|
|
640
|
+
confirmTimer = null;
|
|
641
|
+
}
|
|
480
642
|
};
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
643
|
+
/**
|
|
644
|
+
* 组装完整服务栈(bootstrap → app → 调度器 → 绑定端口 → WS → handoff server)。
|
|
645
|
+
* 首次启动与超时复活共用。复活时新实例可能已绑定端口:serve() 抛 EADDRINUSE
|
|
646
|
+
* 说明新实例实际上已接管(确认请求丢失),旧实例退出即可。
|
|
647
|
+
*/
|
|
648
|
+
const bringUp = async () => {
|
|
649
|
+
const { ctx, close: closeCtx } = await bootstrapServerContext(opts);
|
|
650
|
+
const app = createApp(ctx);
|
|
651
|
+
// config.update.enabled 时启动后台调度器。
|
|
652
|
+
if (ctx.config.update.enabled)
|
|
653
|
+
ctx.updateScheduler.start();
|
|
654
|
+
const server = serve({ fetch: app.fetch, port, hostname: host });
|
|
655
|
+
ctx.port = port;
|
|
656
|
+
ctx.host = host;
|
|
657
|
+
writeServeInfo(resolveDbDir(), port);
|
|
658
|
+
// WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
|
|
659
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
660
|
+
attachTerminalUpgrade(ctx, wss, server);
|
|
661
|
+
let closed = false;
|
|
662
|
+
const closeMain = async () => {
|
|
663
|
+
if (closed)
|
|
664
|
+
return;
|
|
665
|
+
closed = true;
|
|
666
|
+
wss.close();
|
|
667
|
+
server.close();
|
|
668
|
+
removeServeInfo(resolveDbDir());
|
|
669
|
+
await closeCtx();
|
|
670
|
+
};
|
|
671
|
+
const close = async () => {
|
|
672
|
+
clearConfirmTimer();
|
|
673
|
+
await closeMain();
|
|
674
|
+
await handoffServer?.close();
|
|
675
|
+
};
|
|
676
|
+
currentClose = close;
|
|
677
|
+
currentApp = app;
|
|
678
|
+
currentAuthToken = ctx.authToken;
|
|
679
|
+
// spec §18.3 handoff server:旧实例收到 POST /handoff 后关闭主服务与资源,
|
|
680
|
+
// 响应 200。两阶段交接:不再立即退出——等待新实例 POST /handoff-confirm
|
|
681
|
+
// (onConfirm)确认接管;窗口超时未确认则 revive 原地复活。
|
|
682
|
+
// config.update.enabled=false 或 skipHandoff 时跳过。
|
|
683
|
+
if (ctx.config.update.enabled && !opts.skipHandoff) {
|
|
684
|
+
const createHandoff = opts.createHandoffFn ?? createHandoffServer;
|
|
685
|
+
// P0 修复:确认窗口必须在「收到 POST /handoff(交接开始)」时才启动,
|
|
686
|
+
// 而非 bringUp 时无条件启动——否则任何运行超过 HANDOFF_CONFIRM_WINDOW_MS
|
|
687
|
+
// 的普通实例都会在窗口超时后误判「新实例未确认」,重新 bringUp 时第二
|
|
688
|
+
// listen 触发 EADDRINUSE(unhandled 'error')崩溃。窗口从交接实际发生起算。
|
|
689
|
+
const armConfirmWindow = () => {
|
|
690
|
+
clearConfirmTimer();
|
|
691
|
+
confirmTimer = setTimeout(() => {
|
|
692
|
+
void (async () => {
|
|
693
|
+
if (reviving)
|
|
694
|
+
return;
|
|
695
|
+
reviving = true;
|
|
696
|
+
try {
|
|
697
|
+
await bringUp();
|
|
698
|
+
console.warn('[handoff] 新实例未确认接管,旧实例已复活并重新接管端口');
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
if (err.code === 'EADDRINUSE') {
|
|
702
|
+
// 新实例已绑定端口(确认请求丢失)→ 它已接管,旧实例正常退出。
|
|
703
|
+
console.warn('[handoff] 端口已被新实例接管,旧实例退出');
|
|
704
|
+
process.exit(0);
|
|
705
|
+
}
|
|
706
|
+
console.error('[handoff] 旧实例复活失败,退出:', err);
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
reviving = false;
|
|
711
|
+
}
|
|
712
|
+
})();
|
|
713
|
+
}, HANDOFF_CONFIRM_WINDOW_MS);
|
|
714
|
+
};
|
|
715
|
+
handoffServer = await createHandoff(async () => {
|
|
716
|
+
await closeMain();
|
|
717
|
+
armConfirmWindow();
|
|
718
|
+
}, {
|
|
719
|
+
// P2-16:token 轮换后新旧实例 bootstrap 可能不同,用 verifyHandoff 接受
|
|
720
|
+
// 当前/历史 bootstrap 与设备 token;无 authManager 回退字符串比较。
|
|
721
|
+
...(ctx.authManager
|
|
722
|
+
? { verify: (t) => ctx.authManager?.verifyHandoff(t) ?? false }
|
|
723
|
+
: { expectedToken: ctx.authToken }),
|
|
724
|
+
exitAfterResponse: false,
|
|
725
|
+
onConfirm: () => {
|
|
726
|
+
// 新实例确认已接管端口 → 旧实例退出(延迟保证确认响应刷出)。
|
|
727
|
+
clearConfirmTimer();
|
|
728
|
+
setTimeout(() => process.exit(0), 250);
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
ctx.handoff = { port: handoffServer.port, server: handoffServer };
|
|
732
|
+
}
|
|
733
|
+
// 两阶段交接第二阶段:新实例绑定成功后确认旧实例可退出。
|
|
734
|
+
// 旧实例(无 handoffPort)跳过;确认失败静默(窗口超时兜底)。
|
|
735
|
+
if (opts.handoffPort) {
|
|
736
|
+
const confirmToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
|
|
737
|
+
void confirmHandoff(opts.handoffPort, '127.0.0.1', confirmToken);
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
await bringUp();
|
|
741
|
+
if (!currentApp)
|
|
742
|
+
throw new Error('server failed to start: app not built');
|
|
743
|
+
return {
|
|
744
|
+
app: currentApp,
|
|
745
|
+
port,
|
|
746
|
+
close: async () => {
|
|
747
|
+
await currentClose?.();
|
|
748
|
+
},
|
|
749
|
+
// P:仅「首设备待注册」时回传 bootstrap 供 serve 命令拼 ?token= 注册 URL。
|
|
750
|
+
// 已有已注册设备时 auth-token 文件里是注册后被轮换的旧值——带进 URL 会让新浏览器
|
|
751
|
+
// 误点「注册链接」却得到 BOOTSTRAP_CONSUMED 引导配对(横幅文案误导)。此时不携带,
|
|
752
|
+
// serve 打印无 token 的 URL,新浏览器自然走配对流程。
|
|
753
|
+
...(currentAuthToken && !hasRegisteredDevices(resolveDbDir())
|
|
754
|
+
? { authToken: currentAuthToken }
|
|
755
|
+
: {}),
|
|
484
756
|
};
|
|
485
|
-
// spec §18.3 handoff server:旧实例收到 POST /handoff 后关闭主服务与资源,
|
|
486
|
-
// 响应 200,随后退出进程(exitAfterResponse),端口让渡给新实例。
|
|
487
|
-
// config.update.enabled=false 或 skipHandoff 时跳过。
|
|
488
|
-
let handoffServer;
|
|
489
|
-
if (ctx.config.update.enabled && !opts.skipHandoff) {
|
|
490
|
-
const createHandoff = opts.createHandoffFn ?? createHandoffServer;
|
|
491
|
-
handoffServer = await createHandoff(closeMain, {
|
|
492
|
-
// P2-16:token 轮换后新旧实例 bootstrap 可能不同,用 verifyHandoff 接受
|
|
493
|
-
// 当前/历史 bootstrap 与设备 token;无 authManager 回退字符串比较。
|
|
494
|
-
...(ctx.authManager
|
|
495
|
-
? { verify: (t) => ctx.authManager?.verifyHandoff(t) ?? false }
|
|
496
|
-
: { expectedToken: ctx.authToken }),
|
|
497
|
-
exitAfterResponse: true,
|
|
498
|
-
});
|
|
499
|
-
ctx.handoff = { port: handoffServer.port, server: handoffServer };
|
|
500
|
-
}
|
|
501
|
-
return { app, port, close, ...(ctx.authToken ? { authToken: ctx.authToken } : {}) };
|
|
502
757
|
}
|
|
503
|
-
|
|
758
|
+
// P1-1:buildRegistryFromConfig/syncRegistryFromConfig 实现移入 registry-config.ts,
|
|
759
|
+
// 此处 re-export 保留 server.ts 历史导出兼容(测试/CLI 引用)。
|
|
760
|
+
export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, readServeInfo, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
|