c0de-agent 1.9.0 → 1.11.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 (109) hide show
  1. package/README.md +25 -8
  2. package/dist/cli/commands/acp.js +5 -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 +10 -0
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +15 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/serve.d.ts +1 -0
  11. package/dist/cli/commands/serve.js +4 -1
  12. package/dist/cli/commands/sessions.d.ts +10 -0
  13. package/dist/cli/commands/sessions.js +35 -0
  14. package/dist/cli/commands/update.js +18 -10
  15. package/dist/cli/deps.d.ts +7 -5
  16. package/dist/cli/deps.js +23 -7
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +96 -37
  19. package/dist/cli/modes/print.d.ts +3 -1
  20. package/dist/cli/modes/print.js +31 -4
  21. package/dist/core/agent.js +3 -4
  22. package/dist/core/config-path.d.ts +9 -0
  23. package/dist/core/config-path.js +48 -0
  24. package/dist/core/config.d.ts +27 -2
  25. package/dist/core/config.js +83 -13
  26. package/dist/core/index.d.ts +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/loop.js +14 -4
  29. package/dist/core/slash.js +92 -30
  30. package/dist/core/title.js +15 -0
  31. package/dist/core/types.d.ts +2 -0
  32. package/dist/core/workflows/discovery.d.ts +1 -1
  33. package/dist/core/workflows/index.d.ts +1 -1
  34. package/dist/db/client.d.ts +4 -0
  35. package/dist/db/client.js +5 -0
  36. package/dist/db/schema.d.ts +51 -0
  37. package/dist/db/schema.js +7 -1
  38. package/dist/server/agent-manager.d.ts +20 -0
  39. package/dist/server/agent-manager.js +63 -11
  40. package/dist/server/app.js +12 -1
  41. package/dist/server/auth-manager.d.ts +79 -0
  42. package/dist/server/auth-manager.js +270 -0
  43. package/dist/server/context.js +3 -0
  44. package/dist/server/middleware/auth.d.ts +4 -3
  45. package/dist/server/middleware/auth.js +5 -5
  46. package/dist/server/middleware/cors.d.ts +11 -2
  47. package/dist/server/middleware/cors.js +15 -12
  48. package/dist/server/permission/interactive.d.ts +2 -0
  49. package/dist/server/permission/interactive.js +5 -1
  50. package/dist/server/permission/store.d.ts +4 -0
  51. package/dist/server/permission/store.js +1 -0
  52. package/dist/server/routes/auth.d.ts +4 -0
  53. package/dist/server/routes/auth.js +94 -0
  54. package/dist/server/routes/chat.d.ts +6 -1
  55. package/dist/server/routes/chat.js +294 -153
  56. package/dist/server/routes/config.js +60 -8
  57. package/dist/server/routes/permissions.d.ts +4 -1
  58. package/dist/server/routes/permissions.js +27 -4
  59. package/dist/server/routes/project.js +56 -2
  60. package/dist/server/routes/session.js +37 -9
  61. package/dist/server/routes/terminal.js +29 -2
  62. package/dist/server/routes/update.d.ts +1 -0
  63. package/dist/server/routes/update.js +33 -4
  64. package/dist/server/routes/workflows.js +2 -2
  65. package/dist/server/server.d.ts +24 -1
  66. package/dist/server/server.js +149 -29
  67. package/dist/server/types.d.ts +17 -2
  68. package/dist/session/archive.d.ts +1 -1
  69. package/dist/session/branch.d.ts +11 -2
  70. package/dist/session/branch.js +107 -31
  71. package/dist/session/compaction.js +1 -0
  72. package/dist/session/context.d.ts +3 -2
  73. package/dist/session/context.js +36 -3
  74. package/dist/session/index.d.ts +1 -1
  75. package/dist/session/index.js +1 -1
  76. package/dist/session/session.d.ts +25 -5
  77. package/dist/session/session.js +129 -10
  78. package/dist/session/snapshot.d.ts +9 -3
  79. package/dist/session/snapshot.js +45 -4
  80. package/dist/session/squash.js +1 -0
  81. package/dist/session/types.d.ts +3 -1
  82. package/dist/shared/types/agent.d.ts +11 -0
  83. package/dist/shared/types/config.d.ts +12 -6
  84. package/dist/shared/types/message.d.ts +6 -2
  85. package/dist/shared/utils/path.d.ts +2 -3
  86. package/dist/shared/utils/path.js +20 -4
  87. package/dist/tools/builtin/todo.js +5 -4
  88. package/dist/tools/index.d.ts +14 -3
  89. package/dist/tools/index.js +35 -14
  90. package/dist/update/index.d.ts +2 -2
  91. package/dist/update/index.js +2 -2
  92. package/dist/update/ipc.d.ts +38 -5
  93. package/dist/update/ipc.js +56 -7
  94. package/dist/update/snapshot.d.ts +4 -0
  95. package/dist/update/snapshot.js +8 -0
  96. package/dist/update/updater.d.ts +61 -10
  97. package/dist/update/updater.js +236 -25
  98. package/dist/update/version.d.ts +6 -1
  99. package/dist/update/version.js +18 -3
  100. package/drizzle/0005_snapshot_mtime.sql +1 -0
  101. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  102. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  103. package/drizzle/meta/0005_snapshot.json +855 -0
  104. package/drizzle/meta/0006_snapshot.json +867 -0
  105. package/drizzle/meta/0007_snapshot.json +869 -0
  106. package/drizzle/meta/_journal.json +21 -0
  107. package/package.json +1 -7
  108. package/dist/mcp/index.d.ts +0 -1
  109. package/dist/mcp/index.js +0 -3
