deepy-cli 0.2.3__tar.gz → 0.2.5__tar.gz

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 (86) hide show
  1. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/PKG-INFO +1 -1
  2. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/pyproject.toml +1 -1
  3. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/__init__.py +1 -1
  4. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/AskUserQuestion.md +6 -0
  5. deepy_cli-0.2.5/src/deepy/data/tools/todo_write.md +16 -0
  6. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/compaction.py +14 -8
  7. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/runner.py +159 -6
  8. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/compact.py +10 -1
  9. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/system.py +11 -0
  10. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/tool_docs.py +1 -0
  11. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/sessions/jsonl.py +112 -61
  12. deepy_cli-0.2.5/src/deepy/todos.py +111 -0
  13. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/agents.py +47 -0
  14. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/builtin.py +99 -22
  15. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/message_view.py +111 -5
  16. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/terminal.py +138 -88
  17. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/README.md +0 -0
  18. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/__main__.py +0 -0
  19. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/cli.py +0 -0
  20. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/config/__init__.py +0 -0
  21. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/config/settings.py +0 -0
  22. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/__init__.py +0 -0
  23. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  24. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  25. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/WebFetch.md +0 -0
  26. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/WebSearch.md +0 -0
  27. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/__init__.py +0 -0
  28. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/edit.md +0 -0
  29. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/modify.md +0 -0
  30. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/read.md +0 -0
  31. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/shell.md +0 -0
  32. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/write.md +0 -0
  33. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/errors.py +0 -0
  34. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/__init__.py +0 -0
  35. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/agent.py +0 -0
  36. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/context.py +0 -0
  37. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/events.py +0 -0
  38. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/model_capabilities.py +0 -0
  39. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/provider.py +0 -0
  40. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/replay.py +0 -0
  41. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/thinking.py +0 -0
  42. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/mcp.py +0 -0
  43. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/__init__.py +0 -0
  44. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/init_agents.py +0 -0
  45. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/rules.py +0 -0
  46. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/runtime_context.py +0 -0
  47. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/sessions/__init__.py +0 -0
  48. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/sessions/manager.py +0 -0
  49. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/skill_market.py +0 -0
  50. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/skills.py +0 -0
  51. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/status.py +0 -0
  52. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/__init__.py +0 -0
  53. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/file_state.py +0 -0
  54. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/result.py +0 -0
  55. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/shell_output.py +0 -0
  56. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/shell_utils.py +0 -0
  57. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/types/__init__.py +0 -0
  58. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/types/sdk.py +0 -0
  59. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/types/tool_payloads.py +0 -0
  60. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/__init__.py +0 -0
  61. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/app.py +0 -0
  62. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/ask_user_question.py +0 -0
  63. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/exit_summary.py +0 -0
  64. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/file_mentions.py +0 -0
  65. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/loading_text.py +0 -0
  66. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/local_command.py +0 -0
  67. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/markdown.py +0 -0
  68. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/model_picker.py +0 -0
  69. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/prompt_buffer.py +0 -0
  70. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/prompt_input.py +0 -0
  71. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/session_list.py +0 -0
  72. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/session_picker.py +0 -0
  73. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/skill_picker.py +0 -0
  74. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/slash_commands.py +0 -0
  75. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/status_footer.py +0 -0
  76. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/styles.py +0 -0
  77. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/theme_picker.py +0 -0
  78. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/thinking_state.py +0 -0
  79. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/welcome.py +0 -0
  80. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/update_check.py +0 -0
  81. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/usage.py +0 -0
  82. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/__init__.py +0 -0
  83. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/debug_logger.py +0 -0
  84. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/error_logger.py +0 -0
  85. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/json.py +0 -0
  86. {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.5"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -10,6 +10,12 @@
10
10
  标注 `(Recommended)` 或中文等价表达。不要为了低影响细节提问;可以合理假设时
11
11
  继续推进并简短说明假设。
12
12
 
13
+ Agent Skills 可能使用通用说法,例如 ask the user、ask one question at a time、
14
+ wait for the user's response、get approval、review 或 confirm before continuing。
15
+ 在 Deepy 中,除非 skill 明确要求不要使用工具,否则这些等待用户输入的步骤都应通过
16
+ `AskUserQuestion` 完成。开放问题也必须提供选项;可加入“自定义回答”/`Custom answer`
17
+ 作为选项,让用户输入自由文本。
18
+
13
19
  Args: `questions` (non-empty array). Each question needs `question` and non-empty `options`;
14
20
  each option needs `label` and may include `description`. Use `multiSelect=true` only when
15
21
  multiple choices are allowed.
@@ -0,0 +1,16 @@
1
+ ## todo_write
2
+
3
+ Maintain a session-scoped todo list for complex work.
4
+
5
+ Use `todo_write` when the user asks for several deliverables, the work touches
6
+ multiple files, or the task naturally breaks into three or more meaningful
7
+ steps. Do not use it for simple questions, tiny one-step edits, or routine
8
+ commands where a todo list would add noise.
9
+
10
+ Each update replaces the complete list. Keep item `id` values stable across
11
+ updates. Use exactly one `in_progress` item at a time, mark completed work as
12
+ `completed`, and update the list only when real progress changes. Omit `todos`
13
+ to read the current list; pass an empty list to clear it.
14
+
15
+ This tool only tracks progress. It does not delegate to subagents and it does
16
+ not request plan approval.
@@ -8,6 +8,7 @@ from typing import Any, Literal
8
8
  from deepy.config import Settings
9
9
  from deepy.prompts.compact import build_compact_prompt, build_compact_summary_message
10
10
  from deepy.sessions.jsonl import DeepyJsonlSession
11
+ from deepy.todos import todo_state_prompt_text
11
12
  from deepy.usage import TokenUsage, usage_from_run_result
12
13
 
13
14
  from .context import estimate_tokens_for_item, estimate_tokens_for_items
@@ -81,6 +82,7 @@ async def compact_session(
81
82
  settings,
82
83
  provider=provider,
83
84
  focus_instruction=focus_instruction,
85
+ todo_state=session.todo_state(),
84
86
  )
85
87
  replacement = sanitize_sdk_items_for_replay(
86
88
  [build_compact_summary_message(summary), *to_preserve]
@@ -124,9 +126,7 @@ async def ensure_context_ready(
124
126
  before_tokens = state.active_tokens + additional_tokens
125
127
  latest_context_usage = session.latest_context_window_usage()
126
128
  trigger_tokens = (
127
- latest_context_usage.used_tokens
128
- if latest_context_usage is not None
129
- else before_tokens
129
+ latest_context_usage.used_tokens if latest_context_usage is not None else before_tokens
130
130
  )
131
131
  compacted: CompactionResult | None = None
132
132
  if trigger_tokens >= settings.context.resolved_compact_threshold:
@@ -144,9 +144,7 @@ async def ensure_context_ready(
144
144
  else:
145
145
  after_context_usage = session.latest_context_window_usage()
146
146
  fit_tokens = (
147
- after_context_usage.used_tokens
148
- if after_context_usage is not None
149
- else after_tokens
147
+ after_context_usage.used_tokens if after_context_usage is not None else after_tokens
150
148
  )
151
149
  if fit_tokens + settings.context.reserved_context_tokens >= settings.context.window_tokens:
152
150
  raise ContextCompactionError(
@@ -198,11 +196,16 @@ async def run_compaction_model(
198
196
  *,
199
197
  provider: ProviderBundle | None = None,
200
198
  focus_instruction: str | None = None,
199
+ todo_state: list[dict[str, str]] | None = None,
201
200
  ) -> tuple[str, TokenUsage]:
202
201
  from agents import Agent, RunConfig, Runner
203
202
 
204
203
  resolved_provider = provider or build_provider_bundle(settings)
205
- prompt = build_compact_prompt(items, focus_instruction=focus_instruction)
204
+ prompt = build_compact_prompt(
205
+ items,
206
+ focus_instruction=focus_instruction,
207
+ todo_context=todo_state_prompt_text(todo_state or []),
208
+ )
206
209
  agent = Agent(
207
210
  name="Deepy Context Compactor",
208
211
  instructions="Create a compact continuation summary. Do not call tools.",
@@ -254,7 +257,10 @@ def _expand_preserve_start_for_tool_group(items: list[dict[str, Any]], preserve_
254
257
  if not needed_call_ids:
255
258
  return preserve_start
256
259
  for index in range(preserve_start - 1, -1, -1):
257
- if items[index].get("type") == "function_call" and _call_id(items[index]) in needed_call_ids:
260
+ if (
261
+ items[index].get("type") == "function_call"
262
+ and _call_id(items[index]) in needed_call_ids
263
+ ):
258
264
  preserve_start = index
259
265
  needed_call_ids.discard(_call_id(items[index]))
260
266
  if not needed_call_ids:
@@ -12,6 +12,7 @@ from deepy.config import Settings, load_settings
12
12
  from deepy.sessions.jsonl import DeepyJsonlSession
13
13
  from deepy.skills import find_skill
14
14
  from deepy.mcp import DeepyMcpRuntime
15
+ from deepy.todos import normalize_todo_items
15
16
  from deepy.tools import ToolRuntime
16
17
  from deepy.usage import TokenUsage, merge_usage, normalize_usage, usage_from_run_result
17
18
  from deepy.utils import json as json_utils
@@ -24,6 +25,11 @@ from .events import DeepyStreamEvent, normalize_stream_event
24
25
  from .provider import ProviderBundle, build_provider_bundle
25
26
 
26
27
  DEFAULT_MAX_TURNS = 100
28
+ INTERRUPTED_MARKER_TEXT = (
29
+ "Interrupted by user with Esc. This turn was stopped before completion. "
30
+ "Do not continue the interrupted request unless the user explicitly asks to continue."
31
+ )
32
+ INTERRUPTED_TOOL_OUTPUT_TEXT = "Tool execution interrupted by user with Esc."
27
33
 
28
34
 
29
35
  @dataclass(frozen=True)
@@ -60,7 +66,15 @@ async def run_prompt_once(
60
66
  root = (project_root or Path.cwd()).resolve()
61
67
  resolved_settings = settings or load_settings()
62
68
  resolved_provider = provider or build_provider_bundle(resolved_settings)
63
- runtime = ToolRuntime(cwd=root, settings=resolved_settings)
69
+ session = (
70
+ DeepyJsonlSession.open(root, session_id) if session_id else DeepyJsonlSession.create(root)
71
+ )
72
+ initial_todos, _ = normalize_todo_items(session.todo_state())
73
+ runtime = ToolRuntime(
74
+ cwd=root,
75
+ settings=resolved_settings,
76
+ todo_items=initial_todos or [],
77
+ )
64
78
  created_mcp_runtime: DeepyMcpRuntime | None = None
65
79
  if mcp_runtime is None:
66
80
  created_mcp_runtime = DeepyMcpRuntime(resolved_settings, project_root=root)
@@ -76,11 +90,6 @@ async def run_prompt_once(
76
90
  mcp_servers=mcp_runtime.active_servers,
77
91
  preferred_mcp_web_search_tools=mcp_runtime.preferred_web_search_tools,
78
92
  )
79
- session = (
80
- DeepyJsonlSession.open(root, session_id)
81
- if session_id
82
- else DeepyJsonlSession.create(root)
83
- )
84
93
  started_at = time.time()
85
94
  try:
86
95
  readiness = await ensure_context_ready(
@@ -124,6 +133,7 @@ async def run_prompt_once(
124
133
  pending_questions: list[dict[str, Any]] = []
125
134
  usage = TokenUsage()
126
135
  interrupt_task: asyncio.Task[bool] | None = None
136
+ session_baseline_count = len(await session.get_items())
127
137
  try:
128
138
  result = Runner.run_streamed(
129
139
  agent,
@@ -268,6 +278,12 @@ async def run_prompt_once(
268
278
  raise
269
279
 
270
280
  interrupted = interrupted or await _finish_interrupt_task(interrupt_task)
281
+ if interrupted:
282
+ await _reconcile_interrupted_session_tail(
283
+ session,
284
+ baseline_count=session_baseline_count,
285
+ prompt=prompt,
286
+ )
271
287
 
272
288
  final_output = getattr(result, "final_output", None)
273
289
  output = final_output if isinstance(final_output, str) else "".join(chunks)
@@ -508,6 +524,143 @@ async def _finish_interrupt_task(task: asyncio.Task[bool] | None) -> bool:
508
524
  return False
509
525
 
510
526
 
527
+ async def _reconcile_interrupted_session_tail(
528
+ session: DeepyJsonlSession,
529
+ *,
530
+ baseline_count: int,
531
+ prompt: str,
532
+ ) -> None:
533
+ items = await session.get_items()
534
+ if baseline_count < 0 or baseline_count > len(items):
535
+ return
536
+ suffix = items[baseline_count:]
537
+ if not suffix:
538
+ return
539
+
540
+ if len(suffix) == 1 and _is_user_prompt_item(suffix[0], prompt):
541
+ await session.pop_item()
542
+ return
543
+
544
+ additions = _interrupted_tool_output_items(suffix)
545
+ if not _is_interrupt_marker_item(suffix[-1]):
546
+ additions.append(_interrupted_marker_item())
547
+ if additions:
548
+ await session.add_items(additions)
549
+
550
+
551
+ def _is_user_prompt_item(item: dict[str, Any], prompt: str) -> bool:
552
+ if item.get("role") != "user":
553
+ return False
554
+ return _item_text_content(item) == prompt
555
+
556
+
557
+ def _item_text_content(item: dict[str, Any]) -> str:
558
+ content = item.get("content")
559
+ if isinstance(content, str):
560
+ return content
561
+ if not isinstance(content, list):
562
+ return ""
563
+ parts: list[str] = []
564
+ for part in content:
565
+ if isinstance(part, dict):
566
+ text = part.get("text")
567
+ if text is None:
568
+ text = part.get("input_text")
569
+ if isinstance(text, str):
570
+ parts.append(text)
571
+ return "".join(parts)
572
+
573
+
574
+ def _interrupted_tool_output_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
575
+ output_call_ids = {
576
+ call_id
577
+ for item in items
578
+ if (call_id := _function_call_output_id(item))
579
+ }
580
+ additions: list[dict[str, Any]] = []
581
+ added_call_ids: set[str] = set()
582
+ for item in items:
583
+ for call_id, output_item in _missing_output_items_for_call(item, output_call_ids):
584
+ if call_id in added_call_ids:
585
+ continue
586
+ additions.append(output_item)
587
+ added_call_ids.add(call_id)
588
+ return additions
589
+
590
+
591
+ def _missing_output_items_for_call(
592
+ item: dict[str, Any],
593
+ output_call_ids: set[str],
594
+ ) -> list[tuple[str, dict[str, Any]]]:
595
+ call_id = _function_call_id(item)
596
+ if call_id:
597
+ return (
598
+ []
599
+ if call_id in output_call_ids
600
+ else [
601
+ (
602
+ call_id,
603
+ {
604
+ "type": "function_call_output",
605
+ "call_id": call_id,
606
+ "output": INTERRUPTED_TOOL_OUTPUT_TEXT,
607
+ },
608
+ )
609
+ ]
610
+ )
611
+
612
+ missing: list[tuple[str, dict[str, Any]]] = []
613
+ if item.get("role") != "assistant":
614
+ return missing
615
+ tool_calls = item.get("tool_calls")
616
+ if not isinstance(tool_calls, list):
617
+ return missing
618
+ for tool_call in tool_calls:
619
+ if not isinstance(tool_call, dict):
620
+ continue
621
+ chat_call_id = tool_call.get("id")
622
+ if not isinstance(chat_call_id, str) or not chat_call_id or chat_call_id in output_call_ids:
623
+ continue
624
+ missing.append(
625
+ (
626
+ chat_call_id,
627
+ {
628
+ "role": "tool",
629
+ "tool_call_id": chat_call_id,
630
+ "content": INTERRUPTED_TOOL_OUTPUT_TEXT,
631
+ },
632
+ )
633
+ )
634
+ return missing
635
+
636
+
637
+ def _function_call_id(item: dict[str, Any]) -> str:
638
+ if item.get("type") != "function_call":
639
+ return ""
640
+ call_id = item.get("call_id")
641
+ if call_id is None:
642
+ call_id = item.get("id")
643
+ return call_id if isinstance(call_id, str) else ""
644
+
645
+
646
+ def _function_call_output_id(item: dict[str, Any]) -> str:
647
+ if item.get("type") == "function_call_output":
648
+ call_id = item.get("call_id")
649
+ return call_id if isinstance(call_id, str) else ""
650
+ if item.get("role") == "tool":
651
+ tool_call_id = item.get("tool_call_id")
652
+ return tool_call_id if isinstance(tool_call_id, str) else ""
653
+ return ""
654
+
655
+
656
+ def _interrupted_marker_item() -> dict[str, Any]:
657
+ return {"role": "assistant", "content": INTERRUPTED_MARKER_TEXT}
658
+
659
+
660
+ def _is_interrupt_marker_item(item: dict[str, Any]) -> bool:
661
+ return item.get("role") == "assistant" and item.get("content") == INTERRUPTED_MARKER_TEXT
662
+
663
+
511
664
  def _pending_questions_from_tool_output(output: str) -> list[dict[str, Any]]:
512
665
  if not output.strip():
513
666
  return []
@@ -94,6 +94,7 @@ def build_compact_prompt(
94
94
  session_messages: list[dict[str, Any]],
95
95
  *,
96
96
  focus_instruction: str | None = None,
97
+ todo_context: str | None = None,
97
98
  ) -> str:
98
99
  jsonl = "\n".join(_compact_message_json(message) for message in session_messages)
99
100
  focus = ""
@@ -104,7 +105,15 @@ def build_compact_prompt(
104
105
  "dropping current task state:\n"
105
106
  f"{focus_instruction.strip()}"
106
107
  )
107
- return f"{COMPACT_PROMPT_BASE}{focus}\n\nconversation below:\n\n```jsonl\n{jsonl}\n```"
108
+ todo = ""
109
+ if todo_context and todo_context.strip():
110
+ todo = (
111
+ "\n\nCurrent todo state:\n"
112
+ "Preserve this active task plan in the summary so the next model turn can continue "
113
+ "or reconcile progress:\n"
114
+ f"{todo_context.strip()}"
115
+ )
116
+ return f"{COMPACT_PROMPT_BASE}{focus}{todo}\n\nconversation below:\n\n```jsonl\n{jsonl}\n```"
108
117
 
109
118
 
110
119
  def build_compact_summary_message(summary: str) -> dict[str, str]:
@@ -53,6 +53,13 @@ Core rules:
53
53
  - Ask when clarification would materially improve the result: ambiguous intent, unclear scope,
54
54
  user preferences, high-impact trade-offs, or required approval. For low-impact details,
55
55
  proceed with a reasonable assumption and state it briefly.
56
+ - Use `todo_write` for complex multi-step work, multi-file changes, or several
57
+ distinct deliverables. Keep one item `in_progress`, update the complete list
58
+ only when real task state changes, and reconcile completed work before the
59
+ final answer. Skip `todo_write` for simple questions and obvious one-step
60
+ tasks so progress tracking does not create noise.
61
+ - `todo_write` is only for local task tracking. Do not treat it as subagent
62
+ delegation, a `task` tool, or a plan approval mode.
56
63
 
57
64
  Tool protocol:
58
65
  Tool results are JSON strings: ok, name, output, error, metadata, awaitUserResponse.
@@ -63,6 +70,10 @@ Skill protocol:
63
70
  - If the user's task matches an available skill, call `load_skill` with the exact skill name before relying on that skill's detailed instructions, scripts, references, or assets.
64
71
  - If the user explicitly invoked a skill, follow the loaded skill instructions and use the user's request inside that context.
65
72
  - Skill files are standard Agent Skills. Resolve relative scripts, references, and assets from the skill root returned by `load_skill`.
73
+ - If a loaded skill says to ask the user, ask one question at a time, wait for
74
+ the user's response, get approval, or have the user review or confirm before
75
+ continuing, satisfy that wait point with `AskUserQuestion` unless the skill
76
+ explicitly says to ask in the final answer without tools.
66
77
 
67
78
  Tool documentation:
68
79
  {tool_docs_block}
@@ -10,6 +10,7 @@ TOOL_DOC_FILES = (
10
10
  "AskUserQuestion.md",
11
11
  "WebSearch.md",
12
12
  "WebFetch.md",
13
+ "todo_write.md",
13
14
  )
14
15
 
15
16
 
@@ -8,13 +8,21 @@ from typing import Any
8
8
 
9
9
  from deepy.llm.context import estimate_tokens_for_item
10
10
  from deepy.llm.replay import sanitize_sdk_items_for_replay
11
- from deepy.usage import ContextWindowUsage, TokenUsage, context_window_usage, merge_usage, normalize_usage
11
+ from deepy.todos import normalize_persisted_todo_state, todo_state_from_tool_output
12
+ from deepy.usage import (
13
+ ContextWindowUsage,
14
+ TokenUsage,
15
+ context_window_usage,
16
+ merge_usage,
17
+ normalize_usage,
18
+ )
12
19
  from deepy.utils import json as json_utils
13
20
 
14
21
  SESSION_INDEX_VERSION = 2
15
22
  MAX_SESSION_INDEX_ENTRIES = 50
16
23
  CONTEXT_UNDERCOUNT_REPAIR_RATIO = 2
17
24
  CONTEXT_UNDERCOUNT_REPAIR_MIN_DELTA = 128
25
+ _UNSET = object()
18
26
 
19
27
 
20
28
  @dataclass(frozen=True)
@@ -30,6 +38,7 @@ class SessionEntry:
30
38
  last_usage_tokens: int | None = None
31
39
  pending_tokens: int = 0
32
40
  last_usage_record_count: int | None = None
41
+ todo_state: list[dict[str, str]] | None = None
33
42
 
34
43
 
35
44
  def project_code(project_root: Path) -> str:
@@ -123,7 +132,9 @@ class DeepyJsonlSession:
123
132
  with self.path.open("w", encoding="utf-8") as fh:
124
133
  for record in records:
125
134
  fh.write(json_utils.dumps(record) + "\n")
126
- self._loaded_items = [item for item in (_sdk_item_from_record(record) for record in records) if item]
135
+ self._loaded_items = [
136
+ item for item in (_sdk_item_from_record(record) for record in records) if item
137
+ ]
127
138
  state = self.context_token_state(records)
128
139
  self._touch_index(
129
140
  active_tokens=state.active_tokens,
@@ -144,6 +155,7 @@ class DeepyJsonlSession:
144
155
  last_usage_tokens=0,
145
156
  pending_tokens=0,
146
157
  last_usage_record_count=0,
158
+ todo_state=[],
147
159
  )
148
160
 
149
161
  def record_usage(self, usage: TokenUsage | dict[str, Any] | None) -> None:
@@ -186,7 +198,9 @@ class DeepyJsonlSession:
186
198
  last_usage_record_count=None,
187
199
  estimated=True,
188
200
  )
189
- pending_tokens = sum(_estimate_record_tokens(record) for record in source[last_usage_record_count:])
201
+ pending_tokens = sum(
202
+ _estimate_record_tokens(record) for record in source[last_usage_record_count:]
203
+ )
190
204
  checkpoint_tokens = last_usage_tokens + pending_tokens
191
205
  estimated_tokens = self._estimate_active_tokens(source)
192
206
  active_tokens = _repair_undercounted_context_tokens(
@@ -211,7 +225,9 @@ class DeepyJsonlSession:
211
225
 
212
226
  def latest_context_window_usage(self) -> ContextWindowUsage | None:
213
227
  previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
214
- latest_tokens = _optional_int(previous.get("latestContextWindowTokens")) if previous else None
228
+ latest_tokens = (
229
+ _optional_int(previous.get("latestContextWindowTokens")) if previous else None
230
+ )
215
231
  if latest_tokens is not None:
216
232
  return ContextWindowUsage(
217
233
  used_tokens=latest_tokens,
@@ -223,6 +239,13 @@ class DeepyJsonlSession:
223
239
  return context_window_usage(usage)
224
240
  return None
225
241
 
242
+ def todo_state(self) -> list[dict[str, str]]:
243
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
244
+ if not previous:
245
+ return []
246
+ todo_state = normalize_persisted_todo_state(previous.get("todoState"))
247
+ return todo_state or []
248
+
226
249
  async def replace_items(
227
250
  self,
228
251
  items: list[dict[str, Any]],
@@ -308,6 +331,7 @@ class DeepyJsonlSession:
308
331
  last_usage_tokens: int | None = None,
309
332
  pending_tokens: int | None = None,
310
333
  last_usage_record_count: int | None = None,
334
+ todo_state: object = _UNSET,
311
335
  ) -> None:
312
336
  self.path.parent.mkdir(parents=True, exist_ok=True)
313
337
  index_path = self.path.parent / "sessions-index.json"
@@ -316,61 +340,64 @@ class DeepyJsonlSession:
316
340
  sessions = _index_sessions(raw)
317
341
  previous = next((entry for entry in sessions if entry.get("id") == self.session_id), {})
318
342
  sessions = [entry for entry in sessions if entry.get("id") != self.session_id]
319
- sessions.insert(
320
- 0,
321
- {
322
- "id": self.session_id,
323
- "path": self.path.name,
324
- "activeTokens": active_tokens
325
- if active_tokens is not None
326
- else _coerce_int(previous.get("activeTokens"), 0),
327
- **(
328
- {"latestContextWindowTokens": latest_context_window_tokens}
329
- if latest_context_window_tokens is not None
330
- else (
331
- {"latestContextWindowTokens": previous["latestContextWindowTokens"]}
332
- if "latestContextWindowTokens" in previous
333
- else {}
334
- )
335
- ),
336
- **(
337
- {"lastUsageTokens": last_usage_tokens}
338
- if last_usage_tokens is not None
339
- else (
340
- {"lastUsageTokens": previous["lastUsageTokens"]}
341
- if "lastUsageTokens" in previous
342
- else {}
343
- )
344
- ),
345
- **(
346
- {"pendingTokens": pending_tokens}
347
- if pending_tokens is not None
348
- else (
349
- {"pendingTokens": previous["pendingTokens"]}
350
- if "pendingTokens" in previous
351
- else {}
352
- )
353
- ),
354
- **(
355
- {"lastUsageRecordCount": last_usage_record_count}
356
- if last_usage_record_count is not None
357
- else (
358
- {"lastUsageRecordCount": previous["lastUsageRecordCount"]}
359
- if "lastUsageRecordCount" in previous
360
- else {}
361
- )
362
- ),
363
- "createdAt": _coerce_int(previous.get("createdAt"), now),
364
- "updatedAt": now,
365
- **({"usage": usage} if usage is not None else {}),
366
- **(
367
- {"usage": previous["usage"]}
368
- if usage is None and isinstance(previous.get("usage"), dict)
343
+ entry = {
344
+ "id": self.session_id,
345
+ "path": self.path.name,
346
+ "activeTokens": active_tokens
347
+ if active_tokens is not None
348
+ else _coerce_int(previous.get("activeTokens"), 0),
349
+ **(
350
+ {"latestContextWindowTokens": latest_context_window_tokens}
351
+ if latest_context_window_tokens is not None
352
+ else (
353
+ {"latestContextWindowTokens": previous["latestContextWindowTokens"]}
354
+ if "latestContextWindowTokens" in previous
369
355
  else {}
370
- ),
371
- **({"processes": previous["processes"]} if "processes" in previous else {}),
372
- },
373
- )
356
+ )
357
+ ),
358
+ **(
359
+ {"lastUsageTokens": last_usage_tokens}
360
+ if last_usage_tokens is not None
361
+ else (
362
+ {"lastUsageTokens": previous["lastUsageTokens"]}
363
+ if "lastUsageTokens" in previous
364
+ else {}
365
+ )
366
+ ),
367
+ **(
368
+ {"pendingTokens": pending_tokens}
369
+ if pending_tokens is not None
370
+ else (
371
+ {"pendingTokens": previous["pendingTokens"]}
372
+ if "pendingTokens" in previous
373
+ else {}
374
+ )
375
+ ),
376
+ **(
377
+ {"lastUsageRecordCount": last_usage_record_count}
378
+ if last_usage_record_count is not None
379
+ else (
380
+ {"lastUsageRecordCount": previous["lastUsageRecordCount"]}
381
+ if "lastUsageRecordCount" in previous
382
+ else {}
383
+ )
384
+ ),
385
+ "createdAt": _coerce_int(previous.get("createdAt"), now),
386
+ "updatedAt": now,
387
+ **({"usage": usage} if usage is not None else {}),
388
+ **(
389
+ {"usage": previous["usage"]}
390
+ if usage is None and isinstance(previous.get("usage"), dict)
391
+ else {}
392
+ ),
393
+ **({"processes": previous["processes"]} if "processes" in previous else {}),
394
+ }
395
+ if todo_state is _UNSET:
396
+ if "todoState" in previous:
397
+ entry["todoState"] = previous["todoState"]
398
+ elif todo_state is not None:
399
+ entry["todoState"] = todo_state
400
+ sessions.insert(0, entry)
374
401
  payload = {
375
402
  "version": SESSION_INDEX_VERSION,
376
403
  "sessions": sessions[:MAX_SESSION_INDEX_ENTRIES],
@@ -379,10 +406,13 @@ class DeepyJsonlSession:
379
406
 
380
407
  def _touch_index_after_append(self, appended_items: list[dict[str, Any]]) -> None:
381
408
  previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
409
+ todo_state = _latest_todo_state_from_items(appended_items)
382
410
  if previous and _optional_int(previous.get("lastUsageTokens")) is not None:
383
411
  last_usage_tokens = _optional_int(previous.get("lastUsageTokens")) or 0
384
412
  previous_pending = _coerce_int(previous.get("pendingTokens"), 0)
385
- pending_tokens = previous_pending + sum(estimate_tokens_for_item(item) for item in appended_items)
413
+ pending_tokens = previous_pending + sum(
414
+ estimate_tokens_for_item(item) for item in appended_items
415
+ )
386
416
  self._touch_index(
387
417
  active_tokens=last_usage_tokens + pending_tokens,
388
418
  last_usage_tokens=last_usage_tokens,
@@ -391,9 +421,13 @@ class DeepyJsonlSession:
391
421
  previous.get("lastUsageRecordCount"),
392
422
  max(0, len(self._load_records()) - len(appended_items)),
393
423
  ),
424
+ todo_state=todo_state if todo_state is not None else _UNSET,
394
425
  )
395
426
  return
396
- self._touch_index(active_tokens=self._estimate_active_tokens())
427
+ self._touch_index(
428
+ active_tokens=self._estimate_active_tokens(),
429
+ todo_state=todo_state if todo_state is not None else _UNSET,
430
+ )
397
431
 
398
432
 
399
433
  @dataclass(frozen=True)
@@ -436,6 +470,7 @@ def list_session_entries(project_root: Path, deepy_home: Path | None = None) ->
436
470
  last_usage_tokens=_optional_int(item.get("lastUsageTokens")),
437
471
  pending_tokens=_coerce_int(item.get("pendingTokens"), 0),
438
472
  last_usage_record_count=_optional_int(item.get("lastUsageRecordCount")),
473
+ todo_state=normalize_persisted_todo_state(item.get("todoState")),
439
474
  )
440
475
  )
441
476
  return entries
@@ -460,7 +495,11 @@ def _index_sessions(raw: dict[str, Any]) -> list[dict[str, Any]]:
460
495
 
461
496
  def _entry_for_session(index_path: Path, session_id: str) -> dict[str, Any] | None:
462
497
  return next(
463
- (entry for entry in _index_sessions(_read_index(index_path)) if entry.get("id") == session_id),
498
+ (
499
+ entry
500
+ for entry in _index_sessions(_read_index(index_path))
501
+ if entry.get("id") == session_id
502
+ ),
464
503
  None,
465
504
  )
466
505
 
@@ -473,6 +512,18 @@ def _sdk_item_from_record(record: dict[str, Any]) -> dict[str, Any] | None:
473
512
  return dict(item) if isinstance(item, dict) else None
474
513
 
475
514
 
515
+ def _latest_todo_state_from_items(items: list[dict[str, Any]]) -> list[dict[str, str]] | None:
516
+ latest: list[dict[str, str]] | None = None
517
+ for item in items:
518
+ output = item.get("output")
519
+ if output is None and item.get("role") == "tool":
520
+ output = item.get("content")
521
+ todo_state = todo_state_from_tool_output(output)
522
+ if todo_state is not None:
523
+ latest = todo_state
524
+ return latest
525
+
526
+
476
527
  def _coerce_int(value: Any, default: int) -> int:
477
528
  if isinstance(value, bool):
478
529
  return default