tsugite-cli 0.23.0__tar.gz → 0.23.2__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 (182) hide show
  1. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/PKG-INFO +1 -1
  2. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/pyproject.toml +1 -1
  3. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_preparation.py +4 -2
  4. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/history_integration.py +4 -7
  5. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/runner.py +2 -1
  6. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/approval.py +17 -10
  7. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/agents.py +2 -1
  8. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/run.py +2 -1
  9. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/executor.py +9 -0
  10. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/record_store.py +10 -2
  11. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/reconstruction.py +22 -0
  12. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/permissions.py +23 -11
  13. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/anthropic.py +1 -0
  14. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/model_registry.py +8 -4
  15. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/openai_compat.py +2 -0
  16. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/__init__.py +15 -0
  17. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/http.py +20 -3
  18. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/jsonl.py +14 -1
  19. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/usage/quota.py +8 -10
  20. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/utils.py +1 -0
  21. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/.gitignore +0 -0
  22. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/AGENTS.md +0 -0
  23. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/CONTRIBUTING.md +0 -0
  24. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/LICENSE +0 -0
  25. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/README.md +0 -0
  26. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/__init__.py +0 -0
  27. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_inheritance.py +0 -0
  28. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/__init__.py +0 -0
  29. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/exec_directives.py +0 -0
  30. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/exec_runner.py +0 -0
  31. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/helpers.py +0 -0
  32. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/metrics.py +0 -0
  33. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/models.py +0 -0
  34. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/steps.py +0 -0
  35. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_runner/validation.py +0 -0
  36. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/agent_utils.py +0 -0
  37. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/__init__.py +0 -0
  38. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/agent_config.py +0 -0
  39. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/auto_context.py +0 -0
  40. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/base.py +0 -0
  41. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/delegation.py +0 -0
  42. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/file.py +0 -0
  43. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/inline.py +0 -0
  44. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/storage.py +0 -0
  45. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/attachments/url.py +0 -0
  46. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_agents/.gitkeep +0 -0
  47. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_agents/code_searcher.md +0 -0
  48. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_agents/default.md +0 -0
  49. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_agents/file_searcher.md +0 -0
  50. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_agents/job_verifier.md +0 -0
  51. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_agents/job_worker.md +0 -0
  52. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_agents/onboard.md +0 -0
  53. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_context.py +0 -0
  54. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/.gitkeep +0 -0
  55. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  56. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  57. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  58. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  59. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  60. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  61. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  62. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  63. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  64. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cache.py +0 -0
  65. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cancellation.py +0 -0
  66. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/__init__.py +0 -0
  67. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/attachments.py +0 -0
  68. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/cache.py +0 -0
  69. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/chat.py +0 -0
  70. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/config.py +0 -0
  71. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/daemon.py +0 -0
  72. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/exec.py +0 -0
  73. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/helpers.py +0 -0
  74. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/history.py +0 -0
  75. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/init.py +0 -0
  76. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/models.py +0 -0
  77. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/plugins.py +0 -0
  78. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/render.py +0 -0
  79. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/secrets.py +0 -0
  80. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/skills.py +0 -0
  81. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/tools.py +0 -0
  82. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/usage.py +0 -0
  83. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/validate.py +0 -0
  84. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/cli/workspace.py +0 -0
  85. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/config.py +0 -0
  86. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/console.py +0 -0
  87. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/constants.py +0 -0
  88. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/context.py +0 -0
  89. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/context_block.py +0 -0
  90. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/__init__.py +0 -0
  91. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/agent.py +0 -0
  92. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/content_blocks.py +0 -0
  93. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/executor_registry.py +0 -0
  94. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/memory.py +0 -0
  95. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/proxy.py +0 -0
  96. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/sandbox.py +0 -0
  97. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/state.py +0 -0
  98. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/subprocess_executor.py +0 -0
  99. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/core/tools.py +0 -0
  100. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/events/__init__.py +0 -0
  101. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/events/base.py +0 -0
  102. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/events/bus.py +0 -0
  103. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/events/events.py +0 -0
  104. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/events/helpers.py +0 -0
  105. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/exceptions.py +0 -0
  106. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/__init__.py +0 -0
  107. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/base.py +0 -0
  108. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/models.py +0 -0
  109. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/registry.py +0 -0
  110. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/sqlite_backend.py +0 -0
  111. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/sqlite_conn.py +0 -0
  112. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/sqlite_schema.py +0 -0
  113. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/history/ui_events.py +0 -0
  114. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/hooks.py +0 -0
  115. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/interaction.py +0 -0
  116. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/md_agents.py +0 -0
  117. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/models.py +0 -0
  118. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/options.py +0 -0
  119. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/plugins.py +0 -0
  120. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/prompt_xml.py +0 -0
  121. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/__init__.py +0 -0
  122. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/base.py +0 -0
  123. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/model_cache.py +0 -0
  124. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/ollama.py +0 -0
  125. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/providers/openrouter.py +0 -0
  126. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/py.typed +0 -0
  127. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/renderer.py +0 -0
  128. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/schemas/__init__.py +0 -0
  129. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/schemas/agent.schema.json +0 -0
  130. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/__init__.py +0 -0
  131. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/backend.py +0 -0
  132. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/env.py +0 -0
  133. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/exec.py +0 -0
  134. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/file.py +0 -0
  135. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/masking.py +0 -0
  136. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/redaction.py +0 -0
  137. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/registry.py +0 -0
  138. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/secrets/sqlite.py +0 -0
  139. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/shell_tool_config.py +0 -0
  140. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/skill_discovery.py +0 -0
  141. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/templates/AGENTS.md +0 -0
  142. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/templates/IDENTITY.md +0 -0
  143. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/templates/MEMORY.md +0 -0
  144. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/templates/USER.md +0 -0
  145. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/templates/personas/casual-technical.md +0 -0
  146. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/templates/personas/marvin.md +0 -0
  147. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/templates/personas/minimal.md +0 -0
  148. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/agents.py +0 -0
  149. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/artifacts.py +0 -0
  150. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/daemon_control.py +0 -0
  151. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/fs.py +0 -0
  152. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/history.py +0 -0
  153. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/interactive.py +0 -0
  154. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/jobs.py +0 -0
  155. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/notify.py +0 -0
  156. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/schedule.py +0 -0
  157. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/secrets.py +0 -0
  158. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/sessions.py +0 -0
  159. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/shell.py +0 -0
  160. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/shell_tools.py +0 -0
  161. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/skills.py +0 -0
  162. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tools/time.py +0 -0
  163. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/tsugite.py +0 -0
  164. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/__init__.py +0 -0
  165. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/base.py +0 -0
  166. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/chat.py +0 -0
  167. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/dispatch.py +0 -0
  168. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/helpers.py +0 -0
  169. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/plain.py +0 -0
  170. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/repl_chat.py +0 -0
  171. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/repl_commands.py +0 -0
  172. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/repl_completer.py +0 -0
  173. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui/repl_handler.py +0 -0
  174. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui_context.py +0 -0
  175. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/ui_surfaces.py +0 -0
  176. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/usage/__init__.py +0 -0
  177. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/usage/store.py +0 -0
  178. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/user_agent.py +0 -0
  179. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/workspace/__init__.py +0 -0
  180. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/workspace/models.py +0 -0
  181. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/workspace/session.py +0 -0
  182. {tsugite_cli-0.23.0 → tsugite_cli-0.23.2}/tsugite/workspace/templates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.23.0
