tsugite-cli 0.25.3__tar.gz → 0.26.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 (185) hide show
  1. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/PKG-INFO +4 -4
  2. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/README.md +3 -3
  3. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/pyproject.toml +2 -2
  4. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/exec_directives.py +0 -6
  5. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/exec_runner.py +3 -28
  6. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/helpers.py +29 -2
  7. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/history_integration.py +27 -7
  8. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/runner.py +27 -9
  9. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/steps.py +12 -19
  10. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_agents/default.md +2 -0
  11. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/exec.py +1 -1
  12. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/run.py +6 -1
  13. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/usage.py +72 -4
  14. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/config.py +1 -1
  15. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/agent.py +77 -42
  16. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/sandbox.py +6 -7
  17. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/subprocess_executor.py +27 -43
  18. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/md_agents.py +1 -0
  19. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/models.py +12 -0
  20. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/schemas/agent.schema.json +5 -0
  21. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/sqlite.py +8 -0
  22. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/agents.py +1 -1
  23. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/http.py +2 -3
  24. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/jobs.py +8 -1
  25. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/sessions.py +2 -2
  26. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/usage/__init__.py +2 -0
  27. tsugite_cli-0.26.0/tsugite/usage/anomalies.py +85 -0
  28. tsugite_cli-0.26.0/tsugite/usage/cache.py +59 -0
  29. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/usage/store.py +34 -3
  30. tsugite_cli-0.25.3/tsugite/core/proxy.py +0 -246
  31. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/.gitignore +0 -0
  32. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/AGENTS.md +0 -0
  33. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/CONTRIBUTING.md +0 -0
  34. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/LICENSE +0 -0
  35. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/__init__.py +0 -0
  36. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_inheritance.py +0 -0
  37. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_preparation.py +0 -0
  38. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/__init__.py +0 -0
  39. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/metrics.py +0 -0
  40. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/models.py +0 -0
  41. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_runner/validation.py +0 -0
  42. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/agent_utils.py +0 -0
  43. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/approval.py +0 -0
  44. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/__init__.py +0 -0
  45. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/agent_config.py +0 -0
  46. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/auto_context.py +0 -0
  47. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/base.py +0 -0
  48. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/delegation.py +0 -0
  49. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/file.py +0 -0
  50. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/inline.py +0 -0
  51. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/storage.py +0 -0
  52. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/attachments/url.py +0 -0
  53. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_agents/.gitkeep +0 -0
  54. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_agents/code_searcher.md +0 -0
  55. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_agents/file_searcher.md +0 -0
  56. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_agents/job_verifier.md +0 -0
  57. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_agents/job_worker.md +0 -0
  58. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_agents/onboard.md +0 -0
  59. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_context.py +0 -0
  60. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/.gitkeep +0 -0
  61. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  62. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  63. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  64. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  65. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  66. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  67. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  68. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  69. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  70. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cache.py +0 -0
  71. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cancellation.py +0 -0
  72. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/__init__.py +0 -0
  73. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/agents.py +0 -0
  74. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/attachments.py +0 -0
  75. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/cache.py +0 -0
  76. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/chat.py +0 -0
  77. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/config.py +0 -0
  78. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/daemon.py +0 -0
  79. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/helpers.py +0 -0
  80. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/history.py +0 -0
  81. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/init.py +0 -0
  82. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/models.py +0 -0
  83. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/plugins.py +0 -0
  84. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/render.py +0 -0
  85. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/secrets.py +0 -0
  86. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/skills.py +0 -0
  87. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/tools.py +0 -0
  88. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/validate.py +0 -0
  89. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/cli/workspace.py +0 -0
  90. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/console.py +0 -0
  91. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/constants.py +0 -0
  92. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/context.py +0 -0
  93. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/context_block.py +0 -0
  94. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/__init__.py +0 -0
  95. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/content_blocks.py +0 -0
  96. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/executor.py +0 -0
  97. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/executor_registry.py +0 -0
  98. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/memory.py +0 -0
  99. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/record_store.py +0 -0
  100. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/state.py +0 -0
  101. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/core/tools.py +0 -0
  102. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/daemon_client.py +0 -0
  103. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/events/__init__.py +0 -0
  104. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/events/base.py +0 -0
  105. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/events/bus.py +0 -0
  106. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/events/events.py +0 -0
  107. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/events/helpers.py +0 -0
  108. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/exceptions.py +0 -0
  109. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/__init__.py +0 -0
  110. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/base.py +0 -0
  111. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/models.py +0 -0
  112. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/reconstruction.py +0 -0
  113. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/registry.py +0 -0
  114. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/sqlite_backend.py +0 -0
  115. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/sqlite_conn.py +0 -0
  116. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/sqlite_schema.py +0 -0
  117. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/history/ui_events.py +0 -0
  118. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/hooks.py +0 -0
  119. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/interaction.py +0 -0
  120. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/options.py +0 -0
  121. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/permissions.py +0 -0
  122. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/plugins.py +0 -0
  123. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/prompt_xml.py +0 -0
  124. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/__init__.py +0 -0
  125. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/anthropic.py +0 -0
  126. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/base.py +0 -0
  127. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/model_cache.py +0 -0
  128. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/model_registry.py +0 -0
  129. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/ollama.py +0 -0
  130. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/openai_compat.py +0 -0
  131. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/providers/openrouter.py +0 -0
  132. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/py.typed +0 -0
  133. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/renderer.py +0 -0
  134. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/schemas/__init__.py +0 -0
  135. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/__init__.py +0 -0
  136. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/backend.py +0 -0
  137. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/env.py +0 -0
  138. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/exec.py +0 -0
  139. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/file.py +0 -0
  140. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/masking.py +0 -0
  141. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/redaction.py +0 -0
  142. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/secrets/registry.py +0 -0
  143. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/shell_tool_config.py +0 -0
  144. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/skill_discovery.py +0 -0
  145. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/templates/AGENTS.md +0 -0
  146. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/templates/IDENTITY.md +0 -0
  147. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/templates/MEMORY.md +0 -0
  148. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/templates/USER.md +0 -0
  149. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/templates/personas/casual-technical.md +0 -0
  150. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/templates/personas/marvin.md +0 -0
  151. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/templates/personas/minimal.md +0 -0
  152. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/__init__.py +0 -0
  153. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/artifacts.py +0 -0
  154. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/daemon_control.py +0 -0
  155. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/fs.py +0 -0
  156. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/history.py +0 -0
  157. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/interactive.py +0 -0
  158. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/notify.py +0 -0
  159. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/schedule.py +0 -0
  160. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/secrets.py +0 -0
  161. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/shell.py +0 -0
  162. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/shell_tools.py +0 -0
  163. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/skills.py +0 -0
  164. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tools/time.py +0 -0
  165. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/tsugite.py +0 -0
  166. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/__init__.py +0 -0
  167. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/base.py +0 -0
  168. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/chat.py +0 -0
  169. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/dispatch.py +0 -0
  170. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/helpers.py +0 -0
  171. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/jsonl.py +0 -0
  172. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/plain.py +0 -0
  173. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/repl_chat.py +0 -0
  174. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/repl_commands.py +0 -0
  175. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/repl_completer.py +0 -0
  176. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui/repl_handler.py +0 -0
  177. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui_context.py +0 -0
  178. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/ui_surfaces.py +0 -0
  179. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/usage/quota.py +0 -0
  180. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/user_agent.py +0 -0
  181. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/utils.py +0 -0
  182. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/workspace/__init__.py +0 -0
  183. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/workspace/models.py +0 -0
  184. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/workspace/session.py +0 -0
  185. {tsugite_cli-0.25.3 → tsugite_cli-0.26.0}/tsugite/workspace/templates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.25.3
