c0de-agent 1.7.0 → 1.9.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 (39) hide show
  1. package/dist/core/config.js +4 -1
  2. package/dist/core/loop/compaction.d.ts +31 -0
  3. package/dist/core/loop/compaction.js +137 -0
  4. package/dist/core/loop/persist.d.ts +11 -0
  5. package/dist/core/loop/persist.js +107 -0
  6. package/dist/core/loop/segment.d.ts +8 -0
  7. package/dist/core/loop/segment.js +72 -0
  8. package/dist/core/loop/stream-collect.d.ts +47 -0
  9. package/dist/core/loop/stream-collect.js +183 -0
  10. package/dist/core/loop/subagent.d.ts +12 -0
  11. package/dist/core/loop/subagent.js +171 -0
  12. package/dist/core/loop/todo.d.ts +5 -0
  13. package/dist/core/loop/todo.js +26 -0
  14. package/dist/core/loop.d.ts +2 -20
  15. package/dist/core/loop.js +12 -658
  16. package/dist/core/prompt-registry.d.ts +1 -1
  17. package/dist/core/prompt-registry.js +10 -0
  18. package/dist/core/slash.js +1 -1
  19. package/dist/core/todo-tags.d.ts +46 -0
  20. package/dist/core/todo-tags.js +192 -0
  21. package/dist/core/workflows/builtins.js +4 -4
  22. package/dist/core/workflows/discovery.js +4 -1
  23. package/dist/llm/registry.d.ts +30 -3
  24. package/dist/llm/registry.js +25 -7
  25. package/dist/llm/retry.d.ts +4 -2
  26. package/dist/llm/retry.js +7 -6
  27. package/dist/llm/schema/errors.d.ts +13 -3
  28. package/dist/llm/schema/errors.js +35 -3
  29. package/dist/llm/transport.js +5 -1
  30. package/dist/project/resolve.js +2 -2
  31. package/dist/server/routes/chat.js +8 -2
  32. package/dist/server/routes/todo.js +5 -2
  33. package/dist/server/server.d.ts +4 -2
  34. package/dist/server/server.js +20 -12
  35. package/dist/session/squash.js +31 -24
  36. package/dist/shared/types/agent.d.ts +11 -0
  37. package/dist/tools/builtin/todo.d.ts +7 -0
  38. package/dist/tools/builtin/todo.js +23 -16
  39. package/package.json +2 -1
@@ -1,9 +1,9 @@
1
1
  // src/server/routes/todo.ts
2
2
  import { Hono } from 'hono';
3
- import { getMessages, appendMessage } from '../../session/message.js';
3
+ import { appendMessage, getMessages } from '../../session/message.js';
4
4
  import { getSession } from '../../session/session.js';
5
5
  import { generateId } from '../../shared/index.js';
6
- import { todoTool, getLatestTodoPhasesFromMessages } from '../../tools/builtin/todo.js';
6
+ import { formatSummary, getLatestTodoPhasesFromMessages, todoTool, } from '../../tools/builtin/todo.js';
7
7
  import { apiError } from '../middleware/error.js';
8
8
  /** 构造仅供 todo tool execute 使用的最小 ToolContext。 */