3
+ Version: 0.23.2
4
4
  Summary: Micro-agent runner for task automation using markdown definitions
5
5
  Author: Justyn Shull
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tsugite-cli"
3
- version = "0.23.0"
3
+ version = "0.23.2"
4
4
  description = "Micro-agent runner for task automation using markdown definitions"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
@@ -152,7 +152,8 @@ class AgentPreparer:
152
152
 
153
153
  A front-matter attachment defines its cache tier and replaces a same-named
154
154
  caller attachment. Legacy `-filename` string entries drop a same-named
155
- entry. Front-matter paths render with `paths` and resolve against its CWD.
155
+ entry. Front-matter paths render with `paths` and resolve against the
156
+ workspace, or the CWD when the run has no workspace.
156
157
  """
157
158
  all_attachments = list(attachments or [])
158
159
 
@@ -160,7 +161,8 @@ class AgentPreparer:
160
161
  if removals:
161
162
  all_attachments = [a for a in all_attachments if a.name not in removals]
162
163
 
163
- loaded, bindings = resolve_agent_config_attachments(keep_items, Path(paths["CWD"]), paths)
164
+ base = paths["WORKSPACE_DIR"] or paths["CWD"]
165
+ loaded, bindings = resolve_agent_config_attachments(keep_items, Path(base), paths)
164
166
  all_attachments = loaded + all_attachments
165
167
 
166
168
  seen_names: set[str] = set()
@@ -92,14 +92,11 @@ def _current_turn_already_has_user_input(storage: Session) -> bool:
92
92
  session_end after it; the post-hoc save_run_to_history on the error path
93
93
  would otherwise record a second, identical one (a duplicate user bubble).
94
94
  A genuinely repeated message opens a new turn *after* the prior session_end,
95
- so this stays False for it.
95
+ so this stays False for it. A final_result also ends a turn: compaction copies
96
+ retained turns into the successor without their session_end.
96
97
  """