3
+ Version: 0.26.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
@@ -339,7 +339,7 @@ pip install tsugite-cli # or plain pip
339
339
  | --- | --- |
340
340
  | `tsugite-cli[daemon]` | Daemon: HTTP API, web UI, scheduler, jobs, terminals (pty), sandbox, Discord |
341
341
  | `tsugite-cli[web]` | `web_search` + YouTube transcripts |
342
- | `tsugite-cli[sandbox]` | bwrap sandbox backend (also needs the `bwrap` binary) |
342
+ | `tsugite-cli[sandbox]` | [Hozo](https://github.com/justyns/hozo) sandbox backend (needs `bwrap` on Linux; macOS uses the built-in `sandbox-exec`) |
343
343
  | `tsugite-cli[all]` | Everything above plus the optional tmux + provider plugins |
344
344
 
345
345
  ```bash
@@ -384,7 +384,7 @@ in nested tmux / non-Rich-friendly shells. Pick a richer or quieter mode when yo
384
384
  - **Sub-agents** that can spawn other agents for specific subtasks
385
385
  - **Skills** directory-based knowledge modules (mostly) following the [agentskills.io](https://agentskills.io/) SKILL.md format
386
386
  - **Hooks** that fire shell commands on lifecycle events (post-tool, pre-message, pre/post-compact)
387
- - **Sandbox** (linux only) via bubblewrap with filesystem and network isolation
387
+ - **Sandbox** (Linux and macOS) via [Hozo](https://github.com/justyns/hozo) with filesystem and network isolation
388
388
 
389
389
  ## Agents in more detail
390
390
 
@@ -417,7 +417,7 @@ For a complete example, check the built-in [default agent](tsugite/builtin_agent
417
417
 
418
418
  ## Sandbox
419
419
 
420
- On Linux only (for now), agent code runs inside a [bubblewrap](https://github.com/containers/bubblewrap) sandbox when you pass `--sandbox`:
420
+ Agent code runs inside a [Hozo](https://github.com/justyns/hozo) sandbox (bubblewrap on Linux, `sandbox-exec` on macOS) when you pass `--sandbox`:
421
421
 
422
422
  ```bash
423
423
  tsu run +default "task" --sandbox --allow-domain "github.com"
@@ -55,7 +55,7 @@ pip install tsugite-cli # or plain pip
55
55
  | --- | --- |
56
56
  | `tsugite-cli[daemon]` | Daemon: HTTP API, web UI, scheduler, jobs, terminals (pty), sandbox, Discord |
57
57
  | `tsugite-cli[web]` | `web_search` + YouTube transcripts |
58
- | `tsugite-cli[sandbox]` | bwrap sandbox backend (also needs the `bwrap` binary) |
58
+ | `tsugite-cli[sandbox]` | [Hozo](https://github.com/justyns/hozo) sandbox backend (needs `bwrap` on Linux; macOS uses the built-in `sandbox-exec`) |
59
59
  | `tsugite-cli[all]` | Everything above plus the optional tmux + provider plugins |
60
60
 
61
61
  ```bash
@@ -100,7 +100,7 @@ in nested tmux / non-Rich-friendly shells. Pick a richer or quieter mode when yo
100
100
  - **Sub-agents** that can spawn other agents for specific subtasks
101
101
  - **Skills** directory-based knowledge modules (mostly) following the [agentskills.io](https://agentskills.io/) SKILL.md format
102
102
  - **Hooks** that fire shell commands on lifecycle events (post-tool, pre-message, pre/post-compact)
103
- - **Sandbox** (linux only) via bubblewrap with filesystem and network isolation
103
+ - **Sandbox** (Linux and macOS) via [Hozo](https://github.com/justyns/hozo) with filesystem and network isolation
104
104
 
105
105
  ## Agents in more detail
106
106
 
@@ -133,7 +133,7 @@ For a complete example, check the built-in [default agent](tsugite/builtin_agent
133
133
 
134
134
  ## Sandbox
135
135
 
136
- On Linux only (for now), agent code runs inside a [bubblewrap](https://github.com/containers/bubblewrap) sandbox when you pass `--sandbox`:
136
+ Agent code runs inside a [Hozo](https://github.com/justyns/hozo) sandbox (bubblewrap on Linux, `sandbox-exec` on macOS) when you pass `--sandbox`:
137
137
 
138
138
  ```bash
139
139
  tsu run +default "task" --sandbox --allow-domain "github.com"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tsugite-cli"
3
- version = "0.25.3"
3
+ version = "0.26.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"
@@ -238,7 +238,7 @@ ignore_names = [
238
238
  "get_completions", # prompt_toolkit Completer API, dispatched by the session
239
239
  "complete_event", # mandatory prompt_toolkit get_completions() parameter
240
240
  "language", # CodeExecutionEvent field, read by the web UI via serialized events
241
- "BubblewrapSandbox", # plugin entry point (tsugite.sandbox: bwrap), loaded via get_sandbox_class
241
+ "HozoSandbox", # plugin entry point (tsugite.sandbox: hozo), loaded via get_sandbox_class
242
242
  # Public API / test-infra reached only from tests (vulture excludes tests).
243
243
  "subscribe_filtered", # imperative EventBus filtered-subscription API (docs/plugin-events.md)
244
244
  "unsubscribe_filtered",
@@ -41,8 +41,6 @@ def _success_note(directive: ExecDirective) -> str:
41
41
  def execute_exec_directives(
42
42
  content: str,
43
43
  existing_context: Optional[Dict[str, Any]] = None,
44
- sandbox_config: Optional[Any] = None,
45
- workspace_dir: Optional[str] = None,
46
44
  event_bus: Optional[Any] = None,
47
45
  ) -> Tuple[str, Dict[str, Any]]:
48
46
  """Execute exec directives in `content` and return (modified_content, exec_context).
@@ -52,8 +50,6 @@ def execute_exec_directives(
52
50
  existing_context: Vars from prior pipeline stages (prefetch, tool directives,
53
51
  caller). Made available to each exec block as Python locals. Vars assigned
54
52
  by an earlier exec block in the same content are also visible to later ones.
55
- sandbox_config: Optional SandboxConfig; when set, blocks run inside bubblewrap.
56
- workspace_dir: Optional workspace path for sandbox bind mounts.
57
53
  event_bus: Optional EventBus for emitting warnings on continue_on_error paths.
58
54
 
59
55
  Returns:
@@ -89,8 +85,6 @@ def execute_exec_directives(
89
85
  locals_dict=block_locals,
90
86
  timeout=directive.timeout,
91
87
  continue_on_error=directive.continue_on_error,
92
- sandbox_config=sandbox_config,
93
- workspace_dir=workspace_dir,
94
88
  )
95
89
  except Exception as e:
96
90
  if not directive.continue_on_error:
@@ -4,15 +4,10 @@ This is a deliberate parallel path to `tsugite.core.subprocess_executor.Subproce
4
4
  Both run user Python in a subprocess, but they differ:
5
5
 
6
6
  - `SubprocessExecutor` is async, turn-stateful, blocks `open()`, forces tool usage,
7
- and manages a network proxy when sandboxed. It's designed for the LLM agent loop
8
- where each turn is a long-running session.
7
+ and runs inside the sandbox backend when sandboxed. It's designed for the LLM agent
8
+ loop where each turn is a long-running session.
9
9
  - `run_python_block` (this module) is synchronous, one-shot, allows plain Python
10
10
  (`open`, `os.listdir`, etc.), and is invoked at agent-prep time before the LLM runs.
11
-
12
- Sandbox plumbing is deferred: passing `sandbox_config` raises `NotImplementedError`.
13
- The async path's bubblewrap+proxy lifecycle does not translate cleanly to the
14
- synchronous one-shot model; a future shared `build_python_subprocess_command` helper
15
- would converge both paths. Any sandbox-related change must update both call sites.
16
11
  """
17
12
 
18
13
  import json
@@ -126,8 +121,6 @@ def run_python_block(
126
121
  locals_dict: Dict[str, Any],
127
122
  timeout: int = 30,
128
123
  continue_on_error: bool = False,
129
- sandbox_config: Optional[Any] = None,
130
- workspace_dir: Optional[str] = None,
131
124
  ) -> ExecBlockResult:
132
125
  """Run a Python block synchronously in a one-shot subprocess.
133
126
 
@@ -138,8 +131,6 @@ def run_python_block(
138
131
  timeout: Wall-clock timeout in seconds.
139
132
  continue_on_error: If True, exceptions are captured to result.error and
140
133
  return_value is None instead of re-raising.
141
- sandbox_config: Optional SandboxConfig; when set, blocks run in bwrap.
142
- workspace_dir: Optional workspace path for sandbox bind mounts.
143
134
 
144
135
  Raises:
145
136
  TimeoutError: On timeout.
@@ -158,7 +149,7 @@ def run_python_block(
158
149
  with open(payload_path, "w") as f:
159
150
  json.dump({"locals": safe_locals, "code": code}, f)
160
151
 
161
- cmd = _build_command(harness_path, payload_path, result_path, sandbox_config, workspace_dir)
152
+ cmd = [sys.executable, "-u", harness_path, payload_path, result_path]
162
153
 
163
154
  try:
164
155
  proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
@@ -205,19 +196,3 @@ def _filter_json_safe(d: Dict[str, Any]) -> Dict[str, Any]:
205
196
  except (TypeError, ValueError, OverflowError):
206
197
  continue
207
198
  return safe
208
-
209
-
210
- def _build_command(
211
- harness_path: str,
212
- payload_path: str,
213
- result_path: str,
214
- sandbox_config: Optional[Any],
215
- workspace_dir: Optional[str],
216
- ) -> list[str]:
217
- if sandbox_config is None:
218
- return [sys.executable, "-u", harness_path, payload_path, result_path]
219
- raise NotImplementedError(
220
- "sandbox_config support for tsu:exec is not implemented yet - the existing async "
221
- "SubprocessExecutor path manages a network proxy that doesn't translate cleanly to "
222
- "this synchronous one-shot path. Track in a follow-up."
223
- )
@@ -1,6 +1,7 @@
1
1
  """Shared helper functions for agent execution."""
2
2
 
3
3
  import contextvars
4
+ import uuid
4
5
  from dataclasses import dataclass, field
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING, Any, List, Optional
@@ -105,7 +106,7 @@ def build_sandbox_policy(
105
106
  `tsu exec` so the two never drift. Agent frontmatter (network/sandbox) can only
106
107
  tighten the CLI/daemon ceiling, never loosen it.
107
108
 
108
- Raises RuntimeError if the sandbox is requested but bwrap is unavailable.
109
+ Raises RuntimeError if the sandbox is requested but no backend is available.
109
110
  """
110
111
  from tsugite.agent_runner.runner import resolve_effective_sandbox
111
112
  from tsugite.core.sandbox import SandboxConfig, sandbox_available
@@ -121,7 +122,9 @@ def build_sandbox_policy(
121
122
  return None, None
122
123
 
123
124
  if not sandbox_available():
124
- raise RuntimeError("No sandbox backend available. Install tsugite-sandbox and bubblewrap, or use --no-sandbox.")
125
+ raise RuntimeError(
126
+ "No sandbox backend available. Install tsugite-sandbox (and bubblewrap on Linux), or use --no-sandbox."
127
+ )
125
128
 
126
129
  ctx = SandboxContext(
127
130
  allow_domains=allow_domains,
@@ -197,6 +200,30 @@ def get_allowed_secrets() -> Optional[List[str]]:
197
200
  return _allowed_secrets_var.get()
198
201
 
199
202
 
203
+ _turn_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("turn_id", default=None)
204
+
205
+
206
+ def start_turn() -> contextvars.Token:
207
+ """Mint a fresh turn id for the history events recorded in this context. Returns a reset token."""
208
+ return _turn_id_var.set(uuid.uuid4().hex)
209
+
210
+
211
+ def reset_turn(token: contextvars.Token) -> None:
212
+ """Restore whatever turn id was current before the matching start_turn."""
213
+ _turn_id_var.reset(token)
214
+
215
+
216
+ def get_turn_id() -> Optional[str]:
217
+ """Return the current turn's id, or None outside a turn."""
218
+ return _turn_id_var.get()
219
+
220
+
221
+ def turn_fields() -> dict:
222
+ """The turn_id field for an event recorded now, or nothing outside a turn."""
223
+ turn_id = _turn_id_var.get()
224
+ return {"turn_id": turn_id} if turn_id else {}
225
+
226
+
200
227
  def clear_current_agent() -> None:
201
228
  """Clear the currently executing agent (tests and top-level teardown)."""
202
229
  _current_agent_var.set(None)
@@ -13,6 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List, Optional
15
15
 
16
+ from tsugite.agent_runner.helpers import get_turn_id, turn_fields
16
17
  from tsugite.attachments.base import Attachment
17
18
  from tsugite.config import load_config
18
19
  from tsugite.history import (
@@ -88,6 +89,14 @@ def open_or_create_session(
88
89
  return backend.create(agent_name=agent_name, model=model, workspace=workspace)
89
90
 
90
91
 
92
+ def _turn_has(storage: Session, event_type: str) -> Optional[bool]:
93
+ """Whether the current turn recorded an event_type; None outside a turn."""
94
+ turn_id = get_turn_id()
95
+ if turn_id is None:
96
+ return None
97
+ return any(e.data.get("turn_id") == turn_id for e in storage.iter_events(types=(event_type,)))
98
+
99
+
91
100
  def _current_turn_already_has_user_input(storage: Session) -> bool:
92
101
  """True if the in-progress turn already recorded a user_input.
93
102
 
@@ -98,7 +107,13 @@ def _current_turn_already_has_user_input(storage: Session) -> bool:
98
107
  A genuinely repeated message opens a new turn *after* the prior session_end,
99
108
  so this stays False for it. A final_result also ends a turn copied without
100
109
  its session_end (branches, older compacted sessions).
110
+
111
+ That event-order rule covers callers with no turn id. With one set, only a
112
+ user_input carrying it counts.
101
113
  """
114
+ has = _turn_has(storage, "user_input")
115
+ if has is not None:
116
+ return has
102
117
  events = list(storage.iter_events(types=("user_input", "session_end", "final_result")))
103
118
  return bool(events) and events[-1].type == "user_input"
104
119
 
@@ -122,7 +137,7 @@ def record_user_input(
122
137
  """
123
138
  if _current_turn_already_has_user_input(storage):
124
139
  return
125
- data: Dict[str, Any] = {"text": text}
140
+ data: Dict[str, Any] = {"text": text, **turn_fields()}
126
141
  # Only files the user actually uploaded ride here: this field is display-only
127
142
  # (the web UI renders them as clickable `uploads/<name>` chips). The agent's
128
143
  # auto-included context (workspace memory files like USER.md, config
@@ -158,11 +173,15 @@ def record_final_result(
158
173
  the agent reaches its end-of-run recording). Skip in that case so the
159
174
  conversation view doesn't render the answer twice.
160
175
  """
161
- for event in reversed(list(storage.iter_events(types=("user_input", "final_result")))):
162
- if event.type == "user_input":
163
- break
164
- if event.type == "final_result":
165
- return
176
+ has = _turn_has(storage, "final_result")
177
+ if has:
178
+ return
179
+ if has is None:
180
+ for event in reversed(list(storage.iter_events(types=("user_input", "final_result")))):
181
+ if event.type == "user_input":
182
+ break
183
+ if event.type == "final_result":
184
+ return
166
185
  storage.record(
167
186
  "final_result",
168
187
  result=result,
@@ -170,6 +189,7 @@ def record_final_result(
170
189
  turns=turns,
171
190
  tokens=tokens,
172
191
  cost=cost,
192
+ **turn_fields(),
173
193
  )
174
194
 
175
195
 
@@ -189,7 +209,7 @@ def record_session_end(
189
209
  liveness. The two stores tracking different things (last-turn outcome vs
190
210
  conversation open/closed) is expected, not a desync.
191
211
  """
192
- storage.record("session_end", status=status, error_message=error_message)
212
+ storage.record("session_end", status=status, error_message=error_message, **turn_fields())
193
213
 
194
214
 
195
215
  def save_run_to_history(
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional # noqa: E402
12
12
 
13
13
  from tsugite.core.agent import NO_ACTION_MESSAGE, AgentResult, TsugiteAgent # noqa: E402
14
14
  from tsugite.core.executor_registry import get_executor_class # noqa: E402
15
- from tsugite.core.proxy import _parse_pattern # noqa: E402
16
15
  from tsugite.core.state import session_state_path # noqa: E402
17
16
  from tsugite.exceptions import ( # noqa: E402
18
17
  AgentExecutionError,
@@ -29,13 +28,16 @@ from .helpers import ( # noqa: E402
29
28
  _stderr_console,
30
29
  build_sandbox_policy,
31
30
  get_display_console,
31
+ get_turn_id,
32
32
  get_ui_handler,
33
33
  reset_allowed_secrets,
34
34
  reset_current_agent,
35
35
  reset_sandbox_context,
36
+ reset_turn,
36
37
  set_allowed_secrets,
37
38
  set_current_agent,
38
39
  set_sandbox_context,
40
+ start_turn,
39
41
  )
40
42
  from .models import AgentExecutionResult, AgentSkippedError # noqa: E402
41
43
 
@@ -497,7 +499,7 @@ async def _execute_agent_with_prompt(
497
499
  )
498
500
 
499
501
  if sandbox_config is not None:
500
- # bwrap sandboxing is a subprocess feature, so a sandboxed run always uses
502
+ # Sandboxing is a subprocess feature, so a sandboxed run always uses
501
503
  # the subprocess executor regardless of the configured default backend.
502
504
  from tsugite.core.subprocess_executor import SubprocessExecutor
503
505
 
@@ -609,13 +611,14 @@ async def _execute_agent_with_prompt(
609
611
  )
610
612
 
611
613
  try:
614
+ from tsugite.models import resolve_usage_model
612
615
  from tsugite.usage import get_usage_store
613
616
 
614
617
  meta = channel_metadata or {}
615
618
  get_usage_store().record(
616
619
  session_id=continue_conversation_id,
617
620
  agent=prepared.agent_config.name,
618
- model=model_string,
621
+ model=resolve_usage_model(model_string),
619
622
  source=meta.get("source") or "cli",
620
623
  schedule_name=meta.get("schedule_id"),
621
624
  input_tokens=agent.prompt_tokens,
@@ -750,8 +753,8 @@ def resolve_effective_sandbox(
750
753
  desired = set(fm_network.get("domains") or []) | set(fm_sandbox.get("allow_domains") or [])
751
754
  base = list(daemon_domains or [])
752
755
  if desired:
753
- # Keep each desired pattern only if the daemon ceiling permits it, using the
754
- # proxy's glob semantics (so agent "api.github.com" is kept under daemon
756
+ # Keep each desired pattern only if the daemon ceiling permits it, using
757
+ # glob semantics (so agent "api.github.com" is kept under daemon
755
758
  # "*.github.com"). An empty ceiling permits everything.
756
759
  effective = sorted(d for d in desired if _domain_within_ceiling(d, base))
757
760
  if not effective:
@@ -764,10 +767,25 @@ def resolve_effective_sandbox(
764
767
  return enabled, allow_domains, no_network
765
768
 
766
769
 
770
+ def _parse_pattern(pattern: str) -> tuple[str, set[int]]:
771
+ """No port means {80, 443} and `:*` means any port (empty set). IPv6 takes a port
772
+ only in brackets (`[fd00::/8]:443`), as Hozo's proxy does."""
773
+ if pattern.startswith("["):
774
+ domain, _, port_str = pattern[1:].partition("]")
775
+ port_str = port_str.removeprefix(":")
776
+ elif pattern.count(":") == 1:
777
+ domain, port_str = pattern.split(":")
778
+ else:
779
+ domain, port_str = pattern, ""
780
+ if not port_str:
781
+ return (domain, {80, 443})
782
+ return (domain, set() if port_str == "*" else {int(port_str)})
783
+
784
+
767
785
  def _domain_within_ceiling(desired_pattern: str, ceiling: list) -> bool:
768
786
  """True if a desired domain:port pattern is permitted by the daemon ceiling.
769
787
 
770
- Uses the proxy's glob + port semantics so e.g. 'api.github.com' is within
788
+ Uses glob + port semantics so e.g. 'api.github.com' is within
771
789
  '*.github.com', but 'github.com:22' is NOT within 'github.com' (which only allows
772
790
  the default 80/443). The ceiling is a union: the desired ports must be covered by
773
791
  the union of port sets from ALL ceiling patterns whose domain glob matches (so
@@ -777,9 +795,6 @@ def _domain_within_ceiling(desired_pattern: str, ceiling: list) -> bool:
777
795
  if not ceiling:
778
796
  return True
779
797
  d_domain, d_ports = _parse_pattern(desired_pattern.lower())
780
- # The proxy treats the allowlist as a union, so collect the ports from EVERY
781
- # ceiling pattern whose domain glob matches and check the desired ports against
782
- # their union (e.g. ["github.com:80","github.com:443"] together cover 80/443).
783
798
  matched = False
784
799
  allowed_ports: set = set()
785
800
  for c in ceiling:
@@ -1171,6 +1186,7 @@ async def run_agent_async(
1171
1186
  # inside this one and must restore what it found rather than clobber it.
1172
1187
  agent_token = set_current_agent(agent_config.name)
1173
1188
  secrets_token = set_allowed_secrets(agent_config.allowed_secrets)
1189
+ turn_token = start_turn() if get_turn_id() is None else None
1174
1190
 
1175
1191
  setup = _RunSetup(
1176
1192
  exec_options=exec_options,
@@ -1205,6 +1221,8 @@ async def run_agent_async(
1205
1221
  finally:
1206
1222
  reset_current_agent(agent_token)
1207
1223
  reset_allowed_secrets(secrets_token)
1224
+ if turn_token is not None:
1225
+ reset_turn(turn_token)
1208
1226
 
1209
1227
 
1210
1228
  def preview_multistep_agent(
@@ -17,7 +17,9 @@ from .helpers import (
17
17
  clear_multistep_ui_context,
18
18
  get_ui_handler,
19
19
  print_step_progress,
20
+ reset_turn,
20
21
  set_multistep_ui_context,
22
+ start_turn,
21
23
  )
22
24
  from .metrics import StepMetrics, display_step_metrics
23
25
  from .models import AgentExecutionResult, AgentSkippedError
@@ -29,22 +31,6 @@ if TYPE_CHECKING:
29
31
  logger = logging.getLogger(__name__)
30
32
 
31
33
 
32
- class ExecutionContext:
33
- """Namespace for tsugite-provided execution context.
34
-
35
- Provides access to runtime metadata via attribute access (ctx.user_prompt, ctx.tasks, etc.)
36
- while keeping user-assigned step variables as top-level names in the execution namespace.
37
- """
38
-
39
- def __init__(self, **kwargs):
40
- for k, v in kwargs.items():
41
- setattr(self, k, v)
42
-
43
- def __repr__(self):
44
- attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
45
- return f"ExecutionContext({attrs})"
46
-
47
-
48
34
  def _build_step_error_message(
49
35
  error_type: str,
50
36
  step_name: str,
@@ -92,7 +78,7 @@ def _build_step_error_message(
92
78
  def _build_injectable_vars(step_context: Dict[str, Any], assigned_vars: Optional[set] = None) -> Dict[str, Any]:
93
79
  """Build variables for injection into Python execution namespace.
94
80
 
95
- Creates a `ctx` object containing everything. Only user-assigned step variables
81
+ Creates a `ctx` dict containing everything. Only user-assigned step variables
96
82
  (from `assign="varname"`) are exposed at top-level to avoid namespace pollution.
97
83
 
98
84
  Args:
@@ -101,9 +87,9 @@ def _build_injectable_vars(step_context: Dict[str, Any], assigned_vars: Optional
101
87
  If None, no variables are exposed at top-level.
102
88
 
103
89
  Returns:
104
- Dictionary with 'ctx' ExecutionContext and user-assigned variables at top-level
90
+ Dictionary with 'ctx' dict and user-assigned variables at top-level
105
91
  """
106
- ctx = ExecutionContext(**step_context)
92
+ ctx = dict(step_context)
107
93
 
108
94
  # Only user-assigned step variables at top-level (not tsugite metadata)
109
95
  if assigned_vars:
@@ -576,6 +562,8 @@ async def run_steps(
576
562
  except Exception as e:
577
563
  logger.debug("Could not pre-create session for multi-step run: %s", e)
578
564
 
565
+ # The first step continues the run's turn. Each later step or iteration starts its own.
566
+ turn_token = None
579
567
  try:
580
568
  # Execute each step sequentially
581
569
  final_result = None
@@ -600,6 +588,9 @@ async def run_steps(
600
588
  step_context["iteration"] = iteration
601
589
  step_context["max_iterations"] = step.max_iterations
602
590
  step_context["is_looping_step"] = step_is_looping
591
+ if i > 1 or iteration > 1:
592
+ token = start_turn()
593
+ turn_token = turn_token or token
603
594
 
604
595
  # Show step progress (unless in debug mode which has its own output)
605
596
  if step_is_looping:
@@ -714,4 +705,6 @@ async def run_steps(
714
705
  session_id=setup.conversation_id,
715
706
  )
716
707
  finally:
708
+ if turn_token is not None:
709
+ reset_turn(turn_token)
717
710
  clear_multistep_ui_context(custom_logger)
@@ -179,6 +179,8 @@ spawn_job(
179
179
 
180
180
  **Notification cost**: `notify_when` defaults to `"never"`. Set it to `"stuck"` for a wake-up only when the Job needs user intervention, or `"terminal"` for any final state. Every notification adds a turn to this conversation - prefer polling `get_job` over `notify_when="terminal"` for routine tracking.
181
181
 
182
+ **Long Jobs**: pass `notify_after_minutes=N` for one check-in message with the Job's state and last activity when N minutes pass and it has not finished. It does not stop the Job and does not interact with `timeout_minutes` or `notify_when`. Use it in place of polling `get_job`.
183
+
182
184
  **When a Job goes `stuck`** (max_attempts verifier rejections), the user can retry-with-hint or mark-done-anyway from the UI; you usually don't need to act. If asked to diagnose, `get_job(job_id)` returns the worker session id and each `ac_results[].reason` - read those, suggest a different approach, or open the worker session via `session_status` to inspect what went wrong.
183
185
 
184
186
  **A Job spawned by the user via `/job`** appears in your conversation's job list once it resolves. You don't create the Job in that case - the user did. Your role is to make use of its result when relevant.
@@ -40,7 +40,7 @@ def exec_cmd(
40
40
  workspace: Optional[str] = typer.Option(
41
41
  None, "--workspace", "-w", help="Working directory for the snippet and its tools"
42
42
  ),
43
- sandbox: bool = typer.Option(False, "--sandbox", help="Run the snippet in a bubblewrap sandbox"),
43
+ sandbox: bool = typer.Option(False, "--sandbox", help="Run the snippet in a sandbox"),
44
44
  no_sandbox: bool = typer.Option(False, "--no-sandbox", help="Disable sandbox (overrides config)"),
45
45
  allow_domain: Optional[List[str]] = typer.Option(
46
46
  None, "--allow-domain", help="Domain(s) allowed in sandbox (implies --sandbox)"
@@ -257,7 +257,7 @@ def run(
257
257
  ),
258
258
  no_workspace: bool = typer.Option(False, "--no-workspace", help="Disable workspace (ignore default workspace)"),
259
259
  new_session: bool = typer.Option(False, "--new-session", help="Start fresh session (ignore workspace session)"),
260
- sandbox: bool = typer.Option(False, "--sandbox", help="Run agent code in bubblewrap sandbox"),
260
+ sandbox: bool = typer.Option(False, "--sandbox", help="Run agent code in a sandbox"),
261
261
  no_sandbox: bool = typer.Option(False, "--no-sandbox", help="Disable sandbox (overrides config)"),
262
262
  allow_domain: Optional[List[str]] = typer.Option(
263
263
  None,
@@ -283,6 +283,7 @@ def run(
283
283
  tsu run --continue --conversation-id CONV_ID "prompt" # Continue specific conversation
284
284
  """
285
285
  from tsugite.agent_runner import get_agent_info, run_agent
286
+ from tsugite.agent_runner.helpers import reset_turn, start_turn
286
287
  from tsugite.console import get_stderr_console
287
288
  from tsugite.md_agents import validate_agent_execution
288
289
  from tsugite.secrets import init_cli as init_secrets
@@ -597,6 +598,8 @@ def run(
597
598
  except Exception:
598
599
  pass
599
600
 
601
+ # One turn id for the run and the save_run_to_history after it.
602
+ turn_token = start_turn()
600
603
  try:
601
604
  executor_kwargs = _build_executor_kwargs(
602
605
  agent_file,
@@ -653,3 +656,5 @@ def run(
653
656
  if not ui_opts.log_json:
654
657
  stderr_console.print("\n[dim]Use --log-json for machine-readable output[/dim]")
655
658
  raise typer.Exit(1)
659
+ finally:
660
+ reset_turn(turn_token)