c0de-agent 1.8.0 → 1.10.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 +21 -7
- package/dist/cli/commands/acp.js +2 -0
- package/dist/cli/commands/chat.js +2 -0
- package/dist/cli/commands/config.js +8 -4
- package/dist/cli/commands/serve.d.ts +1 -0
- package/dist/cli/commands/serve.js +4 -1
- package/dist/cli/deps.d.ts +7 -5
- package/dist/cli/deps.js +23 -7
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +68 -37
- package/dist/cli/modes/print.d.ts +3 -1
- package/dist/cli/modes/print.js +21 -4
- package/dist/core/agent.js +3 -4
- package/dist/core/config.d.ts +18 -1
- package/dist/core/config.js +55 -4
- package/dist/core/loop/compaction.d.ts +31 -0
- package/dist/core/loop/compaction.js +137 -0
- package/dist/core/loop/persist.d.ts +11 -0
- package/dist/core/loop/persist.js +107 -0
- package/dist/core/loop/segment.d.ts +8 -0
- package/dist/core/loop/segment.js +72 -0
- package/dist/core/loop/stream-collect.d.ts +47 -0
- package/dist/core/loop/stream-collect.js +183 -0
- package/dist/core/loop/subagent.d.ts +12 -0
- package/dist/core/loop/subagent.js +171 -0
- package/dist/core/loop/todo.d.ts +5 -0
- package/dist/core/loop/todo.js +26 -0
- package/dist/core/loop.d.ts +2 -20
- package/dist/core/loop.js +13 -659
- package/dist/core/prompt-registry.d.ts +1 -1
- package/dist/core/prompt-registry.js +10 -0
- package/dist/core/slash.js +1 -1
- package/dist/core/todo-tags.d.ts +46 -0
- package/dist/core/todo-tags.js +192 -0
- package/dist/core/workflows/builtins.js +4 -4
- package/dist/core/workflows/discovery.d.ts +1 -1
- package/dist/core/workflows/discovery.js +4 -1
- package/dist/core/workflows/index.d.ts +1 -1
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.js +3 -1
- package/dist/llm/registry.d.ts +30 -3
- package/dist/llm/registry.js +25 -7
- package/dist/llm/retry.d.ts +4 -2
- package/dist/llm/retry.js +7 -6
- package/dist/llm/schema/errors.d.ts +13 -3
- package/dist/llm/schema/errors.js +35 -3
- package/dist/llm/transport.js +5 -1
- package/dist/project/resolve.js +2 -2
- package/dist/server/agent-manager.d.ts +15 -0
- package/dist/server/agent-manager.js +35 -11
- package/dist/server/app.js +2 -1
- package/dist/server/context.js +2 -0
- package/dist/server/middleware/cors.d.ts +11 -2
- package/dist/server/middleware/cors.js +15 -12
- package/dist/server/routes/chat.js +212 -145
- package/dist/server/routes/config.js +7 -3
- package/dist/server/routes/session.js +6 -2
- package/dist/server/routes/terminal.js +12 -0
- package/dist/server/routes/todo.js +5 -2
- package/dist/server/routes/update.d.ts +1 -0
- package/dist/server/routes/update.js +11 -4
- package/dist/server/routes/workflows.js +2 -2
- package/dist/server/server.d.ts +28 -3
- package/dist/server/server.js +127 -38
- package/dist/server/types.d.ts +9 -1
- package/dist/session/branch.d.ts +10 -2
- package/dist/session/branch.js +98 -29
- package/dist/session/compaction.js +1 -0
- package/dist/session/context.d.ts +3 -2
- package/dist/session/context.js +36 -3
- package/dist/session/snapshot.d.ts +9 -3
- package/dist/session/snapshot.js +45 -4
- package/dist/session/squash.js +32 -24
- package/dist/session/types.d.ts +2 -0
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/shared/types/config.d.ts +7 -6
- package/dist/shared/utils/path.d.ts +2 -3
- package/dist/shared/utils/path.js +20 -4
- package/dist/tools/builtin/todo.d.ts +7 -0
- package/dist/tools/builtin/todo.js +28 -20
- package/dist/tools/index.d.ts +14 -3
- package/dist/tools/index.js +36 -14
- package/dist/update/index.d.ts +1 -1
- package/dist/update/index.js +1 -1
- package/dist/update/ipc.d.ts +32 -5
- package/dist/update/ipc.js +53 -7
- package/dist/update/updater.d.ts +7 -3
- package/dist/update/updater.js +14 -6
- package/dist/update/version.d.ts +6 -1
- package/dist/update/version.js +18 -3
- package/drizzle/0005_snapshot_mtime.sql +1 -0
- package/drizzle/meta/0005_snapshot.json +855 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -7
- package/dist/mcp/index.d.ts +0 -1
- package/dist/mcp/index.js +0 -3
package/dist/server/server.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ type StartServerOptions = {
|
|
|
23
23
|
type RunningServer = {
|
|
24
24
|
app: Hono;
|
|
25
25
|
port: number;
|
|
26
|
+
/** 认证 token(authEnabled=false 时为 undefined);serve 命令据此打印带 token 的 URL。 */
|
|
27
|
+
authToken?: string;
|
|
26
28
|
close(): Promise<void>;
|
|
27
29
|
};
|
|
28
30
|
type BootstrappedServer = {
|
|
@@ -32,8 +34,10 @@ type BootstrappedServer = {
|
|
|
32
34
|
/** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
|
|
33
35
|
declare function buildRegistryFromConfig(config: Config): Registry;
|
|
34
36
|
/**
|
|
35
|
-
* config
|
|
36
|
-
*
|
|
37
|
+
* config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
|
|
38
|
+
* 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
|
|
39
|
+
* 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
|
|
40
|
+
* 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
|
|
37
41
|
*/
|
|
38
42
|
declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
|
|
39
43
|
/** 解析 PGLite 持久化数据目录:优先 C0DE_DB_DIR,否则全局数据根下 pglite 子目录。
|
|
@@ -41,6 +45,15 @@ declare function syncRegistryFromConfig(registry: Registry, config: Config): voi
|
|
|
41
45
|
* 历史上数据库放在 <cwd>/.c0de/pglite(项目级),但项目 DB schema 通过 projectId
|
|
42
46
|
* 区分项目,设计本意是全局共享单库。旧路径已在 migrateLegacyPglite 中自动迁移。 */
|
|
43
47
|
declare function resolveDbDir(): string;
|
|
48
|
+
/**
|
|
49
|
+
* 认证 token 解析(P0 安全修复):
|
|
50
|
+
* 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
|
|
51
|
+
* 2. 用户配置 security.token → 使用。
|
|
52
|
+
* 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
|
|
53
|
+
* 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
|
|
54
|
+
* 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
|
|
55
|
+
*/
|
|
56
|
+
declare function resolveAuthToken(config: Config, dataDir: string): string | undefined;
|
|
44
57
|
/**
|
|
45
58
|
* 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
|
|
46
59
|
*
|
|
@@ -52,6 +65,18 @@ declare function buildServerContext(db: DB, opts?: StartServerOptions): Promise<
|
|
|
52
65
|
ctx: ServerContext;
|
|
53
66
|
dispose: () => Promise<void>;
|
|
54
67
|
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Cross-process guard for PGLite dataDir.
|
|
70
|
+
*
|
|
71
|
+
* PGLite is single-writer WASM Postgres — two processes on the same dataDir
|
|
72
|
+
* always abort (`RuntimeError: Aborted()`). This lock prevents silent
|
|
73
|
+
* corruption when multiple dev servers (e.g. different ports) target the
|
|
74
|
+
* same project.
|
|
75
|
+
*
|
|
76
|
+
* - Live PID in lock → throw clear error instead of cryptic WASM abort.
|
|
77
|
+
* - Dead PID in lock → stale: remove `.dev.lock` + `postmaster.pid`, proceed.
|
|
78
|
+
*/
|
|
79
|
+
declare function acquireDevDbLock(dataDir: string): void;
|
|
55
80
|
/** Release the dev DB lock (best-effort, stale-detection covers missed calls). */
|
|
56
81
|
declare function releaseDevDbLock(dataDir: string): void;
|
|
57
82
|
/** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
|
|
@@ -60,4 +85,4 @@ declare function createDevDb(cwd: string): Promise<DB>;
|
|
|
60
85
|
declare function bootstrapServerContext(opts?: StartServerOptions): Promise<BootstrappedServer>;
|
|
61
86
|
declare function startServer(opts?: StartServerOptions): Promise<RunningServer>;
|
|
62
87
|
export type { BootstrappedServer, RunningServer, StartServerOptions };
|
|
63
|
-
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
|
|
88
|
+
export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
|
package/dist/server/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/server/server.ts
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { connect as tcpConnect } from 'node:net';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
@@ -10,12 +11,13 @@ import { loadConfig } from '../core/config.js';
|
|
|
10
11
|
import { decryptSecret } from '../core/secret.js';
|
|
11
12
|
import { createAndPopulateRegistry } from '../core/workflows/index.js';
|
|
12
13
|
import { createDB, migrateDB } from '../db/index.js';
|
|
13
|
-
import { createRegistry, overrideToCapabilities, registerProvider, } from '../llm/registry.js';
|
|
14
|
+
import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
|
|
14
15
|
import { initPlugins } from '../plugins/index.js';
|
|
15
16
|
import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
|
|
16
17
|
import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
|
|
17
18
|
import { createAgentManager } from './agent-manager.js';
|
|
18
19
|
import { createApp } from './app.js';
|
|
20
|
+
import { isAllowedOrigin } from './middleware/cors.js';
|
|
19
21
|
import { createPermissionStore } from './permission/store.js';
|
|
20
22
|
import { PTYManager } from './terminal/pty-manager.js';
|
|
21
23
|
/** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
|
|
@@ -44,15 +46,17 @@ function registerProviderFromConfig(registry, p) {
|
|
|
44
46
|
});
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
|
-
* config
|
|
48
|
-
*
|
|
49
|
+
* config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
|
|
50
|
+
* 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
|
|
51
|
+
* 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
|
|
52
|
+
* 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
|
|
49
53
|
*/
|
|
50
54
|
function syncRegistryFromConfig(registry, config) {
|
|
51
|
-
registry
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
55
|
+
rebuildRegistry(registry, (next) => {
|
|
56
|
+
for (const p of config.providers) {
|
|
57
|
+
registerProviderFromConfig(next, p);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
56
60
|
}
|
|
57
61
|
/** 全局数据根目录:XDG_DATA_HOME 优先,否则 ~/.local/share/c0de。
|
|
58
62
|
* 与 opencode (~/.local/share/opencode/)、oh-my-pi (~/.omp/agent/) 同约定——
|
|
@@ -73,6 +77,59 @@ function resolveDbDir() {
|
|
|
73
77
|
return envDir;
|
|
74
78
|
return join(resolveGlobalDataRoot(), 'pglite');
|
|
75
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* 认证 token 解析(P0 安全修复):
|
|
82
|
+
* 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
|
|
83
|
+
* 2. 用户配置 security.token → 使用。
|
|
84
|
+
* 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
|
|
85
|
+
* 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
|
|
86
|
+
* 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
|
|
87
|
+
*/
|
|
88
|
+
function resolveAuthToken(config, dataDir) {
|
|
89
|
+
if (config.security.authEnabled === false)
|
|
90
|
+
return undefined;
|
|
91
|
+
if (config.security.token && config.security.token.length > 0)
|
|
92
|
+
return config.security.token;
|
|
93
|
+
const tokenFile = join(dataDir, 'auth-token');
|
|
94
|
+
const envToken = process.env.C0DE_AUTH_TOKEN;
|
|
95
|
+
if (envToken) {
|
|
96
|
+
try {
|
|
97
|
+
mkdirSync(dataDir, { recursive: true });
|
|
98
|
+
writeFileSync(tokenFile, envToken, { mode: 0o600 });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// 落盘失败不影响本次运行
|
|
102
|
+
}
|
|
103
|
+
return envToken;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const existing = readFileSync(tokenFile, 'utf-8').trim();
|
|
107
|
+
if (existing)
|
|
108
|
+
return existing;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// 文件不存在,走生成分支
|
|
112
|
+
}
|
|
113
|
+
const generated = randomBytes(24).toString('hex');
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(dataDir, { recursive: true });
|
|
116
|
+
writeFileSync(tokenFile, generated, { mode: 0o600 });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// 落盘失败:本次运行仍可用内存 token
|
|
120
|
+
}
|
|
121
|
+
return generated;
|
|
122
|
+
}
|
|
123
|
+
/** 只读 dataDir 下已存在的 auth-token(新实例 handoff 握手用;不生成)。 */
|
|
124
|
+
function readAuthTokenFile(dataDir) {
|
|
125
|
+
try {
|
|
126
|
+
const t = readFileSync(join(dataDir, 'auth-token'), 'utf-8').trim();
|
|
127
|
+
return t || undefined;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
76
133
|
/** 一次性迁移:<cwd>/.c0de/pglite → 全局路径。仅当目标不存在时执行。
|
|
77
134
|
*
|
|
78
135
|
* 多个项目各有 .c0de/pglite 时只迁移第一个遇到的(首次启动即触发);
|
|
@@ -139,6 +196,7 @@ async function buildServerContext(db, opts = {}) {
|
|
|
139
196
|
agentManager: createAgentManager(),
|
|
140
197
|
permissionStore: createPermissionStore(),
|
|
141
198
|
permissionMode: config.permission.defaultMode,
|
|
199
|
+
authToken: resolveAuthToken(config, resolveDbDir()),
|
|
142
200
|
// Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
|
|
143
201
|
agentRegistry: (() => {
|
|
144
202
|
const reg = createAgentRegistry();
|
|
@@ -156,30 +214,17 @@ async function buildServerContext(db, opts = {}) {
|
|
|
156
214
|
cwd,
|
|
157
215
|
ptyManager: new PTYManager(),
|
|
158
216
|
};
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// 或测试 skipHandoff 时跳过(减少后台资源占用)。
|
|
162
|
-
let handoffServer;
|
|
163
|
-
if (config.update.enabled && !opts.skipHandoff) {
|
|
164
|
-
const createHandoff = opts.createHandoffFn ?? createHandoffServer;
|
|
165
|
-
handoffServer = await createHandoff(async () => {
|
|
166
|
-
// 序列化在热更新主链路(performHotUpdate)已完成;handoff 回调只需释放资源。
|
|
167
|
-
// 这里提前停 scheduler 以避免 close 期间还在跑检查。
|
|
168
|
-
ctx.updateScheduler.stop();
|
|
169
|
-
});
|
|
170
|
-
ctx.handoff = { port: handoffServer.port, server: handoffServer };
|
|
171
|
-
}
|
|
217
|
+
// handoff server 不在此创建:它需要持有主 HTTP server 引用以触发完整关停,
|
|
218
|
+
// 由 startServer 在 serve() 之后创建(dev 模式走 vite,不创建)。
|
|
172
219
|
return {
|
|
173
220
|
ctx,
|
|
174
221
|
dispose: async () => {
|
|
175
222
|
// dev 重建前调用:中止活跃 run + settle pending permission +
|
|
176
|
-
// 停 scheduler
|
|
223
|
+
// 停 scheduler。**不 close db**(调用方持有)。
|
|
177
224
|
ctx.agentManager.dispose();
|
|
178
225
|
ctx.permissionStore.dispose();
|
|
179
226
|
ctx.updateScheduler.stop();
|
|
180
227
|
ctx.ptyManager.dispose();
|
|
181
|
-
if (handoffServer)
|
|
182
|
-
await handoffServer.close();
|
|
183
228
|
},
|
|
184
229
|
};
|
|
185
230
|
}
|
|
@@ -221,11 +266,15 @@ function acquireDevDbLock(dataDir) {
|
|
|
221
266
|
try {
|
|
222
267
|
unlinkSync(lockPath);
|
|
223
268
|
}
|
|
224
|
-
catch {
|
|
269
|
+
catch {
|
|
270
|
+
/* best-effort */
|
|
271
|
+
}
|
|
225
272
|
try {
|
|
226
273
|
unlinkSync(join(dataDir, 'postmaster.pid'));
|
|
227
274
|
}
|
|
228
|
-
catch {
|
|
275
|
+
catch {
|
|
276
|
+
/* best-effort */
|
|
277
|
+
}
|
|
229
278
|
}
|
|
230
279
|
writeFileSync(lockPath, String(process.pid));
|
|
231
280
|
}
|
|
@@ -234,7 +283,9 @@ function releaseDevDbLock(dataDir) {
|
|
|
234
283
|
try {
|
|
235
284
|
unlinkSync(join(dataDir, DEV_LOCK_FILE));
|
|
236
285
|
}
|
|
237
|
-
catch {
|
|
286
|
+
catch {
|
|
287
|
+
/* best-effort */
|
|
288
|
+
}
|
|
238
289
|
}
|
|
239
290
|
/** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
|
|
240
291
|
async function createDevDb(cwd) {
|
|
@@ -293,13 +344,24 @@ async function bootstrapServerContext(opts = {}) {
|
|
|
293
344
|
* 请求旧实例 handoff 后轮询端口释放(spec §18.3)。旧实例收到 /handoff 后
|
|
294
345
|
* 优雅退出释放端口;新实例轮询 TCP 连接直到被拒(端口已释放)或超时报错。
|
|
295
346
|
*/
|
|
296
|
-
async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
|
|
347
|
+
async function requestHandoffWithRetry(port, token, maxAttempts = 30, delayMs = 100) {
|
|
297
348
|
try {
|
|
298
|
-
await requestHandoff(port);
|
|
349
|
+
await requestHandoff(port, '127.0.0.1', token);
|
|
299
350
|
}
|
|
300
|
-
catch {
|
|
301
|
-
|
|
302
|
-
|
|
351
|
+
catch (err) {
|
|
352
|
+
const handoffErr = err;
|
|
353
|
+
// 连接层失败(ECONNREFUSED 等):旧实例未启 handoff 端点(update 未启用);
|
|
354
|
+
// 直接尝试绑端口。
|
|
355
|
+
if (handoffErr?.kind === 'connect')
|
|
356
|
+
return;
|
|
357
|
+
// HTTP 401:旧实例存在但 auth token 不匹配——立即明确报错退出。若继续
|
|
358
|
+
// bind 必撞 EADDRINUSE,把真实原因(token 校验失败)误报成端口占用。
|
|
359
|
+
if (handoffErr?.kind === 'http' && handoffErr.status === 401) {
|
|
360
|
+
throw new Error(`handoff: 端口 ${port} 上的旧实例拒绝让渡(HTTP 401:auth token 不匹配)。\n` +
|
|
361
|
+
`新旧实例须共享同一 token(C0DE_AUTH_TOKEN 环境变量或数据目录 auth-token 文件),\n` +
|
|
362
|
+
`请核对配置后重试。`);
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
303
365
|
}
|
|
304
366
|
for (let i = 0; i < maxAttempts; i++) {
|
|
305
367
|
const released = await new Promise((resolve) => {
|
|
@@ -319,8 +381,10 @@ async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
|
|
|
319
381
|
async function startServer(opts = {}) {
|
|
320
382
|
const port = opts.port ?? 3000;
|
|
321
383
|
// spec §18.3:新实例从 --handoff-port 拿到旧实例端口,请求优雅退出后绑端口。
|
|
384
|
+
// 握手 token:updater spawn 时经 C0DE_AUTH_TOKEN 环境变量传入,否则读 auth-token 文件。
|
|
322
385
|
if (opts.handoffPort && !opts.skipHandoff) {
|
|
323
|
-
|
|
386
|
+
const pendingToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
|
|
387
|
+
await requestHandoffWithRetry(opts.handoffPort, pendingToken);
|
|
324
388
|
}
|
|
325
389
|
const { ctx, close: closeCtx } = await bootstrapServerContext(opts);
|
|
326
390
|
const app = createApp(ctx);
|
|
@@ -328,10 +392,11 @@ async function startServer(opts = {}) {
|
|
|
328
392
|
if (ctx.config.update.enabled)
|
|
329
393
|
ctx.updateScheduler.start();
|
|
330
394
|
const server = serve({ fetch: app.fetch, port });
|
|
395
|
+
ctx.port = port;
|
|
331
396
|
// WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
|
|
332
397
|
// 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
|
|
333
398
|
const wss = new WebSocketServer({ noServer: true });
|
|
334
|
-
const expectedToken = ctx.
|
|
399
|
+
const expectedToken = ctx.authToken;
|
|
335
400
|
server.on('upgrade', (req, socket, head) => {
|
|
336
401
|
const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
|
|
337
402
|
const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
|
|
@@ -339,6 +404,14 @@ async function startServer(opts = {}) {
|
|
|
339
404
|
socket.destroy();
|
|
340
405
|
return;
|
|
341
406
|
}
|
|
407
|
+
// Origin 校验:浏览器 WS 不受 CORS 约束,必须在服务端显式校验。
|
|
408
|
+
// 非浏览器客户端(无 Origin 头)放行——token 仍是硬门槛。
|
|
409
|
+
const origin = req.headers.origin;
|
|
410
|
+
if (origin && !isAllowedOrigin(origin, ctx.config.security.allowedOrigins)) {
|
|
411
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
412
|
+
socket.destroy();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
342
415
|
// 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)
|
|
343
416
|
if (expectedToken) {
|
|
344
417
|
const token = url.searchParams.get('token');
|
|
@@ -362,7 +435,7 @@ async function startServer(opts = {}) {
|
|
|
362
435
|
});
|
|
363
436
|
});
|
|
364
437
|
let closed = false;
|
|
365
|
-
const
|
|
438
|
+
const closeMain = async () => {
|
|
366
439
|
if (closed)
|
|
367
440
|
return;
|
|
368
441
|
closed = true;
|
|
@@ -370,6 +443,22 @@ async function startServer(opts = {}) {
|
|
|
370
443
|
server.close();
|
|
371
444
|
await closeCtx();
|
|
372
445
|
};
|
|
373
|
-
|
|
446
|
+
const close = async () => {
|
|
447
|
+
await closeMain();
|
|
448
|
+
await handoffServer?.close();
|
|
449
|
+
};
|
|
450
|
+
// spec §18.3 handoff server:旧实例收到 POST /handoff 后关闭主服务与资源,
|
|
451
|
+
// 响应 200,随后退出进程(exitAfterResponse),端口让渡给新实例。
|
|
452
|
+
// config.update.enabled=false 或 skipHandoff 时跳过。
|
|
453
|
+
let handoffServer;
|
|
454
|
+
if (ctx.config.update.enabled && !opts.skipHandoff) {
|
|
455
|
+
const createHandoff = opts.createHandoffFn ?? createHandoffServer;
|
|
456
|
+
handoffServer = await createHandoff(closeMain, {
|
|
457
|
+
expectedToken: ctx.authToken,
|
|
458
|
+
exitAfterResponse: true,
|
|
459
|
+
});
|
|
460
|
+
ctx.handoff = { port: handoffServer.port, server: handoffServer };
|
|
461
|
+
}
|
|
462
|
+
return { app, port, close, ...(ctx.authToken ? { authToken: ctx.authToken } : {}) };
|
|
374
463
|
}
|
|
375
|
-
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
|
|
464
|
+
export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
|
package/dist/server/types.d.ts
CHANGED
|
@@ -8,9 +8,9 @@ import type { Config as SharedConfig } from '../shared/types/config.js';
|
|
|
8
8
|
import type { URLRegistry } from '../shared/types/tool.js';
|
|
9
9
|
import type { ToolRegistry } from '../tools/types.js';
|
|
10
10
|
import type { HandoffServer, UpdateScheduler } from '../update/index.js';
|
|
11
|
-
import type { PTYManager } from './terminal/pty-manager.js';
|
|
12
11
|
import type { AgentManager } from './agent-manager.js';
|
|
13
12
|
import type { PermissionStore } from './permission/store.js';
|
|
13
|
+
import type { PTYManager } from './terminal/pty-manager.js';
|
|
14
14
|
/** 持有所有服务依赖的不可变上下文(config 字段可变用于 PATCH 更新)。 */
|
|
15
15
|
type ServerContext = {
|
|
16
16
|
db: DB;
|
|
@@ -28,6 +28,14 @@ type ServerContext = {
|
|
|
28
28
|
permissionStore: PermissionStore;
|
|
29
29
|
/** 全局授权模式(运行时可临时切换):'default' 逐个确认;'auto' 自动放行 ask 工具(YOLO)。启动时取 config.permission.defaultMode,PUT /api/permissions 临时切换不回写 config。 */
|
|
30
30
|
permissionMode: 'default' | 'auto';
|
|
31
|
+
/**
|
|
32
|
+
* API Bearer token(认证未显式关闭时必存在)。
|
|
33
|
+
* 用户配置 security.token 优先;否则自动生成并持久化到数据目录 auth-token 文件
|
|
34
|
+
* (跨重启/热更新稳定,浏览器首访经 ?token= URL 获取后本地保存)。
|
|
35
|
+
*/
|
|
36
|
+
authToken?: string;
|
|
37
|
+
/** 主 HTTP 服务实际绑定端口(startServer 绑定后回填;热更新 spawn 新实例复用)。 */
|
|
38
|
+
port?: number;
|
|
31
39
|
/** Agent 类型注册表(spec: multi-agent-design)。注入 agent loop 的 runSubAgent。 */
|
|
32
40
|
agentRegistry: AgentRegistry;
|
|
33
41
|
/** 工作流注册表(spec: dynamic-workflow-design)。注入 /workflow slash 命令和 workflowz steering。 */
|
package/dist/session/branch.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { DB } from '../db/client.js';
|
|
2
2
|
import type { Session, SessionTreeNode } from './types.js';
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
|
|
5
|
+
* 与「会话不存在」区分开:消费路由应映射 400 并透出 message,而非 404。
|
|
6
|
+
*/
|
|
7
|
+
declare class BranchPointOutOfRangeError extends Error {
|
|
8
|
+
constructor(messageIndex: number);
|
|
9
|
+
}
|
|
10
|
+
/** Fork a session at a message index — copies all entries (messages + tool pairs)
|
|
11
|
+
* up to and including the branch point message, plus latest file snapshots. */
|
|
4
12
|
declare function forkSession(handle: DB, sessionId: string, messageIndex: number): Promise<Session>;
|
|
5
13
|
/** Get direct child sessions (branches) of a session. */
|
|
6
14
|
declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
|
|
@@ -8,4 +16,4 @@ declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
|
|
|
8
16
|
* 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
|
|
9
17
|
*/
|
|
10
18
|
declare function getTree(handle: DB): Promise<SessionTreeNode[]>;
|
|
11
|
-
export { forkSession, getBranches, getTree };
|
|
19
|
+
export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
|
package/dist/session/branch.js
CHANGED
|
@@ -1,42 +1,111 @@
|
|
|
1
1
|
import { eq } from 'drizzle-orm';
|
|
2
|
-
import { sessions } from '../db/schema.js';
|
|
2
|
+
import { sessionEntries, sessions } from '../db/schema.js';
|
|
3
3
|
import { generateId } from '../shared/index.js';
|
|
4
|
-
import {
|
|
4
|
+
import { getEntries, insertEntry } from './message.js';
|
|
5
5
|
import { createSession, getSession, rowToSession } from './session.js';
|
|
6
|
-
|
|
6
|
+
import { copyFileSnapshots } from './snapshot.js';
|
|
7
|
+
/**
|
|
8
|
+
* fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
|
|
9
|
+
* 与「会话不存在」区分开:消费路由应映射 400 并透出 message,而非 404。
|
|
10
|
+
*/
|
|
11
|
+
class BranchPointOutOfRangeError extends Error {
|
|
12
|
+
constructor(messageIndex) {
|
|
13
|
+
super(`Branch point message index ${messageIndex} out of range`);
|
|
14
|
+
this.name = 'BranchPointOutOfRangeError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** 把 SessionEntry 还原为 sessionEntries 原始行(fork 复制用)。 */
|
|
18
|
+
function entryToRow(e, sessionId) {
|
|
19
|
+
const id = generateId();
|
|
20
|
+
// Message 无 _tag(共享类型);其余条目经 _tag 判别。
|
|
21
|
+
if (!('_tag' in e)) {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
sessionId,
|
|
25
|
+
tag: 'message',
|
|
26
|
+
role: e.role,
|
|
27
|
+
content: e.content,
|
|
28
|
+
tokenCount: e.tokenCount,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
switch (e._tag) {
|
|
32
|
+
case 'compaction':
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
sessionId,
|
|
36
|
+
tag: 'compaction',
|
|
37
|
+
content: {
|
|
38
|
+
summary: e.summary,
|
|
39
|
+
originalEntryIds: e.originalEntryIds,
|
|
40
|
+
archiveId: e.archiveId,
|
|
41
|
+
},
|
|
42
|
+
tokenCount: e.tokenCount,
|
|
43
|
+
};
|
|
44
|
+
case 'squash':
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
sessionId,
|
|
48
|
+
tag: 'squash',
|
|
49
|
+
content: {
|
|
50
|
+
summary: e.summary,
|
|
51
|
+
squashedEntryIds: e.squashedEntryIds,
|
|
52
|
+
archiveId: e.archiveId,
|
|
53
|
+
},
|
|
54
|
+
tokenCount: e.tokenCount,
|
|
55
|
+
};
|
|
56
|
+
case 'branch_summary':
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
sessionId,
|
|
60
|
+
tag: 'branch_summary',
|
|
61
|
+
content: { summary: e.summary, sourceSessionId: e.sourceSessionId },
|
|
62
|
+
};
|
|
63
|
+
case 'steering':
|
|
64
|
+
return { id, sessionId, tag: 'steering', content: { text: e.content } };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Fork a session at a message index — copies all entries (messages + tool pairs)
|
|
68
|
+
* up to and including the branch point message, plus latest file snapshots. */
|
|
7
69
|
async function forkSession(handle, sessionId, messageIndex) {
|
|
8
70
|
const source = await getSession(handle, sessionId);
|
|
9
71
|
if (!source)
|
|
10
72
|
throw new Error(`Session not found: ${sessionId}`);
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
73
|
+
const entries = await getEntries(handle, sessionId);
|
|
74
|
+
const msgEntries = entries.filter((e) => !('_tag' in e));
|
|
75
|
+
const target = msgEntries[messageIndex];
|
|
76
|
+
if (!target) {
|
|
77
|
+
throw new BranchPointOutOfRangeError(messageIndex);
|
|
78
|
+
}
|
|
79
|
+
const targetIdx = entries.findIndex((e) => e.id === target.id);
|
|
80
|
+
const toCopy = entries.slice(0, targetIdx + 1);
|
|
81
|
+
// 多表写入(sessions insert/update + sessionEntries 复制 + 快照复制)包单个事务:
|
|
82
|
+
// 中途失败整体回滚,不残留永久可见的半成品分支(同 compactSession/squashRecent 写法)。
|
|
83
|
+
const updated = await handle.db.transaction(async (tx) => {
|
|
84
|
+
const txHandle = { db: tx, close: handle.close };
|
|
85
|
+
const forked = await createSession(txHandle, `Branch of ${source.title}`, source.projectId ?? undefined);
|
|
86
|
+
await tx
|
|
87
|
+
.update(sessions)
|
|
88
|
+
.set({ parentId: sessionId, branchPoint: messageIndex })
|
|
89
|
+
.where(eq(sessions.id, forked.id));
|
|
90
|
+
for (const e of toCopy) {
|
|
91
|
+
await insertEntry(txHandle, entryToRow(e, forked.id));
|
|
92
|
+
}
|
|
93
|
+
await insertEntry(txHandle, {
|
|
20
94
|
id: generateId(),
|
|
21
95
|
sessionId: forked.id,
|
|
22
|
-
tag: '
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
96
|
+
tag: 'branch_summary',
|
|
97
|
+
content: {
|
|
98
|
+
summary: `Branched from session ${sessionId} at message ${messageIndex}`,
|
|
99
|
+
sourceSessionId: sessionId,
|
|
100
|
+
},
|
|
26
101
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
summary: `Branched from session ${sessionId} at message ${messageIndex}`,
|
|
34
|
-
sourceSessionId: sessionId,
|
|
35
|
-
},
|
|
102
|
+
// 复制源会话最新文件快照(P1-4:@文件上下文随分支保留)。
|
|
103
|
+
await copyFileSnapshots(txHandle, sessionId, forked.id);
|
|
104
|
+
const created = await getSession(txHandle, forked.id);
|
|
105
|
+
if (!created)
|
|
106
|
+
throw new Error('Forked session not found after creation');
|
|
107
|
+
return created;
|
|
36
108
|
});
|
|
37
|
-
const updated = await getSession(handle, forked.id);
|
|
38
|
-
if (!updated)
|
|
39
|
-
throw new Error('Forked session not found after creation');
|
|
40
109
|
return updated;
|
|
41
110
|
}
|
|
42
111
|
/** Get direct child sessions (branches) of a session. */
|
|
@@ -67,4 +136,4 @@ async function getTree(handle) {
|
|
|
67
136
|
}));
|
|
68
137
|
return build(null);
|
|
69
138
|
}
|
|
70
|
-
export { forkSession, getBranches, getTree };
|
|
139
|
+
export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
|
|
@@ -228,6 +228,7 @@ async function compactSession(handle, sessionId, summarizer, config) {
|
|
|
228
228
|
if (config?.preserveSnapshots !== false) {
|
|
229
229
|
const hotFiles = extractHotFiles(compactMessages);
|
|
230
230
|
for (const file of hotFiles) {
|
|
231
|
+
// 不传 mtimeMs:由 upsertFileSnapshot 从已有行透传,压缩后热文件不误判过期。
|
|
231
232
|
const id = await upsertFileSnapshot(txHandle, sessionId, file.path, file.content);
|
|
232
233
|
fileSnapshotIds.push(id);
|
|
233
234
|
}
|
|
@@ -8,8 +8,9 @@ declare function messageToChatMessage(msg: Message): ChatMessage;
|
|
|
8
8
|
declare function entriesToChatMessages(entries: SessionEntry[], snapshots: FileSnapshot[]): ChatMessage[];
|
|
9
9
|
/** Inject file snapshot block after the first message (preserves cache prefix). */
|
|
10
10
|
declare function injectSnapshots(messages: ChatMessage[], snapshots: FileSnapshot[]): ChatMessage[];
|
|
11
|
-
/** Get the full session context: all entries + file snapshots.
|
|
12
|
-
|
|
11
|
+
/** Get the full session context: all entries + file snapshots.
|
|
12
|
+
* cwd 提供时刷新过期快照:文件磁盘 mtime 晚于快照记录 → 重读并升版本(P1-5)。 */
|
|
13
|
+
declare function getSessionContext(handle: DB, sessionId: string, cwd?: string): Promise<{
|
|
13
14
|
entries: SessionEntry[];
|
|
14
15
|
snapshots: FileSnapshot[];
|
|
15
16
|
}>;
|
package/dist/session/context.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { getEntries } from './message.js';
|
|
2
|
-
import { getFileSnapshots } from './snapshot.js';
|
|
4
|
+
import { getFileSnapshots, upsertFileSnapshot } from './snapshot.js';
|
|
3
5
|
/**
|
|
4
6
|
* Drop tool messages whose id cannot be paired with an assistant tool_call,
|
|
5
7
|
* and inject synthetic tool results for orphan tool_calls (assistant 发出了
|
|
@@ -162,12 +164,43 @@ function injectSnapshots(messages, snapshots) {
|
|
|
162
164
|
return [snapshotMessage];
|
|
163
165
|
return [first, snapshotMessage, ...messages.slice(1)];
|
|
164
166
|
}
|
|
165
|
-
/** Get the full session context: all entries + file snapshots.
|
|
166
|
-
|
|
167
|
+
/** Get the full session context: all entries + file snapshots.
|
|
168
|
+
* cwd 提供时刷新过期快照:文件磁盘 mtime 晚于快照记录 → 重读并升版本(P1-5)。 */
|
|
169
|
+
async function getSessionContext(handle, sessionId, cwd) {
|
|
170
|
+
if (cwd)
|
|
171
|
+
await refreshStaleSnapshots(handle, sessionId, cwd);
|
|
167
172
|
const [entries, snapshots] = await Promise.all([
|
|
168
173
|
getEntries(handle, sessionId),
|
|
169
174
|
getFileSnapshots(handle, sessionId),
|
|
170
175
|
]);
|
|
171
176
|
return { entries, snapshots };
|
|
172
177
|
}
|
|
178
|
+
/** 比对每个文件最新快照与磁盘 mtime,过期则重读并写入新版本。 */
|
|
179
|
+
async function refreshStaleSnapshots(handle, sessionId, cwd) {
|
|
180
|
+
const snaps = await getFileSnapshots(handle, sessionId);
|
|
181
|
+
const latest = new Map();
|
|
182
|
+
for (const s of snaps) {
|
|
183
|
+
const prev = latest.get(s.filePath);
|
|
184
|
+
if (!prev || s.version > prev.version)
|
|
185
|
+
latest.set(s.filePath, s);
|
|
186
|
+
}
|
|
187
|
+
for (const s of latest.values()) {
|
|
188
|
+
const p = resolve(cwd, s.filePath);
|
|
189
|
+
// try 只覆盖 fs 错误(stat/readFile):文件已删除/不可读 → 保留旧快照
|
|
190
|
+
// (读工具会报错提示模型),跳过该文件继续处理其余快照。
|
|
191
|
+
const st = await stat(p).catch(() => null);
|
|
192
|
+
if (!st)
|
|
193
|
+
continue;
|
|
194
|
+
// upsertFileSnapshot 落盘时对 mtimeMs 取整(bigint 列),比对侧同样取整,
|
|
195
|
+
// 避免向下舍入导致"存储值 < 真实值"的永久误判过期。
|
|
196
|
+
if ((s.mtimeMs ?? 0) >= Math.round(st.mtimeMs))
|
|
197
|
+
continue;
|
|
198
|
+
const content = await readFile(p, 'utf-8').catch(() => null);
|
|
199
|
+
if (content === null)
|
|
200
|
+
continue;
|
|
201
|
+
// DB 写入放在 try 之外:upsertFileSnapshot 失败必须向上抛,
|
|
202
|
+
// 否则过期内容被静默注入上下文(评审 NIT:错误吞没范围过大)。
|
|
203
|
+
await upsertFileSnapshot(handle, sessionId, s.filePath, content, st.mtimeMs);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
173
206
|
export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage };
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { DB } from '../db/client.js';
|
|
2
2
|
import type { FileSnapshot } from './types.js';
|
|
3
|
-
/** Create or update a file snapshot. Always creates a new version. Returns the snapshot id.
|
|
4
|
-
|
|
3
|
+
/** Create or update a file snapshot. Always creates a new version. Returns the snapshot id.
|
|
4
|
+
* 未传 mtimeMs 时从同文件已有最新行透传(而非置空):compaction/squash 等从
|
|
5
|
+
* 工具结果重建快照的调用点拿不到磁盘 mtime,透传旧值可避免每次压缩后热文件
|
|
6
|
+
* 被误判过期而多一轮冗余重读(自愈无害但浪费;旧值 ≤ 内容实际新鲜度,
|
|
7
|
+
* 绝不会掩盖磁盘真实变更)。 */
|
|
8
|
+
declare function upsertFileSnapshot(handle: DB, sessionId: string, filePath: string, content: string, mtimeMs?: number): Promise<string>;
|
|
5
9
|
/** Get all snapshots for a session. */
|
|
6
10
|
declare function getFileSnapshots(handle: DB, sessionId: string): Promise<FileSnapshot[]>;
|
|
7
11
|
/** Get the latest snapshot for a specific file, or null. */
|
|
8
12
|
declare function getLatestFileSnapshot(handle: DB, sessionId: string, filePath: string): Promise<FileSnapshot | null>;
|
|
9
13
|
/** Quick check: return cached file content if a snapshot exists, else null. */
|
|
10
14
|
declare function checkFileSnapshot(handle: DB, sessionId: string, filePath: string): Promise<string | null>;
|
|
11
|
-
|
|
15
|
+
/** 把源会话每个文件的最新快照复制到目标会话(fork 分支用,P1-4)。 */
|
|
16
|
+
declare function copyFileSnapshots(handle: DB, fromSessionId: string, toSessionId: string): Promise<void>;
|
|
17
|
+
export { checkFileSnapshot, copyFileSnapshots, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, };
|