@@ -29,13 +29,12 @@ const helpCommand = {
29
29
  execute: async () => {
30
30
  const lines = [
31
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',
32
+ ' /compact Manually trigger context compaction',
33
+ ' /clear [id] --yes Clear session messages (default: current session; archives originals)',
34
+ ' /help Show this help',
35
+ ' /fork [id] [index] Fork session (default: current session, latest message)',
36
+ ' /config [key] [value] View or set configuration (dot paths supported)',
37
+ ' /workflow Manage and run workflows',
39
38
  ];
40
39
  return { _tag: 'text', text: lines.join('\n') };
41
40
  },
@@ -51,42 +50,90 @@ const compactCommand = {
51
50
  };
52
51
  const modelCommand = {
53
52
  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)` };
53
+ description: 'Model selection guidance',
54
+ argsHint: '',
55
+ execute: async () => {
56
+ // 诚实化:模型由前端 ModelSelector 决定并随请求 body 传递,服务端不保存会话级模型覆盖。
57
+ // 此前返回 "Model set to X" 但什么都不生效。
58
+ return {
59
+ _tag: 'text',
60
+ text: '会话模型请在聊天页底部的模型选择器中切换(/model 不再直接生效)。',
61
+ };
60
62
  },
61
63
  };
62
64
  const clearCommand = {
63
65
  name: 'clear',
64
- description: 'Clear session messages',
66
+ description: 'Clear session messages (archives originals first)',
67
+ argsHint: '[session-id] [--yes]',
65
68
  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);
69
+ const parts = args.split(/\s+/).filter(Boolean);
70
+ const yes = parts.includes('--yes');
71
+ const sessionId = parts.find((p) => p !== '--yes') ?? ctx.sessionId;
72
+ if (!sessionId) {
73
+ return { _tag: 'error', message: 'Usage: /clear [session-id] --yes(当前会话可省略 id)' };
74
+ }
75
+ if (!yes) {
76
+ return {
77
+ _tag: 'error',
78
+ message: '清空消息不可逆。原始消息将归档保存,确认请执行 /clear --yes(或 /clear <session-id> --yes)。',
79
+ };
80
+ }
81
+ const { getEntries, deleteEntriesByIds } = await import('../session/message.js');
82
+ const entries = await getEntries(ctx.deps.db, sessionId);
70
83
  const ids = entries.map((e) => ('id' in e ? e.id : '')).filter(Boolean);
71
84
  if (ids.length > 0) {
85
+ // 归档原始内容(与 shake 同机制),删除才有后悔路径
86
+ const { archiveOriginalEntries } = await import('../session/archive.js');
87
+ const { generateId } = await import('../shared/index.js');
88
+ await archiveOriginalEntries(ctx.deps.db, sessionId, entries, 'clear', `Cleared ${ids.length} entries`, generateId());
72
89
  await deleteEntriesByIds(ctx.deps.db, ids);
73
90
  }
74
- return { _tag: 'success', message: `Cleared ${ids.length} entries` };
91
+ return { _tag: 'success', message: `Cleared ${ids.length} entries (archived)` };
75
92
  },
76
93
  };
77
94
  const forkCommand = {
78
95
  name: 'fork',
79
96
  description: 'Fork session from a message index',
80
- argsHint: '<session-id> [message-index]',
97
+ argsHint: '[session-id] [message-index]',
81
98
  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;
99
+ const parts = args.split(/\s+/).filter(Boolean);
100
+ // 默认当前会话;第一个参数若是 session id(非纯数字)则用它。
101
+ let sessionId = ctx.sessionId;
102
+ let messageIndex;
103
+ for (const p of parts) {
104
+ if (/^\d+$/.test(p)) {
105
+ messageIndex = Number.parseInt(p, 10);
106
+ }
107
+ else if (!sessionId) {
108
+ sessionId = p;
109
+ }
110
+ }
111
+ if (!sessionId) {
112
+ return {
113
+ _tag: 'error',
114
+ message: 'Usage: /fork [session-id] [message-index](当前会话可省略 id)',
115
+ };
116
+ }
117
+ // 未指定 index → 默认最新一条消息处分支(与 web fork API 语义一致,不再是 index 0)
118
+ if (messageIndex === undefined) {
119
+ const { getMessages } = await import('../session/message.js');
120
+ const messages = await getMessages(ctx.deps.db, sessionId);
121
+ if (messages.length === 0) {
122
+ return { _tag: 'error', message: 'EMPTY_SESSION: 空会话无法分支' };
123
+ }
124
+ messageIndex = messages.length - 1;
125
+ }
87
126
  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}` };
