@scemoon/cdh 1.0.0 → 1.0.4

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 (147) hide show
  1. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  2. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  3. package/cdh/cli.py +473 -87
  4. package/cdh/scaffold.py +386 -121
  5. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  6. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  7. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  8. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  9. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  10. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  11. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  12. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  13. package/onecode/agent/agents/types.py +123 -79
  14. package/onecode/agent/cdh_loader.py +26 -13
  15. package/onecode/agent/engine.py +426 -235
  16. package/onecode/agent/onecode_agent_acp.py +202 -75
  17. package/onecode/agent/session.py +8 -13
  18. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  19. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  20. package/onecode/agent/tools/agent_tools.py +4 -4
  21. package/onecode/agent/tools/todo_tools.py +188 -0
  22. package/onecode/cli.py +3 -3
  23. package/onecode/commands.py +439 -194
  24. package/onecode/config.py +3 -3
  25. package/onecode/config_screen.py +4 -2
  26. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  27. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  28. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  29. package/onecode/models/errors.py +11 -1
  30. package/onecode/models/messages.py +35 -16
  31. package/onecode/models/provider.py +243 -12
  32. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  33. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  34. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  35. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  36. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  37. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  38. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  39. package/onecode/models/providers/anthropic_provider.py +4 -2
  40. package/onecode/models/providers/deepseek_provider.py +2 -0
  41. package/onecode/models/providers/glm_provider.py +2 -1
  42. package/onecode/models/providers/minimax_provider.py +2 -1
  43. package/onecode/models/providers/minimaxi_provider.py +76 -25
  44. package/onecode/models/providers/ollama_provider.py +2 -1
  45. package/onecode/models/providers/openai_provider.py +4 -0
  46. package/onecode/tasks/manager.py +1 -1
  47. package/package.json +1 -1
  48. package/pyproject.toml +10 -0
  49. package/run.js +24 -7
  50. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  51. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  52. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  53. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  54. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  55. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  56. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  57. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  58. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  59. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  60. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  61. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  62. package/tui/acp/agent.py +60 -11
  63. package/tui/acp/api.py +6 -0
  64. package/tui/acp/messages.py +11 -0
  65. package/tui/acp/prompt.py +1 -1
  66. package/tui/acp/protocol.py +31 -0
  67. package/tui/agents.py +8 -7
  68. package/tui/app.py +60 -8
  69. package/tui/data/agents/ampcode.com.toml +1 -0
  70. package/tui/data/agents/augmentcode.com.toml +1 -0
  71. package/tui/data/agents/cdh.cloud-dev-harness.toml +3 -2
  72. package/tui/data/agents/crowai.dev.toml +1 -0
  73. package/tui/data/agents/deepagents.langchain.com +1 -0
  74. package/tui/data/agents/docker.com.toml +1 -0
  75. package/tui/data/agents/goose.ai.toml +1 -0
  76. package/tui/data/agents/inference.huggingface.co.toml +1 -1
  77. package/tui/data/agents/kiro.dev.toml +1 -0
  78. package/tui/data/agents/onecode.dev.toml +22 -0
  79. package/tui/data/agents/openclaw.ai.toml +1 -1
  80. package/tui/data/agents/openhands.dev.toml +1 -0
  81. package/tui/data/agents/stakpak.dev.toml +1 -0
  82. package/tui/data/agents/vibe.mistral.ai.toml +1 -0
  83. package/tui/data/agents/vtcode.dev.toml +1 -0
  84. package/tui/db.py +23 -0
  85. package/tui/directory_watcher.py +35 -1
  86. package/tui/messages.py +4 -1
  87. package/tui/paths.py +3 -3
  88. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  89. package/tui/screens/component_picker.py +148 -0
  90. package/tui/screens/main.py +47 -95
  91. package/tui/screens/projects.tcss +10 -0
  92. package/tui/screens/projects_app.py +33 -5
  93. package/tui/screens/projects_screen.py +171 -9
  94. package/tui/screens/store.py +1 -1
  95. package/tui/settings_schema.py +1 -1
  96. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  97. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  98. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  99. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  100. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  101. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  102. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  103. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  104. package/tui/widgets/ask_user.py +1 -1
  105. package/tui/widgets/conversation.py +99 -42
  106. package/tui/widgets/modified_files.py +191 -65
  107. package/tui/widgets/plan.py +22 -0
  108. package/tui/widgets/project_directory_tree.py +22 -10
  109. package/tui/widgets/project_grid_select.py +23 -1
  110. package/tui/widgets/question.py +1 -1
  111. package/tui/widgets/subagent.py +202 -36
  112. package/tui/widgets/subagent_screen.py +186 -0
  113. package/tui/widgets/tool_call.py +5 -8
  114. package/cdh/__pycache__/__init__.cpython-313.pyc +0 -0
  115. package/cdh/__pycache__/scaffold.cpython-313.pyc +0 -0
  116. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  117. package/onecode/__pycache__/mock_data.cpython-314.pyc +0 -0
  118. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-313.pyc +0 -0
  119. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-314.pyc +0 -0
  120. package/onecode/agent/__pycache__/harness_skill.cpython-314.pyc +0 -0
  121. package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
  122. package/onecode/agent/tools/task_tools.py +0 -249
  123. package/onecode/codebase/__pycache__/config.cpython-314.pyc +0 -0
  124. package/onecode/models/providers/__pycache__/mock_provider.cpython-314.pyc +0 -0
  125. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  126. package/tui/__pycache__/danger.cpython-314.pyc +0 -0
  127. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  128. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  129. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  130. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  131. package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
  132. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  133. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  134. package/tui/widgets/__pycache__/conversation.cpython-313.pyc +0 -0
  135. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  136. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  137. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  138. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  139. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  140. package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
  141. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  142. package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
  143. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  144. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  145. package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
  146. package/tui/widgets/__pycache__/terminal.cpython-313.pyc +0 -0
  147. package/tui/widgets/__pycache__/tool_call.cpython-313.pyc +0 -0
@@ -15,10 +15,15 @@ from onecode.agent.permissions_store import PermissionStore
15
15
  from onecode.models.provider import ContentBlockType, ProviderRegistry
16
16
 
17
17
  from onecode.agent.session import AgentSession
18
+ from onecode.memory import AgentMemory, MemoryLayer
19
+ from onecode.models.errors import safe_error_msg
18
20
  from onecode.models.messages import StreamEvent, StreamEventType
19
21
 
20
22
  logger = logging.getLogger("onecode.agent.engine")
21
23
 
24
+ # Subagent depth limit — subagents are leaf nodes (max depth = 1).
25
+ _MAX_SUBAGENT_DEPTH = 1
26
+
22
27
 
23
28
  @dataclass(frozen=True)
24
29
  class ToolEvent:
