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.
Files changed (177) hide show
  1. package/README.md +63 -16
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +151 -1
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +63 -3
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +109 -3
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +110 -11
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +78 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/compact.d.ts +2 -0
  24. package/dist/core/compact.js +1 -0
  25. package/dist/core/config.d.ts +53 -2
  26. package/dist/core/config.js +235 -19
  27. package/dist/core/loop/compaction.d.ts +2 -1
  28. package/dist/core/loop/compaction.js +19 -6
  29. package/dist/core/loop/segment.d.ts +3 -1
  30. package/dist/core/loop/segment.js +24 -1
  31. package/dist/core/loop/stream-collect.js +9 -1
  32. package/dist/core/loop/subagent.js +88 -37
  33. package/dist/core/loop.d.ts +7 -0
  34. package/dist/core/loop.js +90 -6
  35. package/dist/core/redact.d.ts +11 -0
  36. package/dist/core/redact.js +49 -0
  37. package/dist/core/slash.d.ts +10 -0
  38. package/dist/core/slash.js +237 -65
  39. package/dist/core/title.js +7 -1
  40. package/dist/core/types.d.ts +22 -1
  41. package/dist/core/workflows/context.js +44 -4
  42. package/dist/core/workflows/discovery.d.ts +2 -2
  43. package/dist/core/workflows/discovery.js +5 -7
  44. package/dist/core/workflows/index.d.ts +2 -1
  45. package/dist/core/workflows/index.js +2 -1
  46. package/dist/core/workflows/registry.d.ts +13 -3
  47. package/dist/core/workflows/registry.js +14 -9
  48. package/dist/core/workflows/resolve.d.ts +22 -0
  49. package/dist/core/workflows/resolve.js +23 -0
  50. package/dist/core/workflows/runtime.d.ts +6 -1
  51. package/dist/core/workflows/runtime.js +19 -2
  52. package/dist/db/schema.d.ts +373 -1
  53. package/dist/db/schema.js +60 -4
  54. package/dist/kanban/index.d.ts +2 -1
  55. package/dist/kanban/index.js +1 -1
  56. package/dist/kanban/store.d.ts +87 -1
  57. package/dist/kanban/store.js +429 -22
  58. package/dist/llm/index.d.ts +1 -1
  59. package/dist/llm/index.js +1 -1
  60. package/dist/llm/provider.js +1 -1
  61. package/dist/llm/registry.d.ts +7 -1
  62. package/dist/llm/registry.js +7 -1
  63. package/dist/llm/retry.d.ts +5 -2
  64. package/dist/llm/retry.js +6 -5
  65. package/dist/llm/routing.d.ts +24 -1
  66. package/dist/llm/routing.js +29 -1
  67. package/dist/plugins/init.d.ts +7 -0
  68. package/dist/plugins/init.js +22 -1
  69. package/dist/plugins/loader.d.ts +7 -1
  70. package/dist/plugins/loader.js +12 -4
  71. package/dist/project/index.d.ts +1 -1
  72. package/dist/project/index.js +1 -1
  73. package/dist/project/project.d.ts +37 -0
  74. package/dist/project/project.js +142 -1
  75. package/dist/project/trust.d.ts +65 -0
  76. package/dist/project/trust.js +328 -0
  77. package/dist/server/agent-manager.d.ts +15 -2
  78. package/dist/server/agent-manager.js +18 -3
  79. package/dist/server/app.js +45 -30
  80. package/dist/server/auth-manager.d.ts +26 -9
  81. package/dist/server/auth-manager.js +78 -42
  82. package/dist/server/context.js +14 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/index.js +1 -1
  85. package/dist/server/middleware/auth.d.ts +11 -1
  86. package/dist/server/middleware/auth.js +25 -3
  87. package/dist/server/permission/interactive.d.ts +11 -3
  88. package/dist/server/permission/interactive.js +23 -4
  89. package/dist/server/permission/store.d.ts +14 -2
  90. package/dist/server/permission/store.js +39 -3
  91. package/dist/server/registry-config.d.ts +14 -0
  92. package/dist/server/registry-config.js +44 -0
  93. package/dist/server/routes/auth.js +66 -10
  94. package/dist/server/routes/chat.d.ts +21 -0
  95. package/dist/server/routes/chat.js +685 -52
  96. package/dist/server/routes/commands.d.ts +4 -2
  97. package/dist/server/routes/commands.js +9 -4
  98. package/dist/server/routes/config.js +180 -23
  99. package/dist/server/routes/files.js +142 -28
  100. package/dist/server/routes/kanban.js +218 -26
  101. package/dist/server/routes/permissions.d.ts +0 -4
  102. package/dist/server/routes/permissions.js +140 -7
  103. package/dist/server/routes/project.js +132 -29
  104. package/dist/server/routes/provider.js +54 -3
  105. package/dist/server/routes/session.js +463 -23
  106. package/dist/server/routes/tool.js +24 -5
  107. package/dist/server/routes/update.d.ts +11 -5
  108. package/dist/server/routes/update.js +145 -29
  109. package/dist/server/routes/usage.d.ts +4 -0
  110. package/dist/server/routes/usage.js +133 -0
  111. package/dist/server/routes/workflows.js +476 -87
  112. package/dist/server/server.d.ts +16 -13
  113. package/dist/server/server.js +364 -107
  114. package/dist/server/terminal/pty-manager.d.ts +12 -0
  115. package/dist/server/terminal/pty-manager.js +63 -7
  116. package/dist/server/types.d.ts +16 -0
  117. package/dist/session/archive.d.ts +3 -1
  118. package/dist/session/archive.js +13 -1
  119. package/dist/session/branch.d.ts +2 -1
  120. package/dist/session/branch.js +92 -9
  121. package/dist/session/context.js +28 -2
  122. package/dist/session/import.d.ts +44 -0
  123. package/dist/session/import.js +83 -0
  124. package/dist/session/index.d.ts +1 -1
  125. package/dist/session/index.js +1 -1
  126. package/dist/session/jobs.d.ts +12 -0
  127. package/dist/session/jobs.js +53 -0
  128. package/dist/session/message.d.ts +7 -1
  129. package/dist/session/message.js +21 -1
  130. package/dist/session/session.d.ts +186 -12
  131. package/dist/session/session.js +577 -33
  132. package/dist/session/snapshot.d.ts +5 -2
  133. package/dist/session/snapshot.js +10 -2
  134. package/dist/session/types.d.ts +13 -1
  135. package/dist/session/usage.d.ts +62 -0
  136. package/dist/session/usage.js +194 -0
  137. package/dist/shared/types/agent.d.ts +29 -2
  138. package/dist/shared/types/config.d.ts +64 -5
  139. package/dist/shared/types/kanban.d.ts +17 -2
  140. package/dist/shared/types/message.d.ts +23 -0
  141. package/dist/tools/builtin/kanban.js +13 -2
  142. package/dist/tools/index.d.ts +14 -3
  143. package/dist/tools/index.js +23 -5
  144. package/dist/tools/websearch/index.d.ts +5 -1
  145. package/dist/tools/websearch/index.js +46 -7
  146. package/dist/tools/websearch/providers/brave.js +2 -2
  147. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  148. package/dist/tools/websearch/providers/tavily.js +2 -2
  149. package/dist/tools/websearch/types.d.ts +5 -1
  150. package/dist/tools/websearch/types.js +7 -1
  151. package/dist/update/index.d.ts +3 -3
  152. package/dist/update/index.js +2 -2
  153. package/dist/update/ipc.d.ts +13 -2
  154. package/dist/update/ipc.js +36 -2
  155. package/dist/update/scheduler.js +5 -2
  156. package/dist/update/snapshot.d.ts +18 -3
  157. package/dist/update/snapshot.js +5 -2
  158. package/dist/update/updater.d.ts +17 -1
  159. package/dist/update/updater.js +47 -18
  160. package/dist/update/version.d.ts +3 -0
  161. package/dist/update/version.js +9 -3
  162. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  163. package/drizzle/0010_usage_events_ledger.sql +17 -0
  164. package/drizzle/0011_slow_slapstick.sql +12 -0
  165. package/drizzle/0012_chief_timeslip.sql +1 -0
  166. package/drizzle/0013_swift_zeigeist.sql +1 -0
  167. package/drizzle/0014_flimsy_legion.sql +1 -0
  168. package/drizzle/meta/0008_snapshot.json +875 -0
  169. package/drizzle/meta/0010_snapshot.json +1010 -0
  170. package/drizzle/meta/0011_snapshot.json +1055 -0
  171. package/drizzle/meta/0012_snapshot.json +1062 -0
  172. package/drizzle/meta/0013_snapshot.json +1068 -0
  173. package/drizzle/meta/0014_snapshot.json +1075 -0
  174. package/drizzle/meta/_journal.json +42 -0
  175. package/package.json +42 -42
  176. package/dist/core/agents/discovery.d.ts +0 -6
  177. package/dist/core/agents/discovery.js +0 -96