127
+ try {
128
+ const forked = await forkSession(ctx.deps.db, sessionId, messageIndex);
129
+ return { _tag: 'success', message: `Forked to new session: ${forked.id}` };
130
+ }
131
+ catch (error) {
132
+ return {
133
+ _tag: 'error',
134
+ message: error instanceof Error ? error.message : String(error),
135
+ };
136
+ }
90
137
  },
91
138
  };
92
139
  const configCommand = {
@@ -94,16 +141,31 @@ const configCommand = {
94
141
  description: 'View or set configuration',
95
142
  argsHint: '[key] [value]',
96
143
  execute: async (args, ctx) => {
144
+ const { getByPath, setPathPatch, coerce } = await import('./config-path.js');
97
145
  if (!args) {
98
146
  return { _tag: 'text', text: JSON.stringify(ctx.config, null, 2) };
99
147
  }
100
148
  const parts = args.split(/\s+/);
101
149
  const key = parts[0] ?? '';
102
150
  if (parts.length === 1) {
103
- const value = ctx.config[key];
104
- return { _tag: 'text', text: `${key}: ${JSON.stringify(value)}` };
151
+ try {
152
+ const value = getByPath(ctx.config, key);
153
+ return { _tag: 'text', text: `${key}: ${JSON.stringify(value)}` };
154
+ }
155
+ catch (error) {
156
+ return { _tag: 'error', message: error instanceof Error ? error.message : String(error) };
157
+ }
105
158
  }
106
- return { _tag: 'success', message: 'Config updates are handled via the config API' };
159
+ // 点路径写值:与 CLI config set 同语义(project 作用域最小落盘,null=unset)
160
+ const { applyScopedPatch, loadConfigScopes, saveConfigScoped } = await import('./config.js');
161
+ const scopes = loadConfigScopes(ctx.cwd);
162
+ const value = coerce(parts.slice(1).join(' '));
163
+ const next = applyScopedPatch(scopes.project ?? {}, setPathPatch(key, value));
164
+ await saveConfigScoped('project', ctx.cwd, next);
165
+ return {
166
+ _tag: 'success',
167
+ message: `${value === null ? 'Unset' : 'Set'} ${key} (scope: project)`,
168
+ };
107
169
  },
108
170
  };
109
171
  const workflowCommand = {
@@ -111,6 +111,21 @@ async function generateSessionTitle(deps, sessionId, firstMessage, chatProvider,
111
111
  catch (e) {
112
112
  // 标题生成是尽力而为的辅助功能:失败不阻塞主对话流,但记录以便排查。
113
113
  console.warn('[title] generateSessionTitle failed:', e instanceof Error ? e.message : String(e));
114
+ // P2-12:失败时回退到首条消息摘要,避免列表出现大量同名 "New Session"。
115
+ await fallbackTitleFromMessage(db, sessionId, firstMessage);
114
116
  }
115
117
  }
118
+ /** 从首条消息生成回退标题:取首个非空行,超长截断(与 cleanTitle 一致)。 */
119
+ async function fallbackTitleFromMessage(db, sessionId, firstMessage) {
120
+ const firstLine = firstMessage
121
+ .split('\n')
122
+ .map((l) => l.trim())
123
+ .find((l) => l.length > 0);
124
+ if (!firstLine)
125
+ return;
126
+ const title = firstLine.length > TITLE_MAX_LENGTH
127
+ ? `${firstLine.slice(0, TITLE_MAX_LENGTH - 3)}...`
128
+ : firstLine;
129
+ await updateSessionTitle(db, sessionId, title).catch(() => { });
130
+ }
116
131
  export { DEFAULT_SESSION_TITLE, generateSessionTitle, isDefaultTitle };
@@ -87,6 +87,8 @@ 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;
91
93
  };
92
94
  /** Declarative subcommand definition for commands like /workflow. */
@@ -28,5 +28,5 @@ type SaveResult = {
28
28
  * - 返回 { ok, filePath, meta } 或 { ok: false, error }
29
29
  */
30
30
  declare function saveWorkflow(name: string, source: string, target?: SaveTarget, projectDir?: string): Promise<SaveResult>;
31
- export { discoverGlobalWorkflows, discoverWorkflows, saveWorkflow };
32
31
  export type { SaveResult, SaveTarget };
32
+ export { discoverGlobalWorkflows, discoverWorkflows, saveWorkflow };
@@ -1,7 +1,7 @@
1
1
  export { BUILTIN_WORKFLOWS, createBuiltinWorkflows } from './builtins.js';
2
2
  export { buildWorkflowContext } from './context.js';
3
- export { discoverGlobalWorkflows, discoverWorkflows, saveWorkflow } from './discovery.js';
4
3
  export type { SaveResult, SaveTarget } from './discovery.js';
4
+ export { discoverGlobalWorkflows, discoverWorkflows, saveWorkflow } from './discovery.js';
5
5
  export type { WorkflowRegistry } from './registry.js';
6
6
  export { createAndPopulateRegistry, createWorkflowRegistry, reloadRegistry } from './registry.js';
7
7
  export { executeWorkflow } from './runtime.js';
@@ -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,40 @@ 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
+ }, {}, {}>;
276
310
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
277
311
  name: "created_at";
278
312
  tableName: "sessions";
@@ -783,6 +817,23 @@ export declare const fileSnapshots: import("drizzle-orm/pg-core").PgTableWithCol
783
817
  identity: undefined;
784
818
  generated: undefined;
785
819
  }, {}, {}>;
