c0de-agent 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli/deps.d.ts +16 -5
  2. package/dist/cli/deps.js +18 -5
  3. package/dist/cli/index.js +8 -2
  4. package/dist/core/agent.js +5 -0
  5. package/dist/core/config.js +1 -0
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/index.js +1 -0
  8. package/dist/core/loop.d.ts +10 -0
  9. package/dist/core/loop.js +559 -295
  10. package/dist/core/slash.js +3 -4
  11. package/dist/core/title.js +3 -2
  12. package/dist/core/types.d.ts +2 -0
  13. package/dist/core/workflow.d.ts +25 -0
  14. package/dist/core/workflow.js +98 -0
  15. package/dist/core/worktree.js +6 -4
  16. package/dist/dap/session.js +3 -3
  17. package/dist/llm/provider.js +5 -1
  18. package/dist/plugins/loader.js +3 -2
  19. package/dist/server/agent-manager.d.ts +2 -0
  20. package/dist/server/agent-manager.js +8 -0
  21. package/dist/server/app.js +3 -0
  22. package/dist/server/context.js +2 -0
  23. package/dist/server/dev.d.ts +4 -1
  24. package/dist/server/dev.js +92 -27
  25. package/dist/server/permission/store.d.ts +2 -0
  26. package/dist/server/permission/store.js +9 -0
  27. package/dist/server/routes/chat.js +44 -1
  28. package/dist/server/routes/session.js +75 -1
  29. package/dist/server/routes/terminal.d.ts +5 -0
  30. package/dist/server/routes/terminal.js +66 -0
  31. package/dist/server/server.d.ts +14 -1
  32. package/dist/server/server.js +100 -28
  33. package/dist/server/terminal/pty-manager.d.ts +53 -0
  34. package/dist/server/terminal/pty-manager.js +160 -0
  35. package/dist/server/types.d.ts +3 -0
  36. package/dist/session/archive.d.ts +1 -1
  37. package/dist/session/compaction.d.ts +8 -2
  38. package/dist/session/compaction.js +85 -16
  39. package/dist/session/shake.d.ts +66 -0
  40. package/dist/session/shake.js +304 -0
  41. package/dist/session/types.d.ts +1 -1
  42. package/dist/shared/types/agent.d.ts +27 -0
  43. package/dist/shared/types/config.d.ts +10 -0
  44. package/dist/shared/types/llm.d.ts +1 -0
  45. package/dist/shared/types/tool.d.ts +3 -0
  46. package/package.json +11 -3
@@ -1,9 +1,13 @@
1
1
  import { Hono } from 'hono';
2
2
  import { createSummarizer, runCompaction } from '../../core/compact.js';
3
3
  import { fromDirectory } from '../../project/index.js';
4
+ import { archiveOriginalEntries } from '../../session/archive.js';
4
5
  import { forkSession, getBranches, getTree } from '../../session/branch.js';
5
- import { getMessages } from '../../session/message.js';
6
+ import { deleteEntriesByIds, getMessages, insertEntry } from '../../session/message.js';
6
7
  import { createSession, deleteSession, getLLMSegments, getSession, listSessions, listSessionsByProject, } from '../../session/session.js';
8
+ import { applyShakeRegions, collectShakeRegions, DEFAULT_SHAKE_CONFIG, toRegionViews, } from '../../session/shake.js';
9
+ import { estimateMessageTokens } from '../../session/token.js';
10
+ import { generateId } from '../../shared/index.js';
7
11
  import { apiError } from '../middleware/error.js';
8
12
  function createSessionRoute(ctx) {
9
13
  const app = new Hono();
@@ -120,6 +124,76 @@ function createSessionRoute(ctx) {
120
124
  }
121
125
  return c.json({ _tag: 'idle' });
122
126
  });