@@ -239,20 +244,24 @@ def _extract_legacy_tool_uses(
239
244
  return tool_uses, cleaned, counter
240
245
 
241
246
 
242
- _TASK_STATUSES = {"pending", "in_progress", "completed"}
247
+ _TODO_STATUSES = {"pending", "in_progress", "completed"}
248
+
243
249
 
250
+ class TodoManager:
251
+ """Unified todo manager (formerly TodoManager).
244
252
 
245
- class TaskManager:
246
- """V2 task manager with dependency tracking (Clawd-Code style).
253
+ Each todo has: id, subject, description, activeForm, status, owner, blocks,
254
+ blockedBy, metadata, output. Supports dependency tracking and progress
255
+ display in the TUI Plan sidebar.
247
256
 
248
- Tasks have: id, subject, description, status, owner, blocks, blockedBy, metadata, output.
249
- Supports dependency tracking for multi-agent coordination.
257
+ Persistence format (``to_dict``/``from_dict``) accepts both the new
258
+ single-list layout and the legacy ``{"tasks": [...], "todos": [...]}``
259
+ layout for backwards compatibility with existing session files.
250
260
  """
251
261
 
252
262
  def __init__(self, on_change: Callable[[], None] | None = None):
253
- self._tasks: dict[str, dict] = {} # id -> task dict
254
- self._todos: list[dict] = []
255
- self._plan: list[str] = []
263
+ self._todos: dict[str, dict] = {}
264
+ self._order: list[str] = []
256
265
  self._id_counter = 0
257
266
  self._on_change = on_change
258
267
 
@@ -264,19 +273,19 @@ class TaskManager:
264
273
  self._id_counter += 1
265
274
  return f"t{self._id_counter}"
266
275
 
267
- # ── V2 Task Management ──
276
+ # ── Todo CRUD ──
268
277
 
269
- def create_task(
278
+ def create_todo(
270
279
  self,
271
280
  subject: str,
272
281
  description: str = "",
273
282
  active_form: str = "",
274
283
  metadata: dict | None = None,
275
284
  ) -> dict:
276
- """Create a task with dependency tracking support."""
277
- task_id = self._next_id()
278
- task = {
279
- "id": task_id,
285
+ """Create a todo with dependency tracking support."""
286
+ todo_id = self._next_id()
287
+ todo = {
288
+ "id": todo_id,
280
289
  "_order": self._id_counter,
281
290
  "subject": subject,
282
291
  "description": description,
@@ -288,161 +297,141 @@ class TaskManager:
288
297
  "metadata": dict(metadata or {}),
289
298
  "output": "",
290
299
  }
291
- self._tasks[task_id] = task
300
+ self._todos[todo_id] = todo
301
+ self._order.append(todo_id)
292
302
  self._mark_dirty()
293
- return task
303
+ return todo
294
304
 
295
- def get_task(self, task_id: str) -> dict | None:
296
- return self._tasks.get(task_id)
305
+ def get_todo(self, todo_id: str) -> dict | None:
306
+ return self._todos.get(todo_id)
297
307
 
298
- def list_tasks(self) -> list[dict]:
299
- """List all tasks in creation order."""
300
- return list(self._tasks.values())
301
-
302
- def update_task(self, task_id: str, **fields) -> dict | None:
303
- """Update task fields. Supports: subject, description, activeForm, status,
304
- owner, metadata, addBlocks, addBlockedBy. Returns updated task or None."""
305
- task = self._tasks.get(task_id)
306
- if task is None:
308
+ def list_todos(self) -> list[dict]:
309
+ """List all todos in creation order."""
310
+ return [self._todos[tid] for tid in self._order if tid in self._todos]
311
+
312
+ def update_todo(self, todo_id: str, **fields) -> dict | None:
313
+ """Update todo fields. Supports: subject, description, activeForm, status,
314
+ owner, metadata, addBlocks, addBlockedBy. Returns updated todo or None."""
315
+ todo = self._todos.get(todo_id)
316
+ if todo is None:
307
317
  return None
308
318
 
309
319
  updated = []
310
320
 
311
- # String fields
312
321
  for field in ("subject", "description", "activeForm", "owner"):
313
322
  if field in fields:
314
323
  v = fields[field]
315
- if isinstance(v, str) and v != task.get(field):
316
- task[field] = v
324
+ if isinstance(v, str) and v != todo.get(field):
325
+ todo[field] = v
317
326
  updated.append(field)
318
327
 
319
- # Status field
320
328
  if "status" in fields:
321
329
  status = fields["status"]
322
330
  if status == "deleted":
323
- self._tasks.pop(task_id, None)
331
+ self._todos.pop(todo_id, None)
332
+ if todo_id in self._order:
333
+ self._order.remove(todo_id)
324
334
  self._mark_dirty()
325
- return {"id": task_id, "deleted": True}
326
- if status in _TASK_STATUSES and status != task.get("status"):
327
- task["status"] = status
335
+ return {"id": todo_id, "deleted": True}
336
+ if status in _TODO_STATUSES and status != todo.get("status"):
337
+ todo["status"] = status
328
338
  updated.append("status")
329
339
 
330
- # Dependency fields
331
340
  for rel_field, input_key in (("blocks", "addBlocks"), ("blockedBy", "addBlockedBy")):
332
341
  if input_key in fields:
333
342
  ids = fields[input_key]
334
343
  if isinstance(ids, list):
335
- cur = list(task.get(rel_field) or [])
344
+ cur = list(todo.get(rel_field) or [])
336
345
  for x in ids:
337
346
  if isinstance(x, str) and x not in cur:
338
347
  cur.append(x)
339
- if cur != task.get(rel_field):
340
- task[rel_field] = cur
348
+ if cur != todo.get(rel_field):
349
+ todo[rel_field] = cur
341
350
  updated.append(rel_field)
342
351
 
343
- # Metadata
344
352
  if "metadata" in fields:
345
353
  md = fields["metadata"]
346
354
  if isinstance(md, dict):
347
- existing = dict(task.get("metadata") or {})
355
+ existing = dict(todo.get("metadata") or {})
348
356
  for k, v in md.items():
349
357
  if v is None:
350
358
  existing.pop(k, None)
351
359
  else:
352
360
  existing[k] = v
353
- task["metadata"] = existing
361
+ todo["metadata"] = existing
354
362
  updated.append("metadata")
355
363
 
356
- # Output
357
364
  if "output" in fields:
358
365
  v = fields["output"]
359
366
  if isinstance(v, str):
360
- task["output"] = v
367
+ todo["output"] = v
361
368
  updated.append("output")
362
369
 
363
370
  self._mark_dirty()
364
- return task
371
+ return todo
365
372
 
366
- def get_task_output(self, task_id: str) -> dict:
367
- """Get output for a task."""
368
- task = self._tasks.get(task_id)
369
- if task is None:
373
+ def get_todo_output(self, todo_id: str) -> dict:
374
+ """Get output for a todo."""
375
+ todo = self._todos.get(todo_id)
376
+ if todo is None:
370
377
  return {"retrieval_status": "not_found", "task": None}
371
- output = str(task.get("output") or "")
378
+ output = str(todo.get("output") or "")
372
379
  return {
373
380
  "retrieval_status": "success" if output else "not_ready",
374
381
  "task": {
375
- "task_id": task_id,
376
- "task_type": "task_list",
377
- "status": task.get("status"),
378
- "description": task.get("description"),
382
+ "task_id": todo_id,
383
+ "task_type": "todo_list",
384
+ "status": todo.get("status"),
385
+ "description": todo.get("description"),
379
386
  "output": output,
380
387
  },
381
388
  }
382
389
 
383
- def clear_tasks(self) -> None:
384
- self._tasks.clear()
385
- self._mark_dirty()
386
-
387
- # ── Todo Management (unchanged API) ──
388
-
389
- def add_todo(self, text: str) -> str:
390
- tid = self._next_id()
391
- self._todos.append({"text": text, "done": False, "id": tid})
392
- self._mark_dirty()
393
- return tid
394
-
395
- def complete_todo(self, todo_id: str) -> bool:
396
- for t in self._todos:
397
- if t["id"] == todo_id:
398
- t["done"] = True
399
- self._mark_dirty()
400
- return True
401
- return False
402
-
403
- def remove_todo(self, todo_id: str) -> bool:
404
- before = len(self._todos)
405
- self._todos = [t for t in self._todos if t["id"] != todo_id]
406
- result = len(self._todos) < before
407
- self._mark_dirty()
408
- return result
409
-
410
- def list_todos(self) -> list[dict]:
411
- return self._todos
412
-
413
390
  def clear_todos(self) -> None:
414
- self._todos = []
415
- self._mark_dirty()
416
-
417
- def set_plan(self, plan: list[str]) -> None:
418
- self._plan = plan
391
+ self._todos.clear()
392
+ self._order.clear()
419
393
  self._mark_dirty()
420
394
 
421
- def get_plan(self) -> list[str]:
422
- return self._plan
423
-
424
395
  # ── Serialization ──
425
396
 
426
397
  def to_dict(self) -> dict:
427
- """Serialize all tasks and todos for persistence."""
398
+ """Serialize todos for persistence."""
428
399
  return {
429
- "tasks": [dict(t) for t in self._tasks.values()],
430
- "todos": [dict(t) for t in self._todos],
431
- "plan": list(self._plan),
400
+ "todos": [dict(self._todos[tid]) for tid in self._order if tid in self._todos],
432
401
  "id_counter": self._id_counter,
433
402
  }
434
403
 
435
404
  @classmethod
436
- def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) -> TaskManager:
437
- """Restore task manager from saved dict."""
405
+ def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) -> "TodoManager":
406
+ """Restore todo manager from saved dict.
407
+
408
+ Accepts the unified ``{"todos": [...]}`` layout (preferred) and the
409
+ legacy ``{"tasks": [...], "todos": [...]}`` layout where ``todos``
410
+ entries may use the old ``{text, done}`` shape — these are converted
411
+ on the fly to the new ``{subject, status}`` shape.
412
+ """
438
413
  tm = cls(on_change=on_change)
439
414
  tm._id_counter = data.get("id_counter", 0)
440
- tm._plan = list(data.get("plan", []))
441
- for t in data.get("tasks", []):
442
- tid = t.get("id", tm._next_id())
443
- tm._tasks[tid] = dict(t)
444
- for t in data.get("todos", []):
445
- tm._todos.append(dict(t))
415
+
416
+ raw_todos: list[dict] = []
417
+ if "todos" in data:
418
+ raw_todos.extend(data.get("todos") or [])
419
+ if "tasks" in data:
420
+ raw_todos.extend(data.get("tasks") or [])
421
+
422
+ for t in raw_todos:
423
+ todo = dict(t)
424
+ if "subject" not in todo and "text" in todo:
425
+ todo["subject"] = todo.pop("text")
426
+ if "status" not in todo and "done" in todo:
427
+ todo["status"] = "completed" if todo.pop("done") else "pending"
428
+ todo.setdefault("status", "pending")
429
+ tid = todo.get("id") or tm._next_id()
430
+ todo["id"] = tid
431
+ todo.setdefault("_order", tm._id_counter)
432
+ tm._todos[tid] = todo
433
+ if tid not in tm._order:
434
+ tm._order.append(tid)
446
435
  return tm
447
436
 
448
437
 
@@ -473,9 +462,15 @@ class AgentEngine:
473
462
  self.total_tokens = 0
474
463
  self._skills_loaded = False
475
464
  self._session: Optional[AgentSession] = None
465
+ self._memory = AgentMemory()
476
466
  self._hooks = HookManager()
477
- self._task_manager = TaskManager(on_change=self._on_task_change)
467
+ self._todo_manager = TodoManager(on_change=self._on_todo_change)
478
468
  self._plan_dirty: bool = False
469
+ # Cache of the last plan snapshot that was emitted to the TUI. Used
470
+ # by ``_emit_plan_update`` to dedupe redundant emits when the todos
471
+ # have not actually changed between turns. The first emit of a
472
+ # session/cycle always wins (cache starts empty).
473
+ self._last_emitted_plan: tuple = ()
479
474
  self._project_config: dict = {}
480
475
  self._project_context_loaded = False
481
476
  self._pending_approval: dict | None = None # {tool_call, result_key}
@@ -511,6 +506,9 @@ class AgentEngine:
511
506
  # Cancellation support
512
507
  self._cancelled: bool = False
513
508
 
509
+ # Subagent depth limit — subagents are leaf nodes (max depth = 1).
510
+ self._subagent_depth: int = 0
511
+
514
512
  # Monotonic tool-call id counter — shared across every
515
513
  # ``chat_stream`` turn so the same id is never reused even when the
516
514
  # model re-emits ``legacy-0`` / ``minimax-0`` in a fresh turn.
@@ -535,7 +533,7 @@ class AgentEngine:
535
533
 
536
534
  # ReAct state tracking
537
535
  self._react_phase: str = "thought" # "thought" | "action" | "observation"
538
- self._direct_execution_count: int = 0 # Track direct tool use for Task-first routing
536
+ self._direct_execution_count: int = 0 # Track direct tool use for routing-decision reminder
539
537
 
540
538
  def _build_tool_registry(self) -> ToolRegistry:
541
539
  from onecode.agent.tools.registry import ToolRegistry
@@ -544,8 +542,8 @@ class AgentEngine:
544
542
  from onecode.agent.tools.bash_tool import BashTool
545
543
  from onecode.agent.tools.web_tools import WebFetchTool, WebSearchTool
546
544
  from onecode.agent.tools.communication_tools import SendMessageTool, AskUserTool, ToolSearchTool
547
- from onecode.agent.tools.task_tools import (TaskCreateTool, TaskGetTool, TaskListTool, TaskUpdateTool,
548
- TaskOutputTool, TaskStopTool, TodoCreateTool, TodoListTool, TodoCompleteTool)
545
+ from onecode.agent.tools.todo_tools import (TodoCreateTool, TodoGetTool, TodoListTool, TodoUpdateTool,
546
+ TodoOutputTool, TodoStopTool)
549
547
  from onecode.agent.tools.agent_tools import AgentTool, TaskTool
550
548
  from onecode.agent.tools.skill_tools import SkillTool
551
549
  from onecode.agent.tools.mcp_tools import MCPTool as MCPToolTool, MCPResourcesTool
@@ -573,15 +571,12 @@ class AgentEngine:
573
571
  registry.register(AskUserTool())
574
572
  registry.register(ToolSearchTool(registry))
575
573
  # Task management
576
- registry.register(TaskCreateTool(self._task_manager))
577
- registry.register(TaskGetTool(self._task_manager))
578
- registry.register(TaskListTool(self._task_manager))
579
- registry.register(TaskUpdateTool(self._task_manager))
580
- registry.register(TaskOutputTool(self._task_manager))
581
- registry.register(TaskStopTool(self._task_manager))
582
- registry.register(TodoCreateTool(self._task_manager))
583
- registry.register(TodoListTool(self._task_manager))
584
- registry.register(TodoCompleteTool(self._task_manager))
574
+ registry.register(TodoCreateTool(self._todo_manager))
575
+ registry.register(TodoGetTool(self._todo_manager))
576
+ registry.register(TodoListTool(self._todo_manager))
577
+ registry.register(TodoUpdateTool(self._todo_manager))
578
+ registry.register(TodoOutputTool(self._todo_manager))
579
+ registry.register(TodoStopTool(self._todo_manager))
585
580
  # Agent tools
586
581
  registry.register(AgentTool(registry, permission_checker=self._check_tool_permission))
587
582
  registry.register(TaskTool(self._spawn_subagent_async))
@@ -661,31 +656,104 @@ class AgentEngine:
661
656
  return
662
657
 
663
658
  def _emit_plan_update(self) -> list[StreamEvent]:
664
- """Emit a plan update event from current tasks and todos."""
665
- entries = []
666
- for task in self._task_manager.list_tasks():
667
- entries.append({
668
- "content": task.get("subject", task.get("description", "")),
669
- "status": task.get("status", "pending"),
670
- "priority": task.get("metadata", {}).get("priority", "medium"),
671
- })
672
- for todo in self._task_manager.list_todos():
673
- entries.append({
674
- "content": todo.get("text", ""),
675
- "status": "completed" if todo.get("done") else "pending",
676
- "priority": "low",
677
- })
678
- return [StreamEvent.plan(entries)]
659
+ """Emit a plan update event from current todos, deduped against last emit.
660
+
661
+ Returns an empty list when the plan snapshot is byte-identical to the
662
+ last emission, so callers can no-op without paying the JSON-RPC + TUI
663
+ widget-recompose cost. The cache is a tuple of ``(content, status,
664
+ priority)`` triples — order-sensitive, so reordering the todo list
665
+ also triggers a re-emit.
666
+ """
667
+ entries = [
668
+ (
669
+ t.get("subject") or t.get("description", ""),
670
+ t.get("status", "pending"),
671
+ t.get("metadata", {}).get("priority", "medium"),
672
+ )
673
+ for t in self._todo_manager.list_todos()
674
+ ]
675
+ if entries == self._last_emitted_plan:
676
+ return []
677
+ self._last_emitted_plan = entries
678
+ # Convert the cached tuples back to wire-format dicts for the stream
679
+ # event. The TUI side expects dicts; the dedupe cache stays tuples
680
+ # to avoid the per-emit dict-construction cost on the hot path.
681
+ wire_entries = [
682
+ {"content": c, "status": s, "priority": p}
683
+ for (c, s, p) in entries
684
+ ]
685
+ return [StreamEvent.plan(wire_entries)]
686
+
687
+ def _refresh_pending_todos_nudge(self) -> None:
688
+ """Inject a reminder that nudges the agent to advance unfinished todos.
689
+
690
+ Called at the start of each ReAct turn. If there are pending or
691
+ in-progress todos, a ``<!-- PENDING_TODOS -->`` section is added to
692
+ the system context so the LLM sees it during its next Thought phase
693
+ and prefers to:
694
+ 1. continue the in-progress todo, or
695
+ 2. start the next pending todo (respecting ``addBlockedBy`` DAG), or
696
+ 3. call ``AskUser`` if the todo is blocked on user input.
697
+
698
+ When no todos are open, any stale nudge is removed so the context
699
+ stays clean.
700
+ """
701
+ marker = "<!-- PENDING_TODOS -->"
702
+ todos = self._todo_manager.list_todos()
703
+ open_todos = [t for t in todos if t.get("status") in ("pending", "in_progress")]
704
+ if not open_todos:
705
+ self.context.remove_system_by_marker(marker)
706
+ return
707
+
708
+ in_progress = [t for t in open_todos if t.get("status") == "in_progress"]
709
+ pending = [t for t in open_todos if t.get("status") == "pending"]
710
+
711
+ def _label(t: dict) -> str:
712
+ sid = t.get("id", "?")
713
+ sub = t.get("subject") or t.get("description", "")
714
+ owner = t.get("owner")
715
+ owner_part = f" (owner={owner})" if owner else ""
716
+ return f"- `{sid}`{owner_part}: {sub}"
717
+
718
+ lines: list[str] = []
719
+ lines.append(
720
+ "There are unfinished todos from earlier in this session. Prioritize "
721
+ "them before starting new work: finish in-progress items, then move "
722
+ "to the next pending todo in DAG order (respect `addBlockedBy`). "
723
+ "If a todo is blocked on user input or external information, call "
724
+ "`AskUser` to clarify instead of starting a new branch."
725
+ )
726
+ if in_progress:
727
+ lines.append("")
728
+ lines.append("**In progress:**")
729
+ lines.extend(_label(t) for t in in_progress)
730
+ if pending:
731
+ lines.append("")
732
+ lines.append("**Pending:**")
733
+ lines.extend(_label(t) for t in pending)
734
+ lines.append("")
735
+ lines.append(
736
+ "Next action: either `TodoUpdate(status=\"in_progress\")` to pick one "
737
+ "up, advance it with the appropriate tool, and `TodoUpdate(status=\"completed\")` "
738
+ "when done — or `AskUser` to unblock."
739
+ )
740
+ body = f"{marker}\n" + "\n".join(lines)
741
+ if not self.context.replace_system_section(marker, body):
742
+ self.context.add_system(body)
679
743
 
680
- def _on_task_change(self) -> None:
681
- """Callback invoked by TaskManager whenever tasks/todos mutate.
744
+ def _on_todo_change(self) -> None:
745
+ """Callback invoked by TodoManager whenever todos mutate.
682
746
 
683
747
  Sets a dirty flag so the next opportunity in ``chat_stream`` will
684
748
  emit a fresh plan event. Decoupling the callback from the emit
685
749
  keeps the public API synchronous and avoids re-entrancy when
686
- subagents mutate the same TaskManager concurrently.
750
+ subagents mutate the same TodoManager concurrently.
751
+
752
+ Also persists todos to ``.cdh/todos.json`` immediately so they
753
+ survive a crash or Ctrl+C before the next ACP turn boundary.
687
754
  """
688
755
  self._plan_dirty = True
756
+ self.save_todos_to_project()
689
757
 
690
758
  @property
691
759
  def _workspace(self) -> Path:
@@ -727,10 +795,12 @@ class AgentEngine:
727
795
  "\n## Response style\n"
728
796
  "- **Every turn must start with Chain of Thought reasoning** "
729
797
  "inside `<thinking>`. Analyze the current state, determine the "
730
- "next step, and decide whether to delegate via `Task`.\n"
731
- "- **Prefer `Task` delegation**: For any multi-step operation, "
732
- "file modification, or research, use `Task(agent_type, prompt)` "
733
- "rather than calling execution tools directly.\n"
798
+ "next step, and decide whether to delegate via `Spawn` or track via `TodoCreate`.\n"
799
+ "- **Route by complexity**:\n"
800
+ " - Simple / single-step work → use the direct tool and optionally "
801
+ "`TodoCreate` to surface progress in the sidebar.\n"
802
+ " - Complex / multi-step work → use `Spawn(agent_type, prompt)` to "
803
+ "delegate to a focused subagent rather than calling execution tools directly.\n"
734
804
  "- If you need to reason between tool calls, wrap your "
735
805
  "reasoning in `<thinking>...</thinking>`. The TUI will "
736
806
  "render the wrapped block as a collapsible thought and "
@@ -909,7 +979,7 @@ class AgentEngine:
909
979
  "Glob": "glob",
910
980
  "Grep": "grep",
911
981
  "List": "list",
912
- "Task": "task",
982
+ "Spawn": "task",
913
983
  "Agent": "task",
914
984
  "Skill": "skill",
915
985
  }