820
+ mtimeMs: import("drizzle-orm/pg-core").PgColumn<{
821
+ name: "mtime_ms";
822
+ tableName: "file_snapshots";
823
+ dataType: "number";
824
+ columnType: "PgBigInt53";
825
+ data: number;
826
+ driverParam: string | number;
827
+ notNull: false;
828
+ hasDefault: false;
829
+ isPrimaryKey: false;
830
+ isAutoincrement: false;
831
+ hasRuntimeDefault: false;
832
+ enumValues: undefined;
833
+ baseColumn: never;
834
+ identity: undefined;
835
+ generated: undefined;
836
+ }, {}, {}>;
786
837
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
787
838
  name: "created_at";
788
839
  tableName: "file_snapshots";
package/dist/db/schema.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { sql } from 'drizzle-orm';
2
- import { index, integer, jsonb, pgTable, real, text, timestamp, uniqueIndex, uuid, } from 'drizzle-orm/pg-core';
2
+ import { bigint, index, integer, jsonb, pgTable, real, text, timestamp, uniqueIndex, uuid, } from 'drizzle-orm/pg-core';
3
3
  /**
4
4
  * Projects table — git repos or plain directories tracked for agent workspaces.
5
5
  * Identity: sha256(worktree)[:16](git 仓库用仓库根,普通目录用绝对路径)。gitRemote 仅作
@@ -29,6 +29,10 @@ 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=未删除。回收站保留 30 天后物理清除。 */
35
+ deletedAt: timestamp('deleted_at', { withTimezone: true }).default(sql `null`),
32
36
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
33
37
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
34
38
  }, (table) => [
@@ -89,6 +93,8 @@ export const fileSnapshots = pgTable('file_snapshots', {
89
93
  contentHash: text('content_hash').notNull(),
90
94
  tokenCount: integer('token_count').default(0),
91
95
  version: integer('version').default(1),
96
+ /** 快照创建时文件磁盘 mtime(毫秒)。注入前比对,过期自动重读(P1-5)。 */
97
+ mtimeMs: bigint('mtime_ms', { mode: 'number' }),
92
98
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
93
99
  }, (table) => [
94
100
  index('idx_snapshots_session_path').on(table.sessionId, table.filePath),
@@ -14,13 +14,33 @@ type ActiveRun = {
14
14
  };
15
15
  /** Agent run 跟踪器 + 控制操作。 */
16
16
  type AgentManager = {
17
+ /**
18
+ * 同步原子占位(P0-4 POST /api/chat 并发守卫):sessionId 无条目时立即占位并返回
19
+ * true;已有条目(占位或活跃 run)时返回 false。has+set 一气呵成、全程无 await,
20
+ * 杜绝 check-then-act 竞态——此前守卫用 get 检查、真正注册在 SSE 回调内,中间隔
21
+ * 多个 await,双发 POST 均通过守卫后后注册覆盖前者(runs.set),且先结束者在
22
+ * finally unregister 时误删仍在跑的 run,abort/steer/pause 全部失效。
23
+ * 占位由 register 填充、unregister 释放,二者均幂等。
24
+ */
25
+ tryAcquire(sessionId: string): boolean;
26
+ /** 注册活跃 run;若该 sessionId 已被 tryAcquire 占位则填充该占位。 */
17
27
  register(run: ActiveRun): void;
28
+ /** 取活跃 run;占位期间(register 前)返回 undefined,视为无活跃 run。 */
18
29
  get(sessionId: string): ActiveRun | undefined;
30
+ /** 该 sessionId 是否处于占位状态(tryAcquire 后、register 前)。 */
31
+ isStarting(sessionId: string): boolean;
32
+ /** 释放条目(占位或活跃 run);幂等。 */
19
33
  unregister(sessionId: string): void;
34
+ /** 活跃 run 数(不含占位)。 */
20
35
  size(): number;
21
36
  abort(sessionId: string): boolean;
22
37
  pause(sessionId: string): boolean;
23
38
  resume(sessionId: string): boolean;
39
+ /** 暂停全部活跃 run 并等待到达安全点;超时强制中止(热更新前调用)。 */
40
+ pauseAll(timeoutMs?: number): Promise<{
41
+ paused: number;
42
+ forcedAbort: number;
43
+ }>;
24
44
  steer(sessionId: string, message: string): boolean;
25
45
  /** 查询某 session 的所有子 agent run(恢复/展示用)。 */
26
46
  children(parentSessionId: string): ActiveRun[];
@@ -1,59 +1,111 @@
1
1
  // src/server/agent-manager.ts
2
2
  import { abortAgent, injectSteering, pauseAgent, resumeAgent } from '../core/index.js';
3
+ /** 取 slot 内的活跃 run;占位('starting')或不存在返回 undefined。 */
4
+ function slotRun(slot) {
5
+ return slot?.kind === 'run' ? slot.run : undefined;
6
+ }
3
7
  function createAgentManager() {
4
8
  const runs = new Map();
5
9
  return {
10
+ tryAcquire(sessionId) {
11
+ if (runs.has(sessionId))
12
+ return false;
13
+ runs.set(sessionId, { kind: 'starting' });
14
+ return true;
15
+ },
6
16
  register(run) {
7
- runs.set(run.sessionId, run);
17
+ runs.set(run.sessionId, { kind: 'run', run });
8
18
  },
9
19
  get(sessionId) {
10
- return runs.get(sessionId);
20
+ return slotRun(runs.get(sessionId));
21
+ },
22
+ isStarting(sessionId) {
23
+ return runs.get(sessionId)?.kind === 'starting';
11
24
  },
12
25
  unregister(sessionId) {
13
26
  runs.delete(sessionId);
14
27
  },
15
28
  size() {
16
- return runs.size;
29
+ let n = 0;
30
+ for (const slot of runs.values()) {
31
+ if (slot.kind === 'run')
32
+ n += 1;
33
+ }
34
+ return n;
17
35
  },
18
36
  abort(sessionId) {
19
- const run = runs.get(sessionId);
37
+ const run = slotRun(runs.get(sessionId));
20
38
  if (!run)
21
39
  return false;
22
40
  abortAgent(run.state);
23
41
  return true;
24
42
  },
25
43
  pause(sessionId) {
26
- const run = runs.get(sessionId);
44
+ const run = slotRun(runs.get(sessionId));
27
45
  if (!run)
28
46
  return false;
29
47
  pauseAgent(run.state);
30
48
  return true;
31
49
  },
32
50
  resume(sessionId) {
33
- const run = runs.get(sessionId);
51
+ const run = slotRun(runs.get(sessionId));
34
52
  if (!run)
35
53
  return false;
36
54
  resumeAgent(run.state);
37
55
  return true;
38
56
  },
57
+ /**
58
+ * 请求暂停所有活跃 run,等待其到达安全暂停点(当前原子操作完成后)。
59
+ * 超时后对仍未暂停的 run 强制 abort(热更新不能无限等待)。
60
+ * 返回 { paused: 已暂停数, forcedAbort: 超时中止数 }。
61
+ */
62
+ async pauseAll(timeoutMs = 30_000) {
63
+ const active = Array.from(runs.values())
64
+ .map((slot) => slotRun(slot))
65
+ .filter((r) => r !== undefined && r.state.status._tag === 'running');
66
+ for (const run of active) {
67
+ pauseAgent(run.state);
68
+ }
69
+ const deadline = Date.now() + timeoutMs;
70
+ while (Date.now() < deadline) {
71
+ const stillRunning = active.filter((r) => r.state.status._tag === 'running');
72
+ if (stillRunning.length === 0)
73
+ break;
74
+ await new Promise((res) => setTimeout(res, 100));
75
+ }
76
+ let forcedAbort = 0;
77
+ for (const run of active) {
78
+ if (run.state.status._tag === 'running') {
79
+ abortAgent(run.state);
80
+ forcedAbort += 1;
81
+ }
82
+ }
83
+ return { paused: active.length - forcedAbort, forcedAbort };
84
+ },
39
85
  steer(sessionId, message) {
40
- const run = runs.get(sessionId);
86
+ const run = slotRun(runs.get(sessionId));
41
87
  if (!run)
42
88
  return false;
43
89
  injectSteering(run.state, message);
44
90
  return true;
45
91
  },
46
92
  children(parentSessionId) {
47
- return Array.from(runs.values()).filter((r) => r.parentSessionId === parentSessionId);
93
+ return Array.from(runs.values())
94
+ .map((slot) => slotRun(slot))
95
+ .filter((r) => r?.parentSessionId === parentSessionId);
48
96
  },
49
97
  backgroundJobs() {
50
- return Array.from(runs.values()).filter((r) => r.jobId !== undefined);
98
+ return Array.from(runs.values())
99
+ .map((slot) => slotRun(slot))
100
+ .filter((r) => r?.jobId !== undefined);
51
101
  },
52
102
  dispose() {
53
103
  // dev 热重载重建前调用:中止所有活跃 run(loop 在 turn/流边界检测 signal
54
104
  // → unwind → 调用方 finally 持久化 + unregister),然后清空 Map。
55
- for (const run of runs.values()) {
56
- abortAgent(run.state);
105
+ for (const slot of runs.values()) {
106
+ const run = slotRun(slot);
107
+ if (run)
108
+ abortAgent(run.state);
57
109
  }
58
110
  runs.clear();
59
111
  },
@@ -7,6 +7,7 @@ import { createAuthMiddleware } from './middleware/auth.js';
7
7
  import { createCORSMiddleware } from './middleware/cors.js';
8
8
  import { errorHandler } from './middleware/error.js';
9
9
  import { createAgentRoute } from './routes/agent.js';
10
+ import { createAuthRoute } from './routes/auth.js';
10
11
  import { createCatalogRoute } from './routes/catalog.js';
11
12
  import { createChatRoute } from './routes/chat.js';
12
13
  import { createCommandsRoute } from './routes/commands.js';
@@ -32,9 +33,19 @@ function createApp(ctx) {
32
33
  // spec §24.2:仅允许本地/可信 origin 跨域读,拒绝外部网页。
33
34
  app.use('*', createCORSMiddleware({ allowedOrigins: ctx.config.security.allowedOrigins }));
34
35
  // spec §24.2:Bearer token 认证(配置了 token 时生效;/api/health 探活放行)。
35
- app.use('/api/*', createAuthMiddleware(ctx.config.security.authEnabled ? ctx.config.security.token : undefined));
36
+ // P2-16:authManager 存在时按设备 token 校验;认证/配对引导端点公开。
37
+ app.use('/api/*', createAuthMiddleware(ctx.authToken, {
38
+ publicPaths: [
39
+ '/api/health',
40
+ '/api/auth/register',
41
+ '/api/auth/pairing/request',
42
+ '/api/auth/pairing/status',
43
+ ],
44
+ ...(ctx.authManager ? { verify: (t) => ctx.authManager?.verify(t) ?? false } : {}),
45
+ }));
36
46
  // 路由
37
47
  app.route('/api/health', createHealthRoute());
48
+ app.route('/api/auth', createAuthRoute(ctx));
38
49
  app.route('/api/agents', createAgentRoute(ctx));
39
50
  app.route('/api/sessions', createSessionRoute(ctx));
40
51
  app.route('/api/projects', createProjectRoute(ctx));
@@ -0,0 +1,79 @@
1
+ type DeviceRecord = {
2
+ id: string;
3
+ name: string;
4
+ tokenHash: string;
5
+ createdAt: number;
6
+ };
7
+ type PendingPairing = {
8
+ pairingId: string;
9
+ deviceName: string;
10
+ code: string;
11
+ createdAt: number;
12
+ status: 'pending' | 'approved' | 'denied';
13
+ deviceToken?: string;
14
+ };
15
+ type AuthManagerOptions = {
16
+ dataDir: string;
17
+ /** 显式配置的静态 token(security.token / C0DE_AUTH_TOKEN);非空 → 静态模式。 */
18
+ staticToken?: string;
19
+ /** bootstrap token 文件路径(默认 dataDir/auth-token)。 */
20
+ tokenFilePath?: string;
21
+ /** 配对码有效期(ms),默认 10 分钟。 */
22
+ pairingTtlMs?: number;
23
+ /** 轮换后仍接受为 handoff 凭据的旧 bootstrap 数量,默认 3。 */
24
+ handoffTokenRetention?: number;
25
+ now?: () => number;
26
+ };
27
+ export type AuthManager = {
28
+ /** 当前 bootstrap token(静态模式下即 staticToken)。 */
29
+ readonly bootstrap: string | undefined;
30
+ /** API/WS 请求校验:仅设备 token 有效(静态模式下 staticToken 有效)。 */
31
+ verify(token: string | undefined): boolean;
32
+ /** handoff 端点校验:设备 token + 当前/历史 bootstrap。 */
33
+ verifyHandoff(token: string | undefined): boolean;
34
+ /** 首次设备注册:校验 bootstrap 后换发设备 token 并轮换 bootstrap。
35
+ * 无设备时首个凭 bootstrap 的请求即视为首设备(免审批),后续请求需配对审批。
36
+ * 返回新设备 token;bootstrap 已失效/已注册过 → 返回 null。 */
37
+ registerFirstDevice(bootstrapToken: string, deviceName: string): Promise<string | null>;
38
+ /** 发起配对请求(未认证):返回 { pairingId, code };静态模式/未启用返回 null。 */
39
+ requestPairing(deviceName: string): {
40
+ pairingId: string;
41
+ code: string;
42
+ } | null;
43
+ /** 查询配对状态(未认证):pending / approved(含 token) / denied / not_found。 */
44
+ pairingStatus(pairingId: string): {
45
+ status: 'pending';
46
+ } | {
47
+ status: 'approved';
48
+ deviceToken: string;
49
+ } | {
50
+ status: 'denied';
51
+ } | {
52
+ status: 'not_found';
53
+ };
54
+ /** 列出待审批配对(需已认证设备调用)。 */
55
+ listPairings(): Array<{
56
+ pairingId: string;
57
+ deviceName: string;
58
+ code: string;
59
+ createdAt: number;
60
+ }>;
61
+ /** 审批配对(需已认证设备调用):通过后为请求设备签发 token。 */
62
+ approvePairing(pairingId: string): boolean;
63
+ /** 拒绝配对。 */
64
+ denyPairing(pairingId: string): boolean;
65
+ /** 列出已授权设备(id/name/createdAt)。 */
66
+ listDevices(): Array<{
67
+ id: string;
68
+ name: string;
69
+ createdAt: number;
70
+ }>;
71
+ /** 撤销某设备:立即从内存移除并落盘;返回是否成功。 */
72
+ revokeDevice(id: string): boolean;
73
+ /** 持久化设备注册表到磁盘(fire-and-forget 调用)。 */
74
+ persist(): void;
75
+ /** 关闭 devices.json 热重载 watcher(server dispose 时调用)。 */
76
+ dispose(): void;
77
+ };
78
+ export declare function createAuthManager(opts: AuthManagerOptions): AuthManager;
79
+ export type { DeviceRecord, PendingPairing };