127
+ // shake preview:返回可 shake 的区域列表
128
+ app.post('/:id/shake/preview', async (c) => {
129
+ const id = c.req.param('id');
130
+ let session;
131
+ try {
132
+ session = await getSession(ctx.db, id);
133
+ }
134
+ catch {
135
+ return apiError(c, 404, 'NOT_FOUND', 'Session not found');
136
+ }
137
+ if (!session)
138
+ return apiError(c, 404, 'NOT_FOUND', 'Session not found');
139
+ const messages = await getMessages(ctx.db, id);
140
+ // Manual shake: show ALL candidates (protectTokens=0, minSavings=0).
141
+ // toRegionViews still marks isAfterProtectWindow using the real config.
142
+ const manualConfig = { ...DEFAULT_SHAKE_CONFIG, protectTokens: 0, minSavings: 0 };
143
+ const regions = collectShakeRegions(messages, manualConfig);
144
+ const views = toRegionViews(regions, DEFAULT_SHAKE_CONFIG, messages);
145
+ return c.json({ regions: views });
146
+ });
147
+ // shake apply:归档原始内容 + 原位替换
148
+ app.post('/:id/shake/apply', async (c) => {
149
+ const id = c.req.param('id');
150
+ let session;
151
+ try {
152
+ session = await getSession(ctx.db, id);
153
+ }
154
+ catch {
155
+ return apiError(c, 404, 'NOT_FOUND', 'Session not found');
156
+ }
157
+ if (!session)
158
+ return apiError(c, 404, 'NOT_FOUND', 'Session not found');
159
+ const body = await c.req.json().catch(() => ({}));
160
+ const regionIds = body.regionIds ?? [];
161
+ const messages = await getMessages(ctx.db, id);
162
+ const manualConfig = { ...DEFAULT_SHAKE_CONFIG, protectTokens: 0, minSavings: 0 };
163
+ const regions = collectShakeRegions(messages, manualConfig);
164
+ // 校验:所有 regionIds 必须命中当前 preview 结果(原子性)
165
+ const availableIds = new Set(regions.map((r) => r.id));
166
+ const unknownIds = regionIds.filter((rid) => !availableIds.has(rid));
167
+ if (unknownIds.length > 0) {
168
+ return apiError(c, 400, 'INVALID_REGIONS', '消息已变化,请重新预览');
169
+ }
170
+ const selectedSet = new Set(regionIds);
171
+ const selected = regions.filter((r) => selectedSet.has(r.id));
172
+ if (selected.length === 0) {
173
+ return c.json({ shaken: 0, archiveId: '' });
174
+ }
175
+ const affectedIds = [...new Set(selected.map((r) => r.messageId))];
176
+ const originalMessages = messages.filter((m) => affectedIds.includes(m.id));
177
+ const archiveId = generateId();
178
+ const totalTokens = selected.reduce((sum, r) => sum + r.tokens, 0);
179
+ await archiveOriginalEntries(ctx.db, id, originalMessages, 'shake', `Shaken ${selected.length} regions, saved ${totalTokens} tokens`, archiveId);
180
+ const shakenMessages = applyShakeRegions(messages, selected);
181
+ await deleteEntriesByIds(ctx.db, affectedIds);
182
+ for (const msg of shakenMessages) {
183
+ if (!affectedIds.includes(msg.id))
184
+ continue;
185
+ await insertEntry(ctx.db, {
186
+ id: msg.id,
187
+ sessionId: id,
188
+ tag: 'message',
189
+ role: msg.role,
190
+ content: msg.content,
191
+ tokenCount: estimateMessageTokens(msg.content),
192
+ createdAt: new Date(msg.createdAt),
193
+ });
194
+ }
195
+ return c.json({ shaken: selected.length, archiveId });
196
+ });
123
197
  // 获取分支
