c0de-agent 1.5.0 → 1.6.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/dist/core/agent.js +4 -0
- package/dist/core/config.js +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/loop.js +9 -0
- package/dist/core/prompt-registry.d.ts +2 -2
- package/dist/core/prompt-registry.js +42 -3
- package/dist/core/slash.js +40 -6
- package/dist/core/types.d.ts +10 -1
- package/dist/core/workflow.d.ts +1 -1
- package/dist/core/workflow.js +54 -6
- package/dist/core/workflows/runtime.d.ts +3 -0
- package/dist/core/workflows/runtime.js +2 -2
- package/dist/project/resolve.d.ts +75 -0
- package/dist/project/resolve.js +253 -1
- package/dist/server/app.js +3 -0
- package/dist/server/context.js +2 -1
- package/dist/server/dev.js +3 -1
- package/dist/server/routes/chat.js +12 -0
- package/dist/server/routes/commands.js +1 -0
- package/dist/server/routes/files.js +252 -4
- package/dist/server/routes/terminal.js +2 -1
- package/dist/server/routes/todo.d.ts +4 -0
- package/dist/server/routes/todo.js +107 -0
- package/dist/server/routes/workflows.js +51 -11
- package/dist/server/server.d.ts +8 -1
- package/dist/server/server.js +113 -23
- package/dist/server/terminal/pty-manager.d.ts +14 -0
- package/dist/server/terminal/pty-manager.js +105 -7
- package/dist/shared/types/agent.d.ts +9 -0
- package/dist/shared/types/config.d.ts +6 -0
- package/dist/shared/types/tool.d.ts +13 -0
- package/dist/tools/builtin/todo.d.ts +67 -0
- package/dist/tools/builtin/todo.js +517 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/types.d.ts +32 -1
- package/package.json +2 -1
|
@@ -17,8 +17,9 @@ function createTerminalRoute(ctx) {
|
|
|
17
17
|
const rows = Number.isFinite(body.rows) ? Number(body.rows) : undefined;
|
|
18
18
|
const title = typeof body.title === 'string' ? body.title : undefined;
|
|
19
19
|
const shell = typeof body.shell === 'string' && body.shell.length > 0 ? body.shell : undefined;
|
|
20
|
+
const projectId = typeof body.projectId === 'string' && body.projectId.length > 0 ? body.projectId : undefined;
|
|
20
21
|
try {
|
|
21
|
-
const info = mgr.create({ cwd, cols, rows, title, shell });
|
|
22
|
+
const info = mgr.create({ cwd, cols, rows, title, shell, projectId });
|
|
22
23
|
return c.json(info, 201);
|
|
23
24
|
}
|
|
24
25
|
catch (err) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/server/routes/todo.ts
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { getMessages, appendMessage } from '../../session/message.js';
|
|
4
|
+
import { getSession } from '../../session/session.js';
|
|
5
|
+
import { generateId } from '../../shared/index.js';
|
|
6
|
+
import { todoTool, getLatestTodoPhasesFromMessages } from '../../tools/builtin/todo.js';
|
|
7
|
+
import { apiError } from '../middleware/error.js';
|
|
8
|
+
/** 构造仅供 todo tool execute 使用的最小 ToolContext。 */
|
|
9
|
+
function makeTodoCtx(phases, abort) {
|
|
10
|
+
let state = phases;
|
|
11
|
+
return {
|
|
12
|
+
cwd: '/',
|
|
13
|
+
session: { id: '', cwd: '/' },
|
|
14
|
+
abort,
|
|
15
|
+
todoState: {
|
|
16
|
+
get: () => state,
|
|
17
|
+
set: (p) => {
|
|
18
|
+
state = p;
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function createTodoRoute(ctx) {
|
|
24
|
+
const app = new Hono();
|
|
25
|
+
// GET /:sessionId — 获取当前 todo 状态
|
|
26
|
+
app.get('/:sessionId', async (c) => {
|
|
27
|
+
const sessionId = c.req.param('sessionId');
|
|
28
|
+
const run = ctx.agentManager.get(sessionId);
|
|
29
|
+
let phases;
|
|
30
|
+
if (run) {
|
|
31
|
+
// 活跃 agent:直接从内存状态读取
|
|
32
|
+
phases = run.state.todoPhases;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// 无活跃 agent:从历史消息恢复
|
|
36
|
+
const messages = await getMessages(ctx.db, sessionId);
|
|
37
|
+
phases = getLatestTodoPhasesFromMessages(messages);
|
|
38
|
+
}
|
|
39
|
+
return c.json({ phases });
|
|
40
|
+
});
|
|
41
|
+
// POST /:sessionId — 执行一个 todo 操作(UI 手动操作入口)
|
|
42
|
+
app.post('/:sessionId', async (c) => {
|
|
43
|
+
const sessionId = c.req.param('sessionId');
|
|
44
|
+
const body = await c.req.json().catch(() => ({}));
|
|
45
|
+
// 校验 session 存在
|
|
46
|
+
const session = await getSession(ctx.db, sessionId);
|
|
47
|
+
if (!session) {
|
|
48
|
+
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
49
|
+
}
|
|
50
|
+
// 恢复当前 phases
|
|
51
|
+
const run = ctx.agentManager.get(sessionId);
|
|
52
|
+
let currentPhases;
|
|
53
|
+
if (run) {
|
|
54
|
+
currentPhases = run.state.todoPhases;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const messages = await getMessages(ctx.db, sessionId);
|
|
58
|
+
currentPhases = getLatestTodoPhasesFromMessages(messages);
|
|
59
|
+
}
|
|
60
|
+
// 执行 todo 操作
|
|
61
|
+
const todoCtx = makeTodoCtx(currentPhases, c.req.raw.signal);
|
|
62
|
+
const result = await todoTool.execute(body, todoCtx);
|
|
63
|
+
// 从 todoState 读取操作后的 phases
|
|
64
|
+
const updatedPhases = todoCtx.todoState.get();
|
|
65
|
+
// 如果操作成功且 phases 有变化,持久化为 tool 消息
|
|
66
|
+
if (result._tag === 'success' && body.op !== 'view') {
|
|
67
|
+
// 构造 tool_result 消息内容
|
|
68
|
+
const toolResultContent = {
|
|
69
|
+
_tag: 'tool_result',
|
|
70
|
+
id: generateId(),
|
|
71
|
+
tool: 'todo',
|
|
72
|
+
output: {
|
|
73
|
+
_tag: 'success',
|
|
74
|
+
output: result.output,
|
|
75
|
+
metadata: result.metadata,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const toolCallContent = {
|
|
79
|
+
_tag: 'tool_call',
|
|
80
|
+
id: generateId(),
|
|
81
|
+
tool: 'todo',
|
|
82
|
+
input: body,
|
|
83
|
+
};
|
|
84
|
+
// 存储为一条 tool 消息(tool_call + tool_result 合并)
|
|
85
|
+
await appendMessage(ctx.db, sessionId, {
|
|
86
|
+
role: 'tool',
|
|
87
|
+
content: [toolCallContent, toolResultContent],
|
|
88
|
+
});
|
|
89
|
+
// 更新活跃 agent 的内存状态
|
|
90
|
+
if (run) {
|
|
91
|
+
run.state.todoPhases = updatedPhases;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return c.json({
|
|
95
|
+
phases: updatedPhases,
|
|
96
|
+
output: result._tag === 'success'
|
|
97
|
+
? result.output
|
|
98
|
+
: result._tag === 'error'
|
|
99
|
+
? result.error
|
|
100
|
+
: result._tag === 'permission_required'
|
|
101
|
+
? result.reason
|
|
102
|
+
: 'truncated',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
return app;
|
|
106
|
+
}
|
|
107
|
+
export { createTodoRoute };
|
|
@@ -4,20 +4,37 @@ import { streamSSE } from 'hono/streaming';
|
|
|
4
4
|
import { createAgent } from '../../core/agent.js';
|
|
5
5
|
import { executeWorkflow } from '../../core/workflows/runtime.js';
|
|
6
6
|
import { reloadRegistry } from '../../core/workflows/registry.js';
|
|
7
|
-
import { saveWorkflow } from '../../core/workflows/discovery.js';
|
|
7
|
+
import { discoverWorkflows, saveWorkflow } from '../../core/workflows/discovery.js';
|
|
8
|
+
import { getProject } from '../../project/project.js';
|
|
8
9
|
import { createSession } from '../../session/session.js';
|
|
9
10
|
import { autoAllowChecker } from '../../tools/permission.js';
|
|
10
11
|
import { apiError } from '../middleware/error.js';
|
|
11
12
|
/** 创建工作流 REST API 路由。 */
|
|
12
13
|
function createWorkflowsRoute(ctx) {
|
|
13
14
|
const app = new Hono();
|
|
14
|
-
// GET / —
|
|
15
|
-
app.get('/', (c) => {
|
|
15
|
+
// GET / — 列出所有工作流。可选 ?projectId=xxx 合并项目级 .c0de/workflows/*.js。
|
|
16
|
+
app.get('/', async (c) => {
|
|
16
17
|
const registry = ctx.workflowRegistry;
|
|
17
18
|
if (!registry) {
|
|
18
19
|
return c.json({ workflows: [] });
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
// 注册表已有 builtin + global + server-cwd;以 name 为 key 去重。
|
|
22
|
+
const byName = new Map();
|
|
23
|
+
for (const entry of registry.list()) {
|
|
24
|
+
byName.set(entry.meta.name, entry);
|
|
25
|
+
}
|
|
26
|
+
// 项目级工作流:从 project.worktree/.c0de/workflows/ 动态发现,同名覆盖。
|
|
27
|
+
const projectId = c.req.query('projectId');
|
|
28
|
+
if (projectId) {
|
|
29
|
+
const project = await getProject(ctx.db, projectId);
|
|
30
|
+
if (project) {
|
|
31
|
+
const projectWorkflows = await discoverWorkflows(project.worktree);
|
|
32
|
+
for (const wf of projectWorkflows) {
|
|
33
|
+
byName.set(wf.meta.name, wf);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const workflows = Array.from(byName.values()).map((entry) => ({
|
|
21
38
|
name: entry.meta.name,
|
|
22
39
|
description: entry.meta.description,
|
|
23
40
|
argsHint: entry.meta.argsHint,
|
|
@@ -57,14 +74,25 @@ function createWorkflowsRoute(ctx) {
|
|
|
57
74
|
source: entry?.source ?? 'project',
|
|
58
75
|
});
|
|
59
76
|
});
|
|
60
|
-
// GET /:name — 元数据 +
|
|
61
|
-
app.get('/:name', (c) => {
|
|
77
|
+
// GET /:name — 元数据 + 源码。可选 ?projectId=xxx 查找项目级工作流。
|
|
78
|
+
app.get('/:name', async (c) => {
|
|
62
79
|
const name = c.req.param('name');
|
|
63
80
|
const registry = ctx.workflowRegistry;
|
|
64
81
|
if (!registry) {
|
|
65
82
|
return apiError(c, 500, 'NOT_INITIALIZED', 'Workflow registry not initialized');
|
|
66
83
|
}
|
|
67
|
-
|
|
84
|
+
let entry = registry.get(name);
|
|
85
|
+
// 项目级 fallback
|
|
86
|
+
if (!entry) {
|
|
87
|
+
const projectId = c.req.query('projectId');
|
|
88
|
+
if (projectId) {
|
|
89
|
+
const project = await getProject(ctx.db, projectId);
|
|
90
|
+
if (project) {
|
|
91
|
+
const discovered = await discoverWorkflows(project.worktree);
|
|
92
|
+
entry = discovered.find((w) => w.meta.name === name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
68
96
|
if (!entry) {
|
|
69
97
|
return apiError(c, 404, 'NOT_FOUND', `Workflow "${name}" not found`);
|
|
70
98
|
}
|
|
@@ -77,14 +105,25 @@ function createWorkflowsRoute(ctx) {
|
|
|
77
105
|
sourceCode: entry.sourceCode,
|
|
78
106
|
});
|
|
79
107
|
});
|
|
80
|
-
// POST /:name/run — 执行工作流(SSE
|
|
108
|
+
// POST /:name/run — 执行工作流(SSE 推送进度)。可选 ?projectId=xxx 执行项目级工作流。
|
|
81
109
|
app.post('/:name/run', async (c) => {
|
|
82
110
|
const name = c.req.param('name');
|
|
83
111
|
const registry = ctx.workflowRegistry;
|
|
84
112
|
if (!registry) {
|
|
85
113
|
return apiError(c, 500, 'NOT_INITIALIZED', 'Workflow registry not initialized');
|
|
86
114
|
}
|
|
87
|
-
|
|
115
|
+
let entry = registry.get(name);
|
|
116
|
+
// 项目级 fallback + 解析项目 worktree 作为 agent cwd
|
|
117
|
+
let agentCwd = ctx.cwd;
|
|
118
|
+
const projectId = c.req.query('projectId');
|
|
119
|
+
if (!entry && projectId) {
|
|
120
|
+
const project = await getProject(ctx.db, projectId);
|
|
121
|
+
if (project) {
|
|
122
|
+
agentCwd = project.worktree;
|
|
123
|
+
const discovered = await discoverWorkflows(project.worktree);
|
|
124
|
+
entry = discovered.find((w) => w.meta.name === name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
88
127
|
if (!entry) {
|
|
89
128
|
return apiError(c, 404, 'NOT_FOUND', `Workflow "${name}" not found`);
|
|
90
129
|
}
|
|
@@ -104,7 +143,7 @@ function createWorkflowsRoute(ctx) {
|
|
|
104
143
|
toolRegistry: ctx.toolRegistry,
|
|
105
144
|
permission: autoAllowChecker,
|
|
106
145
|
config: ctx.config,
|
|
107
|
-
cwd:
|
|
146
|
+
cwd: agentCwd,
|
|
108
147
|
agentRegistry: ctx.agentRegistry,
|
|
109
148
|
});
|
|
110
149
|
return streamSSE(c, async (stream) => {
|
|
@@ -114,13 +153,14 @@ function createWorkflowsRoute(ctx) {
|
|
|
114
153
|
toolRegistry: ctx.toolRegistry,
|
|
115
154
|
permission: autoAllowChecker,
|
|
116
155
|
config: ctx.config,
|
|
117
|
-
cwd:
|
|
156
|
+
cwd: agentCwd,
|
|
118
157
|
agentRegistry: ctx.agentRegistry,
|
|
119
158
|
};
|
|
120
159
|
try {
|
|
121
160
|
const result = await executeWorkflow({
|
|
122
161
|
registry,
|
|
123
162
|
name,
|
|
163
|
+
entry,
|
|
124
164
|
args,
|
|
125
165
|
deps,
|
|
126
166
|
parent,
|
package/dist/server/server.d.ts
CHANGED
|
@@ -36,6 +36,11 @@ declare function buildRegistryFromConfig(config: Config): Registry;
|
|
|
36
36
|
* 使运行中的 ServerContext 立即生效,无需重启。
|
|
37
37
|
*/
|
|
38
38
|
declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
|
|
39
|
+
/** 解析 PGLite 持久化数据目录:优先 C0DE_DB_DIR,否则全局数据根下 pglite 子目录。
|
|
40
|
+
*
|
|
41
|
+
* 历史上数据库放在 <cwd>/.c0de/pglite(项目级),但项目 DB schema 通过 projectId
|
|
42
|
+
* 区分项目,设计本意是全局共享单库。旧路径已在 migrateLegacyPglite 中自动迁移。 */
|
|
43
|
+
declare function resolveDbDir(): string;
|
|
39
44
|
/**
|
|
40
45
|
* 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
|
|
41
46
|
*
|
|
@@ -47,10 +52,12 @@ declare function buildServerContext(db: DB, opts?: StartServerOptions): Promise<
|
|
|
47
52
|
ctx: ServerContext;
|
|
48
53
|
dispose: () => Promise<void>;
|
|
49
54
|
}>;
|
|
55
|
+
/** Release the dev DB lock (best-effort, stale-detection covers missed calls). */
|
|
56
|
+
declare function releaseDevDbLock(dataDir: string): void;
|
|
50
57
|
/** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
|
|
51
58
|
declare function createDevDb(cwd: string): Promise<DB>;
|
|
52
59
|
/** 初始化 DB + 配置 + 注册表,返回 ServerContext + 清理函数(dev 与独立后端共用)。 */
|
|
53
60
|
declare function bootstrapServerContext(opts?: StartServerOptions): Promise<BootstrappedServer>;
|
|
54
61
|
declare function startServer(opts?: StartServerOptions): Promise<RunningServer>;
|
|
55
62
|
export type { BootstrappedServer, RunningServer, StartServerOptions };
|
|
56
|
-
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, startServer, syncRegistryFromConfig, };
|
|
63
|
+
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
|
package/dist/server/server.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// src/server/server.ts
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { connect as tcpConnect } from 'node:net';
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
5
6
|
import { serve } from '@hono/node-server';
|
|
6
7
|
import { WebSocketServer } from 'ws';
|
|
7
8
|
import { BUILTIN_AGENTS, createAgentRegistry } from '../core/agents/index.js';
|
|
8
9
|
import { loadConfig } from '../core/config.js';
|
|
9
10
|
import { decryptSecret } from '../core/secret.js';
|
|
10
|
-
import {
|
|
11
|
+
import { createAndPopulateRegistry } from '../core/workflows/index.js';
|
|
11
12
|
import { createDB, migrateDB } from '../db/index.js';
|
|
12
13
|
import { createRegistry, overrideToCapabilities, registerProvider, } from '../llm/registry.js';
|
|
13
14
|
import { initPlugins } from '../plugins/index.js';
|
|
@@ -53,12 +54,53 @@ function syncRegistryFromConfig(registry, config) {
|
|
|
53
54
|
registerProviderFromConfig(registry, p);
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
57
|
+
/** 全局数据根目录:XDG_DATA_HOME 优先,否则 ~/.local/share/c0de。
|
|
58
|
+
* 与 opencode (~/.local/share/opencode/)、oh-my-pi (~/.omp/agent/) 同约定——
|
|
59
|
+
* 数据库等运行时数据放全局,项目 .c0de/ 只留配置和扩展。 */
|
|
60
|
+
function resolveGlobalDataRoot() {
|
|
61
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
62
|
+
if (xdg && xdg.trim() !== '')
|
|
63
|
+
return join(xdg, 'c0de');
|
|
64
|
+
return join(homedir(), '.local', 'share', 'c0de');
|
|
65
|
+
}
|
|
66
|
+
/** 解析 PGLite 持久化数据目录:优先 C0DE_DB_DIR,否则全局数据根下 pglite 子目录。
|
|
67
|
+
*
|
|
68
|
+
* 历史上数据库放在 <cwd>/.c0de/pglite(项目级),但项目 DB schema 通过 projectId
|
|
69
|
+
* 区分项目,设计本意是全局共享单库。旧路径已在 migrateLegacyPglite 中自动迁移。 */
|
|
70
|
+
function resolveDbDir() {
|
|
58
71
|
const envDir = process.env.C0DE_DB_DIR;
|
|
59
72
|
if (envDir && envDir.trim() !== '')
|
|
60
73
|
return envDir;
|
|
61
|
-
return join(
|
|
74
|
+
return join(resolveGlobalDataRoot(), 'pglite');
|
|
75
|
+
}
|
|
76
|
+
/** 一次性迁移:<cwd>/.c0de/pglite → 全局路径。仅当目标不存在时执行。
|
|
77
|
+
*
|
|
78
|
+
* 多个项目各有 .c0de/pglite 时只迁移第一个遇到的(首次启动即触发);
|
|
79
|
+
* 其余项目的旧数据保留在原地(已被 .gitignore 覆盖,不影响 git,但不再读取)。
|
|
80
|
+
* 用户可用 C0DE_DB_DIR 手动指向旧路径访问遗留数据。 */
|
|
81
|
+
function migrateLegacyPglite(cwd, targetDir) {
|
|
82
|
+
const legacy = join(cwd, '.c0de', 'pglite');
|
|
83
|
+
if (!existsSync(legacy))
|
|
84
|
+
return;
|
|
85
|
+
if (existsSync(targetDir))
|
|
86
|
+
return; // 全局目录已有数据,不覆盖
|
|
87
|
+
const parent = dirname(targetDir);
|
|
88
|
+
if (!existsSync(parent))
|
|
89
|
+
mkdirSync(parent, { recursive: true });
|
|
90
|
+
try {
|
|
91
|
+
renameSync(legacy, targetDir);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// 跨文件系统 rename 失败,fallback 到 copy
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
cpSync(legacy, targetDir, { recursive: true });
|
|
99
|
+
rmSync(legacy, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// 迁移失败:旧数据留在项目目录(已 gitignore,无害),用户可手动迁移
|
|
103
|
+
}
|
|
62
104
|
}
|
|
63
105
|
/**
|
|
64
106
|
* 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
|
|
@@ -83,8 +125,9 @@ async function buildServerContext(db, opts = {}) {
|
|
|
83
125
|
toolRegistry,
|
|
84
126
|
llmRegistry,
|
|
85
127
|
});
|
|
86
|
-
//
|
|
87
|
-
|
|
128
|
+
// 工作流注册表:三级发现(builtin → global → project),eager 初始化。
|
|
129
|
+
// 此前是惰性 getter 只注册 builtin,导致项目级 .c0de/workflows/*.js 永远不可见。
|
|
130
|
+
const workflowRegistry = await createAndPopulateRegistry(cwd);
|
|
88
131
|
const ctx = {
|
|
89
132
|
db,
|
|
90
133
|
config,
|
|
@@ -103,16 +146,7 @@ async function buildServerContext(db, opts = {}) {
|
|
|
103
146
|
reg.register(def);
|
|
104
147
|
return reg;
|
|
105
148
|
})(),
|
|
106
|
-
|
|
107
|
-
// 与 context.ts 的 createServerContext 保持一致的模式。
|
|
108
|
-
get workflowRegistry() {
|
|
109
|
-
if (!_workflowRegistry) {
|
|
110
|
-
_workflowRegistry = createWorkflowRegistry();
|
|
111
|
-
for (const wf of BUILTIN_WORKFLOWS)
|
|
112
|
-
_workflowRegistry.register(wf);
|
|
113
|
-
}
|
|
114
|
-
return _workflowRegistry;
|
|
115
|
-
},
|
|
149
|
+
workflowRegistry,
|
|
116
150
|
// spec §18.1 后台版本检查调度器;config.update.enabled 控制是否启动。
|
|
117
151
|
updateScheduler: createUpdateScheduler({
|
|
118
152
|
checkFn: opts.checkForUpdateFn ?? checkForUpdate,
|
|
@@ -149,18 +183,74 @@ async function buildServerContext(db, opts = {}) {
|
|
|
149
183
|
},
|
|
150
184
|
};
|
|
151
185
|
}
|
|
186
|
+
const DEV_LOCK_FILE = '.dev.lock';
|
|
187
|
+
/**
|
|
188
|
+
* Cross-process guard for PGLite dataDir.
|
|
189
|
+
*
|
|
190
|
+
* PGLite is single-writer WASM Postgres — two processes on the same dataDir
|
|
191
|
+
* always abort (`RuntimeError: Aborted()`). This lock prevents silent
|
|
192
|
+
* corruption when multiple dev servers (e.g. different ports) target the
|
|
193
|
+
* same project.
|
|
194
|
+
*
|
|
195
|
+
* - Live PID in lock → throw clear error instead of cryptic WASM abort.
|
|
196
|
+
* - Dead PID in lock → stale: remove `.dev.lock` + `postmaster.pid`, proceed.
|
|
197
|
+
*/
|
|
198
|
+
function acquireDevDbLock(dataDir) {
|
|
199
|
+
const lockPath = join(dataDir, DEV_LOCK_FILE);
|
|
200
|
+
if (existsSync(lockPath)) {
|
|
201
|
+
const oldPid = parseInt(readFileSync(lockPath, 'utf8').trim(), 10);
|
|
202
|
+
let stale = false;
|
|
203
|
+
if (oldPid && oldPid !== process.pid) {
|
|
204
|
+
try {
|
|
205
|
+
process.kill(oldPid, 0); // signal 0 = liveness check
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
// ESRCH = process dead → stale lock, fall through to cleanup.
|
|
209
|
+
// Any other error (EPERM etc.) re-throws — don't clobber a live lock.
|
|
210
|
+
if (err.code !== 'ESRCH')
|
|
211
|
+
throw err;
|
|
212
|
+
stale = true;
|
|
213
|
+
}
|
|
214
|
+
if (!stale) {
|
|
215
|
+
throw new Error(`Database is locked by another c0de process (PID ${oldPid}).\n` +
|
|
216
|
+
`PGLite is single-writer — only one process may use:\n ${dataDir}\n` +
|
|
217
|
+
`Kill the other process and retry:\n kill ${oldPid}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Stale lock cleanup
|
|
221
|
+
try {
|
|
222
|
+
unlinkSync(lockPath);
|
|
223
|
+
}
|
|
224
|
+
catch { /* best-effort */ }
|
|
225
|
+
try {
|
|
226
|
+
unlinkSync(join(dataDir, 'postmaster.pid'));
|
|
227
|
+
}
|
|
228
|
+
catch { /* best-effort */ }
|
|
229
|
+
}
|
|
230
|
+
writeFileSync(lockPath, String(process.pid));
|
|
231
|
+
}
|
|
232
|
+
/** Release the dev DB lock (best-effort, stale-detection covers missed calls). */
|
|
233
|
+
function releaseDevDbLock(dataDir) {
|
|
234
|
+
try {
|
|
235
|
+
unlinkSync(join(dataDir, DEV_LOCK_FILE));
|
|
236
|
+
}
|
|
237
|
+
catch { /* best-effort */ }
|
|
238
|
+
}
|
|
152
239
|
/** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
|
|
153
240
|
async function createDevDb(cwd) {
|
|
154
|
-
const dataDir = resolveDbDir(
|
|
241
|
+
const dataDir = resolveDbDir();
|
|
242
|
+
migrateLegacyPglite(cwd, dataDir);
|
|
155
243
|
if (!existsSync(dataDir))
|
|
156
244
|
mkdirSync(dataDir, { recursive: true });
|
|
245
|
+
acquireDevDbLock(dataDir);
|
|
157
246
|
const db = await createDB({ driver: 'pglite', dataDir });
|
|
158
|
-
// migrateDB 失败必须 close,否则 WASM 实例泄漏锁住 dataDir。
|
|
247
|
+
// migrateDB 失败必须 close + release lock,否则 WASM 实例泄漏锁住 dataDir。
|
|
159
248
|
try {
|
|
160
249
|
await migrateDB(db);
|
|
161
250
|
}
|
|
162
251
|
catch (err) {
|
|
163
252
|
await db.close().catch(() => { });
|
|
253
|
+
releaseDevDbLock(dataDir);
|
|
164
254
|
throw err;
|
|
165
255
|
}
|
|
166
256
|
return db;
|
|
@@ -169,8 +259,8 @@ async function createDevDb(cwd) {
|
|
|
169
259
|
async function bootstrapServerContext(opts = {}) {
|
|
170
260
|
const cwd = opts.cwd ?? process.cwd();
|
|
171
261
|
const ownsDb = !opts.db;
|
|
172
|
-
// 持久化 PGLite
|
|
173
|
-
//
|
|
262
|
+
// 持久化 PGLite 数据:全局路径 ~/.local/share/c0de/pglite(可用 C0DE_DB_DIR 覆盖)。
|
|
263
|
+
// 跨项目共享单库,通过 projects/sessions 表的 projectId 区分。
|
|
174
264
|
// 测试注入 opts.db 时跳过(保持 in-memory 隔离)。
|
|
175
265
|
let db;
|
|
176
266
|
if (opts.db) {
|
|
@@ -282,4 +372,4 @@ async function startServer(opts = {}) {
|
|
|
282
372
|
};
|
|
283
373
|
return { app, port, close };
|
|
284
374
|
}
|
|
285
|
-
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, startServer, syncRegistryFromConfig, };
|
|
375
|
+
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
|
|
@@ -9,6 +9,8 @@ export interface PTYInfo {
|
|
|
9
9
|
cwd: string;
|
|
10
10
|
/** shell 程序路径。 */
|
|
11
11
|
shell: string;
|
|
12
|
+
/** 所属项目 id(未归属时为 undefined)。 */
|
|
13
|
+
projectId?: string;
|
|
12
14
|
}
|
|
13
15
|
export interface CreatePTYOptions {
|
|
14
16
|
cwd: string;
|
|
@@ -17,7 +19,19 @@ export interface CreatePTYOptions {
|
|
|
17
19
|
title?: string;
|
|
18
20
|
/** 覆盖默认 shell;不传则自动检测。 */
|
|
19
21
|
shell?: string;
|
|
22
|
+
/** 所属项目 id。 */
|
|
23
|
+
projectId?: string;
|
|
20
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* 检测当前平台默认 shell。
|
|
27
|
+
*
|
|
28
|
+
* 优先级:process.env.SHELL → os.userInfo().shell(/etc/passwd 登录 shell)→ /bin/bash。
|
|
29
|
+
* 仅依赖 process.env.SHELL 是不可靠的:当 server 经 npm 脚本(sh -c 包装)、
|
|
30
|
+
* 热更新重启或 IDE 启动器等链路启动时,SHELL 往往未被 export 到环境,
|
|
31
|
+
* 导致 node 进程读不到而错误回退到 /bin/bash。userInfo().shell 直接读取
|
|
32
|
+
* /etc/passwd(getpwuid),是用户真实登录 shell 的可靠来源。
|
|
33
|
+
*/
|
|
34
|
+
export declare function detectShell(): string;
|
|
21
35
|
/**
|
|
22
36
|
* PTY 生命周期管理器。
|
|
23
37
|
*
|
|
@@ -1,16 +1,94 @@
|
|
|
1
1
|
// src/server/terminal/pty-manager.ts
|
|
2
|
-
import { spawn } from 'node-pty';
|
|
3
2
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir, userInfo } from 'node:os';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { spawn } from 'node-pty';
|
|
7
|
+
/**
|
|
8
|
+
* 为指定 shell 创建 OSC 133 prompt 标记注入脚本。
|
|
9
|
+
*
|
|
10
|
+
* shell 在每次显示 prompt 前发送 \x1b]133;A\x07(OSC 133;A),
|
|
11
|
+
* 前端 xterm.js parser 捕获后精确记录命令块起始行。
|
|
12
|
+
* 标记嵌入 PTY 输出流 → scrollback 回放时自动重建,无需正则猜测。
|
|
13
|
+
*
|
|
14
|
+
* 支持的 shell:bash(--init-file)、zsh(ZDOTDIR)。
|
|
15
|
+
* 其他 shell 不注入,退化到前端正则启发式。
|
|
16
|
+
*/
|
|
17
|
+
function setupShellIntegration(shell) {
|
|
18
|
+
const base = basename(shell);
|
|
19
|
+
// printf 的 \x1b / \x07 由 shell 自行解释,JS 中双反斜杠确保写入字面量
|
|
20
|
+
const mark = "printf '\\x1b]133;A\\x07'";
|
|
21
|
+
if (base === 'bash') {
|
|
22
|
+
const script = join(tmpdir(), `c0de-bash-${randomUUID()}.sh`);
|
|
23
|
+
writeFileSync(script, [
|
|
24
|
+
'[ -f /etc/bash.bashrc ] && source /etc/bash.bashrc',
|
|
25
|
+
'[ -f ~/.bashrc ] && source ~/.bashrc',
|
|
26
|
+
`__c0de_prompt_mark() { ${mark}; }`,
|
|
27
|
+
// 追加到已有 PROMPT_COMMAND 而非覆盖
|
|
28
|
+
'if [ -n "$PROMPT_COMMAND" ]; then',
|
|
29
|
+
' PROMPT_COMMAND=\'__c0de_prompt_mark;\'"$PROMPT_COMMAND"',
|
|
30
|
+
'else',
|
|
31
|
+
' PROMPT_COMMAND=__c0de_prompt_mark',
|
|
32
|
+
'fi',
|
|
33
|
+
].join('\n'));
|
|
34
|
+
return {
|
|
35
|
+
spawnArgs: ['--init-file', script],
|
|
36
|
+
env: {},
|
|
37
|
+
cleanup: () => {
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(script);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// 文件可能已被清理
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (base === 'zsh') {
|
|
48
|
+
const dir = join(tmpdir(), `c0de-zsh-${randomUUID()}`);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
writeFileSync(join(dir, '.zshrc'), [
|
|
51
|
+
'[ -f ~/.zshrc ] && source ~/.zshrc',
|
|
52
|
+
`__c0de_prompt_mark() { ${mark}; }`,
|
|
53
|
+
'autoload -Uz add-zsh-hook',
|
|
54
|
+
'add-zsh-hook precmd __c0de_prompt_mark',
|
|
55
|
+
].join('\n'));
|
|
56
|
+
return {
|
|
57
|
+
spawnArgs: [],
|
|
58
|
+
env: { ZDOTDIR: dir },
|
|
59
|
+
cleanup: () => {
|
|
60
|
+
try {
|
|
61
|
+
rmSync(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// 目录可能已被清理
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// fish / 其他 shell 暂不支持,退化到前端正则
|
|
70
|
+
return { spawnArgs: [], env: {}, cleanup: () => { } };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 检测当前平台默认 shell。
|
|
74
|
+
*
|
|
75
|
+
* 优先级:process.env.SHELL → os.userInfo().shell(/etc/passwd 登录 shell)→ /bin/bash。
|
|
76
|
+
* 仅依赖 process.env.SHELL 是不可靠的:当 server 经 npm 脚本(sh -c 包装)、
|
|
77
|
+
* 热更新重启或 IDE 启动器等链路启动时,SHELL 往往未被 export 到环境,
|
|
78
|
+
* 导致 node 进程读不到而错误回退到 /bin/bash。userInfo().shell 直接读取
|
|
79
|
+
* /etc/passwd(getpwuid),是用户真实登录 shell 的可靠来源。
|
|
80
|
+
*/
|
|
81
|
+
export function detectShell() {
|
|
6
82
|
if (process.platform === 'win32') {
|
|
7
83
|
return process.env.COMSPEC ?? 'cmd.exe';
|
|
8
84
|
}
|
|
9
|
-
return process.env.SHELL ?? '/bin/bash';
|
|
85
|
+
return process.env.SHELL ?? userInfo().shell ?? '/bin/bash';
|
|
10
86
|
}
|
|
11
87
|
const DEFAULT_COLS = 80;
|
|
12
88
|
const DEFAULT_ROWS = 24;
|
|
13
89
|
const MAX_TITLE_LEN = 100;
|
|
90
|
+
/** scrollback 环形缓冲最大字节数(约 50KB)。 */
|
|
91
|
+
const SCROLLBACK_MAX = 50_000;
|
|
14
92
|
function truncateTitle(title) {
|
|
15
93
|
const clean = title.replace(/[\r\n]/g, ' ').trim();
|
|
16
94
|
return clean.length > MAX_TITLE_LEN ? `${clean.slice(0, MAX_TITLE_LEN)}…` : clean;
|
|
@@ -30,13 +108,15 @@ export class PTYManager {
|
|
|
30
108
|
const cols = opts.cols ?? DEFAULT_COLS;
|
|
31
109
|
const rows = opts.rows ?? DEFAULT_ROWS;
|
|
32
110
|
const shell = opts.shell ?? detectShell();
|
|
33
|
-
const
|
|
111
|
+
const integration = setupShellIntegration(shell);
|
|
112
|
+
const pty = spawn(shell, integration.spawnArgs, {
|
|
34
113
|
name: 'xterm-256color',
|
|
35
114
|
cols,
|
|
36
115
|
rows,
|
|
37
116
|
cwd: opts.cwd,
|
|
38
117
|
env: {
|
|
39
118
|
...process.env,
|
|
119
|
+
...integration.env,
|
|
40
120
|
TERM: 'xterm-256color',
|
|
41
121
|
COLORTERM: 'truecolor',
|
|
42
122
|
},
|
|
@@ -49,10 +129,21 @@ export class PTYManager {
|
|
|
49
129
|
rows,
|
|
50
130
|
cwd: opts.cwd,
|
|
51
131
|
shell,
|
|
132
|
+
projectId: opts.projectId,
|
|
133
|
+
};
|
|
134
|
+
const entry = {
|
|
135
|
+
pty,
|
|
136
|
+
info,
|
|
137
|
+
sockets: new Set(),
|
|
138
|
+
scrollback: '',
|
|
139
|
+
integrationCleanup: integration.cleanup,
|
|
52
140
|
};
|
|
53
|
-
|
|
54
|
-
// PTY 输出 → 广播到所有挂载的 WebSocket
|
|
141
|
+
// PTY 输出 → 广播到所有挂载的 WebSocket + 追加 scrollback
|
|
55
142
|
pty.onData((data) => {
|
|
143
|
+
entry.scrollback += data;
|
|
144
|
+
if (entry.scrollback.length > SCROLLBACK_MAX) {
|
|
145
|
+
entry.scrollback = entry.scrollback.slice(-SCROLLBACK_MAX);
|
|
146
|
+
}
|
|
56
147
|
for (const ws of entry.sockets) {
|
|
57
148
|
if (ws.readyState === ws.OPEN) {
|
|
58
149
|
ws.send(data);
|
|
@@ -113,6 +204,7 @@ export class PTYManager {
|
|
|
113
204
|
catch {
|
|
114
205
|
// 进程可能已退出
|
|
115
206
|
}
|
|
207
|
+
entry.integrationCleanup?.();
|
|
116
208
|
this.entries.delete(id);
|
|
117
209
|
}
|
|
118
210
|
/** 获取 PTY 信息。 */
|
|
@@ -134,6 +226,12 @@ export class PTYManager {
|
|
|
134
226
|
if (!entry)
|
|
135
227
|
return false;
|
|
136
228
|
entry.sockets.add(ws);
|
|
229
|
+
// 先回放 scrollback,让前端恢复终端历史画面。
|
|
230
|
+
// 先 add 再 send 避免漏数据:add 之后 onData 的新输出会广播到此 WS,
|
|
231
|
+
// 而 scrollback 覆盖 add 之前的所有历史输出。Node 单线程保证无竞态。
|
|
232
|
+
if (entry.scrollback) {
|
|
233
|
+
ws.send(entry.scrollback);
|
|
234
|
+
}
|
|
137
235
|
ws.on('message', (data) => {
|
|
138
236
|
const text = typeof data === 'string' ? data : data.toString('utf8');
|
|
139
237
|
try {
|