tsugite-cli 0.22.0__tar.gz → 0.23.0__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.22.0 → tsugite_cli-0.23.0}/.gitignore +3 -1
  2. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/AGENTS.md +16 -9
  3. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/CONTRIBUTING.md +9 -16
  4. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/PKG-INFO +1 -1
  5. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/pyproject.toml +1 -1
  6. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_preparation.py +29 -32
  7. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/exec_runner.py +4 -3
  8. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/helpers.py +3 -7
  9. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/history_integration.py +7 -1
  10. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/runner.py +6 -1
  11. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/steps.py +2 -2
  12. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/agent_config.py +12 -7
  13. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/auto_context.py +11 -20
  14. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/base.py +2 -4
  15. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_agents/default.md +2 -2
  16. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/run.py +17 -2
  17. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/agent.py +4 -0
  18. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/proxy.py +8 -0
  19. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/subprocess_executor.py +11 -3
  20. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/exceptions.py +21 -1
  21. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/md_agents.py +2 -2
  22. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/anthropic.py +1 -2
  23. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/openai_compat.py +5 -13
  24. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/__init__.py +9 -16
  25. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/agents.py +45 -50
  26. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/artifacts.py +19 -18
  27. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/fs.py +73 -103
  28. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/http.py +45 -53
  29. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/interactive.py +20 -47
  30. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/jobs.py +25 -29
  31. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/schedule.py +8 -7
  32. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/shell_tools.py +5 -14
  33. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/utils.py +3 -2
  34. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/LICENSE +0 -0
  35. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/README.md +0 -0
  36. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/__init__.py +0 -0
  37. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_inheritance.py +0 -0
  38. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/__init__.py +0 -0
  39. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/exec_directives.py +0 -0
  40. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/metrics.py +0 -0
  41. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/models.py +0 -0
  42. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_runner/validation.py +0 -0
  43. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/agent_utils.py +0 -0
  44. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/approval.py +0 -0
  45. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/__init__.py +0 -0
  46. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/delegation.py +0 -0
  47. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/file.py +0 -0
  48. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/inline.py +0 -0
  49. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/storage.py +0 -0
  50. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/attachments/url.py +0 -0
  51. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_agents/.gitkeep +0 -0
  52. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_agents/code_searcher.md +0 -0
  53. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_agents/file_searcher.md +0 -0
  54. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_agents/job_verifier.md +0 -0
  55. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_agents/job_worker.md +0 -0
  56. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_agents/onboard.md +0 -0
  57. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_context.py +0 -0
  58. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/.gitkeep +0 -0
  59. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  60. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  61. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  62. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  63. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  64. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  65. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  66. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  67. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  68. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cache.py +0 -0
  69. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cancellation.py +0 -0
  70. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/__init__.py +0 -0
  71. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/agents.py +0 -0
  72. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/attachments.py +0 -0
  73. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/cache.py +0 -0
  74. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/chat.py +0 -0
  75. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/config.py +0 -0
  76. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/daemon.py +0 -0
  77. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/exec.py +0 -0
  78. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/helpers.py +0 -0
  79. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/history.py +0 -0
  80. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/init.py +0 -0
  81. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/models.py +0 -0
  82. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/plugins.py +0 -0
  83. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/render.py +0 -0
  84. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/secrets.py +0 -0
  85. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/skills.py +0 -0
  86. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/tools.py +0 -0
  87. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/usage.py +0 -0
  88. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/validate.py +0 -0
  89. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/cli/workspace.py +0 -0
  90. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/config.py +0 -0
  91. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/console.py +0 -0
  92. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/constants.py +0 -0
  93. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/context.py +0 -0
  94. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/context_block.py +0 -0
  95. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/__init__.py +0 -0
  96. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/content_blocks.py +0 -0
  97. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/executor.py +0 -0
  98. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/executor_registry.py +0 -0
  99. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/memory.py +0 -0
  100. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/record_store.py +0 -0
  101. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/sandbox.py +0 -0
  102. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/state.py +0 -0
  103. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/core/tools.py +0 -0
  104. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/events/__init__.py +0 -0
  105. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/events/base.py +0 -0
  106. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/events/bus.py +0 -0
  107. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/events/events.py +0 -0
  108. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/events/helpers.py +0 -0
  109. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/__init__.py +0 -0
  110. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/base.py +0 -0
  111. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/models.py +0 -0
  112. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/reconstruction.py +0 -0
  113. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/registry.py +0 -0
  114. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/sqlite_backend.py +0 -0
  115. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/sqlite_conn.py +0 -0
  116. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/sqlite_schema.py +0 -0
  117. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/history/ui_events.py +0 -0
  118. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/hooks.py +0 -0
  119. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/interaction.py +0 -0
  120. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/models.py +0 -0
  121. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/options.py +0 -0
  122. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/permissions.py +0 -0
  123. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/plugins.py +0 -0
  124. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/prompt_xml.py +0 -0
  125. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/__init__.py +0 -0
  126. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/base.py +0 -0
  127. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/model_cache.py +0 -0
  128. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/model_registry.py +0 -0
  129. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/ollama.py +0 -0
  130. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/providers/openrouter.py +0 -0
  131. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/py.typed +0 -0
  132. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/renderer.py +0 -0
  133. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/schemas/__init__.py +0 -0
  134. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/schemas/agent.schema.json +0 -0
  135. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/__init__.py +0 -0
  136. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/backend.py +0 -0
  137. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/env.py +0 -0
  138. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/exec.py +0 -0
  139. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/file.py +0 -0
  140. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/masking.py +0 -0
  141. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/redaction.py +0 -0
  142. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/registry.py +0 -0
  143. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/secrets/sqlite.py +0 -0
  144. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/shell_tool_config.py +0 -0
  145. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/skill_discovery.py +0 -0
  146. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/templates/AGENTS.md +0 -0
  147. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/templates/IDENTITY.md +0 -0
  148. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/templates/MEMORY.md +0 -0
  149. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/templates/USER.md +0 -0
  150. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/templates/personas/casual-technical.md +0 -0
  151. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/templates/personas/marvin.md +0 -0
  152. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/templates/personas/minimal.md +0 -0
  153. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/daemon_control.py +0 -0
  154. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/history.py +0 -0
  155. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/notify.py +0 -0
  156. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/secrets.py +0 -0
  157. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/sessions.py +0 -0
  158. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/shell.py +0 -0
  159. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/skills.py +0 -0
  160. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tools/time.py +0 -0
  161. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/tsugite.py +0 -0
  162. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/__init__.py +0 -0
  163. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/base.py +0 -0
  164. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/chat.py +0 -0
  165. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/dispatch.py +0 -0
  166. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/helpers.py +0 -0
  167. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/jsonl.py +0 -0
  168. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/plain.py +0 -0
  169. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/repl_chat.py +0 -0
  170. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/repl_commands.py +0 -0
  171. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/repl_completer.py +0 -0
  172. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui/repl_handler.py +0 -0
  173. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui_context.py +0 -0
  174. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/ui_surfaces.py +0 -0
  175. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/usage/__init__.py +0 -0
  176. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/usage/quota.py +0 -0
  177. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/usage/store.py +0 -0
  178. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/user_agent.py +0 -0
  179. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/workspace/__init__.py +0 -0
  180. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/workspace/models.py +0 -0
  181. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/workspace/session.py +0 -0
  182. {tsugite_cli-0.22.0 → tsugite_cli-0.23.0}/tsugite/workspace/templates.py +0 -0