@@ -954,7 +1024,7 @@ class AgentEngine:
954
1024
  return {**base, "content": output, "is_error": result.is_error}
955
1025
  except Exception as e:
956
1026
  logger.exception(f"Tool execution error: {e}")
957
- return {**base, "content": f"Error: {e}", "is_error": True}
1027
+ return {**base, "content": f"Error: {safe_error_msg(e)}", "is_error": True}
958
1028
 
959
1029
  def _format_tool_output(self, result: RegistryToolResult) -> str:
960
1030
  import json
@@ -1012,6 +1082,20 @@ class AgentEngine:
1012
1082
  except Exception as e:
1013
1083
  logger.warning("Codebase retrieval failed: %s", e)
1014
1084
 
1085
+ # ── Long-term memory recall ──
1086
+ if isinstance(user_input, str) and self._session:
1087
+ try:
1088
+ results = self._memory.search_memories(user_input, top_k=5)
1089
+ if results:
1090
+ lines = ["## Relevant past memories"]
1091
+ for r in results:
1092
+ lines.append(r.content[:300])
1093
+ ctx_text = "<!-- MEMORY -->\n" + "\n".join(lines)
1094
+ if not self.context.replace_system_section("MEMORY", ctx_text):
1095
+ self.context.add_system(ctx_text)
1096
+ except Exception as e:
1097
+ logger.warning("Memory recall failed: %s", e)
1098
+
1015
1099
  self.context.add_user(user_input)