124
198
  app.get('/:id/branches', async (c) => {
125
199
  const branches = await getBranches(ctx.db, c.req.param('id'));
@@ -0,0 +1,5 @@
1
+ import { Hono } from 'hono';
2
+ import type { ServerContext } from '../types.js';
3
+ /** 创建终端路由(REST 部分;WebSocket 升级在 server.ts 中处理)。 */
4
+ declare function createTerminalRoute(ctx: ServerContext): Hono;
5
+ export { createTerminalRoute };
@@ -0,0 +1,66 @@
1
+ // src/server/routes/terminal.ts
2
+ import { Hono } from 'hono';
3
+ import { apiError } from '../middleware/error.js';
4
+ /** 创建终端路由(REST 部分;WebSocket 升级在 server.ts 中处理)。 */
5
+ function createTerminalRoute(ctx) {
6
+ const app = new Hono();
7
+ const mgr = ctx.ptyManager;
8
+ // 列出所有活跃 PTY
9
+ app.get('/', (c) => {
10
+ return c.json({ terminals: mgr.list() });
11
+ });
12
+ // 创建新 PTY 会话
13
+ app.post('/', async (c) => {
14
+ const body = await c.req.json().catch(() => ({}));
15
+ const cwd = typeof body.cwd === 'string' && body.cwd.length > 0 ? body.cwd : ctx.cwd;
16
+ const cols = Number.isFinite(body.cols) ? Number(body.cols) : undefined;
17
+ const rows = Number.isFinite(body.rows) ? Number(body.rows) : undefined;
18
+ const title = typeof body.title === 'string' ? body.title : undefined;
19
+ const shell = typeof body.shell === 'string' && body.shell.length > 0 ? body.shell : undefined;
20
+ try {
21
+ const info = mgr.create({ cwd, cols, rows, title, shell });
22
+ return c.json(info, 201);
23
+ }
24
+ catch (err) {
25
+ const message = err instanceof Error ? err.message : 'Failed to create terminal';
26
+ return apiError(c, 500, 'PTY_CREATE_FAILED', message);
27
+ }
28
+ });
29
+ // 获取单个 PTY 信息
30
+ app.get('/:id', (c) => {
31
+ const info = mgr.get(c.req.param('id'));
32
+ if (!info)
33
+ return apiError(c, 404, 'PTY_NOT_FOUND', 'Terminal not found');
34
+ return c.json(info);
35
+ });
36
+ // 调整尺寸 / 更新标题
37
+ app.put('/:id', async (c) => {
38
+ const id = c.req.param('id');
39
+ const info = mgr.get(id);
40
+ if (!info)
41
+ return apiError(c, 404, 'PTY_NOT_FOUND', 'Terminal not found');
42
+ const body = await c.req.json().catch(() => ({}));
43
+ if (body.cols != null && body.rows != null) {
44
+ const cols = Number(body.cols);
45
+ const rows = Number(body.rows);
46
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
47
+ return apiError(c, 400, 'INVALID_SIZE', 'cols and rows must be numbers');
48
+ }
49
+ mgr.resize(id, cols, rows);
50
+ }
51
+ if (typeof body.title === 'string') {
52
+ mgr.setTitle(id, body.title);
53
+ }
54
+ return c.json(mgr.get(id));
55
+ });
56
+ // 终止 PTY
57
+ app.delete('/:id', (c) => {
58
+ const id = c.req.param('id');
59
+ if (!mgr.get(id))
60
+ return apiError(c, 404, 'PTY_NOT_FOUND', 'Terminal not found');
61
+ mgr.kill(id);
62
+ return c.json({ ok: true });
63
+ });
64
+ return app;
65
+ }
66
+ export { createTerminalRoute };
@@ -36,8 +36,21 @@ declare function buildRegistryFromConfig(config: Config): Registry;
36
36
  * 使运行中的 ServerContext 立即生效,无需重启。
37
37
  */
38
38
  declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
39
+ /**
40
+ * 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
41
+ *
42
+ * dev 热重载重建复用此函数:PGLite 单写者约束下 DB handle 必须跨重载存活,
43
+ * 但 ctx 其余资源(agentManager/permissionStore/registries/plugins)全部重建为新实例。
44
+ * 返回的 dispose 清理 ctx 资源但**不 close db**——db 由调用方持有。
45
+ */
46
+ declare function buildServerContext(db: DB, opts?: StartServerOptions): Promise<{
47
+ ctx: ServerContext;
48
+ dispose: () => Promise<void>;
49
+ }>;
50
+ /** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
51
+ declare function createDevDb(cwd: string): Promise<DB>;
39
52
  /** 初始化 DB + 配置 + 注册表,返回 ServerContext + 清理函数(dev 与独立后端共用)。 */
40
53
  declare function bootstrapServerContext(opts?: StartServerOptions): Promise<BootstrappedServer>;
41
54
  declare function startServer(opts?: StartServerOptions): Promise<RunningServer>;
42
55
  export type { BootstrappedServer, RunningServer, StartServerOptions };
43
- export { bootstrapServerContext, buildRegistryFromConfig, startServer, syncRegistryFromConfig };
56
+ export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, startServer, syncRegistryFromConfig, };
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
3
  import { connect as tcpConnect } from 'node:net';