97
- for event in reversed(list(storage.iter_events(types=("user_input", "session_end")))):
98
- if event.type == "session_end":
99
- return False
100
- if event.type == "user_input":
101
- return True
102
- return False
98
+ events = list(storage.iter_events(types=("user_input", "session_end", "final_result")))
99
+ return bool(events) and events[-1].type == "user_input"
103
100
 
104
101
 
105
102
  def record_user_input(
@@ -1281,6 +1281,7 @@ def preview_multistep_agent(
1281
1281
 
1282
1282
  # Extract steps
1283
1283
  from tsugite.md_agents import extract_step_directives, has_step_directives
1284
+ from tsugite.tools import format_tool_specs
1284
1285
 
1285
1286
  if not has_step_directives(agent.content):
1286
1287
  output("[yellow]This is a single-step agent (no step directives).[/yellow]", is_warning=True)
@@ -1302,7 +1303,7 @@ def preview_multistep_agent(
1302
1303
  output(f"Prompt: {prompt}")
1303
1304
  output(f"Steps: {len(steps)}")
1304
1305
  output(f"Model: {resolve_effective_model(agent_model=agent.config.model) or 'unknown'}")
1305
- output(f"Tools: {', '.join(agent.config.tools) if agent.config.tools else 'None'}")
1306
+ output(f"Tools: {format_tool_specs(agent.config.tools) if agent.config.tools else 'None'}")
1306
1307
  output("")
1307
1308
 
1308
1309
  # Show steps in table format
@@ -1,9 +1,9 @@
1
1
  """Backend-driven approval primitive.
2
2
 
3
3
  A human-in-the-loop gate that pauses a turn before an action and asks the user
4
- to approve or deny it. It rides the same InteractionBackend used by ``ask_user``,
5
- so an approval surfaces on whatever adapter the user is on (CLI prompt, web SSE
6
- frame, etc.).
4
+ to approve or deny it, once or persistently. It rides the same InteractionBackend
5
+ used by ``ask_user``, so an approval surfaces on whatever adapter the user is on
6
+ (CLI prompt, web SSE frame, etc.).
7
7
 
8
8
  Fail-closed by design: when no interactive surface is available (no backend, or a
9
9
  non-interactive scheduler/headless backend), or when the reply is unrecognized,
@@ -15,34 +15,39 @@ from typing import Literal, Optional
15
15
 
16
16
  from tsugite.interaction import NonInteractiveBackend, get_interaction_backend
17
17
 
18
- ApprovalDecision = Literal["approve", "deny", "always"]
18
+ ApprovalDecision = Literal["approve", "deny", "always", "never"]
19
19
 
20
20
  APPROVE_LABEL = "Approve"
21
21
  DENY_LABEL = "Deny"
22
22
  ALWAYS_LABEL = "Always allow"
23
+ NEVER_LABEL = "Always deny"
23
24
 
24
25
  _LABEL_TO_DECISION = {
25
26
  APPROVE_LABEL: "approve",
26
27
  DENY_LABEL: "deny",
27
28
  ALWAYS_LABEL: "always",
29
+ NEVER_LABEL: "never",
28
30
  }
29
31
 
30
32
 
31
- def request_approval(prompt: str, *, allow_always: bool = False, detail: Optional[str] = None) -> str:
33
+ def request_approval(
34
+ prompt: str, *, allow_always: bool = False, allow_never: bool = False, detail: Optional[str] = None
35
+ ) -> str:
32
36
  """Ask the user to approve an action, returning an :data:`ApprovalDecision`.
33
37
 
34
- Builds ``Approve``/``Deny`` options (plus ``Always allow`` when
35
- ``allow_always``) and routes them through the active interaction backend as an
36
- ``"approval"`` question, then maps the chosen label back to ``"approve"``,
37
- ``"deny"``, or ``"always"``.
38
+ Builds ``Approve``/``Deny`` options (plus ``Always allow`` when ``allow_always``
39
+ and ``Always deny`` when ``allow_never``) and routes them through the active
40
+ interaction backend as an ``"approval"`` question, then maps the chosen label
41
+ back to a decision.
38
42
 
39
43
  Args:
40
44
  prompt: The action to approve, phrased as a question.
41
45
  allow_always: Offer an "Always allow" option (a persistent allow).
46
+ allow_never: Offer an "Always deny" option (a persistent deny).
42
47
  detail: Extra context appended below the prompt for the user to read.
43
48
 
44
49
  Returns:
45
- One of ``"approve"``, ``"deny"``, or ``"always"``.
50
+ One of ``"approve"``, ``"deny"``, ``"always"``, or ``"never"``.
46
51
 
47
52
  Fail-closed: returns ``"deny"`` WITHOUT prompting when there is no backend or
48
53
  the backend is non-interactive, and for any unrecognized reply.
@@ -54,6 +59,8 @@ def request_approval(prompt: str, *, allow_always: bool = False, detail: Optiona
54
59
  options = [APPROVE_LABEL, DENY_LABEL]
55
60
  if allow_always:
56
61
  options.append(ALWAYS_LABEL)
62
+ if allow_never:
63
+ options.append(NEVER_LABEL)
57
64
 
58
65
  question = f"{prompt}\n\n{detail}" if detail else prompt
59
66
  answer = backend.ask_user(question, "approval", options)
@@ -75,6 +75,7 @@ def agents_show(
75
75
  """
76
76
  from tsugite.agent_inheritance import find_agent_file
77
77
  from tsugite.md_agents import parse_agent_file
78
+ from tsugite.tools import format_tool_spec
78
79
 
79
80
  try:
80
81
  agent_file = Path(agent_path)
@@ -113,7 +114,7 @@ def agents_show(
113
114
  if config.tools:
114
115
  console.print(f"\n[bold]Tools ({len(config.tools)}):[/bold]")
115
116
  for tool in config.tools:
116
- console.print(f" • {tool}")
117
+ console.print(f" • {format_tool_spec(tool)}")
117
118
 
118
119
  if config.extends:
119
120
  console.print(f"\n[bold]Extends:[/bold] {config.extends}")
@@ -288,6 +288,7 @@ def run(
288
288
  from tsugite.console import get_stderr_console
289
289
  from tsugite.md_agents import validate_agent_execution
290
290
  from tsugite.secrets import init_cli as init_secrets
291
+ from tsugite.tools import format_tool_specs
291
292
  from tsugite.utils import should_use_plain_output
292
293
 
293
294
  from . import console
@@ -562,7 +563,7 @@ def run(
562
563
  "Directory": str(Path.cwd()),
563
564
  "Model": resolve_effective_model(exec_opts.model_override, agent_info.get("model_raw")) or "unknown",
564
565
  "Instructions": instruction_label,
565
- "Tools": ", ".join(agent_info.get("tools", [])),
566
+ "Tools": format_tool_specs(agent_info.get("tools", [])),
566
567
  }
567
568
 
568
569
  agent_attachments = agent_info.get("attachments")
@@ -98,6 +98,13 @@ def _jsonable_call_args(kwargs: Dict[str, Any]) -> Dict[str, Any]:
98
98
  return safe
99
99
 
100
100
 
101
+ NAME_ERROR_NOTE = (
102
+ " (each code block starts with a fresh namespace: names from earlier blocks and"
103
+ " <content> blocks do not carry over, so re-import or recompute them, or keep"
104
+ " values in state['key'])"
105
+ )
106
+
107
+
101
108
  def truncate_observation(text: str, max_output_kb: int = MAX_EXECUTION_OUTPUT_KB) -> tuple[str, bool]:
102
109
  """Clip text to the observation cap, returning (text, was_truncated).
103
110
 
@@ -134,6 +141,8 @@ def build_execution_result(
134
141
 
135
142
  children = [El("output", [output], inline=True)]
136
143
  if error:
144
+ if error.startswith("NameError:"):
145
+ error += NAME_ERROR_NOTE
137
146
  children.append(El("error", [error], inline=True))
138
147
  if traceback:
139
148
  children.append(El("traceback", [traceback], inline=True))
@@ -201,6 +201,7 @@ class RecordStore:
201
201
  valid_transitions: dict[str, frozenset[str]]
202
202
  terminal_states: frozenset[str]
203
203
  transition_error_cls: type[ValueError] = ValueError
204
+ missing_error_cls: type[LookupError] = KeyError
204
205
 
205
206
  def __init__(self, path: Path):
206
207
  self._path = path # legacy JSON location; migration source only
@@ -237,11 +238,18 @@ class RecordStore:
237
238
  with self._lock:
238
239
  return [r for r in self._records.values() if r.parent_session_id == parent_session_id]
239
240
 
241
+ def require(self, record_id: str):
242
+ """Raises `missing_error_cls` when no record has that id."""
243
+ record = self.get(record_id)
244
+ if record is None:
245
+ raise self.missing_error_cls(f"Unknown {self.record_label}: {record_id}")
246
+ return record
247
+
240
248
  def update_state(self, record_id: str, new_state: str):
241
249
  with self._lock:
242
250
  record = self._records.get(record_id)
243
251
  if record is None:
244
- raise KeyError(f"Unknown {self.record_label}: {record_id}")
252
+ raise self.missing_error_cls(f"Unknown {self.record_label}: {record_id}")
245
253
  allowed = self.valid_transitions.get(record.state, frozenset())
246
254
  if new_state not in allowed:
247
255
  raise self.transition_error_cls(
@@ -270,7 +278,7 @@ class RecordStore:
270
278
  with self._lock:
271
279
  record = self._records.get(record_id)
272
280
  if record is None:
273
- raise KeyError(f"Unknown {self.record_label}: {record_id}")
281
+ raise self.missing_error_cls(f"Unknown {self.record_label}: {record_id}")
274
282
  for key, value in fields.items():
275
283
  if not hasattr(record, key):
276
284
  raise ValueError(f"Unknown {self.record_cls.__name__} field: {key}")
@@ -11,6 +11,7 @@ from datetime import datetime
11
11
  from typing import Any, Dict, Iterable, List, Optional
12
12
  from xml.sax.saxutils import escape, quoteattr
13
13
 
14
+ from tsugite.prompt_xml import El
14
15
  from tsugite.renderer import format_prompt_ts, parse_iso_utc
15
16
 
16
17
  from .models import Event
@@ -176,6 +177,11 @@ def _event_to_message(event: Event, executed: bool = False) -> Optional[Dict[str
176
177
  return {"role": "user", "content": _format_error_xml(event.data)}
177
178
  if event.type == "delivery":
178
179
  return {"role": "user", "content": _delivery_xml(event.data, event.ts)}
180
+ if event.type == "error":
181
+ # send_failed is a notification that never left the daemon, not a turn failure.
182
+ if event.data.get("kind") == "send_failed":
183
+ return None
184
+ return {"role": "user", "content": _turn_failed_xml(event.data, event.ts)}
179
185
  return None
180
186
 
181
187
 
@@ -223,6 +229,22 @@ def _delivery_xml(data: Dict[str, Any], ts: Optional[datetime] = None) -> str:
223
229
  return "\n".join(parts)
224
230
 
225
231
 
232
+ _TURN_FAILED_NOTE = (
233
+ "The previous attempt failed before it finished. Continue from where it stopped rather than starting over."
234
+ )
235
+
236
+
237
+ def _turn_failed_xml(data: Dict[str, Any], ts: datetime) -> str:
238
+ return El(
239
+ "tsugite_turn_failed",
240
+ [
241
+ El("note", [_TURN_FAILED_NOTE], inline=True),
242
+ El("error", [data.get("error")], inline=True),
243
+ ],
244
+ {"ts": _format_event_ts(ts)},
245
+ ).render()
246
+
247
+
226
248
  def _format_error_xml(data: Dict[str, Any]) -> str:
227
249
  reason = data.get("reason", "")
228
250
  msg = (
@@ -3,23 +3,26 @@
3
3
  Merges two YAML sources, the same way hooks does:
4
4
 
5
5
  1. ``runtime_path``: a mutable ``permissions.yaml`` next to ``daemon.yaml``.
6
- "Always allow" decisions append here, so they survive a restart.
6
+ "Always allow" and "Always deny" decisions append here, so they survive a restart.
7
7
  2. ``workspace_dir``: an optional, read-only ``<workspace_dir>/.tsugite/permissions.yaml``,
8
8
  hand-authored per agent (exactly like ``.tsugite/hooks.yaml``). The store never
9
9
  writes it.
10
10
 
11
- ``is_allowed`` is the union of both files. ``allow`` only ever writes the runtime
11
+ ``is_listed`` is the union of both files. ``add`` only ever writes the runtime
12
12
  file, atomically, preserving every unrelated section.
13
13
 
14
- The file groups allowlists by section, e.g.::
14
+ The file groups lists by section, e.g.::
15
15
 
16
16
  web:
17
17
  fetch_allowlist:
18
18
  - example.com
19
+ fetch_denylist:
20
+ - ads.example.com
19
21
 
20
- The generic ``is_allowed(section, key)`` / ``allow(section, key)`` address a list
21
- by dotted path (``"web.fetch_allowlist"``). The ``web_fetch_*`` wrappers hide that
22
- layout so callers stay decoupled from it.
22
+ The generic ``is_listed(section, key)`` / ``add(section, key)`` address a list by
23
+ dotted path (``"web.fetch_allowlist"``). The ``web_fetch_*`` wrappers hide that
24
+ layout so callers stay decoupled from it. The store applies no precedence between
25
+ lists. The caller decides.
23
26
  """
24
27
 
25
28
  import contextvars
@@ -31,6 +34,7 @@ import yaml
31
34
  from tsugite.utils import atomic_write_text
32
35
 
33
36
  WEB_FETCH_ALLOWLIST = "web.fetch_allowlist"
37
+ WEB_FETCH_DENYLIST = "web.fetch_denylist"
34
38
 
35
39
 
36
40
  def _load_yaml_mapping(path: Optional[Path]) -> dict:
@@ -70,20 +74,20 @@ def _atomic_write_yaml(path: Path, data: dict) -> None:
70
74
 
71
75
 
72
76
  class Permissions:
73
- """Union of a mutable runtime allowlist and a read-only workspace allowlist."""
77
+ """Union of a mutable runtime permissions file and a read-only workspace one."""
74
78
 
75
79
  def __init__(self, runtime_path: Path, workspace_dir: Optional[Path] = None):
76
80
  self.runtime_path = Path(runtime_path)
77
81
  self._workspace_path = (Path(workspace_dir) / ".tsugite" / "permissions.yaml") if workspace_dir else None
78
82
 
79
- def is_allowed(self, section: str, key: str) -> bool:
83
+ def is_listed(self, section: str, key: str) -> bool:
80
84
  """True if ``key`` is listed at dotted ``section`` in EITHER source."""
81
85
  parts = section.split(".")
82
86
  return key in _get_list(_load_yaml_mapping(self.runtime_path), parts) or key in _get_list(
83
87
  _load_yaml_mapping(self._workspace_path), parts
84
88
  )
85
89
 
86
- def allow(self, section: str, key: str) -> None:
90
+ def add(self, section: str, key: str) -> None:
87
91
  """Append ``key`` to the RUNTIME file's list at dotted ``section``.
88
92
 
89
93
  Only ever touches the runtime file (never the workspace file), writes
@@ -102,11 +106,19 @@ class Permissions:
102
106
 
103
107
  def web_fetch_allowed(self, domain: str) -> bool:
104
108
  """True if ``domain`` is on the web fetch allowlist (either source)."""
105
- return self.is_allowed(WEB_FETCH_ALLOWLIST, domain.lower())
109
+ return self.is_listed(WEB_FETCH_ALLOWLIST, domain.lower())
106
110
 
107
111
  def web_fetch_allow(self, domain: str) -> None:
108
112
  """Persist ``domain`` to the runtime web fetch allowlist."""
109
- self.allow(WEB_FETCH_ALLOWLIST, domain.lower())
113
+ self.add(WEB_FETCH_ALLOWLIST, domain.lower())
114
+
115
+ def web_fetch_denied(self, domain: str) -> bool:
116
+ """True if ``domain`` is on the web fetch denylist (either source)."""
117
+ return self.is_listed(WEB_FETCH_DENYLIST, domain.lower())
118
+
119
+ def web_fetch_deny(self, domain: str) -> None:
120
+ """Persist ``domain`` to the runtime web fetch denylist."""
121
+ self.add(WEB_FETCH_DENYLIST, domain.lower())
110
122
 
111
123
 
112
124
  _permissions_var: contextvars.ContextVar[Optional[Permissions]] = contextvars.ContextVar("permissions", default=None)
@@ -48,6 +48,7 @@ _ANTHROPIC_MODELS: dict[str, ModelInfo] = {
48
48
  "anthropic/claude-opus-4-7": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
49
49
  "anthropic/claude-opus-4-8": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
50
50
  "anthropic/claude-opus-5": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
51
+ "anthropic/claude-opus-5-5": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=4.0, output_cost_per_million=20.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
51
52
  "anthropic/claude-sonnet-4-5": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
52
53
  "anthropic/claude-sonnet-4-5-20250929": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
53
54
  "anthropic/claude-sonnet-4-6": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
6
+
5
7
  from .base import ModelInfo, Usage
6
8
 
7
9
  _REGISTRY: dict[str, ModelInfo] = {}
@@ -22,11 +24,13 @@ def get_model_info(provider: str, model: str) -> ModelInfo | None:
22
24
  for reg_key, info in _REGISTRY.items():
23
25
  if not key.startswith(reg_key) or len(reg_key) <= best_len:
24
26
  continue
25
- # Only treat the prefix as the same model when the remainder is a date/version
26
- # continuation (e.g. "-20250805"), not a distinct variant ("-mini", "-turbo")
27
- # — otherwise an unlisted variant silently inherits a sibling's pricing.
27
+ # Only treat the prefix as the same model when the remainder is a dated
28
+ # continuation (e.g. "-20250805", "-2024-05-13"), not a distinct variant
29
+ # ("-mini", "-turbo") or a point release ("-5"). An unlisted variant
30
+ # would otherwise silently inherit a sibling's pricing. A year is 4+
31
+ # digits, so require the digit run right after the dash to be that long.
28
32
  suffix = key[len(reg_key) :]
29
- if suffix and not (suffix.startswith("-") and len(suffix) > 1 and suffix[1].isdigit()):
33
+ if suffix and not re.match(r"^-\d{4}", suffix):
30
34
  continue
31
35
  best_match = info
32
36
  best_len = len(reg_key)
@@ -55,6 +55,8 @@ _OPENAI_MODELS: dict[str, ModelInfo] = {
55
55
  "openai/gpt-5.6-sol": ModelInfo(max_input_tokens=1_050_000, max_output_tokens=128_000, input_cost_per_million=4.0, output_cost_per_million=20.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["none", "low", "medium", "high", "xhigh", "max"]),
56
56
  "openai/gpt-5.6-terra": ModelInfo(max_input_tokens=1_050_000, max_output_tokens=128_000, input_cost_per_million=2.0, output_cost_per_million=12.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["none", "low", "medium", "high", "xhigh", "max"]),
57
57
  "openai/gpt-6-astra": ModelInfo(max_input_tokens=1_050_000, max_output_tokens=128_000, input_cost_per_million=10.0, output_cost_per_million=50.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"]),
58
+ "openai/gpt-6-luna": ModelInfo(max_input_tokens=1_050_000, max_output_tokens=128_000, input_cost_per_million=0.1, output_cost_per_million=0.5, supports_vision=True, supports_reasoning=True, supported_effort_levels=["none", "low", "medium", "high", "xhigh", "max"]),
59
+ "openai/gpt-6-sol": ModelInfo(max_input_tokens=1_050_000, max_output_tokens=128_000, input_cost_per_million=2.0, output_cost_per_million=10.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["none", "low", "medium", "high", "xhigh", "max"]),
58
60
  "openai/o1": ModelInfo(max_input_tokens=200_000, max_output_tokens=100_000, input_cost_per_million=15.0, output_cost_per_million=60.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high"]),
59
61
  "openai/o1-pro": ModelInfo(max_input_tokens=200_000, max_output_tokens=100_000, input_cost_per_million=150.0, output_cost_per_million=600.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high"]),
60
62
  "openai/o3": ModelInfo(max_input_tokens=200_000, max_output_tokens=100_000, input_cost_per_million=2.0, output_cost_per_million=8.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high"]),
@@ -376,6 +376,21 @@ def expand_tool_specs(tool_specs: List[str], strict: bool = False) -> List[str]:
376
376
  return result
377
377
 
378
378
 
379
+ def format_tool_spec(spec: str) -> str:
380
+ """Render one frontmatter tool spec for console output, with `@category` as `name (toolset)`.
381
+
382
+ An agent that shells out to tsugite reads this through its own tool layer, where `@name`
383
+ expands as a file mention if it matches a directory.
384
+ """
385
+ prefix, name = ("-", spec[1:]) if spec.startswith("-") else ("", spec)
386
+ return f"{prefix}{name[1:]} (toolset)" if name.startswith("@") else spec
387
+
388
+
389
+ def format_tool_specs(tool_specs: List[str]) -> str:
390
+ """Render a frontmatter tool list as one comma-separated line."""
391
+ return ", ".join(format_tool_spec(spec) for spec in tool_specs)
392
+
393
+
379
394
  def load_custom_shell_tools() -> None:
380
395
  """Register the shell tools defined in custom_tools.yaml.
381
396
 
@@ -18,6 +18,11 @@ _WEB_EXTRA_HINT = "Install it with: pip install 'tsugite-cli[web]'"
18
18
  # reach anything the original request couldn't.
19
19
  _MAX_REDIRECTS = 5
20
20
 
21
+ # readability emits a near-empty node when it finds no article. An extraction that is
22
+ # both short and a sliver of the whole page means extraction failed, not a short article.
23
+ _MIN_ARTICLE_CHARS = 200
24
+ _ARTICLE_PAGE_RATIO = 10
25
+
21
26
 
22
27
  def _extract_article(html: str) -> str:
23
28
  """Extract the main article body from HTML using readability (optional dep)."""
@@ -150,9 +155,21 @@ def fetch_text(
150
155
  if "text/html" not in content_type or (not strip_html and not extract_article):
151
156
  return text
152
157
 
153
- if extract_article:
154
- text = _extract_article(text)
155
- return convert_html_to_markdown(text)
158
+ if not extract_article:
159
+ return convert_html_to_markdown(text)
160
+
161
+ article = convert_html_to_markdown(_extract_article(text))
162
+ article_len = len(article.strip())
163
+ if article_len >= _MIN_ARTICLE_CHARS:
164
+ return article
165
+
166
+ page = convert_html_to_markdown(text)
167
+ page_len = len(page.strip())
168
+ if not page_len:
169
+ raise RuntimeError(f"{url} has no readable text; the page is likely rendered client-side")
170
+ if article_len * _ARTICLE_PAGE_RATIO < page_len:
171
+ return page
172
+ return article
156
173
 
157
174
 
158
175
  # No sensitive_args: the built-in key rules already redact Authorization,
@@ -1,7 +1,7 @@
1
1
  """JSONL UI handler for subprocess-based subagent communication."""
2
2
 
3
3
  import json
4
- from typing import Any, Dict
4
+ from typing import Any, Dict, Optional
5
5
 
6
6
  from tsugite.events import (
7
7
  CodeExecutionEvent,
@@ -32,6 +32,7 @@ from tsugite.events import (
32
32
  ToolResultEvent,
33
33
  WarningEvent,
34
34
  )
35
+ from tsugite.events.base import BaseEvent
35
36
  from tsugite.ui.dispatch import EventDispatchMixin, handles
36
37
 
37
38
  # Event types that share the same file-io payload shape
@@ -73,6 +74,17 @@ class JSONLUIHandler(EventDispatchMixin):
73
74
  - Failed tool: {"type": "tool_result", "tool": "read_file", "success": false, "error": "..."}
74
75
  """
75
76
 
77
+ final_seen = False
78
+ latest_prompt_messages: Optional[list] = None
79
+
80
+ def handle_event(self, event: BaseEvent) -> None:
81
+ if isinstance(event, PromptSnapshotEvent):
82
+ if event.messages:
83
+ self.latest_prompt_messages = event.messages
84
+ if not event.token_breakdown:
85
+ return
86
+ super().handle_event(event)
87
+
76
88
  @handles(TaskStartEvent)
77
89
  def _handle_task_start(self, event: TaskStartEvent) -> None:
78
90
  self._emit("init", {"agent": event.task, "model": event.model})
@@ -132,6 +144,7 @@ class JSONLUIHandler(EventDispatchMixin):
132
144
 
133
145
  @handles(FinalAnswerEvent)
134
146
  def _handle_final_answer(self, event: FinalAnswerEvent) -> None:
147
+ self.final_seen = True
135
148
  self._emit(
136
149
  "final_result",
137
150
  {
@@ -3,9 +3,8 @@ subscription or budget the user has consumed ("week 73%, resets Aug 19").
3
3
 
4
4
  A source is contributed by a plugin through the ``tsugite.usage_providers``
5
5
  entry point (a module-only entry point whose import registers the source) and
6
- fetches from whatever local state that provider keeps. The shape has no severity
7
- field: the UI derives its own warn threshold, and a second source of truth would
8
- let the two disagree.
6
+ fetches from that provider's local state or API. The shape has no severity
7
+ field because the UI derives its own warn threshold.
9
8
  """
10
9
 
11
10
  from __future__ import annotations
@@ -31,8 +30,8 @@ class QuotaWindow:
31
30
 
32
31
  @dataclass(frozen=True)
33
32
  class QuotaReport:
34
- """One provider's quota. ``error`` is set instead of windows when local
35
- state was unreadable."""
33
+ """One provider's quota. ``error`` is set instead of windows when the
34
+ source could not be read."""
36
35
 
37
36
  provider: str
38
37
  label: str
@@ -69,10 +68,9 @@ def reset_quota_sources() -> None:
69
68
 
70
69
 
71
70
  def ensure_loaded() -> None:
72
- """Import the ``tsugite.usage_providers`` entry-point modules once, so every
73
- ``register_quota_source`` call has run before the registry is read. The
74
- daemon collects from worker threads, so a reader racing the first load
75
- waits for it rather than seeing an empty registry."""
71
+ """Import the ``tsugite.usage_providers`` entry-point modules once, before
72
+ the registry is read. A daemon worker thread racing the first load waits
73
+ for it."""
76
74
  global _loaded
77
75
  if _loaded:
78
76
  return
@@ -102,7 +100,7 @@ def collect_quota_reports() -> list[dict]:
102
100
  logger.warning("Quota source '%s' failed: %s", source.provider, e)
103
101
  report = QuotaReport(provider=source.provider, label=source.label, error=str(e))
104
102
  row = asdict(report)
105
- # asdict keeps the dataclass's tuple; the wire shape is an array.
103
+ # asdict keeps windows as a tuple and the wire shape is an array.
106
104
  row["windows"] = list(row["windows"])
107
105
  rows.append(row)
108
106
  return rows
@@ -128,6 +128,7 @@ def execute_shell_command(
128
128
  shell=shell,
129
129
  capture_output=True,
130
130
  text=True,
131
+ errors="replace",
131
132
  timeout=timeout,
132
133
  check=False,
133
134
  cwd=resolved_cwd,
File without changes
File without changes
File without changes
File without changes