1016
1100
 
1017
1101
  # Plan gate: activate based on agent mode, not content heuristics.
@@ -1028,11 +1112,10 @@ class AgentEngine:
1028
1112
  model_name = self.app.current_model
1029
1113
  logger.info(f"Using provider='{provider_name}', model='{model_name}'")
1030
1114
  logger.info(
1031
- "chat_stream engine state: project=%s agent=%s tasks=%d todos=%d context_msgs=%d ctx_tokens=%d",
1115
+ "chat_stream engine state: project=%s agent=%s todos=%d context_msgs=%d ctx_tokens=%d",
1032
1116
  project_name or "(none)",
1033
1117
  self.current_agent.name if self.current_agent else "None",
1034
- len(self._task_manager.list_tasks()),
1035
- len(self._task_manager.list_todos()),
1118
+ len(self._todo_manager.list_todos()),
1036
1119
  len(self.context.messages),
1037
1120
  self.context._token_count,
1038
1121
  )
@@ -1074,10 +1157,12 @@ class AgentEngine:
1074
1157
  "Begin this turn by reasoning step by step in `<thinking>`:\n"
1075
1158
  "1. **Current state**: Analyze the user request and what needs to be done.\n"
1076
1159
  "2. **Goal**: What should I accomplish this turn?\n"