@@ -21,6 +21,8 @@ export interface CreatePTYOptions {
21
21
  shell?: string;
22
22
  /** 所属项目 id。 */
23
23
  projectId?: string;
24
+ /** 指定 PTY id(热更新快照恢复时沿用原 id,前端布局无感重连)。 */
25
+ id?: string;
24
26
  }
25
27
  /**
26
28
  * 检测当前平台默认 shell。
@@ -32,6 +34,16 @@ export interface CreatePTYOptions {
32
34
  * /etc/passwd(getpwuid),是用户真实登录 shell 的可靠来源。
33
35
  */
34
36
  export declare function detectShell(): string;
37
+ /** 把 argv 拼回可安全经 shell 重放的命令行(导出便于单测安全转义)。 */
38
+ export declare function argvToCommand(argv: string[]): string;
39
+ /**
40
+ * 尽力捕获 shell 当前前台命令(Linux 专有,最佳努力)——热更新后可选自动重跑。
41
+ * 读取 /proc/<shellPid>/task/<shellPid>/children:
42
+ * - 恰好 1 个直接子进程 → 返回其 cmdline(如 `npm run dev`)。
43
+ * - 0 个 / 多个(后台任务、管道、子 shell)→ 返回 null(不猜,回退默认不重跑)。
44
+ * 非 Linux 平台或任何读取异常 → null(能力不可用,优雅降级)。
45
+ */
46
+ export declare function captureForegroundCommand(shellPid: number): string | null;
35
47
  /**
36
48
  * PTY 生命周期管理器。
37
49
  *
@@ -1,6 +1,6 @@
1
1
  // src/server/terminal/pty-manager.ts
2
2
  import { randomUUID } from 'node:crypto';
3
- import { mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir, userInfo } from 'node:os';
5
5
  import { basename, join } from 'node:path';
6
6
  import { spawn } from 'node-pty';
@@ -86,6 +86,10 @@ export function detectShell() {
86
86
  }
87
87
  const DEFAULT_COLS = 80;
88
88
  const DEFAULT_ROWS = 24;
89
+ /** P3-13:cols/rows 上限钳制——路由层只查 Number.isFinite,负值/超大值直接进
90
+ * spawn 可能打爆 node-pty 缓冲区或 UI 布局。 */
91
+ const MAX_COLS = 1000;
92
+ const MAX_ROWS = 500;
89
93
  const MAX_TITLE_LEN = 100;
90
94
  /** scrollback 环形缓冲最大字节数(约 50KB)。 */
91
95
  const SCROLLBACK_MAX = 50_000;