9
9
  function makeTodoCtx(phases, abort) {
@@ -89,6 +89,9 @@ function createTodoRoute(ctx) {
89
89
  // 更新活跃 agent 的内存状态
90
90
  if (run) {
91
91
  run.state.todoPhases = updatedPhases;
92
+ // 前端手动修改了 todo 状态 → 注入 steering 通知 LLM(通道 A)
93
+ const summary = formatSummary(updatedPhases, [], true);
94
+ run.state.steeringQueue.push(`<todo-state-external>\nA todo change was made externally (via UI).\n${summary}\n</todo-state-external>`);
92
95
  }
93
96
  }
94
97
  return c.json({
@@ -32,8 +32,10 @@ type BootstrappedServer = {
32
32
  /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
33
33
  declare function buildRegistryFromConfig(config: Config): Registry;
34
34
  /**
35
- * config 变更后原地同步 registry(清空旧路由 + 重新注册),
36
- * 使运行中的 ServerContext 立即生效,无需重启。
35
+ * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
36
+ * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
37
+ * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
38
+ * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
37
39
  */
38
40
  declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
39
41
  /** 解析 PGLite 持久化数据目录:优先 C0DE_DB_DIR,否则全局数据根下 pglite 子目录。
@@ -1,5 +1,5 @@
1
1
  // src/server/server.ts
2
- import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
3
3
  import { connect as tcpConnect } from 'node:net';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
@@ -10,7 +10,7 @@ import { loadConfig } from '../core/config.js';
10
10
  import { decryptSecret } from '../core/secret.js';
11
11
  import { createAndPopulateRegistry } from '../core/workflows/index.js';
12
12
  import { createDB, migrateDB } from '../db/index.js';
13
- import { createRegistry, overrideToCapabilities, registerProvider, } from '../llm/registry.js';
13
+ import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
14
14
  import { initPlugins } from '../plugins/index.js';
15
15
  import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
16
16
  import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
@@ -44,15 +44,17 @@ function registerProviderFromConfig(registry, p) {
44
44
  });
45
45
  }
46
46
  /**
47
- * config 变更后原地同步 registry(清空旧路由 + 重新注册),
48
- * 使运行中的 ServerContext 立即生效,无需重启。
47
+ * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
48
+ * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
49
+ * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
50
+ * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
49
51
  */
50
52
  function syncRegistryFromConfig(registry, config) {
51
- registry.routes.clear();
52
- registry.roles.clear();
53
- for (const p of config.providers) {
54
- registerProviderFromConfig(registry, p);
55
- }
53
+ rebuildRegistry(registry, (next) => {
54
+ for (const p of config.providers) {
55
+ registerProviderFromConfig(next, p);
56
+ }
57
+ });
56
58
  }
57
59
  /** 全局数据根目录:XDG_DATA_HOME 优先,否则 ~/.local/share/c0de。
58
60
  * 与 opencode (~/.local/share/opencode/)、oh-my-pi (~/.omp/agent/) 同约定——
@@ -221,11 +223,15 @@ function acquireDevDbLock(dataDir) {
221
223
  try {
222
224
  unlinkSync(lockPath);
223
225
  }
224
- catch { /* best-effort */ }
226
+ catch {
227
+ /* best-effort */
228
+ }
225
229
  try {
226
230
  unlinkSync(join(dataDir, 'postmaster.pid'));
227
231
  }
228
- catch { /* best-effort */ }
232
+ catch {
233
+ /* best-effort */
234
+ }
229
235
  }
230
236
  writeFileSync(lockPath, String(process.pid));
231
237
  }
@@ -234,7 +240,9 @@ function releaseDevDbLock(dataDir) {
234
240
  try {
235
241
  unlinkSync(join(dataDir, DEV_LOCK_FILE));
236
242
  }
237
- catch { /* best-effort */ }
243
+ catch {
244
+ /* best-effort */
245
+ }
238
246
  }
239
247
  /** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
240
248
  async function createDevDb(cwd) {
@@ -50,31 +50,38 @@ async function squashRecent(handle, sessionId, count, summarizer, config) {
50
50
  ${history}`;
51
51
  const summary = await summarizer(prompt);
52
52
  const squashEntryId = generateId();
53
- const archiveId = cfg.archiveOriginal
54
- ? await archiveOriginalEntries(handle, sessionId, toSquash, 'squash', summary, squashEntryId)
55
- : generateId();
56
- const fileSnapshotIds = [];
57
- if (cfg.preserveFileSnapshots) {
58
- const hotFiles = extractHotFiles(toSquash);
59
- for (const file of hotFiles) {
60
- const id = await upsertFileSnapshot(handle, sessionId, file.path, file.content);
61
- fileSnapshotIds.push(id);
53
+ // Atomic rewrite: archive originals → upsert snapshots → delete originals →
54
+ // insert summary. If any step throws, the whole transaction rolls back and
55
+ // the original message history is left intact. (Mirrors compactSession.)
56
+ const { archiveId, fileSnapshotIds } = await handle.db.transaction(async (tx) => {
57
+ const txHandle = { db: tx, close: handle.close };
58
+ const archiveId = cfg.archiveOriginal
59
+ ? await archiveOriginalEntries(txHandle, sessionId, toSquash, 'squash', summary, squashEntryId)
60
+ : generateId();
61
+ const fileSnapshotIds = [];
62
+ if (cfg.preserveFileSnapshots) {
63
+ const hotFiles = extractHotFiles(toSquash);
64
+ for (const file of hotFiles) {
65
+ const id = await upsertFileSnapshot(txHandle, sessionId, file.path, file.content);
66
+ fileSnapshotIds.push(id);
67
+ }
62
68
  }
63
- }
64
- await deleteEntriesByIds(handle, toSquash.map((m) => m.id));
65
- await insertEntry(handle, {
66
- id: squashEntryId,
67
- sessionId,
68
- tag: 'squash',
69
- content: {
70
- summary,
71
- squashedEntryIds: toSquash.map((m) => m.id),
72
- archiveId,
73
- },
74
- tokenCount: estimateTokens(summary),
75
- // Position at the first squashed message's timestamp so it sorts between
76
- // the prefix and the kept tail under createdAt-ascending order.
77
- createdAt: toSquash[0] ? new Date(toSquash[0].createdAt) : new Date(),
69
+ await deleteEntriesByIds(txHandle, toSquash.map((m) => m.id));
70
+ await insertEntry(txHandle, {
71
+ id: squashEntryId,
72
+ sessionId,
73
+ tag: 'squash',
74
+ content: {
75
+ summary,
76
+ squashedEntryIds: toSquash.map((m) => m.id),
77
+ archiveId,
78
+ },
79
+ tokenCount: estimateTokens(summary),
80
+ // Position at the first squashed message's timestamp so it sorts between
81
+ // the prefix and the kept tail under createdAt-ascending order.
82
+ createdAt: toSquash[0] ? new Date(toSquash[0].createdAt) : new Date(),
83
+ });
84
+ return { archiveId, fileSnapshotIds };
78
85
  });
79
86
  return {
80
87
  compacted: true,
@@ -184,6 +184,17 @@ type AgentEvent = {
184
184
  archiveId?: string;
185
185
  compactedCount: number;
186
186
  keptCount: number;
187
+ }
188
+ /** Tag-based todo 操作成功后发射,前端据此刷新 TodoPanel。 */
189
+ | {
190
+ _tag: 'todo_update';
191
+ phases: {
192
+ name: string;
193
+ tasks: {
194
+ content: string;
195
+ status: string;
196
+ }[];
197
+ }[];
187
198
  } | {
188
199
  _tag: 'done';
189
200
  };
@@ -47,6 +47,11 @@ export declare function nextActionableTask(phases: readonly TodoPhase[]): TodoIt
47
47
  * `descriptions`. Normalize-then-equal first, with a substring fallback
48
48
  * in either direction (≥6 char overlap on the contained side). */
49
49
  export declare function todoMatchesAnyDescription(content: string, descriptions: readonly string[]): boolean;
50
+ /** Apply a single todo op to existing phases. Returns new phases + errors. */
51
+ declare function applyParams(phases: TodoPhase[], params: TodoInput): {
52
+ phases: TodoPhase[];
53
+ errors: string[];
54
+ };
50
55
  /** Render todo phases as a Markdown checklist suitable for editing/copying. */
51
56
  export declare function phasesToMarkdown(phases: TodoPhase[]): string;
52
57
  /** Parse a Markdown checklist back into todo phases. */
@@ -54,6 +59,7 @@ export declare function markdownToPhases(md: string): {
54
59
  phases: TodoPhase[];
55
60
  errors: string[];
56
61
  };
62
+ export declare function formatSummary(phases: TodoPhase[], errors: string[], readOnly?: boolean): string;
57
63
  /** Extract the latest todo phases from stored messages (tool results).
58
64
  * Scans backwards for the most recent `todo` tool result with phases metadata. */
59
65
  export declare function getLatestTodoPhasesFromMessages(messages: {
@@ -65,3 +71,4 @@ export declare function getLatestTodoPhasesFromMessages(messages: {
65
71
  * State is held in-memory via ctx.todoState hook (dependency-reversal). */
66
72
  export declare const todoTool: ToolDef;
67
73
  export type { TodoInput, TodoItem, TodoPhase, TodoStatus };
74
+ export { applyParams };
@@ -293,10 +293,9 @@ export function phasesToMarkdown(phases) {
293
293
  if (phases.length === 0)
294
294
  return '# Todos\n';
295
295
  const out = [];
296
- for (let i = 0; i < phases.length; i++) {
296
+ for (const [i, phase] of phases.entries()) {
297
297
  if (i > 0)
298
298
  out.push('');
299
- const phase = phases[i];
300
299
  out.push(`# ${phase.name}`);
301
300
  for (const task of phase.tasks) {
302
301
  out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
@@ -320,14 +319,13 @@ export function markdownToPhases(md) {
320
319
  const phases = [];
321
320
  let currentPhase;
322
321
  const lines = md.split(/\r?\n/);
323
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
324
- const raw = lines[lineNum];
322
+ for (const [lineNum, raw] of lines.entries()) {
325
323
  const trimmed = raw.trim();
326
324
  if (!trimmed)
327
325
  continue;
328
326
  const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
329
327
  if (headingMatch) {
330
- currentPhase = { name: headingMatch[1].trim(), tasks: [] };
328
+ currentPhase = { name: (headingMatch[1] ?? '').trim(), tasks: [] };
331
329
  phases.push(currentPhase);
332
330
  continue;
333
331
  }
@@ -343,7 +341,7 @@ export function markdownToPhases(md) {
343
341
  errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
344
342
  continue;
345
343
  }
346
- currentPhase.tasks.push({ content: taskMatch[2].trim(), status });
344
+ currentPhase.tasks.push({ content: (taskMatch[2] ?? '').trim(), status });
347
345
  continue;
348
346
  }
349
347
  errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
@@ -354,7 +352,7 @@ export function markdownToPhases(md) {
354
352
  // =============================================================================
355
353
  // Summary formatter
356
354
  // =============================================================================
357
- function formatSummary(phases, errors, readOnly = false) {
355
+ export function formatSummary(phases, errors, readOnly = false) {
358
356
  const tasks = phases.flatMap((phase) => phase.tasks);
359
357
  if (tasks.length === 0) {
360
358
  if (errors.length > 0)
@@ -362,16 +360,20 @@ function formatSummary(phases, errors, readOnly = false) {
362
360
  return readOnly ? 'Todo list is empty.' : 'Todo list cleared.';
363
361
  }
364
362
  const remainingByPhase = phases
365
- .map((phase) => ({
363
+ .map((phase, pi) => ({
366
364
  name: phase.name,
367
- tasks: phase.tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress'),
365
+ tasks: phase.tasks
366
+ .map((task, ti) => ({ task, seq: `${pi + 1}-${ti + 1}` }))
367
+ .filter(({ task }) => task.status === 'pending' || task.status === 'in_progress'),
368
368
  }))
369
369
  .filter((phase) => phase.tasks.length > 0);
370
- const remainingTasks = remainingByPhase.flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name })));
370
+ const remainingTasks = remainingByPhase.flatMap((phase) => phase.tasks.map(({ task, seq }) => ({ ...task, seq, phase: phase.name })));
371
371
  let currentIdx = phases.findIndex((phase) => phase.tasks.some((task) => task.status === 'pending' || task.status === 'in_progress'));
372
372
  if (currentIdx === -1)
373
373
  currentIdx = phases.length - 1;
374
374
  const current = phases[currentIdx];
375
+ if (!current)
376
+ return errors.length > 0 ? `Errors: ${errors.join('; ')}` : 'Todo list cleared.';
375
377
  const done = current.tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
376
378
  const lines = [];
377
379
  if (errors.length > 0)
@@ -382,7 +384,7 @@ function formatSummary(phases, errors, readOnly = false) {
382
384
  else {
383
385
  lines.push(`Remaining items (${remainingTasks.length}):`);
384
386
  for (const task of remainingTasks) {
385
- lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
387
+ lines.push(` - ${task.seq}: ${task.content} [${task.status}] (${task.phase})`);
386
388
  }
387
389
  }
388
390
  const closedAll = tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
@@ -392,16 +394,17 @@ function formatSummary(phases, errors, readOnly = false) {
392
394
  lines.push(`Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${workedAhead
393
395
  ? ' — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed).'
394
396
  : '.'}`);
395
- for (const phase of phases) {
397
+ for (const [pi, phase] of phases.entries()) {
396
398
  lines.push(` ${phase.name}:`);
397
- for (const task of phase.tasks) {
399
+ for (const [ti, task] of phase.tasks.entries()) {
400
+ const seq = `${pi + 1}-${ti + 1}`;
398
401
  const checkbox = task.status === 'completed' ? '[X]' : '[ ]';
399
402
  const tag = task.status === 'in_progress'
400
403
  ? ' (in progress)'
401
404
  : task.status === 'abandoned'
402
405
  ? ' (dropped)'
403
406
  : '';
404
- lines.push(` - ${checkbox} ${task.content}${tag}`);
407
+ lines.push(` - ${checkbox} ${seq}: ${task.content}${tag}`);
405
408
  }
406
409
  }
407
410
  return lines.join('\n');
@@ -414,7 +417,7 @@ function formatSummary(phases, errors, readOnly = false) {
414
417
  export function getLatestTodoPhasesFromMessages(messages) {
415
418
  for (let i = messages.length - 1; i >= 0; i--) {
416
419
  const msg = messages[i];
417
- if (msg.role !== 'tool')
420
+ if (msg?.role !== 'tool')
418
421
  continue;
419
422
  for (let j = msg.content.length - 1; j >= 0; j--) {
420
423
  const part = msg.content[j];
@@ -463,7 +466,10 @@ const todoParameters = {
463
466
  },
464
467
  },
465
468
  task: { type: 'string', description: 'Task content (for start/done/drop/rm)' },
466
- phase: { type: 'string', description: 'Phase name (for done/drop/rm/append, or init flat mode)' },
469
+ phase: {
470
+ type: 'string',
471
+ description: 'Phase name (for done/drop/rm/append, or init flat mode)',
472
+ },
467
473
  items: {
468
474
  type: 'array',
469
475
  items: { type: 'string' },
@@ -515,3 +521,4 @@ export const todoTool = {
515
521
  return { _tag: 'success', output, metadata };
516
522
  },
517
523
  };
524
+ export { applyParams };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c0de-agent",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Open-source AI coding assistant with Browser-Server architecture",
5
5
  "type": "module",
6
6
  "bin": {
@@ -94,6 +94,7 @@
94
94
  "@native-router/core": "^1.1.0",
95
95
  "@native-router/react": "^1.1.2",
96
96
  "@tanstack/react-query": "^5.101.2",
97
+ "@tanstack/react-virtual": "^3.14.6",
97
98
  "@xterm/addon-fit": "^0.11.0",
98
99
  "@xterm/addon-web-links": "^0.12.0",
99
100
  "@xterm/xterm": "^6.0.0",