1077
- "3. **Approach**: Should I delegate via `Task`, plan via `task_create`, "
1078
- "or use a direct tool?\n"
1079
- "4. **Task-first**: For any multi-step work or file modifications, "
1080
- "prefer `Task(agent_type, prompt)` over direct execution tools.\n"
1160
+ "3. **Routing**: For the next action, choose by complexity:\n"
1161
+ " - Simple (1 tool call, 1 file) → direct tool + optional `TodoCreate` for tracking\n"
1162
+ " - Complex (multi-file, multi-step, research-heavy) `Spawn(agent_type, prompt)`\n"
1163
+ " - Pure research → `Read/Grep/Glob/WebFetch` directly, or `explore/scout` subagent\n"
1164
+ "4. **Plan when needed**: For multi-step work, create a todo plan first via "
1165
+ "`TodoCreate`, then mark each todo as `in_progress` / `completed` as you go.\n"
1081
1166
  )
1082
1167
  if not self.context.replace_system_section("REACT_PHASE", cot_phase_init):
1083
1168
  self.context.add_system(cot_phase_init)
@@ -1089,7 +1174,11 @@ class AgentEngine:
1089
1174
 
1090
1175
  # ── Thought Phase: update CoT reasoning guidance for this turn ──
1091
1176
  # Cleanup stale reminders that may have accumulated
1092
- self.context.remove_system_by_marker("<!-- TASK_FIRST_REMINDER -->")
1177
+ self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
1178
+ # Inject (or refresh) a nudge that prioritizes any unfinished todos
1179
+ # from this session. The marker is replaced in-place so the context
1180
+ # stays bounded — the agent always sees the current open list.
1181
+ self._refresh_pending_todos_nudge()
1093
1182
  if turn > 0:
1094
1183
  cot_phase = (
1095
1184
  f"<!-- REACT_PHASE -->\n"
@@ -1097,10 +1186,11 @@ class AgentEngine:
1097
1186
  "Begin this turn by reasoning step by step in `<thinking>`:\n"
1098
1187
  "1. **Current state**: What just happened? What results do I have?\n"
1099
1188
  "2. **Goal**: What should I accomplish this turn?\n"
1100
- "3. **Approach**: Should I delegate via `Task`, plan via `task_create`, "
1101
- "or use a direct tool?\n"
1102
- "4. **Task-first**: For any multi-step work or file modifications, "
1103
- "prefer `Task(agent_type, prompt)` over direct execution tools.\n"
1189
+ "3. **Routing**: For the next action, choose by complexity:\n"
1190
+ " - Simple (1 tool call, 1 file) → direct tool + optional `TodoCreate` for tracking\n"
1191
+ " - Complex (multi-file, multi-step, research-heavy) `Spawn(agent_type, prompt)`\n"
1192
+ "4. **Plan when needed**: For multi-step work, advance todos via `TodoUpdate` "
1193
+ "and delegate substantial items via `Spawn`.\n"
1104
1194
  )
1105
1195
  self.context.replace_system_section("REACT_PHASE", cot_phase)
1106
1196
 
@@ -1199,7 +1289,7 @@ class AgentEngine:
1199
1289
  turn_usage = model_response.usage
1200
1290
  except Exception as e:
1201
1291
  logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
1202
- yield StreamEvent.error(str(e))
1292
+ yield StreamEvent.error(safe_error_msg(e))
1203
1293
  break
1204
1294
  except Exception as e:
1205
1295
  ctx_msgs = len(self.context.messages)
@@ -1209,7 +1299,7 @@ class AgentEngine:
1209
1299
  f" provider={provider_name} model={model_name} "
1210
1300
  f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
1211
1301
  )
1212
- yield StreamEvent.error(f"Provider error (turn {turn+1}): {e}")
1302
+ yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
1213
1303
  break
1214
1304
 
1215
1305
  # Track usage
@@ -1237,7 +1327,7 @@ class AgentEngine:
1237
1327
  # Log the raw model response so developers can verify the
1238
1328
  # model is actually emitting ``<thinking>`` markers (and not
1239
1329
  # bleeding planning prose into the visible answer). Goes
1240
- # to the onecode root logger → ~/.onecode/logs/cdh.log when
1330
+ # to the onecode root logger → ~/.cdh/logs/cdh.log when
1241
1331
  # ``setup_logging(DEBUG)`` is in effect.