4
4
  import { join } from 'node:path';
5
5
  import { serve } from '@hono/node-server';
6
+ import { WebSocketServer } from 'ws';
6
7
  import { BUILTIN_AGENTS, createAgentRegistry } from '../core/agents/index.js';
7
8
  import { loadConfig } from '../core/config.js';
8
9
  import { decryptSecret } from '../core/secret.js';
@@ -12,6 +13,7 @@ import { initPlugins } from '../plugins/index.js';
12
13
  import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
13
14
  import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
14
15
  import { createAgentManager } from './agent-manager.js';
16
+ import { PTYManager } from './terminal/pty-manager.js';
15
17
  import { createApp } from './app.js';
16
18
  import { createPermissionStore } from './permission/store.js';
17
19
  /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
@@ -54,33 +56,15 @@ function resolveDbDir(cwd) {
54
56
  return envDir;
55
57
  return join(cwd, '.c0de', 'pglite');
56
58
  }
57
- /** 初始化 DB + 配置 + 注册表,返回 ServerContext + 清理函数(dev 与独立后端共用)。 */
58
- async function bootstrapServerContext(opts = {}) {
59
+ /**
60
+ * 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
61
+ *
62
+ * dev 热重载重建复用此函数:PGLite 单写者约束下 DB handle 必须跨重载存活,
63
+ * 但 ctx 其余资源(agentManager/permissionStore/registries/plugins)全部重建为新实例。
64
+ * 返回的 dispose 清理 ctx 资源但**不 close db**——db 由调用方持有。
65
+ */
66
+ async function buildServerContext(db, opts = {}) {
59
67
  const cwd = opts.cwd ?? process.cwd();
60
- const ownsDb = !opts.db;
61
- // 持久化 PGLite 数据:默认 <cwd>/.c0de/pglite(与 .c0de/cache、.c0de/config.json 同约定),
62
- // 可用 C0DE_DB_DIR 覆盖。此前默认 in-memory,进程重启即丢全部会话/消息/调用详情。
63
- // 测试注入 opts.db 时跳过(保持 in-memory 隔离)。
64
- let db;
65
- if (opts.db) {
66
- db = opts.db;
67
- }
68
- else {
69
- const dataDir = resolveDbDir(cwd);
70
- if (!existsSync(dataDir))
71
- mkdirSync(dataDir, { recursive: true });
72
- db = await createDB({ driver: 'pglite', dataDir });
73
- }
74
- // migrateDB 失败时必须 close db,否则 PGLite WASM 实例泄漏并锁住 dataDir,
75
- // 导致后续重试全部 abort(RuntimeError: Aborted())。
76
- try {
77
- await migrateDB(db);
78
- }
79
- catch (err) {
80
- if (ownsDb)
81
- await db.close().catch(() => { });
82
- throw err;
83
- }
84
68
  if (opts.restoreFrom) {
85
69
  const snapshot = JSON.parse(readFileSync(opts.restoreFrom, 'utf8'));
86
70
  await restoreSessions(db, snapshot);
@@ -120,6 +104,7 @@ async function bootstrapServerContext(opts = {}) {
120
104
  initialDelayMs: config.update.initialDelayMs,
121
105
  }),
122
106
  cwd,
107
+ ptyManager: new PTYManager(),
123
108
  };
124
109
  // spec §18.3 handoff server:旧实例监听随机端口,收到 POST /handoff 时
125
110
  // 序列化当前会话状态 + 优雅关闭,让新实例接管。config.update.enabled=false
@@ -136,10 +121,63 @@ async function bootstrapServerContext(opts = {}) {
136
121
  }
137
122
  return {
138
123
  ctx,
139
- close: async () => {
124
+ dispose: async () => {
125
+ // dev 重建前调用:中止活跃 run + settle pending permission +
126
+ // 停 scheduler + 关 handoff。**不 close db**(调用方持有)。
127
+ ctx.agentManager.dispose();
128
+ ctx.permissionStore.dispose();
140
129
  ctx.updateScheduler.stop();
130
+ ctx.ptyManager.dispose();
141
131
  if (handoffServer)
142
132
  await handoffServer.close();
133
+ },
134
+ };
135
+ }
136
+ /** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
137
+ async function createDevDb(cwd) {
138
+ const dataDir = resolveDbDir(cwd);
139
+ if (!existsSync(dataDir))
140
+ mkdirSync(dataDir, { recursive: true });
141
+ const db = await createDB({ driver: 'pglite', dataDir });
142
+ // migrateDB 失败必须 close,否则 WASM 实例泄漏锁住 dataDir。
143
+ try {
144
+ await migrateDB(db);
145
+ }
146
+ catch (err) {
147
+ await db.close().catch(() => { });
148
+ throw err;
149
+ }
150
+ return db;
151
+ }
152
+ /** 初始化 DB + 配置 + 注册表,返回 ServerContext + 清理函数(dev 与独立后端共用)。 */
153
+ async function bootstrapServerContext(opts = {}) {
154
+ const cwd = opts.cwd ?? process.cwd();
155
+ const ownsDb = !opts.db;
156
+ // 持久化 PGLite 数据:默认 <cwd>/.c0de/pglite(与 .c0de/cache、.c0de/config.json 同约定),
157
+ // 可用 C0DE_DB_DIR 覆盖。此前默认 in-memory,进程重启即丢全部会话/消息/调用详情。
158
+ // 测试注入 opts.db 时跳过(保持 in-memory 隔离)。
159
+ let db;
160
+ if (opts.db) {
161
+ db = opts.db;
162
+ // 注入 db 仍需 migrate(测试可能注入全新 in-memory db)。
163
+ // migrateDB 失败时若 ownsDb 才 close(注入的由调用方管)。
164
+ try {
165
+ await migrateDB(db);
166
+ }
167
+ catch (err) {
168
+ if (ownsDb)
169
+ await db.close().catch(() => { });
170
+ throw err;
171
+ }
172
+ }
173
+ else {
174
+ db = await createDevDb(cwd);
175
+ }
176
+ const { ctx, dispose } = await buildServerContext(db, opts);
177
+ return {
178
+ ctx,
179
+ close: async () => {
180
+ await dispose();
143
181
  if (ownsDb)
144
182
  await db.close();
145
183
  },
@@ -184,14 +222,48 @@ async function startServer(opts = {}) {
184
222
  if (ctx.config.update.enabled)
185
223
  ctx.updateScheduler.start();
186
224
  const server = serve({ fetch: app.fetch, port });
225
+ // WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
226
+ // 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
227
+ const wss = new WebSocketServer({ noServer: true });
228
+ const expectedToken = ctx.config.security.authEnabled ? ctx.config.security.token : undefined;
229
+ server.on('upgrade', (req, socket, head) => {
230
+ const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
231
+ const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
232
+ if (!match) {
233
+ socket.destroy();
234
+ return;
235
+ }
236
+ // 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)
237
+ if (expectedToken) {
238
+ const token = url.searchParams.get('token');
239
+ if (token !== expectedToken) {
240
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
241
+ socket.destroy();
242
+ return;
243
+ }
244
+ }
245
+ const ptyId = match[1];
246
+ if (!ptyId) {
247
+ socket.destroy();
248
+ return;
249
+ }
250
+ wss.handleUpgrade(req, socket, head, (ws) => {
251
+ const attached = ctx.ptyManager.attachWebSocket(ptyId, ws);
252
+ if (!attached) {
253
+ ws.send(JSON.stringify({ type: 'error', message: 'Terminal not found' }));
254
+ ws.close(1008, 'Terminal not found');
255
+ }
256
+ });
257
+ });
187
258
  let closed = false;
188
259
  const close = async () => {
189
260
  if (closed)
190
261
  return;
191
262
  closed = true;
263
+ wss.close();
192
264
  server.close();
193
265
  await closeCtx();
194
266
  };
195
267
  return { app, port, close };
196
268
  }
197
- export { bootstrapServerContext, buildRegistryFromConfig, startServer, syncRegistryFromConfig };
269
+ export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, startServer, syncRegistryFromConfig, };
@@ -0,0 +1,53 @@
1
+ import type { WebSocket } from 'ws';
2
+ /** PTY 会话信息(返回给前端)。 */
3
+ export interface PTYInfo {
4
+ id: string;
5
+ pid: number;
6
+ title: string;
7
+ cols: number;
8
+ rows: number;
9
+ cwd: string;
10
+ /** shell 程序路径。 */
11
+ shell: string;
12
+ }
13
+ export interface CreatePTYOptions {
14
+ cwd: string;
15
+ cols?: number;
16
+ rows?: number;
17
+ title?: string;
18
+ /** 覆盖默认 shell;不传则自动检测。 */
19
+ shell?: string;
20
+ }
21
+ /**
22
+ * PTY 生命周期管理器。
23
+ *
24
+ * 负责 spawn / write / resize / kill 伪终端进程,并将 PTY 输出
25
+ * 通过 WebSocket 推送到前端。一个 PTY 可同时挂多个 WebSocket
26
+ * (多标签共享同一终端),任一 WS 断开不影响进程。
27
+ */
28
+ export declare class PTYManager {
29
+ private entries;
30
+ /** 创建新 PTY 会话。 */
31
+ create(opts: CreatePTYOptions): PTYInfo;
32
+ /** 向 PTY stdin 写入数据。 */
33
+ write(id: string, data: string): void;
34
+ /** 调整 PTY 尺寸。 */
35
+ resize(id: string, cols: number, rows: number): void;
36
+ /** 更新 PTY 标题。 */
37
+ setTitle(id: string, title: string): void;
38
+ /** 终止 PTY 进程。 */
39
+ kill(id: string): void;
40
+ /** 获取 PTY 信息。 */
41
+ get(id: string): PTYInfo | undefined;
42
+ /** 列出所有活跃 PTY。 */
43
+ list(): PTYInfo[];
44
+ /**
45
+ * 将 WebSocket 挂载到 PTY,建立双向数据流:
46
+ * - PTY onData → WS send(终端输出)
47
+ * - WS onMessage → PTY write(用户输入)
48
+ * - WS onClose → 摘除连接(PTY 保持存活)
49
+ */
50
+ attachWebSocket(id: string, ws: WebSocket): boolean;
51
+ /** 终止所有 PTY 进程(服务器关闭时调用)。 */
52
+ dispose(): void;
53
+ }
@@ -0,0 +1,160 @@
1
+ // src/server/terminal/pty-manager.ts
2
+ import { spawn } from 'node-pty';
3
+ import { randomUUID } from 'node:crypto';
4
+ /** 检测当前平台默认 shell。 */
5
+ function detectShell() {
6
+ if (process.platform === 'win32') {
7
+ return process.env.COMSPEC ?? 'cmd.exe';
8
+ }
9
+ return process.env.SHELL ?? '/bin/bash';
10
+ }
11
+ const DEFAULT_COLS = 80;
12
+ const DEFAULT_ROWS = 24;
13
+ const MAX_TITLE_LEN = 100;
14
+ function truncateTitle(title) {
15
+ const clean = title.replace(/[\r\n]/g, ' ').trim();
16
+ return clean.length > MAX_TITLE_LEN ? `${clean.slice(0, MAX_TITLE_LEN)}…` : clean;
17
+ }
18
+ /**
19
+ * PTY 生命周期管理器。
20
+ *
21
+ * 负责 spawn / write / resize / kill 伪终端进程,并将 PTY 输出
22
+ * 通过 WebSocket 推送到前端。一个 PTY 可同时挂多个 WebSocket
23
+ * (多标签共享同一终端),任一 WS 断开不影响进程。
24
+ */
25
+ export class PTYManager {
26
+ entries = new Map();
27
+ /** 创建新 PTY 会话。 */
28
+ create(opts) {
29
+ const id = `pty_${randomUUID()}`;
30
+ const cols = opts.cols ?? DEFAULT_COLS;
31
+ const rows = opts.rows ?? DEFAULT_ROWS;
32
+ const shell = opts.shell ?? detectShell();
33
+ const pty = spawn(shell, [], {
34
+ name: 'xterm-256color',
35
+ cols,
36
+ rows,
37
+ cwd: opts.cwd,
38
+ env: {
39
+ ...process.env,
40
+ TERM: 'xterm-256color',
41
+ COLORTERM: 'truecolor',
42
+ },
43
+ });
44
+ const info = {
45
+ id,
46
+ pid: pty.pid,
47
+ title: truncateTitle(opts.title ?? shell),
48
+ cols,
49
+ rows,
50
+ cwd: opts.cwd,
51
+ shell,
52
+ };
53
+ const entry = { pty, info, sockets: new Set() };
54
+ // PTY 输出 → 广播到所有挂载的 WebSocket
55
+ pty.onData((data) => {
56
+ for (const ws of entry.sockets) {
57
+ if (ws.readyState === ws.OPEN) {
58
+ ws.send(data);
59
+ }
60
+ }
61
+ });
62
+ // PTY 退出 → 通知所有 WS 并清理
63
+ pty.onExit(({ exitCode }) => {
64
+ for (const ws of entry.sockets) {
65
+ if (ws.readyState === ws.OPEN) {
66
+ ws.send(JSON.stringify({ type: 'exit', exitCode }));
67
+ ws.close(1000, 'pty exited');
68
+ }
69
+ }
70
+ this.entries.delete(id);
71
+ });
72
+ this.entries.set(id, entry);
73
+ return info;
74
+ }
75
+ /** 向 PTY stdin 写入数据。 */
76
+ write(id, data) {
77
+ const entry = this.entries.get(id);
78
+ if (!entry)
79
+ throw new Error(`PTY not found: ${id}`);
80
+ entry.pty.write(data);
81
+ }
82
+ /** 调整 PTY 尺寸。 */
83
+ resize(id, cols, rows) {
84
+ const entry = this.entries.get(id);
85
+ if (!entry)
86
+ throw new Error(`PTY not found: ${id}`);
87
+ entry.pty.resize(Math.max(1, cols), Math.max(1, rows));
88
+ entry.info.cols = cols;
89
+ entry.info.rows = rows;
90
+ }
91
+ /** 更新 PTY 标题。 */
92
+ setTitle(id, title) {
93
+ const entry = this.entries.get(id);
94
+ if (!entry)
95
+ throw new Error(`PTY not found: ${id}`);
96
+ entry.info.title = truncateTitle(title);
97
+ }
98
+ /** 终止 PTY 进程。 */
99
+ kill(id) {
100
+ const entry = this.entries.get(id);
101
+ if (!entry)
102
+ return;
103
+ // 通知所有 WS
104
+ for (const ws of entry.sockets) {
105
+ if (ws.readyState === ws.OPEN) {
106
+ ws.send(JSON.stringify({ type: 'exit', exitCode: 0 }));
107
+ ws.close(1000, 'pty killed');
108
+ }
109
+ }
110
+ try {
111
+ entry.pty.kill();
112
+ }
113
+ catch {
114
+ // 进程可能已退出
115
+ }
116
+ this.entries.delete(id);
117
+ }
118
+ /** 获取 PTY 信息。 */
119
+ get(id) {
120
+ return this.entries.get(id)?.info;
121
+ }
122
+ /** 列出所有活跃 PTY。 */
123
+ list() {
124
+ return [...this.entries.values()].map((e) => ({ ...e.info }));
125
+ }
126
+ /**
127
+ * 将 WebSocket 挂载到 PTY,建立双向数据流:
128
+ * - PTY onData → WS send(终端输出)
129
+ * - WS onMessage → PTY write(用户输入)
130
+ * - WS onClose → 摘除连接(PTY 保持存活)
131
+ */
132
+ attachWebSocket(id, ws) {
133
+ const entry = this.entries.get(id);
134
+ if (!entry)
135
+ return false;
136
+ entry.sockets.add(ws);
137
+ ws.on('message', (data) => {
138
+ const text = typeof data === 'string' ? data : data.toString('utf8');
139
+ try {
140
+ entry.pty.write(text);
141
+ }
142
+ catch {
143
+ // PTY 可能已退出
144
+ }
145
+ });
146
+ ws.on('close', () => {
147
+ entry.sockets.delete(ws);
148
+ });
149
+ ws.on('error', () => {
150
+ entry.sockets.delete(ws);
151
+ });
152
+ return true;
153
+ }
154
+ /** 终止所有 PTY 进程(服务器关闭时调用)。 */
155
+ dispose() {
156
+ for (const id of this.entries.keys()) {
157
+ this.kill(id);
158
+ }
159
+ }
160
+ }
@@ -8,6 +8,7 @@ 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';
11
12
  import type { AgentManager } from './agent-manager.js';
12
13
  import type { PermissionStore } from './permission/store.js';
13
14
  /** 持有所有服务依赖的不可变上下文(config 字段可变用于 PATCH 更新)。 */
@@ -37,6 +38,8 @@ type ServerContext = {
37
38
  server: HandoffServer;
38
39
  };
39
40
  cwd: string;
41
+ /** 终端 PTY 管理器(Web 终端面板用)。 */
42
+ ptyManager: PTYManager;
40
43
  /** 测试注入:覆盖 LLM chat stream。生产环境为 undefined。 */
41
44
  chatStream?: typeof chatStreamFn;
42
45
  };
@@ -1,7 +1,7 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import type { ArchiveRef, CompactionArchive, SessionEntry } from './types.js';
3
3
  /** Archive original entries before compaction/squash. Returns the archive id. */
4
- declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash', summary: string, compactionId: string): Promise<string>;
4
+ declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash' | 'shake', summary: string, compactionId: string): Promise<string>;
5
5
  /** Get an archive by id. Returns null for invalid or non-existent ids. */
6
6
  declare function getArchive(handle: DB, id: string): Promise<CompactionArchive | null>;
7
7
  /** Get the original entries stored in an archive. */
@@ -9,8 +9,14 @@ import type { CompactionConfig, CompactionResult, HotFile, Summarizer } from './
9
9
  declare function findSafeCutPoint(messages: Message[], preferredCut: number): number;
10
10
  /** Extract frequently-accessed files (read ≥ 2 times) from message history. */
11
11
  declare function extractHotFiles(messages: Message[]): HotFile[];
12
- /** Build the LLM summarization prompt for a set of messages. */
13
- declare function buildCompactionPrompt(messages: Message[]): string;
12
+ /**
13
+ * Build the LLM summarization prompt for a set of messages.
14
+ *
15
+ * 当存在 previousSummary(上一次压缩生成的摘要)时,prompt 头部改为增量更新指令,
16
+ * 引导模型在已有摘要上叠加新事实、剔除过时信息,从而避免连续多次压缩导致的
17
+ * 信息逐次丢失。无 previousSummary 时退回原始的从零压缩指令。
18
+ */
19
+ declare function buildCompactionPrompt(messages: Message[], previousSummary?: string): string;
14
20
  /**
15
21
  * Compact a session: summarize old messages, archive them, keep recent ones.
16
22
  *