c0de-agent 1.10.0 → 1.12.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 (124) hide show
  1. package/README.md +28 -6
  2. package/dist/cli/commands/acp.js +3 -3
  3. package/dist/cli/commands/auth.d.ts +17 -0
  4. package/dist/cli/commands/auth.js +73 -0
  5. package/dist/cli/commands/chat.js +95 -1
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +25 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/sessions.d.ts +10 -0
  11. package/dist/cli/commands/sessions.js +94 -0
  12. package/dist/cli/commands/update.js +18 -10
  13. package/dist/cli/index.js +51 -4
  14. package/dist/cli/modes/print.js +19 -2
  15. package/dist/core/compact.d.ts +2 -0
  16. package/dist/core/compact.js +1 -0
  17. package/dist/core/config-path.d.ts +9 -0
  18. package/dist/core/config-path.js +48 -0
  19. package/dist/core/config.d.ts +20 -3
  20. package/dist/core/config.js +103 -14
  21. package/dist/core/index.d.ts +1 -1
  22. package/dist/core/index.js +1 -1
  23. package/dist/core/loop/compaction.d.ts +2 -1
  24. package/dist/core/loop/compaction.js +19 -6
  25. package/dist/core/loop/stream-collect.js +9 -1
  26. package/dist/core/loop/subagent.js +23 -5
  27. package/dist/core/loop.js +13 -3
  28. package/dist/core/redact.d.ts +11 -0
  29. package/dist/core/redact.js +49 -0
  30. package/dist/core/slash.js +167 -63
  31. package/dist/core/title.js +22 -1
  32. package/dist/core/types.d.ts +4 -0
  33. package/dist/db/client.d.ts +4 -0
  34. package/dist/db/client.js +5 -0
  35. package/dist/db/schema.d.ts +51 -0
  36. package/dist/db/schema.js +6 -0
  37. package/dist/kanban/index.d.ts +1 -1
  38. package/dist/kanban/index.js +1 -1
  39. package/dist/kanban/store.d.ts +5 -1
  40. package/dist/kanban/store.js +76 -2
  41. package/dist/llm/index.d.ts +1 -1
  42. package/dist/llm/index.js +1 -1
  43. package/dist/llm/provider.js +1 -1
  44. package/dist/llm/retry.d.ts +5 -2
  45. package/dist/llm/retry.js +6 -5
  46. package/dist/llm/routing.d.ts +24 -1
  47. package/dist/llm/routing.js +29 -1
  48. package/dist/project/index.d.ts +1 -1
  49. package/dist/project/index.js +1 -1
  50. package/dist/project/project.d.ts +14 -0
  51. package/dist/project/project.js +77 -1
  52. package/dist/server/agent-manager.d.ts +14 -0
  53. package/dist/server/agent-manager.js +41 -0
  54. package/dist/server/app.js +12 -2
  55. package/dist/server/auth-manager.d.ts +85 -0
  56. package/dist/server/auth-manager.js +289 -0
  57. package/dist/server/context.js +4 -0
  58. package/dist/server/middleware/auth.d.ts +4 -3
  59. package/dist/server/middleware/auth.js +5 -5
  60. package/dist/server/permission/interactive.d.ts +7 -2
  61. package/dist/server/permission/interactive.js +25 -5
  62. package/dist/server/permission/store.d.ts +15 -1
  63. package/dist/server/permission/store.js +31 -2
  64. package/dist/server/registry-config.d.ts +14 -0
  65. package/dist/server/registry-config.js +44 -0
  66. package/dist/server/routes/auth.d.ts +4 -0
  67. package/dist/server/routes/auth.js +124 -0
  68. package/dist/server/routes/chat.d.ts +18 -1
  69. package/dist/server/routes/chat.js +295 -37
  70. package/dist/server/routes/commands.d.ts +4 -2
  71. package/dist/server/routes/commands.js +10 -3
  72. package/dist/server/routes/config.js +143 -15
  73. package/dist/server/routes/files.js +22 -4
  74. package/dist/server/routes/kanban.js +68 -6
  75. package/dist/server/routes/permissions.d.ts +0 -1
  76. package/dist/server/routes/permissions.js +153 -4
  77. package/dist/server/routes/project.js +106 -3
  78. package/dist/server/routes/provider.js +48 -3
  79. package/dist/server/routes/session.js +319 -19
  80. package/dist/server/routes/terminal.js +17 -2
  81. package/dist/server/routes/update.d.ts +11 -5
  82. package/dist/server/routes/update.js +98 -16
  83. package/dist/server/server.d.ts +4 -12
  84. package/dist/server/server.js +235 -100
  85. package/dist/server/types.d.ts +15 -1
  86. package/dist/session/archive.d.ts +4 -2
  87. package/dist/session/archive.js +13 -1
  88. package/dist/session/branch.d.ts +1 -0
  89. package/dist/session/branch.js +32 -7
  90. package/dist/session/import.d.ts +44 -0
  91. package/dist/session/import.js +83 -0
  92. package/dist/session/index.d.ts +1 -1
  93. package/dist/session/index.js +1 -1
  94. package/dist/session/jobs.d.ts +12 -0
  95. package/dist/session/jobs.js +53 -0
  96. package/dist/session/message.d.ts +7 -1
  97. package/dist/session/message.js +21 -1
  98. package/dist/session/session.d.ts +143 -6
  99. package/dist/session/session.js +561 -11
  100. package/dist/session/snapshot.d.ts +5 -2
  101. package/dist/session/snapshot.js +10 -2
  102. package/dist/session/types.d.ts +12 -2
  103. package/dist/shared/types/agent.d.ts +22 -0
  104. package/dist/shared/types/config.d.ts +10 -1
  105. package/dist/shared/types/kanban.d.ts +13 -0
  106. package/dist/shared/types/message.d.ts +15 -2
  107. package/dist/tools/index.js +2 -3
  108. package/dist/tools/websearch/index.js +6 -3
  109. package/dist/update/index.d.ts +3 -3
  110. package/dist/update/index.js +2 -2
  111. package/dist/update/ipc.d.ts +19 -2
  112. package/dist/update/ipc.js +43 -6
  113. package/dist/update/snapshot.d.ts +5 -0
  114. package/dist/update/snapshot.js +10 -0
  115. package/dist/update/updater.d.ts +69 -8
  116. package/dist/update/updater.js +263 -32
  117. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  118. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  119. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  120. package/drizzle/meta/0006_snapshot.json +867 -0
  121. package/drizzle/meta/0007_snapshot.json +869 -0
  122. package/drizzle/meta/0008_snapshot.json +875 -0
  123. package/drizzle/meta/_journal.json +21 -0
  124. package/package.json +41 -42
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 敏感字段脱敏工具:/config 斜杠命令与 `c0de config get` 展示配置时使用,
3
+ * 防止 apiKey / token 等明文出现在聊天界面或终端输出中。
4
+ */
5
+ export declare function isSecretKey(key: string): boolean;
6
+ /** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。 */
7
+ export declare function maskSecret(value: unknown): unknown;
8
+ /** 递归脱敏:键名命中敏感模式的字段被掩码,其余结构原样保留。 */
9
+ export declare function redactSecrets(value: unknown, key?: string): unknown;
10
+ /** 判断对象树中是否含非空敏感值(用于「配置文件含密钥」类警告)。 */
11
+ export declare function containsSecrets(value: unknown): boolean;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 敏感字段脱敏工具:/config 斜杠命令与 `c0de config get` 展示配置时使用,
3
+ * 防止 apiKey / token 等明文出现在聊天界面或终端输出中。
4
+ */
5
+ /** 敏感字段名模式(大小写不敏感,匹配完整键名)。 */
6
+ const SECRET_KEY_RE = /(api[_-]?key|token|password|passwd|secret|authorization|auth[_-]?token)$/i;
7
+ export function isSecretKey(key) {
8
+ return SECRET_KEY_RE.test(key);
9
+ }
10
+ /** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。 */
11
+ export function maskSecret(value) {
12
+ if (typeof value !== 'string')
13
+ return '****';
14
+ if (value.length <= 8)
15
+ return '****';
16
+ return `${value.slice(0, 4)}****${value.slice(-4)}`;
17
+ }
18
+ /** 递归脱敏:键名命中敏感模式的字段被掩码,其余结构原样保留。 */
19
+ export function redactSecrets(value, key) {
20
+ if (typeof key === 'string' && isSecretKey(key))
21
+ return maskSecret(value);
22
+ if (Array.isArray(value))
23
+ return value.map((v) => redactSecrets(v));
24
+ if (value !== null && typeof value === 'object') {
25
+ const out = {};
26
+ for (const [k, v] of Object.entries(value)) {
27
+ out[k] = redactSecrets(v, k);
28
+ }
29
+ return out;
30
+ }
31
+ return value;
32
+ }
33
+ /** 判断对象树中是否含非空敏感值(用于「配置文件含密钥」类警告)。 */
34
+ export function containsSecrets(value) {
35
+ if (Array.isArray(value))
36
+ return value.some((v) => containsSecrets(v));
37
+ if (value !== null && typeof value === 'object') {
38
+ for (const [k, v] of Object.entries(value)) {
39
+ if (isSecretKey(k)) {
40
+ if (typeof v === 'string' ? v.length > 0 : v != null)
41
+ return true;
42
+ continue;
43
+ }
44
+ if (containsSecrets(v))
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
@@ -25,24 +25,28 @@ function createSlashRegistry() {
25
25
  }
26
26
  const helpCommand = {
27
27
  name: 'help',
28
- description: 'List available slash commands',
29
- execute: async () => {
30
- const lines = [
31
- 'Available commands:',
32
- ' /compact Manually trigger context compaction',
33
- ' /model <name> Switch the current session model',
34
- ' /clear Clear session messages',
35
- ' /help Show this help',
36
- ' /fork [index] Fork session from a message',
37
- ' /config [k][v] View or set configuration',
38
- ' /workflow Manage and run workflows',
39
- ];
28
+ description: '列出可用斜杠命令',
29
+ execute: async (_args, ctx) => {
30
+ // 从 registry 动态生成,避免静态文案随命令增删漂移;
31
+ // 尊重 slashCommands.enabled 过滤(空 = 全部启用,与消费方语义一致)。
32
+ const enabledList = ctx.config.slashCommands?.enabled ?? [];
33
+ const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
34
+ const commands = createSlashRegistry()
35
+ .list()
36
+ .filter((c) => enabledSet.size === 0 || enabledSet.has(c.name));
37
+ const width = Math.max(...commands.map((c) => `/${c.name}`.length));
38
+ const lines = ['可用命令:'];
39
+ for (const cmd of commands) {
40
+ const hint = cmd.argsHint ? ` ${cmd.argsHint}` : '';
41
+ const pad = ' '.repeat(Math.max(1, width - `/${cmd.name}`.length));
42
+ lines.push(` /${cmd.name}${pad}${hint} ${cmd.description}`);
43
+ }
40
44
  return { _tag: 'text', text: lines.join('\n') };
41
45
  },
42
46
  };
43
47
  const compactCommand = {
44
48
  name: 'compact',
45
- description: 'Manually trigger context compaction',
49
+ description: '手动触发上下文压缩',
46
50
  execute: async () => {
47
51
  // 仅声明意图:真正的压缩由消费方(loop.compactContext / chat 路由)执行,
48
52
  // 复用 createSummarizer + runCompaction,且不把 /compact 当作 user 消息发给 LLM。
@@ -51,64 +55,167 @@ const compactCommand = {
51
55
  };
52
56
  const modelCommand = {
53
57
  name: 'model',
54
- description: 'Switch the current session model',
55
- argsHint: '<model-name>',
56
- execute: async (args) => {
57
- if (!args)
58
- return { _tag: 'error', message: 'Usage: /model <model-name>' };
59
- return { _tag: 'success', message: `Model set to ${args} (takes effect next turn)` };
58
+ description: '查看当前会话使用的模型',
59
+ argsHint: '',
60
+ execute: async (_args, ctx) => {
61
+ // P2:诚实化——此前返回「Model set to X」但什么都不生效,后改为引导文案。
62
+ // 现在提供真实信息:优先读本会话活跃段的 provider/model(若存在),
63
+ // 否则回退默认配置。会话模型切换由底部 ModelSelector 完成(Web);
64
+ // CLI 按渠道给出可操作指引(P3:此前 CLI 输出指向 Web-only 控件,是死路)。
65
+ const model = async () => {
66
+ if (!ctx.sessionId)
67
+ return null;
68
+ try {
69
+ const { getLLMSegments } = await import('../session/session.js');
70
+ const segs = await getLLMSegments(ctx.deps.db, ctx.sessionId);
71
+ const last = segs[segs.length - 1];
72
+ if (last)
73
+ return `${last.model}(provider: ${last.provider})`;
74
+ }
75
+ catch {
76
+ // 段查询失败回退默认值展示
77
+ }
78
+ return null;
79
+ };
80
+ const sessionModel = await model();
81
+ if (ctx.channel === 'cli') {
82
+ return {
83
+ _tag: 'text',
84
+ text: `${sessionModel ? `当前会话模型:${sessionModel}` : `默认模型:${ctx.config.defaultModel}(provider: ${ctx.config.defaultProvider})`}\n` +
85
+ 'CLI 模式不支持会话内切换模型:新会话用 c0de chat --model <model>,' +
86
+ '或 c0de config set defaultModel <model> 修改默认模型。',
87
+ };
88
+ }
89
+ return {
90
+ _tag: 'text',
91
+ text: `${sessionModel ? `当前会话模型:${sessionModel}` : `默认模型:${ctx.config.defaultModel}(provider: ${ctx.config.defaultProvider})`}\n` +
92
+ '切换模型请使用聊天页底部的模型选择器。',
93
+ };
60
94
  },
61
95
  };
62
96
  const clearCommand = {
63
97
  name: 'clear',
64
- description: 'Clear session messages',
98
+ description: '清空会话消息(先归档原始内容)',
99
+ argsHint: '[session-id] [--yes]',
65
100
  execute: async (args, ctx) => {
66
- if (!args)
67
- return { _tag: 'error', message: 'Usage: /clear <session-id>' };
68
- const { deleteEntriesByIds, getEntries } = await import('../session/message.js');
69
- const entries = await getEntries(ctx.deps.db, args);
101
+ const parts = args.split(/\s+/).filter(Boolean);
102
+ const yes = parts.includes('--yes');
103
+ const sessionId = parts.find((p) => p !== '--yes') ?? ctx.sessionId;
104
+ if (!sessionId) {
105
+ return { _tag: 'error', message: 'Usage: /clear [session-id] --yes(当前会话可省略 id)' };
106
+ }
107
+ if (!yes) {
108
+ return {
109
+ _tag: 'error',
110
+ message: '清空消息不可逆。原始消息将归档到「会话归档」面板(会话页顶部「归档」按钮可查看/搜索),确认请执行 /clear --yes(或 /clear <session-id> --yes)。',
111
+ };
112
+ }
113
+ const { getEntries, deleteEntriesByIds } = await import('../session/message.js');
114
+ const entries = await getEntries(ctx.deps.db, sessionId);
70
115
  const ids = entries.map((e) => ('id' in e ? e.id : '')).filter(Boolean);
71
116
  if (ids.length > 0) {
117
+ // 归档原始内容(与 shake 同机制),删除才有后悔路径
118
+ const { archiveOriginalEntries } = await import('../session/archive.js');
119
+ const { generateId } = await import('../shared/index.js');
120
+ await archiveOriginalEntries(ctx.deps.db, sessionId, entries, 'clear', `Cleared ${ids.length} entries`, generateId());
72
121
  await deleteEntriesByIds(ctx.deps.db, ids);
73
122
  }
74
- return { _tag: 'success', message: `Cleared ${ids.length} entries` };
123
+ return {
124
+ _tag: 'success',
125
+ message: `已清空 ${ids.length} 条消息(原内容已归档,可在会话页「归档」面板查看)`,
126
+ };
75
127
  },
76
128
  };
77
129
  const forkCommand = {
78
130
  name: 'fork',
79
- description: 'Fork session from a message index',
80
- argsHint: '<session-id> [message-index]',
131
+ description: '从指定消息处分支会话',
132
+ argsHint: '[session-id] [message-index]',
81
133
  execute: async (args, ctx) => {
82
- if (!args)
83
- return { _tag: 'error', message: 'Usage: /fork <session-id> [message-index]' };
84
- const parts = args.split(/\s+/);
85
- const sessionId = parts[0] ?? '';
86
- const messageIndex = parts[1] !== undefined ? Number.parseInt(parts[1], 10) : 0;
134
+ const parts = args.split(/\s+/).filter(Boolean);
135
+ // 默认当前会话;第一个参数若是 session id(非纯数字)则用它。
136
+ let sessionId = ctx.sessionId;
137
+ let messageIndex;
138
+ for (const p of parts) {
139
+ if (/^\d+$/.test(p)) {
140
+ messageIndex = Number.parseInt(p, 10);
141
+ }
142
+ else if (!sessionId) {
143
+ sessionId = p;
144
+ }
145
+ }
146
+ if (!sessionId) {
147
+ return {
148
+ _tag: 'error',
149
+ message: 'Usage: /fork [session-id] [message-index](当前会话可省略 id)',
150
+ };
151
+ }
152
+ // 未指定 index → 默认最新一条消息处分支(与 web fork API 语义一致,不再是 index 0)
153
+ if (messageIndex === undefined) {
154
+ const { getMessages } = await import('../session/message.js');
155
+ const messages = await getMessages(ctx.deps.db, sessionId);
156
+ if (messages.length === 0) {
157
+ return { _tag: 'error', message: 'EMPTY_SESSION: 空会话无法分支' };
158
+ }
159
+ messageIndex = messages.length - 1;
160
+ }
87
161
  const { forkSession } = await import('../session/branch.js');
88
- const forked = await forkSession(ctx.deps.db, sessionId, messageIndex);
89
- return { _tag: 'success', message: `Forked to new session: ${forked.id}` };
162
+ try {
163
+ const forked = await forkSession(ctx.deps.db, sessionId, messageIndex);
164
+ return { _tag: 'success', message: `已分支到新会话:${forked.id}` };
165
+ }
166
+ catch (error) {
167
+ return {
168
+ _tag: 'error',
169
+ message: error instanceof Error ? error.message : String(error),
170
+ };
171
+ }
90
172
  },
91
173
  };
92
174
  const configCommand = {
93
175
  name: 'config',
94
- description: 'View or set configuration',
176
+ description: '查看或设置配置(支持点路径)',
95
177
  argsHint: '[key] [value]',
96
178
  execute: async (args, ctx) => {
179
+ const { getByPath, setPathPatch, coerce } = await import('./config-path.js');
180
+ // 展示前脱敏:apiKey/token 等敏感字段绝不原样出现在聊天里(P0 密钥暴露)。
181
+ const { redactSecrets } = await import('./redact.js');
97
182
  if (!args) {
98
- return { _tag: 'text', text: JSON.stringify(ctx.config, null, 2) };
183
+ return {
184
+ _tag: 'text',
185
+ text: `(合并视图:global + project 作用域;修改请用 /config <key> <value>,写入 project 作用域)\n${JSON.stringify(redactSecrets(ctx.config), null, 2)}`,
186
+ };
99
187
  }
100
188
  const parts = args.split(/\s+/);
101
189
  const key = parts[0] ?? '';
102
190
  if (parts.length === 1) {
103
- const value = ctx.config[key];
104
- return { _tag: 'text', text: `${key}: ${JSON.stringify(value)}` };
191
+ try {
192
+ // 用点路径末段作为键名参与脱敏判定:/config security.token 等单键读取同样掩码。
193
+ const leaf = key.split('.').pop();
194
+ const value = redactSecrets(getByPath(ctx.config, key), leaf);
195
+ return {
196
+ _tag: 'text',
197
+ text: `${key}: ${JSON.stringify(value)}(合并视图:global + project 作用域)`,
198
+ };
199
+ }
200
+ catch (error) {
201
+ return { _tag: 'error', message: error instanceof Error ? error.message : String(error) };
202
+ }
105
203
  }
106
- return { _tag: 'success', message: 'Config updates are handled via the config API' };
204
+ // 点路径写值:与 CLI config set 同语义(project 作用域最小落盘,null=unset)
205
+ const { applyScopedPatch, loadConfigScopes, saveConfigScoped } = await import('./config.js');
206
+ const scopes = loadConfigScopes(ctx.cwd);
207
+ const value = coerce(parts.slice(1).join(' '));
208
+ const next = applyScopedPatch(scopes.project ?? {}, setPathPatch(key, value));
209
+ await saveConfigScoped('project', ctx.cwd, next);
210
+ return {
211
+ _tag: 'success',
212
+ message: `${value === null ? '已取消设置' : '已设置'} ${key} (scope: project)`,
213
+ };
107
214
  },
108
215
  };
109
216
  const workflowCommand = {
110
217
  name: 'workflow',
111
- description: 'Manage and run workflows',
218
+ description: '管理并运行工作流',
112
219
  argsHint: '[list|run|show|create|edit] [name] [args]',
113
220
  subcommands: [
114
221
  { name: 'list', description: 'List available workflows' },
@@ -139,13 +246,13 @@ const workflowCommand = {
139
246
  for (const wf of projectWorkflows)
140
247
  byName.set(wf.meta.name, wf);
141
248
  const workflows = Array.from(byName.values());
142
- const lines = ['Available workflows:'];
249
+ const lines = ['可用工作流:'];
143
250
  for (const wf of workflows) {
144
251
  const phases = wf.meta.phases ? ` [${wf.meta.phases.join('→')}]` : '';
145
252
  lines.push(` /${wf.meta.name}${phases} — ${wf.meta.description} (${wf.source})`);
146
253
  }
147
254
  lines.push('');
148
- lines.push('Usage:');
255
+ lines.push('用法:');
149
256
  lines.push(' /workflow run <name> [args] — 执行工作流');
150
257
  lines.push(' /workflow create <name> --file <path> — 从文件创建工作流');
151
258
  lines.push(' /workflow edit <name> — 编辑工作流源码');
@@ -158,7 +265,7 @@ const workflowCommand = {
158
265
  return { _tag: 'error', message: 'Usage: /workflow show <name>' };
159
266
  const wf = resolveEntry(name);
160
267
  if (!wf) {
161
- return { _tag: 'error', message: `Unknown workflow: ${name}` };
268
+ return { _tag: 'error', message: `未知工作流:${name}` };
162
269
  }
163
270
  const code = wf.sourceCode ?? '// source not available';
164
271
  return {
@@ -184,7 +291,7 @@ const workflowCommand = {
184
291
  source = await import('node:fs/promises').then((fs) => fs.readFile(filePath, 'utf-8'));
185
292
  }
186
293
  catch {
187
- return { _tag: 'error', message: `Cannot read file: ${filePath}` };
294
+ return { _tag: 'error', message: `无法读取文件:${filePath}` };
188
295
  }
189
296
  const result = await saveWorkflow(name, source, 'project', ctx.cwd);
190
297
  if (!result.ok) {
@@ -196,7 +303,7 @@ const workflowCommand = {
196
303
  }
197
304
  return {
198
305
  _tag: 'success',
199
- message: `Workflow "${name}" saved to ${result.filePath}\n现在可以用 /workflow run ${name} 执行,或在对话中输入 /${name} 调用。`,
306
+ message: `工作流 "${name}" 已保存到 ${result.filePath}\n现在可以用 /workflow run ${name} 执行,或在对话中输入 /${name} 调用。`,
200
307
  };
201
308
  }
202
309
  if (subcommand === 'edit') {
@@ -205,30 +312,27 @@ const workflowCommand = {
205
312
  return { _tag: 'error', message: 'Usage: /workflow edit <name>' };
206
313
  const wf = resolveEntry(name);
207
314
  if (!wf) {
208
- return { _tag: 'error', message: `Unknown workflow: ${name}` };
315
+ return { _tag: 'error', message: `未知工作流:${name}` };
209
316
  }
210
317
  if (wf.source === 'builtin') {
211
318
  return {
212
319
  _tag: 'error',
213
- message: 'Cannot edit builtin workflow. Fork it first: /workflow create <new-name> --file <path>',
320
+ message: '内置工作流不可编辑。请先复制:/workflow create <新名称> --file <路径>',
214
321
  };
215
322
  }
216
323
  if (!wf.filePath) {
217
- return { _tag: 'error', message: `Workflow file path not available for "${name}"` };
324
+ return { _tag: 'error', message: `工作流 "${name}" 的文件路径不可用` };
218
325
  }
219
- const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
220
- try {
221
- const { spawnSync } = await import('node:child_process');
222
- spawnSync(editor, [wf.filePath], { stdio: 'inherit' });
223
- }
224
- catch {
225
- return { _tag: 'error', message: `Failed to launch editor: ${editor}` };
226
- }
227
- // 编辑后热重载
228
- if (ctx.workflowRegistry) {
229
- await reloadRegistry(ctx.workflowRegistry, ctx.cwd);
230
- }
231
- return { _tag: 'success', message: `Workflow "${name}" reloaded after edit.` };
326
+ // 斜杠命令经 Web SSE 执行,无法在浏览器里交互式打开终端编辑器——
327
+ // 在 serve 进程 spawn vi 会让用户既看不到也无法输入,SSE 流还会阻塞。
328
+ // 改为给出文件路径引导用户在本地编辑器/文件面板中编辑。
329
+ return {
330
+ _tag: 'error',
331
+ message: `Web 端不支持交互式编辑器。请用本地编辑器打开工作流文件后保存:\n` +
332
+ ` ${wf.filePath}\n` +
333
+ `保存后下次 /workflow run ${name} 会读取最新内容(项目工作流按需从磁盘发现)。` +
334
+ `或使用 /workflow create <name> --file <path> 覆盖。`,
335
+ };
232
336
  }
233
337
  if (subcommand === 'run') {
234
338
  const name = parts[1];
@@ -243,7 +347,7 @@ const workflowCommand = {
243
347
  ].join(', ');
244
348
  return {
245
349
  _tag: 'error',
246
- message: `Unknown workflow: "${name}". Available: ${available || '(none)'}`,
350
+ message: `未知工作流:"${name}"。可用:${available || '(无)'}`,
247
351
  };
248
352
  }
249
353
  const agentConfig = {
@@ -266,7 +370,7 @@ const workflowCommand = {
266
370
  }
267
371
  return {
268
372
  _tag: 'error',
269
- message: `Unknown subcommand: ${subcommand}. Use: list, run, show, create, edit`,
373
+ message: `未知子命令:${subcommand}。可用:list, run, show, create, edit`,
270
374
  };
271
375
  },
272
376
  };
@@ -1,4 +1,5 @@
1
1
  import { chat as defaultChat } from '../llm/provider.js';
2
+ import { buildFallbackChain } from '../llm/routing.js';
2
3
  import { updateSessionTitle } from '../session/session.js';
3
4
  /** 会话默认占位标题;仍是该值时才有资格自动生成。 */
4
5
  const DEFAULT_SESSION_TITLE = 'New Session';
@@ -102,7 +103,12 @@ async function generateSessionTitle(deps, sessionId, firstMessage, chatProvider,
102
103
  stream: true,
103
104
  system: TITLE_PROMPT,
104
105
  };
105
- const text = await chatFn({ registry: llmRegistry }, request, { provider, model });
106
+ const fallback = buildFallbackChain(config, provider, model);
107
+ const text = await chatFn({ registry: llmRegistry }, request, {
108
+ provider,
109
+ model,
110
+ ...(fallback ? { fallback } : {}),
111
+ });
106
112
  const title = cleanTitle(text);
107
113
  if (title.length === 0)
108
114
  return;
@@ -111,6 +117,21 @@ async function generateSessionTitle(deps, sessionId, firstMessage, chatProvider,
111
117
  catch (e) {
112
118
  // 标题生成是尽力而为的辅助功能:失败不阻塞主对话流,但记录以便排查。
113
119
  console.warn('[title] generateSessionTitle failed:', e instanceof Error ? e.message : String(e));
120
+ // P2-12:失败时回退到首条消息摘要,避免列表出现大量同名 "New Session"。
121
+ await fallbackTitleFromMessage(db, sessionId, firstMessage);
114
122
  }
115
123
  }
124
+ /** 从首条消息生成回退标题:取首个非空行,超长截断(与 cleanTitle 一致)。 */
125
+ async function fallbackTitleFromMessage(db, sessionId, firstMessage) {
126
+ const firstLine = firstMessage
127
+ .split('\n')
128
+ .map((l) => l.trim())
129
+ .find((l) => l.length > 0);
130
+ if (!firstLine)
131
+ return;
132
+ const title = firstLine.length > TITLE_MAX_LENGTH
133
+ ? `${firstLine.slice(0, TITLE_MAX_LENGTH - 3)}...`
134
+ : firstLine;
135
+ await updateSessionTitle(db, sessionId, title).catch(() => { });
136
+ }
116
137
  export { DEFAULT_SESSION_TITLE, generateSessionTitle, isDefaultTitle };
@@ -87,7 +87,11 @@ type CommandContext = {
87
87
  cwd: string;
88
88
  config: Config;
89
89
  deps: AgentDependencies;
90
+ /** 当前会话 id(Web chat 路由注入):/clear /fork 等命令默认作用于当前会话。 */
91
+ sessionId?: string;
90
92
  workflowRegistry?: import('./workflows/registry.js').WorkflowRegistry;
93
+ /** 消费渠道(P3):/model 等命令按渠道给可操作指引(CLI 无底部模型选择器)。 */
94
+ channel?: 'web' | 'cli';
91
95
  };
92
96
  /** Declarative subcommand definition for commands like /workflow. */
93
97
  type SubcommandDef = {
@@ -5,10 +5,14 @@ import type { DBConfig } from './types.js';
5
5
  * Unified database handle.
6
6
  * `db` is the Drizzle ORM instance with schema bound.
7
7
  * `close()` cleans up the connection.
8
+ * `sync()` (PGLite only) flushes WAL to filesystem — periodic call 缩短
9
+ * 突然停机(kill -9)时的数据丢失窗口,是 WAL 之外的额外保险。
8
10
  */
9
11
  type DB = {
10
12
  db: PgliteDatabase<typeof schema>;
11
13
  close(): Promise<void>;
14
+ /** PGLite:syncToFs(false) 全量刷盘;PostgreSQL 模式下为 no-op(服务端自行管理)。 */
15
+ sync?(): Promise<void>;
12
16
  };
13
17
  /**
14
18
  * Create a database connection.
package/dist/db/client.js CHANGED
@@ -23,6 +23,11 @@ async function createDB(config) {
23
23
  async close() {
24
24
  await client.close();
25
25
  },
26
+ async sync() {
27
+ // P2-15:主动把 WAL 刷到文件系统(默认非 relaxedDurability 全量 fsync),
28
+ // 使 kill -9 等非优雅退出时已提交事务不丢。失败吞掉(尽力而为)。
29
+ await client.syncToFs().catch(() => { });
30
+ },
26
31
  };
27
32
  }
28
33
  // PostgreSQL mode — cast to PgliteDatabase for a unified interface.
@@ -273,6 +273,57 @@ export declare const sessions: import("drizzle-orm/pg-core").PgTableWithColumns<
273
273
  identity: undefined;
274
274
  generated: undefined;
275
275
  }, {}, {}>;
276
+ source: import("drizzle-orm/pg-core").PgColumn<{
277
+ name: "source";
278
+ tableName: "sessions";
279
+ dataType: "string";
280
+ columnType: "PgText";
281
+ data: string;
282
+ driverParam: string;
283
+ notNull: false;
284
+ hasDefault: true;
285
+ isPrimaryKey: false;
286
+ isAutoincrement: false;
287
+ hasRuntimeDefault: false;
288
+ enumValues: [string, ...string[]];
289
+ baseColumn: never;
290
+ identity: undefined;
291
+ generated: undefined;
292
+ }, {}, {}>;
293
+ deletedAt: import("drizzle-orm/pg-core").PgColumn<{
294
+ name: "deleted_at";
295
+ tableName: "sessions";
296
+ dataType: "date";
297
+ columnType: "PgTimestamp";
298
+ data: Date;
299
+ driverParam: string;
300
+ notNull: false;
301
+ hasDefault: true;
302
+ isPrimaryKey: false;
303
+ isAutoincrement: false;
304
+ hasRuntimeDefault: false;
305
+ enumValues: undefined;
306
+ baseColumn: never;
307
+ identity: undefined;
308
+ generated: undefined;
309
+ }, {}, {}>;
310
+ deletedBatchId: import("drizzle-orm/pg-core").PgColumn<{
311
+ name: "deleted_batch_id";
312
+ tableName: "sessions";
313
+ dataType: "string";
314
+ columnType: "PgUUID";
315
+ data: string;
316
+ driverParam: string;
317
+ notNull: false;
318
+ hasDefault: false;
319
+ isPrimaryKey: false;
320
+ isAutoincrement: false;
321
+ hasRuntimeDefault: false;
322
+ enumValues: undefined;
323
+ baseColumn: never;
324
+ identity: undefined;
325
+ generated: undefined;
326
+ }, {}, {}>;
276
327
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
277
328
  name: "created_at";
278
329
  tableName: "sessions";
package/dist/db/schema.js CHANGED
@@ -29,6 +29,12 @@ export const sessions = pgTable('sessions', {
29
29
  metadata: jsonb('metadata').notNull().default({}),
30
30
  agentType: text('agent_type'),
31
31
  worktreePath: text('worktree_path'),
32
+ /** 会话来源:'web'(Web UI)/ 'cli'(CLI print 模式);null=旧数据视为 web。 */
33
+ source: text('source').default(sql `null`),
34
+ /** 软删除时间戳;null=未删除。回收站保留 60 天后物理清除。 */
35
+ deletedAt: timestamp('deleted_at', { withTimezone: true }).default(sql `null`),
36
+ /** 删除级联批次号:同一次 softDeleteSession 的父+后代共享;恢复时仅还原同批次后代。 */
37
+ deletedBatchId: uuid('deleted_batch_id'),
32
38
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
33
39
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
34
40
  }, (table) => [
@@ -1 +1 @@
1
- export { createKanbanStore } from './store.js';
1
+ export { createKanbanStore, KanbanColumnInUseError } from './store.js';
@@ -1 +1 @@
1
- export { createKanbanStore } from './store.js';
1
+ export { createKanbanStore, KanbanColumnInUseError } from './store.js';
@@ -1,8 +1,12 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import type { KanbanStore } from '../shared/types/kanban.js';
3
+ /** 列内仍有卡片时禁止删除该列(否则卡片静默不可见)。 */
4
+ declare class KanbanColumnInUseError extends Error {
5
+ constructor(columnNames: string[], cardCount: number);
6
+ }
3
7
  /**
4
8
  * Create a project-scoped KanbanStore backed by the given db handle.
5
9
  * The board is created on first access (lazy) with the default 5 columns.
6
10
  */
7
11
  declare function createKanbanStore(handle: DB, projectId: string): KanbanStore;
8
- export { createKanbanStore };
12
+ export { createKanbanStore, KanbanColumnInUseError };