1242
1332
  logger.debug(
1243
1333
  "RAW_RESPONSE turn=%d text_len=%d first_200=%r "
@@ -1279,6 +1369,19 @@ class AgentEngine:
1279
1369
  yield StreamEvent.text_delta(clean_text[i:i + 12])
1280
1370
  elif self._last_user_msg:
1281
1371
  yield StreamEvent.text_delta(self._last_user_msg)
1372
+
1373
+ # ── Store conversation in long-term memory ──
1374
+ if self._session:
1375
+ try:
1376
+ conv_id = self._session.id
1377
+ input_str = user_input if isinstance(user_input, str) else str(user_input)
1378
+ self._memory.remember(
1379
+ MemoryLayer.L0_CONVERSATION,
1380
+ f"user: {input_str}\nassistant: {clean_text}",
1381
+ {"conversation_id": conv_id},
1382
+ )
1383
+ except Exception as e:
1384
+ logger.warning("Memory store failed: %s", e)
1282
1385
  break
1283
1386
 
1284
1387
  # Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
@@ -1296,13 +1399,13 @@ class AgentEngine:
1296
1399
  for tu in tool_uses:
1297
1400
  logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
1298
1401
 
1299
- # Task tool: forward subagent text deltas to the TUI as
1402
+ # Spawn tool: forward subagent text deltas to the TUI as
1300
1403
  # subagent_chunk events so the SubAgent widget actually has
1301
- # content to render. The Task tool's spec is (agent_type,
1404
+ # content to render. The Spawn tool's spec is (agent_type,
1302
1405
  # prompt); we read them from the tool input.
1303
- if tu["name"] == "Task":
1406
+ if tu["name"] == "Spawn":
1304
1407
  # Permission check
1305
- denied = self._check_tool_permission("Task", tu["input"])
1408
+ denied = self._check_tool_permission("Spawn", tu["input"])
1306
1409
  if denied:
1307
1410
  result = {
1308
1411
  "tool_use_id": tu["id"],
@@ -1313,78 +1416,118 @@ class AgentEngine:
1313
1416
  else:
1314
1417
  subagent_type = tu["input"].get("agent_type", "general")
1315
1418
  subagent_prompt = tu["input"].get("prompt", "")
1316
- yield StreamEvent.subagent_start(subagent_type, tu["id"])
1419
+ is_failed = False
1420
+ error_msg = ""
1421
+ yield StreamEvent.subagent_start(subagent_type, tu["id"], subagent_prompt)
1317
1422
  accumulated: list[str] = []
1318
1423
  async for sub_event, sub_text in self._spawn_subagent_async_streaming(
1319
1424
  subagent_type, subagent_prompt
1320
1425
  ):
1321
- if sub_text and sub_event.type == StreamEventType.SUBAGENT_END:
1322
- accumulated.append(sub_text)
1323
- elif sub_text:
1324
- accumulated.append(sub_text)
1325
- yield StreamEvent.subagent_chunk(tu["id"], sub_text)
1326
- yield StreamEvent.subagent_end(tu["id"])
1426
+ if not sub_text:
1427
+ continue
1428
+ if sub_event.type == StreamEventType.SUBAGENT_END:
1429
+ # Carry status/error from subagent_end event
1430
+ if hasattr(sub_event, "subagent_status") and sub_event.subagent_status == "failed":
1431
+ is_failed = True
1432
+ error_msg = sub_event.subagent_error or ""
1433
+ continue # 修复 Bug 1: 不 append final combined text(避免文本重复)
1434
+ if sub_event.type == StreamEventType.SUBAGENT_THINKING:
1435
+ yield StreamEvent.subagent_thinking(tu["id"], sub_text)
1436
+ continue
1437
+ # TEXT_DELTA
1438
+ accumulated.append(sub_text)
1439
+ yield StreamEvent.subagent_chunk(tu["id"], sub_text)
1440
+ yield StreamEvent.subagent_end(
1441
+ tu["id"],
1442
+ agent_type=subagent_type,
1443
+ status="failed" if is_failed else "completed",
1444
+ error=error_msg,
1445
+ )
1446
+ raw_output = "".join(accumulated)
1327
1447
  formatted = self._format_subagent_output(
1328
- subagent_type, subagent_prompt, "".join(accumulated)
1448
+ subagent_type, subagent_prompt, raw_output
1449
+ ) if not is_failed else (
1450
+ f"SUMMARY:\nSub-agent failed with error: {error_msg}\n\n"
1451
+ "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
1452
+ f"BLOCKERS:\n{error_msg}"
1329
1453
  )
1330
1454
  result = {
1331
1455
  "tool_use_id": tu["id"],
1332
- "is_error": False,
1456
+ "is_error": is_failed, # 修复 Bug 2: 子智能体失败时 is_error=True
1333
1457
  "category": "task",
1334
1458
  "content": formatted,
1459
+ "raw_content": raw_output,
1335
1460
  }
1336
1461
  elif (self._plan_gate_mode != "off"
1337
- and not self._task_manager.list_tasks()
1462
+ and not self._todo_manager.list_todos()
1338
1463
  and tu["name"] in _EXECUTION_TOOLS
1339
1464
  and self._plan_gate_first_turn()):
1340
1465
  if self._plan_gate_mode == "hard":
1341
1466
  result = {
1342
1467
  "tool_use_id": tu["id"],
1343
1468
  "is_error": True,
1344
- "category": "task",
1469
+ "category": "todo",
1345
1470
  "content": json.dumps({
1346
1471
  "success": False,
1347
1472
  "error": (
1348
1473
  "Plan required. You are in plan mode (ReAct: Thought → Action → Observation). "
1349
- "You MUST create a task plan using task_create() before using execution tools. "
1350
- "First Think (analyze/explore), then Act by creating ALL tasks upfront "
1351
- "with dependencies, present the plan for user review, "
1474
+ "Before using execution tools, create a todo plan via `TodoCreate()`. "
1475
+ "Choose the right granularity:\n"
1476
+ "- Each todo = one focused unit of work (file, function, or concern).\n"
1477
+ "- Set `addBlockedBy` on dependent todos to express the DAG.\n"
1478
+ "- For complex multi-step work, mark the todo with "
1479
+ "`metadata={\"delegate_to\": \"general\"}` to indicate it should be "
1480
+ "executed by a `Spawn` subagent rather than direct tool calls.\n"
1481
+ "Create ALL todos upfront, present the plan for user review, "
1352
1482
  "and only then proceed to execute."
1353
1483
  ),
1354
1484
  }),
1355
1485
  }
1356
1486
  else:
1357
- # Soft gate: execute but inject a reminder for next turn
1487
+ # Soft gate: execute but inject a routing reminder for next turn
1358
1488
  self.context.add_system(
1359
1489
  "<!-- PLAN_REMINDER -->\n"
1360
- "Note: You used a direct execution tool without delegating via Task. "
1361
- "For any non-trivial operation, prefer `Task(agent_type, prompt)` "
1362
- "to delegate the work to a focused subagent instead. Task subagents "
1363
- "encapsulate multi-step logic, keep the main context clean, and "
1364
- "return structured SUMMARY/CHANGES/EVIDENCE/RISKS/BLOCKERS output."
1490
+ "## Routing Decision\n"
1491
+ "Before your next action, choose the right tool based on complexity:\n\n"
1492
+ "**Simple / single-step work** use `TodoCreate` to track a lightweight "
1493
+ "checklist item in the sidebar. Examples: 1 file edit, 1 Read, 1 Bash. "
1494
+ "The Todo widget shows progress to the user.\n\n"
1495
+ "**Complex / multi-step work** → use `Spawn(agent_type, prompt)` to "
1496
+ "delegate to a focused subagent. Examples: cross-file refactor, multi-"
1497
+ "file feature, research requiring 3+ tool calls, work needing isolated "
1498
+ "context. The subagent encapsulates work and returns structured output.\n\n"
1499
+ "**Default**: When unsure → `Spawn`. Todo is for visible progress on "
1500
+ "simple items; Spawn is for substantial work.\n"
1365
1501
  )
1366
1502
  result = await self._execute_tool(tu)
1367
1503
  else:
1368
1504
  result = await self._execute_tool(tu)
1369
- # Task-first routing: track direct execution tool use
1505
+ # Routing: track direct execution tool use → nudge routing decision
1370
1506
  if tu["name"] in _EXECUTION_TOOLS:
1371
1507
  self._direct_execution_count += 1
1372
1508
  if self._direct_execution_count >= 2:
1373
1509
  self.context.add_system(
1374
- "<!-- TASK_FIRST_REMINDER -->\n"
1375
- "Reminder: You're using direct execution tools repeatedly. "
1376
- "For multi-step work, delegate via `Task(agent_type, prompt)` instead. "
1377
- "This keeps the main context focused and work properly encapsulated."
1510
+ "<!-- ROUTING_REMINDER -->\n"
1511
+ "You've used direct execution tools repeatedly. Pause and decide:\n"
1512
+ "- If this is a single-step trivial change continue, but "
1513
+ "consider `TodoCreate` to surface progress in the sidebar.\n"
1514
+ "- If this work involves >1 tool call, multiple files, or "
1515
+ "research → STOP direct execution and delegate via "
1516
+ "`Spawn(agent_type=\"general\", prompt=\"...\")` instead. The "
1517
+ "subagent isolates context and returns a structured summary."
1378
1518
  )
1379
1519
  result_str = str(result.get("content", ""))
1380
1520
  is_error = result.get("is_error", False)
1381
1521
  category = result.get("category", "unknown")
1522
+ # For Task tools, use raw subagent output for TUI display
1523
+ # (no SUMMARY/CHANGES/… structured wrapper)
1524
+ tui_content = str(result.get("raw_content", result_str))
1382
1525
 
1383
1526
  # Handle SendMessage — user-visible only, skip from LLM context
1384
1527
  if tu["name"] == "SendMessage":
1385
1528
  try:
1386
1529
  parsed = json.loads(result_str) if result_str else {}
1387
- msg = parsed.get("message", "")
1530
+ msg = parsed.get("message", "") if isinstance(parsed, dict) else ""
1388
1531
  if msg:
1389
1532
  self._last_user_msg = msg
1390
1533
  yield StreamEvent.text_delta(f"\n💬 {msg}\n")
@@ -1401,9 +1544,9 @@ class AgentEngine:
1401
1544
  # Handle AskUser — trigger user interaction dialog, don't add to LLM context yet
1402
1545
  if tu["name"] == "AskUser":
1403
1546
  parsed = json.loads(result_str) if result_str else {}
1404
- question = parsed.get("question", "")
1405
- options = parsed.get("options", [])
1406
- questions = parsed.get("questions", [])
1547
+ question = parsed.get("question", "") if isinstance(parsed, dict) else ""
1548
+ options = parsed.get("options", []) if isinstance(parsed, dict) else []
1549
+ questions = parsed.get("questions", []) if isinstance(parsed, dict) else []
1407
1550
 
1408
1551
  # Check for auto-default on single question + single option
1409
1552
  if not questions and len(options) == 1 and options[0].get("default"):
@@ -1462,7 +1605,7 @@ class AgentEngine:
1462
1605
  requires_approval = False
1463
1606
  try:
1464
1607
  parsed = json.loads(result_str) if result_str else {}
1465
- requires_approval = parsed.get("requires_approval", False)
1608
+ requires_approval = isinstance(parsed, dict) and parsed.get("requires_approval", False)
1466
1609
  except (json.JSONDecodeError, ValueError):
1467
1610
  pass
1468
1611
 
@@ -1499,7 +1642,7 @@ class AgentEngine:
1499
1642
  ))