@@ -93,6 +97,41 @@ function truncateTitle(title) {
93
97
  const clean = title.replace(/[\r\n]/g, ' ').trim();
94
98
  return clean.length > MAX_TITLE_LEN ? `${clean.slice(0, MAX_TITLE_LEN)}…` : clean;
95
99
  }
100
+ /** POSIX 单引号转义:仅安全字符(字母数字与常见路径/别名符号)原样,否则单引号包裹。 */
101
+ function shellQuote(arg) {
102
+ if (/^[A-Za-z0-9_./:,+@%=-]+$/.test(arg))
103
+ return arg;
104
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
105
+ }
106
+ /** 把 argv 拼回可安全经 shell 重放的命令行(导出便于单测安全转义)。 */
107
+ export function argvToCommand(argv) {
108
+ return argv.map(shellQuote).join(' ');
109
+ }
110
+ /**
111
+ * 尽力捕获 shell 当前前台命令(Linux 专有,最佳努力)——热更新后可选自动重跑。
112
+ * 读取 /proc/<shellPid>/task/<shellPid>/children:
113
+ * - 恰好 1 个直接子进程 → 返回其 cmdline(如 `npm run dev`)。
114
+ * - 0 个 / 多个(后台任务、管道、子 shell)→ 返回 null(不猜,回退默认不重跑)。
115
+ * 非 Linux 平台或任何读取异常 → null(能力不可用,优雅降级)。
116
+ */
117
+ export function captureForegroundCommand(shellPid) {
118
+ if (process.platform !== 'linux')
119
+ return null;
120
+ try {
121
+ const childrenRaw = readFileSync(`/proc/${shellPid}/task/${shellPid}/children`, 'utf8');
122
+ const children = childrenRaw.trim().split(/\s+/).filter(Boolean).map(Number);
123
+ if (children.length !== 1)
124
+ return null;
125
+ const cmdline = readFileSync(`/proc/${children[0]}/cmdline`, 'utf8');
126
+ const argv = cmdline.split('\0').filter((s) => s.length > 0);
127
+ if (argv.length === 0)
128
+ return null;
129
+ return argvToCommand(argv);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
96
135
  /**
97
136
  * PTY 生命周期管理器。
98
137
  *
@@ -104,9 +143,15 @@ export class PTYManager {
104
143
  entries = new Map();
105
144
  /** 创建新 PTY 会话。 */
106
145
  create(opts) {
107
- const id = `pty_${randomUUID()}`;
108
- const cols = opts.cols ?? DEFAULT_COLS;
109
- const rows = opts.rows ?? DEFAULT_ROWS;
146
+ const id = opts.id ?? `pty_${randomUUID()}`;
147
+ // 指定 id 已存在(重复 restore 快照)→ 返回既有条目,避免泄漏/覆盖进程。
148
+ const existing = this.entries.get(id);
149
+ if (existing)
150
+ return existing.info;
151
+ // P3-13:钳制到 [1, MAX]——负值/超大值不进入 spawn。
152
+ const clampDim = (v, dflt, max) => Math.min(max, Math.max(1, Number.isFinite(v) ? v : dflt));
153
+ const cols = clampDim(opts.cols, DEFAULT_COLS, MAX_COLS);
154
+ const rows = clampDim(opts.rows, DEFAULT_ROWS, MAX_ROWS);
110
155
  const shell = opts.shell ?? detectShell();
111
156
  const integration = setupShellIntegration(shell);
112
157
  const pty = spawn(shell, integration.spawnArgs, {
@@ -158,6 +203,14 @@ export class PTYManager {
158
203
  ws.close(1000, 'pty exited');
159
204
  }
160
205
  }
206
+ // P3-12 修复:自然退出同样清理 shell 注入脚本/目录(bash --init-file、
207
+ // zsh ZDOTDIR 临时文件)——此前仅 kill() 清理,自然退出残留 /tmp。
208
+ try {
209
+ entry.integrationCleanup?.();
210
+ }
211
+ catch {
212
+ // 清理失败不影响退出流程
213
+ }
161
214
  this.entries.delete(id);
162
215
  });
163
216
  this.entries.set(id, entry);
@@ -175,9 +228,12 @@ export class PTYManager {
175
228
  const entry = this.entries.get(id);
176
229
  if (!entry)
177
230
  throw new Error(`PTY not found: ${id}`);
178
- entry.pty.resize(Math.max(1, cols), Math.max(1, rows));
179
- entry.info.cols = cols;
180
- entry.info.rows = rows;
231
+ // P3-13:与 create 同口径钳制
232
+ const c = Math.min(MAX_COLS, Math.max(1, cols));
233
+ const r = Math.min(MAX_ROWS, Math.max(1, rows));
234
+ entry.pty.resize(c, r);
235
+ entry.info.cols = c;
236
+ entry.info.rows = r;
181
237
  }
182
238
  /** 更新 PTY 标题。 */
183
239
  setTitle(id, title) {
@@ -32,6 +32,9 @@ type ServerContext = {
32
32
  /** 会话级授权模式覆盖:Map<sessionId, mode>。会话未覆盖时回退 permissionMode。
33
33
  * P1-5:auto 是高风险状态,按会话隔离避免一个标签页切换影响全部会话。 */
34
34
  sessionPermissionModes: Map<string, 'default' | 'auto'>;
35
+ /** 会话级「始终允许」工具白名单:Map<sessionId, string[]>。持久化于
36
+ * session.metadata.alwaysAllow;checker 经 getter 实时读取(run 中可追加)。 */
37
+ sessionAlwaysAllow: Map<string, string[]>;
35
38
  /**
36
39
  * API Bearer token(认证未显式关闭时必存在)。
37
40
  * 用户配置 security.token 优先;否则自动生成并持久化到数据目录 auth-token 文件
@@ -43,10 +46,23 @@ type ServerContext = {
43
46
  authManager?: AuthManager;
44
47
  /** 主 HTTP 服务实际绑定端口(startServer 绑定后回填;热更新 spawn 新实例复用)。 */
45
48
  port?: number;
49
+ /** 主 HTTP 服务绑定地址(startServer 回填;热更新 spawn 新实例须复用同一地址)。 */
50
+ host?: string;
51
+ /** P1-1:项目级 LLM 注册表缓存 Map<projectId, Registry>——按项目合并配置
52
+ * (global + 项目 .c0de/config.json 的 providers)构建,会话聊天按所属项目
53
+ * 解析路由。项目配置 PATCH 后失效重建。 */
54
+ projectRegistries?: Map<string, Registry>;
46
55
  /** Agent 类型注册表(spec: multi-agent-design)。注入 agent loop 的 runSubAgent。 */
47
56
  agentRegistry: AgentRegistry;
48
57
  /** 工作流注册表(spec: dynamic-workflow-design)。注入 /workflow slash 命令和 workflowz steering。 */
49
58
  workflowRegistry?: import('../core/workflows/registry.js').WorkflowRegistry;
59
+ /** 工作流运行占用:发起会话 id → 工作流会话 id(/workflow run 通道)。
60
+ * 发起会话在其工作流运行期间视为占用——主 run 与再次 /workflow run 均 409;
61
+ * abort/pause/resume 控制端点据此路由到实际的工作流 run。 */
62
+ workflowBusyBySession: Map<string, string>;
63
+ /** 工作流运行占用:执行作用域键 → 工作流会话 id。键为 `project:<projectId>`
64
+ * 或 `dir:<cwd>`(REST 无 projectId 时)。同一项目/目录不允许并发工作流执行。 */
65
+ workflowBusyByScope: Map<string, string>;
50
66
  /** 后台版本检查调度器(spec §18.1);/api/update 读取其缓存结果。 */
51
67
  updateScheduler: UpdateScheduler;
52
68
  /** Handoff HTTP 端点(spec §18.3);热更新时新实例 POST /handoff 触发优雅退出。 undefined 表示未启用。 */
@@ -8,8 +8,10 @@ declare function getArchive(handle: DB, id: string): Promise<CompactionArchive |
8
8
  declare function getArchiveOriginalEntries(handle: DB, archiveId: string): Promise<SessionEntry[]>;
9
9
  /** Search archives by keyword (case-insensitive substring on searchable text). */
10
10
  declare function searchArchives(handle: DB, sessionId: string, query: string): Promise<CompactionArchive[]>;
11
+ /** List archives for a session(按创建时间倒序)。可选 query 走搜索过滤。 */
12
+ declare function listArchives(handle: DB, sessionId: string, query?: string): Promise<CompactionArchive[]>;
11
13
  /** Parse a `@[archive:<id>]` or `@[squash:<n>]` reference from text. Returns null if none. */
12
14
  declare function parseArchiveReference(text: string): ArchiveRef | null;
13
15
  /** Resolve an archive reference to displayable text. */
14
16
  declare function resolveArchiveReference(handle: DB, sessionId: string, ref: ArchiveRef): Promise<string | null>;
15
- export { archiveOriginalEntries, getArchive, getArchiveOriginalEntries, parseArchiveReference, resolveArchiveReference, searchArchives, };
17
+ export { archiveOriginalEntries, getArchive, getArchiveOriginalEntries, listArchives, parseArchiveReference, resolveArchiveReference, searchArchives, };
@@ -98,6 +98,18 @@ async function searchArchives(handle, sessionId, query) {
98
98
  .orderBy(desc(compactionArchives.createdAt));
99
99
  return rows.map(rowToArchive);
100
100
  }
101
+ /** List archives for a session(按创建时间倒序)。可选 query 走搜索过滤。 */
102
+ async function listArchives(handle, sessionId, query) {
103
+ if (query && query.trim().length > 0) {
104
+ return searchArchives(handle, sessionId, query.trim());
105
+ }
106
+ const rows = await handle.db
107
+ .select()
108
+ .from(compactionArchives)
109
+ .where(eq(compactionArchives.sessionId, sessionId))
110
+ .orderBy(desc(compactionArchives.createdAt));
111
+ return rows.map(rowToArchive);
112
+ }
101
113
  /** Parse a `@[archive:<id>]` or `@[squash:<n>]` reference from text. Returns null if none. */
102
114
  function parseArchiveReference(text) {
103
115
  const archiveId = text.match(/@\[archive:([^\]]+)\]/)?.[1];
@@ -126,4 +138,4 @@ async function resolveArchiveReference(handle, sessionId, ref) {
126
138
  return null;
127
139
  return `[Referenced Squash #${index + 1}]\n${row.summary}`;
128
140
  }
129
- export { archiveOriginalEntries, getArchive, getArchiveOriginalEntries, parseArchiveReference, resolveArchiveReference, searchArchives, };
141
+ export { archiveOriginalEntries, getArchive, getArchiveOriginalEntries, listArchives, parseArchiveReference, resolveArchiveReference, searchArchives, };
@@ -14,7 +14,8 @@ declare function forkSession(handle: DB, sessionId: string, messageIndex: number
14
14
  declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
15
15
  /** Build a full session tree from root sessions down.
16
16
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
17
- * 排除软删除会话与 CLI 来源会话(Web 树仅展示 web 会话)。
17
+ * 排除软删除会话与临时 CLI 会话(print/workflow);持久化 CLI 会话(--continue 续接)
18
+ * 与 Web 会话同树可见(CLI/Web 同库不同视图 → 同库同视图)。
18
19
  */
19
20
  declare function getTree(handle: DB): Promise<SessionTreeNode[]>;
20
21
  export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
@@ -1,8 +1,8 @@
1
- import { and, eq, isNull, ne, or } from 'drizzle-orm';
2
- import { sessions } from '../db/schema.js';
1
+ import { and, eq, isNull, lte } from 'drizzle-orm';
2
+ import { compactionArchives, sessions } from '../db/schema.js';
3
3
  import { generateId } from '../shared/index.js';
4
4
  import { getEntries, insertEntry } from './message.js';
5
- import { createSession, getSession, rowToSession } from './session.js';
5
+ import { createSession, getSession, rowToSession, webVisibleSessionCondition } from './session.js';
6
6
  import { copyFileSnapshots } from './snapshot.js';
7
7
  /**
8
8
  * fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
@@ -82,13 +82,72 @@ async function forkSession(handle, sessionId, messageIndex) {
82
82
  // 中途失败整体回滚,不残留永久可见的半成品分支(同 compactSession/squashRecent 写法)。
83
83
  const updated = await handle.db.transaction(async (tx) => {
84
84
  const txHandle = { db: tx, close: handle.close };
85
- const forked = await createSession(txHandle, `Branch of ${source.title}`, source.projectId ?? undefined, undefined, source.source === 'cli' ? 'cli' : 'web');
85
+ const branchPointMs = typeof target.createdAt === 'number' ? target.createdAt : new Date(target.createdAt).getTime();
86
+ const forked = await createSession(txHandle, `Branch of ${source.title}`, source.projectId ?? undefined, undefined, source.source === 'cli' ? 'cli' : 'web', undefined, source.worktreePath ?? undefined);
86
87
  await tx
87
88
  .update(sessions)
88
89
  .set({ parentId: sessionId, branchPoint: messageIndex })
89
90
  .where(eq(sessions.id, forked.id));
91
+ // P2:复制分支点之前的归档(compaction/squash/shake/clear 原始内容)——
92
+ // 此前 fork 后归档面板为空、时间线压缩条目引用悬空 archiveId。
93
+ // 新归档换新 id,并重映射复制条目中的 archiveId 引用。
94
+ const sourceArchives = await tx
95
+ .select()
96
+ .from(compactionArchives)
97
+ .where(and(eq(compactionArchives.sessionId, sessionId), lte(compactionArchives.createdAt, new Date(branchPointMs))));
98
+ const archiveIdMap = new Map();
99
+ for (const a of sourceArchives) {
100
+ const newId = generateId();
101
+ archiveIdMap.set(a.id, newId);
102
+ await tx.insert(compactionArchives).values({
103
+ id: newId,
104
+ sessionId: forked.id,
105
+ compactionId: a.compactionId,
106
+ archiveType: a.archiveType,
107
+ originalEntries: a.originalEntries,
108
+ fileSnapshots: a.fileSnapshots,
109
+ summary: a.summary,
110
+ tokenCount: a.tokenCount,
111
+ searchableText: a.searchableText,
112
+ createdAt: a.createdAt,
113
+ });
114
+ }
115
+ // P2:继承分支点之前的用量 segments(徽标/会话信息面板显示继承成本;
116
+ // calls 按时间过滤,分支点之后的调用不属于本分支)。usage_events 账本不受影响——
117
+ // fork 不产生新调用,backfill 经 callId 唯一约束去重,无重复记账。
118
+ const inheritedSegments = (source.metadata.segments ?? [])
119
+ .map((seg) => ({
120
+ ...seg,
121
+ calls: seg.calls.filter((c) => c.timestamp <= branchPointMs),
122
+ }))
123
+ .filter((seg) => seg.calls.length > 0);
124
+ // fork 继承权限态(permissionMode/alwaysAllow)。fork 语义是「完整上下文副本」,
125
+ // 此前不复制权限态会使分支静默回退 default 模式、白名单丢失、用户重遇权限疲劳。
126
+ // 同机同用户 fork 继承 auto/白名单安全(与跨机器 import 的「默认剥离」相反——
127
+ // import 面向陌生来源,fork 面向本人上下文)。
128
+ const srcMeta = source.metadata;
129
+ const forkedMeta = {};
130
+ if (inheritedSegments.length > 0)
131
+ forkedMeta.segments = inheritedSegments;
132
+ if (srcMeta.permissionMode === 'auto' || srcMeta.permissionMode === 'default') {
133
+ forkedMeta.permissionMode = srcMeta.permissionMode;
134
+ }
135
+ if (Array.isArray(srcMeta.alwaysAllow)) {
136
+ forkedMeta.alwaysAllow = srcMeta.alwaysAllow.filter((x) => typeof x === 'string');
137
+ }
138
+ if (Object.keys(forkedMeta).length > 0) {
139
+ await tx.update(sessions).set({ metadata: forkedMeta }).where(eq(sessions.id, forked.id));
140
+ }
90
141
  for (const e of toCopy) {
91
- await insertEntry(txHandle, entryToRow(e, forked.id));
142
+ const row = entryToRow(e, forked.id);
143
+ // 归档 id 重映射:compaction/squash 条目引用新复制的归档
144
+ if (row.tag === 'compaction' || row.tag === 'squash') {
145
+ const content = row.content;
146
+ if (typeof content.archiveId === 'string' && archiveIdMap.has(content.archiveId)) {
147
+ row.content = { ...content, archiveId: archiveIdMap.get(content.archiveId) };
148
+ }
149
+ }
150
+ await insertEntry(txHandle, row);
92
151
  }
93
152
  await insertEntry(txHandle, {
94
153
  id: generateId(),
@@ -99,8 +158,9 @@ async function forkSession(handle, sessionId, messageIndex) {
99
158
  sourceSessionId: sessionId,
100
159
  },
101
160
  });
102
- // 复制源会话最新文件快照(P1-4:@文件上下文随分支保留)。
103
- await copyFileSnapshots(txHandle, sessionId, forked.id);
161
+ // 复制源会话文件快照(P1-4:@文件上下文随分支保留)。
162
+ // C3:按分支点时间过滤——分支点之后更新的快照是「未来」状态,不复制。
163
+ await copyFileSnapshots(txHandle, sessionId, forked.id, branchPointMs);
104
164
  const created = await getSession(txHandle, forked.id);
105
165
  if (!created)
106
166
  throw new Error('Forked session not found after creation');
@@ -116,15 +176,37 @@ async function getBranches(handle, sessionId) {
116
176
  .where(and(eq(sessions.parentId, sessionId), isNull(sessions.deletedAt)));
117
177
  return rows.map(rowToSession);
118
178
  }
179
+ /** 从会话 metadata.segments 聚合用量(零额外查询——段数据已在行内)。 */
180
+ function sessionUsage(session) {
181
+ const calls = (session.metadata.segments ?? []).flatMap((s) => s.calls ?? []);
182
+ let inputTokens = 0;
183
+ let outputTokens = 0;
184
+ let cacheRead = 0;
185
+ let cost = 0;
186
+ let unknownCostCalls = 0;
187
+ for (const c of calls) {
188
+ inputTokens += c.usage.input;
189
+ outputTokens += c.usage.output;
190
+ cacheRead += c.usage.cacheRead ?? 0;
191
+ if (c.cost == null) {
192
+ unknownCostCalls += 1;
193
+ }
194
+ else {
195
+ cost += c.cost;
196
+ }
197
+ }
198
+ return { inputTokens, outputTokens, cacheRead, cost, unknownCostCalls, calls: calls.length };
199
+ }
119
200
  /** Build a full session tree from root sessions down.
120
201
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
121
- * 排除软删除会话与 CLI 来源会话(Web 树仅展示 web 会话)。
202
+ * 排除软删除会话与临时 CLI 会话(print/workflow);持久化 CLI 会话(--continue 续接)
203
+ * 与 Web 会话同树可见(CLI/Web 同库不同视图 → 同库同视图)。
122
204
  */
123
205
  async function getTree(handle) {
124
206
  const rows = await handle.db
125
207
  .select()
126
208
  .from(sessions)
127
- .where(and(isNull(sessions.deletedAt), or(isNull(sessions.source), ne(sessions.source, 'cli'))));
209
+ .where(and(isNull(sessions.deletedAt), webVisibleSessionCondition()));
128
210
  const byParent = new Map();
129
211
  for (const row of rows) {
130
212
  const session = rowToSession(row);
@@ -140,6 +222,7 @@ async function getTree(handle) {
140
222
  .map((session) => ({
141
223
  session,
142
224
  children: build(session.id),
225
+ usage: sessionUsage(session),
143
226
  }));
144
227
  return build(null);
145
228
  }
@@ -1,5 +1,7 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
+ import { eq } from 'drizzle-orm';
4
+ import { sessions } from '../db/schema.js';
3
5
  import { getEntries } from './message.js';
4
6
  import { getFileSnapshots, upsertFileSnapshot } from './snapshot.js';
5
7
  /**
@@ -164,16 +166,40 @@ function injectSnapshots(messages, snapshots) {
164
166
  return [snapshotMessage];
165
167
  return [first, snapshotMessage, ...messages.slice(1)];
166
168
  }
169
+ /**
170
+ * M3:剔除「未完成轮次」条目——metadata 记录的 (since, until] 区间(中断 run
171
+ * 的半截回复/工具结果)不进入 LLM 上下文,避免模型基于自己未跑完的半截成果
172
+ * 产生「我已改过了」的错位叙事。区间外条目原样保留。
173
+ * 两端点都必须存在于当前条目序列:端点因 /clear、压缩归档而消失时区间失效
174
+ * (整体不剔除)——绝不能把区间后新产生的正常轮次误伤。
175
+ */
176
+ function excludeUnfinishedTurn(entries, sinceId, untilId) {
177
+ if (!sinceId || !untilId)
178
+ return entries;
179
+ const sinceIdx = entries.findIndex((e) => e.id === sinceId);
180
+ const untilIdx = entries.findIndex((e) => e.id === untilId);
181
+ if (sinceIdx < 0 || untilIdx <= sinceIdx)
182
+ return entries;
183
+ return entries.filter((_, i) => i <= sinceIdx || i > untilIdx);
184
+ }
167
185
  /** Get the full session context: all entries + file snapshots.
168
186
  * cwd 提供时刷新过期快照:文件磁盘 mtime 晚于快照记录 → 重读并升版本(P1-5)。 */
169
187
  async function getSessionContext(handle, sessionId, cwd) {
170
188
  if (cwd)
171
189
  await refreshStaleSnapshots(handle, sessionId, cwd);
172
- const [entries, snapshots] = await Promise.all([
190
+ const [metaRows, entries, snapshots] = await Promise.all([
191
+ handle.db
192
+ .select({ metadata: sessions.metadata })
193
+ .from(sessions)
194
+ .where(eq(sessions.id, sessionId)),
173
195
  getEntries(handle, sessionId),
174
196
  getFileSnapshots(handle, sessionId),
175
197
  ]);
176
- return { entries, snapshots };
198
+ const meta = (metaRows[0]?.metadata ?? {});
199
+ return {
200
+ entries: excludeUnfinishedTurn(entries, meta.unfinishedSinceEntryId, meta.unfinishedUntilEntryId),
201
+ snapshots,
202
+ };
177
203
  }
178
204
  /** 比对每个文件最新快照与磁盘 mtime,过期则重读并写入新版本。 */
179
205
  async function refreshStaleSnapshots(handle, sessionId, cwd) {
@@ -0,0 +1,44 @@
1
+ import type { DB } from '../db/client.js';
2
+ /** 导入消息的宽松形状(导出 JSON 反序列化后,字段可能缺失/类型漂移)。 */
3
+ type ImportMessage = {
4
+ id?: string;
5
+ role?: string;
6
+ content?: unknown;
7
+ tokenCount?: number;
8
+ createdAt?: number | string;
9
+ };
10
+ /** 导入归档的宽松形状。 */
11
+ type ImportArchive = {
12
+ id?: string;
13
+ compactionId?: string;
14
+ archiveType?: string;
15
+ originalEntries?: unknown;
16
+ fileSnapshots?: unknown;
17
+ summary?: string;
18
+ tokenCount?: number;
19
+ searchableText?: string;
20
+ createdAt?: number | string;
21
+ };
22
+ /**
23
+ * 导入会话数据(GET /sessions/:id/export 的逆操作):
24
+ * 新建根会话(source 'web'),消息与归档**始终生成新 id**(保留 role/content/时间戳)——
25
+ * 导入副本可重复执行(同库内复制/恢复场景),不与原会话或既往导入冲突。
26
+ * 说明:导出仅含 tag='message' 的消息——compaction/steering 等特殊条目不随迁,
27
+ * 导入后会话显示完整原始消息流(上下文重建略长,但不丢内容)。
28
+ * P0:权限态(permissionMode/alwaysAllow)仅在调用方显式确认后随迁——
29
+ * metadata 由路由层过滤,未确认时不传入本函数。
30
+ */
31
+ declare function importSessionData(handle: DB, opts: {
32
+ title: string;
33
+ projectId?: string;
34
+ messages: ImportMessage[];
35
+ archives: ImportArchive[];
36
+ /** 随迁的会话 metadata(仅权限态等安全字段,由调用方过滤)。 */
37
+ metadata?: Record<string, unknown>;
38
+ }): Promise<{
39
+ sessionId: string;
40
+ messageCount: number;
41
+ archiveCount: number;
42
+ }>;
43
+ export type { ImportArchive, ImportMessage };
44
+ export { importSessionData };
@@ -0,0 +1,83 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { compactionArchives, sessionEntries, sessions } from '../db/schema.js';
3
+ import { generateId } from '../shared/index.js';
4
+ import { createSession } from './session.js';
5
+ const VALID_ROLES = new Set([
6
+ 'user',
7
+ 'assistant',
8
+ 'system',
9
+ 'tool',
10
+ ]);
11
+ function toDate(value) {
12
+ if (typeof value === 'number')
13
+ return new Date(value);
14
+ if (typeof value === 'string') {
15
+ const d = new Date(value);
16
+ if (!Number.isNaN(d.getTime()))
17
+ return d;
18
+ }
19
+ return new Date();
20
+ }
21
+ function sanitizeContent(content) {
22
+ if (Array.isArray(content)) {
23
+ // 只保留结构完整的分片;其余丢弃(导入宁少勿坏)
24
+ return content.filter((p) => p !== null && typeof p === 'object' && typeof p._tag === 'string');
25
+ }
26
+ if (typeof content === 'string' && content.length > 0) {
27
+ return [{ _tag: 'text', text: content }];
28
+ }
29
+ return [];
30
+ }
31
+ /**
32
+ * 导入会话数据(GET /sessions/:id/export 的逆操作):
33
+ * 新建根会话(source 'web'),消息与归档**始终生成新 id**(保留 role/content/时间戳)——
34
+ * 导入副本可重复执行(同库内复制/恢复场景),不与原会话或既往导入冲突。
35
+ * 说明:导出仅含 tag='message' 的消息——compaction/steering 等特殊条目不随迁,
36
+ * 导入后会话显示完整原始消息流(上下文重建略长,但不丢内容)。
37
+ * P0:权限态(permissionMode/alwaysAllow)仅在调用方显式确认后随迁——
38
+ * metadata 由路由层过滤,未确认时不传入本函数。
39
+ */
40
+ async function importSessionData(handle, opts) {
41
+ const session = await createSession(handle, opts.title, opts.projectId, undefined, 'web');
42
+ return handle.db.transaction(async (tx) => {
43
+ if (opts.metadata && Object.keys(opts.metadata).length > 0) {
44
+ await tx.update(sessions).set({ metadata: opts.metadata }).where(eq(sessions.id, session.id));
45
+ }
46
+ let messageCount = 0;
47
+ for (const m of opts.messages) {
48
+ const role = m.role;
49
+ if (typeof role !== 'string' || !VALID_ROLES.has(role))
50
+ continue;
51
+ await tx.insert(sessionEntries).values({
52
+ id: generateId(),
53
+ sessionId: session.id,
54
+ tag: 'message',
55
+ role,
56
+ content: sanitizeContent(m.content),
57
+ tokenCount: typeof m.tokenCount === 'number' ? m.tokenCount : 0,
58
+ createdAt: toDate(m.createdAt),
59
+ });
60
+ messageCount += 1;
61
+ }
62
+ let archiveCount = 0;
63
+ for (const a of opts.archives) {
64
+ await tx.insert(compactionArchives).values({
65
+ id: generateId(),
66
+ sessionId: session.id,
67
+ compactionId: typeof a.compactionId === 'string' && a.compactionId.length > 0
68
+ ? a.compactionId
69
+ : generateId(),
70
+ archiveType: typeof a.archiveType === 'string' ? a.archiveType : 'compaction',
71
+ originalEntries: Array.isArray(a.originalEntries) ? a.originalEntries : [],
72
+ fileSnapshots: Array.isArray(a.fileSnapshots) ? a.fileSnapshots : [],
73
+ summary: typeof a.summary === 'string' ? a.summary : '',
74
+ tokenCount: typeof a.tokenCount === 'number' ? a.tokenCount : null,
75
+ searchableText: typeof a.searchableText === 'string' ? a.searchableText : null,
76
+ createdAt: toDate(a.createdAt),
77
+ });
78
+ archiveCount += 1;
79
+ }
80
+ return { sessionId: session.id, messageCount, archiveCount };
81
+ });
82
+ }
83
+ export { importSessionData };
@@ -3,7 +3,7 @@ export { forkSession, getBranches, getTree } from './branch.js';
3
3
  export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
4
4
  export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
5
5
  export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
6
- export { createSession, getLLMSegments, getSession, listDeletedSessions, listSessions, migrateLegacyDetails, purgeDeletedSessions, restoreSession, saveLLMSegments, segmentFingerprint, softDeleteSession, touchSession, updateSessionTitle, } from './session.js';
6
+ export { consumeBudgetPauseMarker, createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listSessions, markBudgetPause, migrateLegacyDetails, permanentlyDeleteSession, purgeDeletedSessions, restoreSession, restoreSessionCore, saveLLMSegments, segmentFingerprint, softDeleteSession, TRASH_RETENTION_MS, touchSession, touchTrashSeen, updateSessionTitle, } from './session.js';
7
7
  export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
8
8
  export { squashRecent } from './squash.js';
9
9
  export { estimateMessageTokens, estimateTokens } from './token.js';
@@ -4,7 +4,7 @@ export { forkSession, getBranches, getTree } from './branch.js';
4
4
  export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
5
5
  export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
6
6
  export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
7
- export { createSession, getLLMSegments, getSession, listDeletedSessions, listSessions, migrateLegacyDetails, purgeDeletedSessions, restoreSession, saveLLMSegments, segmentFingerprint, softDeleteSession, touchSession, updateSessionTitle, } from './session.js';
7
+ export { consumeBudgetPauseMarker, createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listSessions, markBudgetPause, migrateLegacyDetails, permanentlyDeleteSession, purgeDeletedSessions, restoreSession, restoreSessionCore, saveLLMSegments, segmentFingerprint, softDeleteSession, TRASH_RETENTION_MS, touchSession, touchTrashSeen, updateSessionTitle, } from './session.js';
8
8
  export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
9
9
  export { squashRecent } from './squash.js';
10
10
  export { estimateMessageTokens, estimateTokens } from './token.js';
@@ -0,0 +1,12 @@
1
+ import type { DB } from '../db/client.js';
2
+ /**
3
+ * 标记崩溃遗留的后台子 agent 任务并通知父会话(P2)。
4
+ *
5
+ * background task 在子 session 上写 lastRun.status='running'(subagent.ts),
6
+ * 完成后置 'completed'。进程崩溃时悬空任务永远停在 'running',父会话收不到
7
+ * 完成通知(原实现仅内存 jobId,重启即丢)。启动时调用:
8
+ * - 悬空任务 lastRun 置 'completed'(死任务不再显示 interrupted);
9
+ * - 合成一条 state="failed" 通知写入父会话(子 session 已挂 parentId)。
10
+ * 返回标记数量。
11
+ */
12
+ export declare function markDeadBackgroundJobs(handle: DB): Promise<number>;
@@ -0,0 +1,53 @@
1
+ import { and, inArray, isNotNull, sql } from 'drizzle-orm';
2
+ import { sessions } from '../db/schema.js';
3
+ import { appendMessage } from './message.js';
4
+ import { updateSessionLastRun } from './session.js';
5
+ /**
6
+ * 标记崩溃遗留的后台子 agent 任务并通知父会话(P2)。
7
+ *
8
+ * background task 在子 session 上写 lastRun.status='running'(subagent.ts),
9
+ * 完成后置 'completed'。进程崩溃时悬空任务永远停在 'running',父会话收不到
10
+ * 完成通知(原实现仅内存 jobId,重启即丢)。启动时调用:
11
+ * - 悬空任务 lastRun 置 'completed'(死任务不再显示 interrupted);
12
+ * - 合成一条 state="failed" 通知写入父会话(子 session 已挂 parentId)。
13
+ * 返回标记数量。
14
+ */
15
+ export async function markDeadBackgroundJobs(handle) {
16
+ const rows = await handle.db
17
+ .select({
18
+ id: sessions.id,
19
+ parentId: sessions.parentId,
20
+ agentType: sessions.agentType,
21
+ })
22
+ .from(sessions)
23
+ .where(and(isNotNull(sessions.agentType), isNotNull(sessions.parentId), sql `${sessions.metadata}->'lastRun'->>'status' = 'running'`));
24
+ // 父会话已入回收站的不再注入失败通知(否则向 60 天后将清除的会话写死消息)。
25
+ const parentIds = Array.from(new Set(rows.map((r) => r.parentId).filter((p) => !!p)));
26
+ const trashedParents = new Set();
27
+ if (parentIds.length > 0) {
28
+ const parents = await handle.db
29
+ .select({ id: sessions.id, deletedAt: sessions.deletedAt })
30
+ .from(sessions)
31
+ .where(inArray(sessions.id, parentIds));
32
+ for (const p of parents)
33
+ if (p.deletedAt)
34
+ trashedParents.add(p.id);
35
+ }
36
+ for (const row of rows) {
37
+ await updateSessionLastRun(handle, row.id, {
38
+ status: 'completed',
39
+ agentName: row.agentType ?? undefined,
40
+ startedAt: Date.now(),
41
+ });
42
+ if (row.parentId && !trashedParents.has(row.parentId)) {
43
+ const synthetic = `<task id="${row.id}" state="failed">\n<task_error>\n后台任务在服务重启前未完成,已标记为失败(可重新派发)\n</task_error>\n</task>`;
44
+ await appendMessage(handle, row.parentId, {
45
+ role: 'user',
46
+ content: [{ _tag: 'text', text: synthetic }],
47
+ }).catch(() => {
48
+ // 通知写入失败:任务已标记,父会话可能收不到失败提示——仅记录
49
+ });
50
+ }
51
+ }
52
+ return rows.length;
53
+ }