@@ -193,4 +193,6 @@ plugins/tsugite-daemon/tsugite_daemon/web/
193
193
  # Re-include frontend source that generic lib/ rules (incl. contributors' global
194
194
  # gitignores with Python-style `lib/`) would otherwise silently exclude.
195
195
  !plugins/tsugite-daemon/frontend/src/lib/
196
- !plugins/tsugite-daemon/frontend/src/lib/**
196
+ !plugins/tsugite-daemon/frontend/src/lib/**
197
+ # Per-job git worktrees provisioned by the daemon.
198
+ .tsugite-jobs/
@@ -289,7 +289,7 @@ Tsugite supports vision (images), audio, and document understanding through the
289
289
  **How It Works:**
290
290
 
291
291
  1. **URL Attachments** (Images/Documents):
292
- - The provider fetches the URL directly (no download overhead — support varies by provider and content type; see `tsugite/attachments/url.py`)
292
+ - The provider fetches the URL directly (no download overhead, though support varies by provider and content type; see `tsugite/attachments/url.py`)
293
293
  - Example: `tsu run -f https://example.com/chart.png "Describe this chart"`
294
294
  - Automatically detected via HTTP HEAD request
295
295
 
@@ -425,7 +425,7 @@ See `examples/attachment_assign_demo.md` and `examples/attachment_index_demo.md`
425
425
 
426
426
  ### Swappable batteries
427
427
 
428
- Subsystems are pluggable behind a uniform seam: (1) a Protocol, (2) a `tsugite.<area>`
428
+ Subsystems are pluggable behind a uniform interface: (1) a Protocol, (2) a `tsugite.<area>`
429
429
  entry-point group, (3) a `config.<area>.backend` selector resolved via
430
430
  `load_backend_entry_point` (`plugins.py`), with a built-in default. Areas: tools, providers,
431
431
  adapters, secrets, hooks, event_subscribers, history, attachments, sandbox, executors.
@@ -448,12 +448,12 @@ For any non-trivial bug fix or feature, write a failing test **first**. Exceptio
448
448
 
449
449
  The workflow:
450
450
 
451
- 1. **Reproduce before fixing.** Write the smallest test that demonstrates the symptom as reported. Run it. If it passes on master, the hypothesis is wrong — do not write a fix. Investigate further, ask the user for more detail, or add logging.
451
+ 1. **Reproduce before fixing.** Write the smallest test that demonstrates the symptom as reported. Run it. If it passes on master, the hypothesis is wrong. Do not write a fix. Investigate further, ask the user for more detail, or add logging.
452
452
  2. **Implement the fix.** Keep the change minimal. Do not refactor or add adjacent cleanup in the same diff.
453
- 3. **Verify the test is load-bearing.** Temp-revert the production change and re-run. The test must go red again. If it still passes, the test isn't actually exercising the fix — rework it.
453
+ 3. **Verify the test is load-bearing.** Temp-revert the production change and re-run. The test must go red again. If it still passes, the test isn't exercising the fix. Rework it.
454
454
  4. **Cover adjacent cases before declaring done.** When touching a parser, protocol boundary, or state machine, enumerate at least two adjacent input shapes and add tests for them. Narrow tests let bugs sneak in through the cases you didn't think about.
455
455
  5. **Run the local regression suite.** At minimum, the test files next to the modules you changed. Faster than running everything, catches silent breakage of existing tests.
456
- 6. **Honest uncertainty.** If a hypothesis doesn't reproduce, say so. Don't ship a "probably-this" fix with no failing test to anchor it — that's how placebo fixes get merged.
456
+ 6. **Honest uncertainty.** If a hypothesis doesn't reproduce, say so. Don't ship a "probably-this" fix with no failing test to anchor it. That's how placebo fixes get merged.
457
457
 
458
458
  Reproducing tests don't need to be elaborate. A 10-line test that flips red→green is worth more than a 100-line one that nobody understands.
459
459
 
@@ -461,7 +461,7 @@ Reproducing tests don't need to be elaborate. A 10-line test that flips red→gr
461
461
 
462
462
  - **Unit tests**: Individual functions and classes
463
463
  - **Pipeline tests**: Mock only `TsugiteAgent` / the provider's `acompletion` but exercise the full pipeline (parsing → rendering → preparation → tool expansion → execution). Most tests in the suite are this style.
464
- - **Integration tests**: Live under `tests/integration/` (not collected by default — see `pyproject.toml` `norecursedirs`). Run explicitly with `uv run pytest tests/integration/`. Good for concurrency, cwd, and daemon-wiring tests that need real threading or a real workspace.
464
+ - **Integration tests**: Live under `tests/integration/` (not collected by default; see `pyproject.toml` `norecursedirs`). Run explicitly with `uv run pytest tests/integration/`. Good for concurrency, cwd, and daemon-wiring tests that need real threading or a real workspace.
465
465
  - **Smoke tests**: `tests/smoke_test.sh` hits a real LLM API (requires `OPENAI_API_KEY`, not run in CI)
466
466
  - **Fixtures**: `conftest.py` provides shared test data
467
467
  - **Mocking**: Use `@pytest.fixture` for LLM responses
@@ -471,9 +471,16 @@ Reproducing tests don't need to be elaborate. A 10-line test that flips red→gr
471
471
  ## Code Style
472
472
 
473
473
  - Python 3.11+ (type hints on public APIs)
474
- - Line length: 120 characters (Black)
474
+ - Line length: 120 characters (ruff)
475
475
  - Import order: stdlib → third-party → local (blank lines between groups)
476
- - Errors: `ValueError` for input validation, `RuntimeError` for execution failures
476
+ - Errors, by condition:
477
+ - `ValueError` - input validation
478
+ - `RuntimeError` - execution failures
479
+ - `TimeoutError` - timeouts
480
+ - `PermissionError` - policy denials
481
+ - `ToolUnavailableError` - the tool cannot run in this context
482
+ - `FileNotFoundError` / `IsADirectoryError` / `NotADirectoryError` - paths
483
+ - Never wrap a tool body in a blanket `except Exception`
477
484
  - Docstrings: Google style with `Args:` and `Returns:` sections
478
485
  - Pydantic models: Use `extra="forbid"` to catch typos in YAML frontmatter
479
486
 
@@ -485,7 +492,7 @@ Reproducing tests don't need to be elaborate. A 10-line test that flips red→gr
485
492
  4. **Don't hardcode paths**: Use XDG utilities (`get_xdg_config_path()`, etc.)
486
493
  5. **Don't use blocking IO in async**: Use `asyncio.to_thread()` for sync tools
487
494
  6. **Test both sync and async paths**: Many tools support both execution modes
488
- 7. **Don't embed prompts in adapters/code**: Use context variables + conditional blocks in `default.md` instead. Add new context vars in `_build_agent_context()` (base adapter) and default them in `agent_preparation.py`, then use `{% if var %}` in the agent template. This keeps all prompt content in one place and leverages the existing rendering pipeline.
495
+ 7. **Don't embed prompts in adapters/code**: Use context variables + conditional blocks in `default.md` instead. Add new context vars in `_build_agent_context()` (base adapter) and default them in `agent_preparation.py`, then use `{% if var %}` in the agent template. This keeps all prompt content in one place and uses the existing rendering pipeline.
489
496
 
490
497
  ## Web UI
491
498
 
@@ -10,8 +10,8 @@ Use [Conventional Commits](https://www.conventionalcommits.org/). Format:
10
10
  <type>(<scope>): <subject>
11
11
  ```
12
12
 
13
- Keep the subject on one line, lowercase, no trailing period. Prefer no body. When the subject alone
14
- does not say what changed for someone using tsugite, add a `Release-Note:` trailer (below).
13
+ Keep the subject on one line, lowercase, no trailing period, and write it as the line a user would
14
+ read in the release notes. No body, no trailers.
15
15
 
16
16
  ### Allowed types
17
17
 
@@ -46,21 +46,14 @@ Scopes are optional, but should be limited to one of these:
46
46
 
47
47
  ### Release notes
48
48
 
49
- git-cliff (`cliff.toml`) generates the release notes from the commits. A subject that already reads
50
- as the change is the release line; when it reads as the patch instead, add a `Release-Note:` trailer:
49
+ git-cliff (`cliff.toml`) generates the release notes from the commit subjects, so the subject is the
50
+ release line. Write it as what the software does now for the person using it, and it needs nothing
51
+ else. `chore`, `docs`, `test` and `ci` commits never appear.
51
52
 
52
- ```
53
- fix(webui): scope agent artifact panes by session
54
-
55
- Release-Note: Opening an artifact from one chat keeps every other chat's pane as it was.
56
- ```
57
-
58
- One line, present tense, stating what the software does now. No "instead of", no because-clause. A
59
- commit with nothing to tell a user (a test fix, an internal rename) carries no trailer and prints its
60
- subject instead. `chore`, `docs`, `test` and `ci` commits never appear.
61
-
62
- The trailer has to be the last paragraph of the message. When squashing, keep one `Release-Note:`
63
- line at the end of the squashed message; a trailer buried mid-body is not parsed.
53
+ `cliff.toml` also prints a `Release-Note:` trailer in place of the subject when one is present. That
54
+ is an escape hatch for the rare commit whose subject cannot say what a user sees, not something to
55
+ add by default; almost every commit should have none. When one is needed it has to be the last
56
+ paragraph of the message, and a squash keeps at most one.
64
57
 
65
58
  ### Examples
66
59
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.22.0
3
+ Version: 0.23.0
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.22.0"
3
+ version = "0.23.0"
4
4
  description = "Micro-agent runner for task automation using markdown definitions"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
@@ -89,7 +89,7 @@ class AgentPreparer:
89
89
  _resolve_attachments caller + frontmatter attachments, removals, dedupe
90
90
  _install_skill_manager workspace-aware SkillManager, made active
91
91
  _run_prefetch frontmatter prefetch tools + agent-list injection
92
- _resolve_paths cwd / invoked_from / workspace_dir
92
+ _resolve_paths CWD / INVOKED_FROM / WORKSPACE_DIR
93
93
  _build_template_context the Jinja context and its framework defaults
94
94
  _expand_tools tool specs + capability-based auto-injection
95
95
  _load_skills auto-load, sticky TTL, trigger matching
@@ -119,8 +119,9 @@ class AgentPreparer:
119
119
  placeholders: Dict[str, str] = {}
120
120
  modified = content
121
121
  for d in directives:
122
- if d.assign_var:
123
- placeholders[d.assign_var] = f"[{kind} directive: {d.name}(...) - not executed in render mode]"
122
+ for var in (d.assign_var, getattr(d, "stdout_assign", None)):
123
+ if var:
124
+ placeholders[var] = f"[{kind} directive: {d.name}(...) - not executed in render mode]"
124
125
  if rewrite_to is not None:
125
126
  modified = modified.replace(d.raw_match, rewrite_to(d))
126
127
  return modified, placeholders
@@ -142,14 +143,16 @@ class AgentPreparer:
142
143
  )
143
144
 
144
145
  def _resolve_attachments(
145
- self, agent_config: AgentConfig, attachments: Optional[List[Attachment]]
146
+ self,
147
+ agent_config: AgentConfig,
148
+ attachments: Optional[List[Attachment]],
149
+ paths: Dict[str, Optional[str]],
146
150
  ) -> Tuple[List[Attachment], Dict[str, Any]]:
147
151
  """Merge caller attachments with the agent's own, honoring removals.
148
152
 
149
- Front-matter attachments carry the cache tiers and are the intended
150
- source, so they dedupe ahead of any same-named attachment the caller
151
- passed in. Legacy `-filename` string entries drop a same-named entry.
152
- Front-matter paths resolve via cwd (workspace_path is None in production).
153
+ A front-matter attachment defines its cache tier and replaces a same-named
154
+ caller attachment. Legacy `-filename` string entries drop a same-named
155
+ entry. Front-matter paths render with `paths` and resolve against its CWD.
153
156
  """
154
157
  all_attachments = list(attachments or [])
155
158
 
@@ -157,7 +160,7 @@ class AgentPreparer:
157
160
  if removals:
158
161
  all_attachments = [a for a in all_attachments if a.name not in removals]
159
162
 
160
- loaded, bindings = resolve_agent_config_attachments(keep_items, None)
163
+ loaded, bindings = resolve_agent_config_attachments(keep_items, Path(paths["CWD"]), paths)
161
164
  all_attachments = loaded + all_attachments
162
165
 
163
166
  seen_names: set[str] = set()
@@ -201,8 +204,7 @@ class AgentPreparer:
201
204
  except Exception:
202
205
  prefetch_context = {}
203
206
 
204
- # Default agents call list_available_agents() on demand instead of
205
- # carrying the full list, so this stays opt-in.
207
+ # The agent list is opt-in. Default agents call list_available_agents() on demand.
206
208
  if "available_agents" not in prefetch_context and (
207
209
  agent_config.auto_load_agent_list or agent_config.auto_load_agents
208
210
  ):
@@ -254,7 +256,7 @@ class AgentPreparer:
254
256
  continue
255
257
  meta = registry.get(name)
256
258
  if meta is None:
257
- # Skill vanished (renamed/removed) between turns — drop it.
259
+ # The skill vanished (renamed or removed) between turns.
258
260
  expired_sticky.append(name)
259
261
  continue
260
262
  effective_ttl = meta.ttl if meta.ttl is not None else ttl_default
@@ -298,9 +300,8 @@ class AgentPreparer:
298
300
  ) -> List[Tool]:
299
301
  """Expand the agent's tool specs and apply the capability-based auto-injections.
300
302
 
301
- Interactive tools are added only when something can actually answer, and
302
- stripped otherwise, so a scheduled run cannot offer the model a prompt
303
- nobody will see.
303
+ Interactive tools are added only when something can answer, and stripped
304
+ otherwise.
304
305
  """
305
306
  from tsugite.core.tools import create_tool_from_tsugite
306
307
  from tsugite.interaction import get_interaction_backend
@@ -332,8 +333,8 @@ class AgentPreparer:
332
333
  raise RuntimeError(f"Failed to create tools: {e}") from e
333
334
 
334
335
  @staticmethod
335
- def _resolve_paths(path_context: Optional["PathContext"]) -> Tuple[str, Optional[str], Optional[str]]:
336
- """Return (cwd, invoked_from, workspace_dir) as strings for the template context.
336
+ def _resolve_paths(path_context: Optional["PathContext"]) -> Dict[str, Optional[str]]:
337
+ """CWD, INVOKED_FROM and WORKSPACE_DIR for the template context.
337
338
 
338
339
  The daemon supplies an effective_cwd that differs from the process cwd.
339
340
  """
@@ -341,9 +342,11 @@ class AgentPreparer:
341
342
  cwd = str(path_context.effective_cwd)
342
343
  else:
343
344
  cwd = str(Path.cwd())
344
- invoked_from = str(path_context.invoked_from) if path_context else None
345
- workspace_dir = str(path_context.workspace_dir) if path_context and path_context.workspace_dir else None
346
- return cwd, invoked_from, workspace_dir
345
+ return {
346
+ "CWD": cwd,
347
+ "INVOKED_FROM": str(path_context.invoked_from) if path_context else None,
348
+ "WORKSPACE_DIR": str(path_context.workspace_dir) if path_context and path_context.workspace_dir else None,
349
+ }
347
350
 
348
351
  def _build_template_context(
349
352
  self,
@@ -354,9 +357,7 @@ class AgentPreparer:
354
357
  directive_context: Dict[str, Any],
355
358
  attachment_bindings: Dict[str, Any],
356
359
  interactive_mode: bool,
357
- cwd: str,
358
- invoked_from: Optional[str],
359
- workspace_dir: Optional[str],
360
+ paths: Dict[str, Optional[str]],
360
361
  ) -> Dict[str, Any]:
361
362
  """Assemble the Jinja context every agent template renders against.
362
363
 
@@ -386,9 +387,7 @@ class AgentPreparer:
386
387
  "is_subagent": context.get("is_subagent", False),
387
388
  "parent_agent": context.get("parent_agent", None),
388
389
  "chat_history": context.get("chat_history", []),
389
- "CWD": cwd,
390
- "INVOKED_FROM": invoked_from,
391
- "WORKSPACE_DIR": workspace_dir,
390
+ **paths,
392
391
  }
393
392
 
394
393
  # User-specified attachment `assign:` bindings win over built-in or
@@ -444,7 +443,8 @@ class AgentPreparer:
444
443
 
445
444
  agent_config = agent.config
446
445
 
447
- all_attachments, attachment_bindings = self._resolve_attachments(agent_config, attachments)
446
+ paths = self._resolve_paths(path_context)
447
+ all_attachments, attachment_bindings = self._resolve_attachments(agent_config, attachments, paths)
448
448
 
449
449
  _skill_manager = self._install_skill_manager(agent_config, path_context)
450
450
 
@@ -469,7 +469,6 @@ class AgentPreparer:
469
469
  )
470
470
 
471
471
  interactive_mode = is_interactive()
472
- cwd, invoked_from, workspace_dir = self._resolve_paths(path_context)
473
472
  full_context = self._build_template_context(
474
473
  agent_config=agent_config,
475
474
  prompt=prompt,
@@ -477,9 +476,7 @@ class AgentPreparer:
477
476
  directive_context={**prefetch_context, **tool_context, **exec_context},
478
477
  attachment_bindings=attachment_bindings,
479
478
  interactive_mode=interactive_mode,
480
- cwd=cwd,
481
- invoked_from=invoked_from,
482
- workspace_dir=workspace_dir,
479
+ paths=paths,
483
480
  )
484
481
 
485
482
  renderer = AgentRenderer()
@@ -535,7 +532,7 @@ class AgentPreparer:
535
532
 
536
533
  system_message = build_system_prompt(tools, combined_instructions)
537
534
 
538
- # Add environment context when invoked_from differs from CWD
535
+ cwd, invoked_from = paths["CWD"], paths["INVOKED_FROM"]
539
536
  if invoked_from and invoked_from != cwd:
540
537
  env_block = f"""
541
538
  ## Environment
@@ -142,8 +142,9 @@ def run_python_block(
142
142
  workspace_dir: Optional workspace path for sandbox bind mounts.
143
143
 
144
144
  Raises:
145
- RuntimeError: On timeout, on hard execution failure when not continuing,
146
- or on non-coercible return-value types.
145
+ TimeoutError: On timeout.
146
+ RuntimeError: On hard execution failure when not continuing, or on
147
+ non-coercible return-value types.
147
148
  """
148
149
  safe_locals = _filter_json_safe(locals_dict)
149
150
 
@@ -162,7 +163,7 @@ def run_python_block(
162
163
  try:
163
164
  proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
164
165
  except subprocess.TimeoutExpired as e:
165
- raise RuntimeError(f"tsu:exec block timed out after {timeout}s") from e
166
+ raise TimeoutError(f"tsu:exec block timed out after {timeout}s") from e
166
167
 
167
168
  result_data: Dict[str, Any] = {}
168
169
  if os.path.exists(result_path):
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, List, Optional
8
8
  from rich.console import Console
9
9
 
10
10
  from tsugite.console import get_stderr_console
11
+ from tsugite.exceptions import SandboxToolDeniedError # noqa: F401
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from tsugite.options import ExecutionOptions
@@ -24,8 +25,8 @@ _stderr_console = get_stderr_console()
24
25
  _current_agent_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("current_agent", default=None)
25
26
 
26
27
  # Active sandbox policy. Presence means "this agent is running sandboxed"; tools
27
- # read it to propagate the same isolation or to refuse. A nested run leaking its
28
- # teardown here would fail OPEN, so the reset is load-bearing.
28
+ # read it to propagate the same isolation or to refuse. A nested run that leaks
29
+ # its teardown here fails OPEN.
29
30
  _sandbox_context_var: contextvars.ContextVar[Optional["SandboxContext"]] = contextvars.ContextVar(
30
31
  "sandbox_context", default=None
31
32
  )
@@ -47,11 +48,6 @@ class SandboxContext:
47
48
  workspace_dir: Optional[Path] = None
48
49
 
49
50
 
50
- class SandboxToolDeniedError(RuntimeError):
51
- """Raised when a host-exec tool is refused because the agent runs sandboxed
52
- (see the deny_when_sandboxed decorator)."""
53
-
54
-
55
51
  def set_sandbox_context(ctx: Optional["SandboxContext"]) -> contextvars.Token:
56
52
  """Set (or clear, with None) the active sandbox policy. Returns a reset token."""
57
53
  return _sandbox_context_var.set(ctx)
@@ -231,11 +231,14 @@ def save_run_to_history(
231
231
  # agent ran to completion against this storage.
232
232
  existing = list(storage.iter_events())
233
233
  last_session_start = -1
234
+ last_user_input = -1
234
235
  last_model_response = -1
235
236
  last_session_end = -1
236
237
  for i, e in enumerate(existing):
237
238
  if e.type == "session_start":
238
239
  last_session_start = i
240
+ elif e.type == "user_input":
241
+ last_user_input = i
239
242
  elif e.type == "model_response":
240
243
  last_model_response = i
241
244
  elif e.type == "session_end":
@@ -244,7 +247,10 @@ def save_run_to_history(
244
247
  agent_already_recorded = last_model_response > last_session_start
245
248
 
246
249
  if agent_already_recorded:
247
- if last_session_end <= last_session_start:
250
+ # A continued conversation pins session_start at index 0, so the current
251
+ # turn starts at its own user_input.
252
+ turn_start = max(last_session_start, last_user_input)
253
+ if last_session_end < turn_start:
248
254
  record_session_end(storage, status=status, error_message=error_message)
249
255
  return storage.session_id
250
256
 
@@ -646,10 +646,15 @@ async def _execute_agent_with_prompt(
646
646
  try:
647
647
  from tsugite.usage import get_usage_store
648
648
 
649
+ meta = channel_metadata or {}
649
650
  get_usage_store().record(
651
+ session_id=continue_conversation_id,
650
652
  agent=prepared.agent_config.name,
651
653
  model=model_string,
652
- source="cli",
654
+ source=meta.get("source") or "cli",
655
+ schedule_name=meta.get("schedule_id"),
656
+ input_tokens=agent.prompt_tokens,
657
+ output_tokens=agent.completion_tokens,
653
658
  total_tokens=agent.total_tokens,
654
659
  cost_usd=agent.reported_cost,
655
660
  cache_creation_tokens=agent.cache_creation_tokens,
@@ -355,8 +355,8 @@ async def _execute_step_with_retries(
355
355
  step_duration = time.time() - step_start_time
356
356
  return step_result, step_duration
357
357
 
358
- except asyncio.TimeoutError:
359
- error_msg = f"Step timed out after {step.timeout} seconds"
358
+ except asyncio.TimeoutError as e:
359
+ error_msg = str(e) or f"Step timed out after {step.timeout} seconds"
360
360
  errors.append(error_msg)
361
361
  code_executed_this_attempt = False
362
362
  except AgentSkippedError:
@@ -17,12 +17,12 @@ from tsugite.utils import has_glob_chars
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
- def _render_path(template: str) -> Optional[str]:
20
+ def _render_path(template: str, template_vars: Dict[str, Any]) -> Optional[str]:
21
21
  """Render a Jinja path template; return None on render failure."""
22
22
  from tsugite.renderer import AgentRenderer
23
23
 
24
24
  try:
25
- return AgentRenderer().render_string(template)
25
+ return AgentRenderer().render_string(template, template_vars)
26
26
  except Exception as e:
27
27
  logger.debug("Failed to render attachment path %r: %s", template, e)
28
28
  return None
@@ -67,6 +67,7 @@ def _expand_glob(pattern: str, workspace_path: Optional[Path]) -> List[Path]:
67
67
  def resolve_agent_config_attachments(
68
68
  items: List[Union[str, AttachmentSpec]],
69
69
  workspace_path: Optional[Path] = None,
70
+ template_vars: Optional[Dict[str, Any]] = None,
70
71
  ) -> Tuple[List[Attachment], Dict[str, Any]]:
71
72
  """Resolve agent config attachment items to Attachment objects and Jinja bindings.
72
73
 
@@ -76,6 +77,7 @@ def resolve_agent_config_attachments(
76
77
  Args:
77
78
  items: List of strings or AttachmentSpec objects from agent_config.attachments
78
79
  workspace_path: Optional workspace path for resolving relative paths
80
+ template_vars: Jinja variables for path templates (CWD, INVOKED_FROM, WORKSPACE_DIR)
79
81
 
80
82
  Returns:
81
83
  Tuple of (attachments to inject, dict of {assign_name: bound_value}).
@@ -90,13 +92,14 @@ def resolve_agent_config_attachments(
90
92
  file_handler = FileHandler()
91
93
  attachments: List[Attachment] = []
92
94
  bindings: Dict[str, Any] = {}
95
+ template_vars = template_vars or {}
93
96
 
94
97
  for item in items:
95
98
  before = len(attachments)
96
99
  if isinstance(item, str):
97
- _resolve_string_item(item, workspace_path, file_handler, attachments)
100
+ _resolve_string_item(item, workspace_path, file_handler, attachments, template_vars)
98
101
  else:
99
- _resolve_spec(item, workspace_path, file_handler, attachments, bindings)
102
+ _resolve_spec(item, workspace_path, file_handler, attachments, bindings, template_vars)
100
103
  # Tag everything this item produced with its cache tier (a spec's tier;
101
104
  # plain strings are always tier 0). The context turn renders one cache
102
105
  # block per tier so a volatile file only invalidates its own block.
@@ -113,9 +116,10 @@ def _resolve_string_item(
113
116
  workspace_path: Optional[Path],
114
117
  file_handler: "FileHandler",
115
118
  attachments: List[Attachment],
119
+ template_vars: Dict[str, Any],
116
120
  ) -> None:
117
- """Legacy string handling: render Jinja, resolve path, fetch single file."""
118
- rendered = _render_path(template)
121
+ """Render the Jinja path, resolve it, fetch the single file."""
122
+ rendered = _render_path(template, template_vars)
119
123
  if rendered is None:
120
124
  return
121
125
  resolved = _resolve_path(rendered, workspace_path)
@@ -134,9 +138,10 @@ def _resolve_spec(
134
138
  file_handler: "FileHandler",
135
139
  attachments: List[Attachment],
136
140
  bindings: Dict[str, Any],
141
+ template_vars: Dict[str, Any],
137
142
  ) -> None:
138
143
  """Resolve a single AttachmentSpec, populating attachments and bindings."""
139
- rendered = _render_path(spec.path)
144
+ rendered = _render_path(spec.path, template_vars)
140
145
  if rendered is None:
141
146
  if spec.assign:
142
147
  bindings[spec.assign] = None
@@ -170,31 +170,22 @@ class AutoContextHandler(AttachmentHandler):
170
170
  return dirs
171
171
 
172
172
  def _discover_files(self, search_dirs: List[Path], context_files: List[str]) -> List[tuple[Path, str]]:
173
- """Discover context files in search directories.
173
+ """Return every context file on the walk, outermost directory first.
174
174
 
175
- Deduplicates by filename to prefer closer (more specific) directories.
176
-
177
- Args:
178
- search_dirs: Directories to search
179
- context_files: List of filenames to search for
180
-
181
- Returns:
182
- List of (file_path, relative_name) tuples for found files
175
+ Names are relative to the outermost directory that has one. A nested
176
+ copy reads as `repos/app/AGENTS.md` next to the outer `AGENTS.md`.
183
177
  """
184
- found = []
185
- seen = set()
178
+ found: List[tuple[Path, str]] = []
179
+ outermost_dir: Optional[Path] = None
186
180
 
187
- # Search in order (most specific directory first)
188
- for directory in search_dirs:
181
+ for directory in reversed(search_dirs):
189
182
  for filename in context_files:
190
- # Skip if we've already found a file with this name
191
- if filename in seen:
192
- continue
193
-
194
183
  file_path = directory / filename
195
- if file_path.exists() and file_path.is_file():
196
- found.append((file_path, filename))
197
- seen.add(filename)
184
+ if not file_path.is_file():
185
+ continue
186
+ if outermost_dir is None:
187
+ outermost_dir = directory
188
+ found.append((file_path, file_path.relative_to(outermost_dir).as_posix()))
198
189
 
199
190
  return found
200
191
 
@@ -68,8 +68,7 @@ class Attachment:
68
68
 
69
69
  ``key`` is the stable id used for dedupe and the UI chip; ``label`` is the
70
70
  human name the model and UI show; ``value`` is the text. Fills the text
71
- content-type so context producers don't repeat it. This is the shape the
72
- old ``ContextItem`` carried, now just an Attachment.
71
+ content-type so context producers don't repeat it.
73
72
  """
74
73
  return cls(
75
74
  name=label,
@@ -88,8 +87,7 @@ class Attachment:
88
87
 
89
88
  @property
90
89
  def value(self) -> str:
91
- """The text content (an alias of ``content`` when it is text, else "").
92
- Lets context code read ``.value`` the way the old ``ContextItem`` did."""
90
+ """The text content (an alias of ``content`` when it is text, else "")."""
93
91
  return self.content if isinstance(self.content, str) else ""
94
92
 
95
93
  def to_metadata(self) -> dict:
@@ -157,12 +157,12 @@ You are managing a shared channel. When a user asks for something that would ben
157
157
  - **Inline** - single edit, one-shot read, small calculation. Don't spawn anything.
158
158
  - **`spawn_job`** - background work: long-running tasks, parallel work, or delegating to a different agent/model. Fire-and-forget by default; add `acceptance_criteria=[...]` when "done" has a checkable shape (tests pass, copy satisfies criteria, refactor preserves behavior) and the verification loop becomes the value.
159
159
 
160
- **Acceptance criteria**: pass each criterion as a plain string. The verifier reads them verbatim and returns per-criterion pass/fail in `result.ac_results`:
160
+ **Acceptance criteria**: pass each criterion as a plain string. A criterion starting with `cmd:<command>` (passes on exit 0), `exit_code:<command>:<n>` (passes on exit code `n`, default 0) or `file_exists:<path>` is checked in the Job's working directory with no verifier model. Write one whenever the check is mechanical. The verifier reads the rest verbatim and returns per-criterion pass/fail in `result.ac_results`:
161
161
  ```python-exec
162
162
  spawn_job(
163
163
  prompt="Refactor the agent loop to handle nested tool calls",
164
164
  acceptance_criteria=[
165
- "All existing tests still pass",
165
+ "cmd:uv run pytest -q",
166
166
  "Adds at least 2 new tests for nested calls",
167
167
  "No new dependencies",
168
168
  ],
@@ -1,6 +1,7 @@
1
1
  """CLI run command - execute agents."""
2
2
 
3
3
  from pathlib import Path
4
+ from types import SimpleNamespace
4
5
  from typing import Any, Dict, List, Optional, Tuple
5
6
 
6
7
  import typer
@@ -137,11 +138,18 @@ def _execute_agent_with_ui(
137
138
  """Execute agent with appropriate UI mode.
138
139
 
139
140
  Dispatch order:
140
- 1. --headless / --final-only -> custom_agent_ui with silent progress
141
- 2. fall-through default -> plain logger (covers no flags, --plain, piped/NO_COLOR)
141
+ 1. --log-json -> JSONL event stream on stdout
142
+ 2. --headless / --final-only -> custom_agent_ui with silent progress
143
+ 3. fall-through default -> plain logger (covers no flags, --plain, piped/NO_COLOR)
142
144
  """
143
145
  from tsugite.ui import create_plain_logger, custom_agent_ui
144
146
 
147
+ if ui_opts.log_json:
148
+ from tsugite.ui.jsonl import JSONLUIHandler
149
+
150
+ executor_kwargs["custom_logger"] = SimpleNamespace(ui_handler=JSONLUIHandler())
151
+ return executor(**executor_kwargs)
152
+
145
153
  if ui_opts.headless or ui_opts.final_only:
146
154
  stderr_console = get_error_console(True, console)
147
155
  show_progress_items = ui_opts.verbose and not ui_opts.final_only
@@ -188,6 +196,9 @@ def _display_result(result_str: str, ui_opts: UIOptions, stderr_console: Console
188
196
 
189
197
  from tsugite.console import get_stdout_console
190
198
 
199
+ if ui_opts.log_json:
200
+ return
201
+
191
202
  if not (ui_opts.headless or ui_opts.final_only):
192
203
  stderr_console.print()
193
204
  stderr_console.rule("[bold green]Agent Execution Complete[/bold green]")
@@ -358,6 +369,10 @@ def run(
358
369
 
359
370
  if history_opts.storage_dir:
360
371
  history_opts.storage_dir.mkdir(parents=True, exist_ok=True)
372
+ from tsugite.history import set_history_backend
373
+ from tsugite.history.sqlite_backend import SqliteHistoryBackend
374
+
375
+ set_history_backend(SqliteHistoryBackend(history_opts.storage_dir / "history.db"))
361
376
 
362
377
  if ui_opts.no_color:
363
378
  console.no_color = True
@@ -394,6 +394,8 @@ class TsugiteAgent:
394
394
  # cost data at all" (interrupted turn) - only the latter records NULL.
395
395
  self.cost_reported = False
396
396
  self.total_tokens = 0
397
+ self.prompt_tokens = 0
398
+ self.completion_tokens = 0
397
399
  self.last_input_tokens = 0
398
400
  self.cache_creation_tokens = 0
399
401
  self.cache_read_tokens = 0
@@ -1333,6 +1335,8 @@ class TsugiteAgent:
1333
1335
  return cost or 0.0
1334
1336
 
1335
1337
  self.total_tokens += usage.total_tokens
1338
+ self.prompt_tokens += usage.prompt_tokens
1339
+ self.completion_tokens += usage.completion_tokens
1336
1340
  self.last_input_tokens = (
1337
1341
  usage.prompt_tokens + (usage.cache_creation_input_tokens or 0) + (usage.cache_read_input_tokens or 0)
1338
1342
  )