1500
1643
  yield StreamEvent.tool_result(
1501
1644
  call_id=tu["id"],
1502
- content=result_str,
1645
+ content=tui_content,
1503
1646
  is_error=is_error,
1504
1647
  category=result_cat,
1505
1648
  )
@@ -1513,7 +1656,7 @@ class AgentEngine:
1513
1656
  name=tu["id"],
1514
1657
  )
1515
1658
  # Emit plan update when tasks/todos changed. Any
1516
- # mutating path through TaskManager flips
1659
+ # mutating path through TodoManager flips
1517
1660
  # ``_plan_dirty`` via the ``on_change`` callback, so we
1518
1661
  # do not need to enumerate tool names here.
1519
1662
  if self._plan_dirty:
@@ -1604,8 +1747,12 @@ class AgentEngine:
1604
1747
  self._react_phase = "thought"
1605
1748
  self._direct_execution_count = 0
1606
1749
  self._plan_gate_fired = False
1750
+ # Invalidate the plan-emit dedupe cache so the next chat_stream
1751
+ # always produces a fresh snapshot for the TUI.
1752
+ self._last_emitted_plan = ()
1607
1753
  self.context.remove_system_by_marker("<!-- REACT_PHASE -->")
1608
- self.context.remove_system_by_marker("<!-- TASK_FIRST_REMINDER -->")
1754
+ self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
1755
+ self.context.remove_system_by_marker("<!-- PENDING_TODOS -->")
1609
1756
 
1610
1757
  def reset(self):
1611
1758
  self.context.reset()
@@ -1691,16 +1838,23 @@ class AgentEngine:
1691
1838
 
1692
1839
  Yields ``(event, text)`` pairs so the caller (typically the Task-tool
1693
1840
  path in :meth:`chat_stream`) can forward each text delta as a
1694
- ``subagent_chunk`` notification on the ACP wire. The full agent text
1695
- is also accumulated and emitted in a single ``(subagent_end, text)``
1696
- payload at the end so callers that only care about the final output
1697
- can ignore the intermediate chunks.
1841
+ ``subagent_chunk`` notification on the ACP wire.
1698
1842
 
1699
1843
  Inherits project context and skills from the parent
1700
1844
  engine so the subagent does not start from a blank slate.
1701
1845
  """
1846
+ # Depth check: subagents are leaf nodes (max depth = 1).
1847
+ if self._subagent_depth >= _MAX_SUBAGENT_DEPTH:
1848
+ err = "Subagent cannot spawn nested subagents (max depth=1)"
1849
+ yield (StreamEvent.subagent_end(
1850
+ "", agent_type=agent_type, status="failed", error=err,
1851
+ ), err)
1852
+ return
1853
+
1702
1854
  sub_engine = AgentEngine(self.app, project_dir=self._project_dir, perm_store=self._perm_store)
1703
1855
 
1856
+ sub_engine._subagent_depth = self._subagent_depth + 1
1857
+
1704
1858
  # Inherit parent context: project info, skills
1705
1859
  sub_engine._project_context_loaded = self._project_context_loaded
1706
1860
  sub_engine._project_config = dict(self._project_config)
@@ -1714,7 +1868,6 @@ class AgentEngine:
1714
1868
  # Track child so parent cancellation cascades
1715
1869
  self._child_engines.append(sub_engine)
1716
1870
 
1717
- # Link the subagent's cancellation flag to the parent
1718
1871
  parts: list[str] = []
1719
1872
  try:
1720
1873
  async for chunk in sub_engine.chat_stream(prompt):
@@ -1722,6 +1875,9 @@ class AgentEngine:
1722
1875
  if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
1723
1876
  parts.append(chunk.text)
1724
1877
  yield chunk, chunk.text
1878
+ elif chunk.type == StreamEventType.THINKING and chunk.thinking:
1879
+ # 修复 Bug 4: 转发思考块
1880
+ yield chunk, chunk.thinking
1725
1881
  else:
1726
1882
  if chunk:
1727
1883
  parts.append(chunk)
@@ -1732,9 +1888,13 @@ class AgentEngine:
1732
1888
  "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
1733
1889
  f"BLOCKERS:\n{e}"
1734
1890
  )
1735
- yield StreamEvent.subagent_end(""), err_text
1891
+ yield (StreamEvent.subagent_end(
1892
+ "", agent_type=agent_type, status="failed", error=str(e),
1893
+ ), err_text)
1736
1894
  else:
1737
- yield StreamEvent.subagent_end(""), "".join(parts)
1895
+ yield (StreamEvent.subagent_end(
1896
+ "", agent_type=agent_type, status="completed",
1897
+ ), "".join(parts))
1738
1898
  finally:
1739
1899
  # Clean up completed subagent to prevent memory leak
1740
1900
  if sub_engine in self._child_engines:
@@ -1787,7 +1947,7 @@ class AgentEngine:
1787
1947
  result = asyncio.run(self._spawn_subagent_async(agent_type, prompt))
1788
1948
  except Exception as e:
1789
1949
  logger.exception(f"Subagent error: {e}")
1790
- return {"success": False, "error": str(e)}
1950
+ return {"success": False, "error": safe_error_msg(e)}
1791
1951
  return {
1792
1952
  "success": True,
1793
1953
  "agent_type": agent_type,
@@ -1799,19 +1959,17 @@ class AgentEngine:
1799
1959
  result = self.spawn_subagent(agent_type, prompt)
1800
1960
  if result["success"]:
1801
1961
  return result["response"]
1802
- return f"Error: {result.get('error', 'Unknown error')}"
1962
+ return f"Error: {result.get('error') or 'Unknown error'}"
1803
1963
 
1804
1964
  def attach_session(self, session: AgentSession) -> None:
1805
1965
  self._session = session
1806
1966
  if session.messages:
1807
1967
  self.context.load_from_session(session.messages)
1808
- if session.tasks or session.todos:
1809
- self._task_manager = TaskManager.from_dict({
1810
- "tasks": session.tasks,
1968
+ if session.todos:
1969
+ self._todo_manager = TodoManager.from_dict({
1811
1970
  "todos": session.todos,
1812
- "plan": [],
1813
- "id_counter": len(session.tasks) + len(session.todos),
1814
- }, on_change=self._on_task_change)
1971
+ "id_counter": len(session.todos),
1972
+ }, on_change=self._on_todo_change)
1815
1973
  # Restore context usage stats
1816
1974
  self._restore_session_stats(session)
1817
1975
 
@@ -1819,8 +1977,7 @@ class AgentEngine:
1819
1977
  if self._session:
1820
1978
  try:
1821
1979
  self._session.messages = self.context.to_session_format()
1822
- tm_data = self._task_manager.to_dict()
1823
- self._session.tasks = tm_data.get("tasks", [])
1980
+ tm_data = self._todo_manager.to_dict()
1824
1981
  self._session.todos = tm_data.get("todos", [])
1825
1982
  # Persist context usage stats so they survive session reload
1826
1983
  self._session.update_state("stats", {
@@ -1837,20 +1994,54 @@ class AgentEngine:
1837
1994
 
1838
1995
  def load_session(self, session_id: str) -> bool:
1839
1996
  session = AgentSession(session_id)
1840
- if session.load():
1841
- self._session = session
1842
- self.context.load_from_session(session.messages)
1843
- if session.tasks or session.todos:
1844
- self._task_manager = TaskManager.from_dict({
1845
- "tasks": session.tasks,
1846
- "todos": session.todos,
1847
- "plan": [],
1848
- "id_counter": len(session.tasks) + len(session.todos),
1849
- }, on_change=self._on_task_change)
1850
- # Restore context usage stats
1851
- self._restore_session_stats(session)
1852
- return True
1853
- return False
1997
+ if not session.load():
1998
+ return False
1999
+ self._session = session
2000
+ self.context.load_from_session(session.messages)
2001
+ if session.todos:
2002
+ self._todo_manager = TodoManager.from_dict({
2003
+ "todos": session.todos,
2004
+ "id_counter": len(session.todos),
2005
+ }, on_change=self._on_todo_change)
2006
+ self._inject_loaded_tasks_into_context()
2007
+ # Restore context usage stats
2008
+ self._restore_session_stats(session)
2009
+ return True
2010
+
2011
+ def _inject_loaded_tasks_into_context(self) -> None:
2012
+ """Surface loaded todos to the LLM so it can continue them.
2013
+
2014
+ Todos loaded from a previous session are not automatically visible
2015
+ in the LLM context — without this nudge the agent would re-create
2016
+ them on every fresh turn instead of resuming pending work.
2017
+ """
2018
+ all_todos = self._todo_manager.list_todos()
2019
+ pending = [t for t in all_todos if t.get("status") in ("pending", "in_progress")]
2020
+ if not pending:
2021
+ return
2022
+
2023
+ lines = ["# Resumed todos from previous session", ""]
2024
+ lines.append("## Todos")
2025
+ for t in pending:
2026
+ status = t.get("status", "pending")
2027
+ marker = {"in_progress": "[~]", "pending": "[ ]"}.get(status, "[?]")
2028
+ subject = t.get("subject") or t.get("description", "")
2029
+ desc = t.get("description") or ""
2030
+ if desc and desc != subject:
2031
+ lines.append(f"- {marker} (id={t.get('id')}) {subject} — {desc}")
2032
+ else:
2033
+ lines.append(f"- {marker} (id={t.get('id')}) {subject}")
2034
+ lines.append("")
2035
+
2036
+ lines.append(
2037
+ "Continue working on any in_progress or pending todos above. "
2038
+ "Use TodoList/TodoGet to inspect details and TodoUpdate to "
2039
+ "advance status. Do NOT recreate todos that already exist."
2040
+ )
2041
+ marker = "<!-- loaded_todos_resume -->"
2042
+ body = f"{marker}\n" + "\n".join(lines)
2043
+ if not self.context.replace_system_section(marker, body):
2044
+ self.context.add_system(body)
1854
2045
 
1855
2046
  def _restore_session_stats(self, session: AgentSession) -> None:
1856
2047
  """Restore context usage stats from session lifecycle_state."""
@@ -1863,32 +2054,32 @@ class AgentEngine:
1863
2054
  if restored_turns and isinstance(restored_turns, list):
1864
2055
  self._turn_usages = list(restored_turns)
1865
2056
 
1866
- def save_tasks_to_project(self) -> None:
1867
- """Save tasks/todos to .cdh/tasks.json in the project directory."""
2057
+ def save_todos_to_project(self) -> None:
2058
+ """Save todos to .cdh/todos.json in the project directory."""
1868
2059
  from onecode.agent.cdh_loader import CdhProjectLoader
1869
2060
  cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
1870
2061
  if cdh_dir is None:
1871
2062
  return
1872
2063
  try:
1873
- tm_data = self._task_manager.to_dict()
1874
- CdhProjectLoader.save_tasks(cdh_dir, tm_data)
2064
+ tm_data = self._todo_manager.to_dict()
2065
+ CdhProjectLoader.save_todos(cdh_dir, tm_data)
1875
2066
  except Exception as e:
1876
- logger.warning("Failed to save tasks to .cdh: %s", e)
2067
+ logger.warning("Failed to save todos to .cdh: %s", e)
1877
2068
 
1878
- def load_tasks_from_project(self) -> None:
1879
- """Restore tasks/todos from .cdh/tasks.json."""
2069
+ def load_todos_from_project(self) -> None:
2070
+ """Restore todos from .cdh/todos.json (with legacy tasks.json fallback)."""
1880
2071
  from onecode.agent.cdh_loader import CdhProjectLoader
1881
2072
  cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
1882
2073
  if cdh_dir is None:
1883
2074
  return
1884
2075
  try:
1885
- data = CdhProjectLoader.load_tasks(cdh_dir)
2076
+ data = CdhProjectLoader.load_todos(cdh_dir)
1886
2077
  if data and (data.get("tasks") or data.get("todos")):
1887
- self._task_manager = TaskManager.from_dict(
1888
- data, on_change=self._on_task_change
2078
+ self._todo_manager = TodoManager.from_dict(
2079
+ data, on_change=self._on_todo_change
1889
2080
  )
1890
2081
  except Exception as e:
1891
- logger.warning("Failed to load tasks from .cdh: %s", e)
2082
+ logger.warning("Failed to load todos from .cdh: %s", e)
1892
2083
 
1893
2084
  def get_session(self) -> Optional[AgentSession]:
1894
2085
  return self._session