tsugite-cli 0.21.2__tar.gz → 0.22.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 (183) hide show
  1. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/AGENTS.md +14 -2
  2. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/CONTRIBUTING.md +20 -1
  3. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/PKG-INFO +1 -1
  4. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/pyproject.toml +1 -1
  5. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/metrics.py +11 -13
  6. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/runner.py +6 -8
  7. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/default.md +14 -0
  8. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/usage.py +36 -0
  9. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/agent.py +2 -1
  10. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/subprocess_executor.py +5 -1
  11. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/reconstruction.py +6 -2
  12. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/plugins.py +2 -0
  13. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/__init__.py +2 -0
  14. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/agents.py +8 -0
  15. tsugite_cli-0.22.0/tsugite/tools/artifacts.py +187 -0
  16. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/fs.py +6 -5
  17. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/jobs.py +13 -1
  18. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/sessions.py +15 -6
  19. tsugite_cli-0.22.0/tsugite/usage/__init__.py +18 -0
  20. tsugite_cli-0.22.0/tsugite/usage/quota.py +108 -0
  21. tsugite_cli-0.21.2/tsugite/usage/__init__.py +0 -5
  22. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/.gitignore +0 -0
  23. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/LICENSE +0 -0
  24. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/README.md +0 -0
  25. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/__init__.py +0 -0
  26. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_inheritance.py +0 -0
  27. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_preparation.py +0 -0
  28. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/__init__.py +0 -0
  29. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/exec_directives.py +0 -0
  30. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/exec_runner.py +0 -0
  31. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/helpers.py +0 -0
  32. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/history_integration.py +0 -0
  33. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/models.py +0 -0
  34. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/steps.py +0 -0
  35. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/validation.py +0 -0
  36. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_utils.py +0 -0
  37. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/approval.py +0 -0
  38. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/__init__.py +0 -0
  39. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/agent_config.py +0 -0
  40. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/auto_context.py +0 -0
  41. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/base.py +0 -0
  42. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/delegation.py +0 -0
  43. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/file.py +0 -0
  44. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/inline.py +0 -0
  45. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/storage.py +0 -0
  46. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/url.py +0 -0
  47. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/.gitkeep +0 -0
  48. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/code_searcher.md +0 -0
  49. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/file_searcher.md +0 -0
  50. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/job_verifier.md +0 -0
  51. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/job_worker.md +0 -0
  52. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/onboard.md +0 -0
  53. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_context.py +0 -0
  54. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/.gitkeep +0 -0
  55. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  56. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  57. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  58. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  59. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  60. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  61. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  62. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  63. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  64. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cache.py +0 -0
  65. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cancellation.py +0 -0
  66. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/__init__.py +0 -0
  67. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/agents.py +0 -0
  68. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/attachments.py +0 -0
  69. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/cache.py +0 -0
  70. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/chat.py +0 -0
  71. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/config.py +0 -0
  72. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/daemon.py +0 -0
  73. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/exec.py +0 -0
  74. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/helpers.py +0 -0
  75. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/history.py +0 -0
  76. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/init.py +0 -0
  77. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/models.py +0 -0
  78. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/plugins.py +0 -0
  79. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/render.py +0 -0
  80. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/run.py +0 -0
  81. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/secrets.py +0 -0
  82. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/skills.py +0 -0
  83. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/tools.py +0 -0
  84. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/validate.py +0 -0
  85. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/workspace.py +0 -0
  86. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/config.py +0 -0
  87. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/console.py +0 -0
  88. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/constants.py +0 -0
  89. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/context.py +0 -0
  90. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/context_block.py +0 -0
  91. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/__init__.py +0 -0
  92. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/content_blocks.py +0 -0
  93. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/executor.py +0 -0
  94. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/executor_registry.py +0 -0
  95. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/memory.py +0 -0
  96. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/proxy.py +0 -0
  97. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/record_store.py +0 -0
  98. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/sandbox.py +0 -0
  99. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/state.py +0 -0
  100. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/tools.py +0 -0
  101. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/__init__.py +0 -0
  102. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/base.py +0 -0
  103. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/bus.py +0 -0
  104. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/events.py +0 -0
  105. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/helpers.py +0 -0
  106. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/exceptions.py +0 -0
  107. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/__init__.py +0 -0
  108. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/base.py +0 -0
  109. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/models.py +0 -0
  110. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/registry.py +0 -0
  111. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/sqlite_backend.py +0 -0
  112. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/sqlite_conn.py +0 -0
  113. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/sqlite_schema.py +0 -0
  114. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/ui_events.py +0 -0
  115. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/hooks.py +0 -0
  116. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/interaction.py +0 -0
  117. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/md_agents.py +0 -0
  118. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/models.py +0 -0
  119. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/options.py +0 -0
  120. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/permissions.py +0 -0
  121. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/prompt_xml.py +0 -0
  122. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/__init__.py +0 -0
  123. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/anthropic.py +0 -0
  124. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/base.py +0 -0
  125. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/model_cache.py +0 -0
  126. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/model_registry.py +0 -0
  127. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/ollama.py +0 -0
  128. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/openai_compat.py +0 -0
  129. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/openrouter.py +0 -0
  130. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/py.typed +0 -0
  131. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/renderer.py +0 -0
  132. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/schemas/__init__.py +0 -0
  133. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/schemas/agent.schema.json +0 -0
  134. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/__init__.py +0 -0
  135. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/backend.py +0 -0
  136. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/env.py +0 -0
  137. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/exec.py +0 -0
  138. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/file.py +0 -0
  139. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/masking.py +0 -0
  140. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/redaction.py +0 -0
  141. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/registry.py +0 -0
  142. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/sqlite.py +0 -0
  143. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/shell_tool_config.py +0 -0
  144. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/skill_discovery.py +0 -0
  145. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/AGENTS.md +0 -0
  146. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/IDENTITY.md +0 -0
  147. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/MEMORY.md +0 -0
  148. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/USER.md +0 -0
  149. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/personas/casual-technical.md +0 -0
  150. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/personas/marvin.md +0 -0
  151. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/personas/minimal.md +0 -0
  152. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/daemon_control.py +0 -0
  153. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/history.py +0 -0
  154. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/http.py +0 -0
  155. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/interactive.py +0 -0
  156. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/notify.py +0 -0
  157. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/schedule.py +0 -0
  158. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/secrets.py +0 -0
  159. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/shell.py +0 -0
  160. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/shell_tools.py +0 -0
  161. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/skills.py +0 -0
  162. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/time.py +0 -0
  163. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tsugite.py +0 -0
  164. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/__init__.py +0 -0
  165. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/base.py +0 -0
  166. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/chat.py +0 -0
  167. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/dispatch.py +0 -0
  168. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/helpers.py +0 -0
  169. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/jsonl.py +0 -0
  170. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/plain.py +0 -0
  171. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_chat.py +0 -0
  172. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_commands.py +0 -0
  173. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_completer.py +0 -0
  174. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_handler.py +0 -0
  175. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui_context.py +0 -0
  176. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui_surfaces.py +0 -0
  177. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/usage/store.py +0 -0
  178. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/user_agent.py +0 -0
  179. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/utils.py +0 -0
  180. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/workspace/__init__.py +0 -0
  181. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/workspace/models.py +0 -0
  182. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/workspace/session.py +0 -0
  183. {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/workspace/templates.py +0 -0
@@ -129,7 +129,7 @@ Tsugite is an agentic CLI that executes AI agents defined as markdown files with
129
129
  - Code execution via `LocalExecutor`
130
130
 
131
131
  8. **Tool System** (`tsugite/tools/`)
132
- - Tool registry with built-in tools (fs, http, shell, agents, skills, history, interactive) plus optional categories (notify, schedule, sessions, secrets, tmux — gated by `_OPTIONAL_CATEGORIES` in `tools/__init__.py`)
132
+ - Tool registry with built-in tools (fs, http, shell, agents, skills, history, interactive) plus optional categories (notify, schedule, sessions, secrets, tmux, artifacts - gated by `_OPTIONAL_CATEGORIES` in `tools/__init__.py`)
133
133
  - Custom shell tools (config-based command wrappers in `tools/shell_tools.py`)
134
134
  - Tool expansion supports globs (`*_search`), categories (`@fs`), and exclusions (`-delete_file`)
135
135
 
@@ -506,6 +506,18 @@ Source lives at `plugins/tsugite-daemon/frontend/` (**Svelte 5 runes + TypeScrip
506
506
  - `mise run web-build` - production build into `tsugite_daemon/web/` (gitignored; never committed).
507
507
  - `mise run web-check` - svelte-check + vitest unit project + prettier check. It does not run component tests (`*.svelte.test.ts`); those need `npm --prefix plugins/tsugite-daemon/frontend run test:browser`.
508
508
 
509
+ ### Rendered HTML + agent artifact panes
510
+
511
+ - A workspace `.html`/`.htm` file gets a rendered/raw/edit toggle in the file
512
+ surface. The isolation policy for that frame (`sandbox=""` plus an injected
513
+ `default-src 'none'` CSP, and the same-workspace asset inlining) lives in
514
+ `lib/media/htmlPreview.ts`; the frame itself is `lib/components/media/HtmlPreview.svelte`.
515
+ - The `open_artifact` agent tool (daemon-only, `tsugite/tools/artifacts.py`,
516
+ category `@artifacts`) broadcasts one `session_event` frame with
517
+ `event_type: "artifact_open"`. The shell records it in
518
+ `lib/stores/artifacts.svelte.ts`, keyed by the daemon's `artifact_id` slot, and
519
+ docks it with the `openBeside` layout reducer. Paths are validated daemon-side.
520
+
509
521
  ### Theme tokens
510
522
 
511
523
  - Design tokens ride `[data-theme]`: surfaces `--bg0..--bg4` (app shell → panel → surface → raised → hover), text `--tx0..--tx3` (strong → faint), lines `--bd0..--bd2`, accent `--acc`/`--on-acc`/`--brand`, semantic states `--st-ok/-verify/-warn/-err/-info/-queue/-mute`, radii `--r-sm/-md/-lg/-full`, spacing `--sp-0..6`, motion `--t-1..3` + `--ease`. Five themes: mocha (default), macchiato, frappe, latte, gruvbox.
@@ -515,7 +527,7 @@ Source lives at `plugins/tsugite-daemon/frontend/` (**Svelte 5 runes + TypeScrip
515
527
 
516
528
  - e2e selectors use `data-testid` values from `frontend/src/lib/testids.ts` (a frozen contract - extend, don't rename). App readiness marker: `[data-testid="app-ready"]`. There are no window-global test hooks; drive the real DOM and seed over HTTP.
517
529
  - Frontend behavioral logic (stores, SSE replay, parsers/reducers) is vitest-tested (`frontend/src/**/*.test.ts`), failing-test-first.
518
- - Browser verification on UI changes is mandatory. Daemon spinup + Playwright wiring recipe: project memory `project_daemon_ui_smoketest.md` (its DOM selector list predates the Svelte rebuild - trust the testids contract instead).
530
+ - Browser verification on UI changes is mandatory. The reproducible harness is `tests/e2e/` (`mise run e2e`): a real uvicorn daemon on a free port plus a real chromium, seeded through the daemon fixtures. `tests/e2e/conftest.py` owns the spinup; `tests/e2e/helpers.py` owns readiness + nav. Ad-hoc alternative: project memory `project_daemon_ui_smoketest.md` (its DOM selector list predates the Svelte rebuild - trust the testids contract instead).
519
531
 
520
532
  ## Code Review Policy
521
533
 
@@ -10,7 +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. Body is optional and rarely needed.
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).
14
15
 
15
16
  ### Allowed types
16
17
 
@@ -43,6 +44,24 @@ Scopes are optional, but should be limited to one of these:
43
44
 
44
45
  `webui`, `daemon`, `agent`, `cli`, `skills`, `history`, `sandbox`
45
46
 
47
+ ### Release notes
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:
51
+
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.
64
+
46
65
  ### Examples
47
66
 
48
67
  ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.21.2
3
+ Version: 0.22.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.21.2"
3
+ version = "0.22.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"
@@ -77,21 +77,19 @@ def display_step_metrics(metrics: List[StepMetrics], custom_logger: Optional[Any
77
77
  # Emit through event system
78
78
  ui_handler = get_ui_handler(custom_logger)
79
79
  if ui_handler:
80
- from io import StringIO
81
-
82
80
  from tsugite.events import EventBus, InfoEvent
83
81
 
84
82
  event_bus = EventBus()
85
83
  event_bus.subscribe(ui_handler.handle_event)
86
84
 
87
- # Render table to string
88
- buffer = StringIO()
89
- temp_console = get_display_console(custom_logger)
90
- temp_console.file = buffer
91
- temp_console.print() # noqa: T201 - Rendering to buffer
92
- temp_console.print(table) # noqa: T201 - Rendering to buffer
93
- temp_console.print() # noqa: T201 - Rendering to buffer
94
- temp_console.print(summary) # noqa: T201 - Rendering to buffer
95
- temp_console.print() # noqa: T201 - Rendering to buffer
96
-
97
- event_bus.emit(InfoEvent(message=buffer.getvalue()))
85
+ # `capture` borrows the live console for its width, then hands it back;
86
+ # assigning `.file` would leave the UI printing into a dead buffer.
87
+ console = get_display_console(custom_logger)
88
+ with console.capture() as capture:
89
+ console.print()
90
+ console.print(table)
91
+ console.print()
92
+ console.print(summary)
93
+ console.print()
94
+
95
+ event_bus.emit(InfoEvent(message=capture.get()))
@@ -1354,14 +1354,12 @@ def preview_multistep_agent(
1354
1354
 
1355
1355
  # Output table (via console fallback since tables need special rendering)
1356
1356
  if event_bus:
1357
- # For events, render table to string
1358
- from io import StringIO
1359
-
1360
- buffer = StringIO()
1361
- temp_console = get_display_console(custom_logger)
1362
- temp_console.file = buffer
1363
- temp_console.print(table) # noqa: T201 - Rendering to buffer, not user console
1364
- event_bus.emit(InfoEvent(message=buffer.getvalue()))
1357
+ # `capture` borrows the live console for its width, then hands it back;
1358
+ # assigning `.file` would leave the UI printing into a dead buffer.
1359
+ preview_console = get_display_console(custom_logger)
1360
+ with preview_console.capture() as capture:
1361
+ preview_console.print(table)
1362
+ event_bus.emit(InfoEvent(message=capture.get()))
1365
1363
  elif console:
1366
1364
  console.print(table) # noqa: T201 - Intentional fallback when no event system available
1367
1365
 
@@ -29,6 +29,7 @@ tools:
29
29
  - "@schedule"
30
30
  - "@sessions"
31
31
  - "@jobs"
32
+ - "@artifacts"
32
33
  - "@terminal"
33
34
  - "@tmux"
34
35
  auto_load_skills:
@@ -315,6 +316,19 @@ These skills had errors or warnings during discovery/load. Errors mean the skill
315
316
  - Use `web_search(query="...", max_results=5)` to get search results (returns title, url, snippet)
316
317
  - Format results nicely for the user. Use `fetch_text(url="...")` for full page content when snippets aren't enough.
317
318
 
319
+ {% endif %}
320
+ {% if "open_artifact" in (available_tools | default([])) %}
321
+ ## Showing the user a document
322
+
323
+ `open_artifact(path=..., title=...)` opens a workspace file in a pane beside this chat, so the user can read it while the conversation continues. Use it when the thing you want to show is longer than a reply should be: a report you generated, a file you are about to change, a diff, a coverage page. Then keep the reply short and point at the pane.
324
+
325
+ - `open_artifact(path="reports/coverage.html", title="Coverage")` - a workspace file. Markdown and HTML render; anything else shows as source.
326
+ - `open_artifact(content=summary_md, content_type="markdown", title="Summary")` - generated text with no file on disk (max 128KB).
327
+ - Repeat calls update the SAME pane by default. Don't pass `replace_existing=False` unless the user needs two documents open at once.
328
+ - Only workspace files; it will not fetch a URL. HTML renders sandboxed - no scripts, no network - so it is safe to show but will not behave like a live page.
329
+
330
+ Opening a pane is not an answer on its own. Say what the user should look at, and why.
331
+
318
332
  {% endif %}
319
333
  {% if "get_secret" in tools %}
320
334
  ## Secrets
@@ -132,3 +132,39 @@ def total(
132
132
  console.print(f"[bold]Runs:[/bold] {t['runs']}")
133
133
  console.print(f"[bold]Tokens:[/bold] {_fmt_tokens(t['total_tokens'])}")
134
134
  console.print(f"[bold]Cost:[/bold] {_fmt_cost(t['total_cost'])}")
135
+
136
+
137
+ def _fmt_reset(resets_at: str | None) -> str:
138
+ # resets_at is UTC by contract; sliced rather than parsed, so say so.
139
+ return f"{resets_at[:16].replace('T', ' ')} UTC" if resets_at else "-"
140
+
141
+
142
+ @usage_app.command("providers")
143
+ def providers() -> None:
144
+ """Show how much of each provider's subscription or budget is used."""
145
+ from tsugite.usage import collect_quota_reports
146
+
147
+ rows = collect_quota_reports()
148
+ if not rows:
149
+ console.print("[dim]No provider quota sources registered.[/dim]")
150
+ return
151
+
152
+ table = Table(show_header=True, header_style="bold cyan", padding=(0, 1))
153
+ table.add_column("Provider")
154
+ table.add_column("Window")
155
+ table.add_column("Used", justify="right")
156
+ table.add_column("Resets")
157
+
158
+ for row in rows:
159
+ if row["error"]:
160
+ table.add_row(row["label"], f"[red]{row['error']}[/red]", "-", "-")
161
+ continue
162
+ for i, window in enumerate(row["windows"]):
163
+ table.add_row(
164
+ row["label"] if i == 0 else "",
165
+ window["label"],
166
+ f"{window['used_pct']:g}%",
167
+ _fmt_reset(window["resets_at"]),
168
+ )
169
+
170
+ console.print(table)
@@ -1416,7 +1416,8 @@ def parse_response_text(content: str) -> ParsedResponse:
1416
1416
 
1417
1417
  first_open = cleaned.find(_EXEC_FENCE)
1418
1418
  prose_end = first_open if first_open != -1 else len(cleaned)
1419
- thought_start = cleaned.find("Thought:")
1419
+ # Bounded by the fence: a "Thought:" inside the code is not the prose marker.
1420
+ thought_start = cleaned.find("Thought:", 0, prose_end)
1420
1421
  if thought_start != -1:
1421
1422
  thought = cleaned[thought_start + len("Thought:") : prose_end].strip()
1422
1423
  else:
@@ -474,11 +474,15 @@ if os.path.exists(INJECTIONS_PATH):
474
474
  with open(INJECTIONS_PATH, "r") as f:
475
475
  namespace.update(json.load(f))
476
476
 
477
- # Load content blocks from files (consumed once per turn)
477
+ # Load content blocks from files (consumed once per turn). They are
478
+ # model-authored, so never let one shadow a tool / builtin (a block named
479
+ # read_file would replace the callable with a str).
478
480
  _cb_manifest = os.path.join(os.path.dirname(STATE_PATH), "content_blocks.json")
479
481
  if os.path.exists(_cb_manifest):
480
482
  with open(_cb_manifest, "r") as f:
481
483
  for _cb_name, _cb_path in json.load(f).items():
484
+ if _cb_name in namespace:
485
+ continue
482
486
  with open(_cb_path, "r") as _cb_f:
483
487
  namespace[_cb_name] = _cb_f.read()
484
488
  os.remove(_cb_manifest)
@@ -200,16 +200,20 @@ def _execution_xml(data: Dict[str, Any], ts: Optional[datetime] = None) -> str:
200
200
  ).render()
201
201
 
202
202
 
203
+ _UNPROMPTED_NOTE = "This card arrived on its own and was not sent by the user. Only act on it if the user refers to it."
204
+ _ADDRESSED_NOTE = "This message came from another agent session, not the user. It is addressed to you: act on it."
205
+
206
+
203
207
  def _delivery_xml(data: Dict[str, Any], ts: Optional[datetime] = None) -> str:
204
208
  attrs = f"source={quoteattr(data.get('source') or '')} kind={quoteattr(data.get('kind') or '')}"
205
209
  ts_str = _format_event_ts(ts)
206
210
  if ts_str:
207
211
  attrs += f' ts="{ts_str}"'
208
212
 
213
+ note = _ADDRESSED_NOTE if data.get("addressed") else _UNPROMPTED_NOTE
209
214
  parts = [
210
215
  f"<tsugite_delivery {attrs}>",
211
- "<note>This card arrived on its own and was not sent by the user. "
212
- "Only act on it if the user refers to it.</note>",
216
+ f"<note>{note}</note>",
213
217
  ]
214
218
  title = data.get("title")
215
219
  if title:
@@ -24,6 +24,7 @@ GROUP_SANDBOX = "tsugite.sandbox"
24
24
  GROUP_EXECUTORS = "tsugite.executors"
25
25
  GROUP_COMMANDS = "tsugite.commands"
26
26
  GROUP_CONTEXT_PROVIDERS = "tsugite.context_providers"
27
+ GROUP_USAGE_PROVIDERS = "tsugite.usage_providers"
27
28
  PLUGIN_GROUPS = (
28
29
  GROUP_PLUGINS,
29
30
  GROUP_ADAPTERS,
@@ -35,6 +36,7 @@ PLUGIN_GROUPS = (
35
36
  GROUP_EXECUTORS,
36
37
  GROUP_COMMANDS,
37
38
  GROUP_CONTEXT_PROVIDERS,
39
+ GROUP_USAGE_PROVIDERS,
38
40
  )
39
41
 
40
42
  _plugin_hooks: dict[str, list] = {}
@@ -261,6 +261,7 @@ def get_tools_by_category(category: str) -> List[str]:
261
261
 
262
262
 
263
263
  _OPTIONAL_CATEGORIES = {
264
+ "artifacts",
264
265
  "schedule",
265
266
  "notify",
266
267
  "sessions",
@@ -435,6 +436,7 @@ def _ensure_tools_loaded():
435
436
 
436
437
  # Import tool modules (they need to import 'tool' decorator from this module)
437
438
  from . import agents as agents # noqa: E402, F401
439
+ from . import artifacts as artifacts # noqa: E402, F401
438
440
  from . import daemon_control as daemon_control # noqa: E402, F401
439
441
  from . import fs as fs # noqa: E402, F401
440
442
  from . import history as history # noqa: E402, F401
@@ -172,6 +172,7 @@ def spawn_agent(
172
172
  # Set up progress spinner
173
173
  import queue
174
174
  import threading
175
+ import time
175
176
 
176
177
  from ..ui_context import get_progress, get_ui_handler
177
178
 
@@ -223,8 +224,15 @@ def spawn_agent(
223
224
  # Read JSONL stream and collect events
224
225
  final_result = None
225
226
  errors = []
227
+ # The child signals EOF by closing stdout, so one that hangs without
228
+ # exiting queues nothing at all, and one stuck in a chatty loop never
229
+ # goes quiet. The deadline is what bounds both.
230
+ deadline = time.monotonic() + timeout
226
231
 
227
232
  while True:
233
+ if time.monotonic() >= deadline:
234
+ proc.kill()
235
+ raise RuntimeError(f"Subagent timed out after {timeout}s")
228
236
  # Try to get line from queue with timeout for periodic updates
229
237
  try:
230
238
  line = line_queue.get(timeout=0.5)
@@ -0,0 +1,187 @@
1
+ """Open a document in a pane beside the chat.
2
+
3
+ Daemon-gated, so it runs in the daemon's parent process (the subprocess executor
4
+ proxies `require_daemon` tools out of the sandbox): the path validation here is
5
+ the server-side gate, and the daemon serves nothing the browser could not
6
+ already read through `GET /api/workspace/content` for the same session.
7
+ """
8
+
9
+ import re
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from ..cli.helpers import get_workspace_dir
15
+ from . import tool
16
+ from .sessions import get_current_session_id
17
+
18
+ # Any RFC 3986 scheme prefix (`https:`, `javascript:`, `data:`, ...), not just
19
+ # `://`, so a URL that omits the double slash gets the same error.
20
+ _URI_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
21
+
22
+ # `event_type` of the SSE frame. Frozen: the frontend reducer keys off it.
23
+ ARTIFACT_EVENT = "artifact_open"
24
+
25
+ # The one reusable pane slot: every open with `replace_existing=True` (the
26
+ # default) lands here.
27
+ AGENT_ARTIFACT_ID = "agent"
28
+
29
+ # Ephemeral content travels inline in the SSE frame, so it is capped well below
30
+ # anything that would stall the event stream. Bigger output belongs in a file.
31
+ MAX_INLINE_CONTENT = 128_000
32
+
33
+ # An upper bound on what the tool will point the browser at. The authoritative
34
+ # limit is `max_workspace_file_size` on the endpoint the pane reads through,
35
+ # which is configurable and lower by default.
36
+ MAX_ARTIFACT_FILE_BYTES = 4 * 1024 * 1024
37
+
38
+ CONTENT_TYPES = ("markdown", "html", "text")
39
+ MODES = ("rendered", "source")
40
+ PLACEMENTS = ("right", "below")
41
+
42
+ _adapter = None
43
+ _event_bus = None
44
+
45
+
46
+ def set_artifact_bridge(adapter, event_bus) -> None:
47
+ """Called by the daemon gateway to set/clear the workspace + broadcaster."""
48
+ global _adapter, _event_bus
49
+ _adapter = adapter
50
+ _event_bus = event_bus
51
+
52
+
53
+ def _infer_content_type(name: str) -> str:
54
+ suffix = Path(name).suffix.lower()
55
+ if suffix in (".md", ".markdown"):
56
+ return "markdown"
57
+ if suffix in (".html", ".htm"):
58
+ return "html"
59
+ return "text"
60
+
61
+
62
+ def _resolve_in_workspace(path: str) -> str:
63
+ """Resolve `path` inside the session workspace and return it workspace-relative, or raise."""
64
+ if path.startswith("//") or _URI_SCHEME.match(path):
65
+ raise ValueError(f"Invalid path '{path}': open_artifact does not fetch an external URL")
66
+
67
+ # The workspace CV carries the running session's directory, which a job
68
+ # worker overrides to its provisioned worktree; every adapter shares the one
69
+ # runtime workspace, so that is only the fallback.
70
+ workspace_dir = Path(get_workspace_dir() or _adapter.runtime.workspace_dir).resolve()
71
+ try:
72
+ resolved = (workspace_dir / path).resolve()
73
+ except (ValueError, OSError) as e:
74
+ raise ValueError(f"Invalid path '{path}': {e}") from e
75
+ if not resolved.is_relative_to(workspace_dir):
76
+ raise ValueError(f"Invalid path '{path}': outside the workspace ({workspace_dir})")
77
+ if not resolved.exists():
78
+ raise ValueError(f"Invalid path '{path}': does not exist in the workspace")
79
+ if not resolved.is_file():
80
+ raise ValueError(f"Invalid path '{path}': not a file")
81
+ size = resolved.stat().st_size
82
+ if size > MAX_ARTIFACT_FILE_BYTES:
83
+ raise ValueError(f"Invalid path '{path}': too large to open ({size} bytes)")
84
+ return str(resolved.relative_to(workspace_dir))
85
+
86
+
87
+ @tool(require_daemon=True, category="artifacts")
88
+ def open_artifact(
89
+ path: Optional[str] = None,
90
+ content: Optional[str] = None,
91
+ content_type: Optional[str] = None,
92
+ mode: Optional[str] = None,
93
+ title: Optional[str] = None,
94
+ placement: str = "right",
95
+ replace_existing: bool = True,
96
+ ) -> str:
97
+ """Open a workspace file (or generated text) in a pane beside the user's chat.
98
+
99
+ Use this when the user should READ something while the conversation keeps
100
+ going: a report you just generated, a file you are about to change, a diff, a
101
+ coverage page. The chat stays visible and usable; the pane sits next to it and
102
+ the user can resize or close it. This does not replace answering - it is a
103
+ place to put the long artifact so your reply can stay short.
104
+
105
+ By default every call updates the SAME pane, so opening five documents over a
106
+ turn leaves one pane showing the latest, not five stacked tabs. Pass
107
+ `replace_existing=False` only when the user genuinely needs two open at once.
108
+
109
+ Only files inside the session's workspace can be opened; the path is validated
110
+ daemon-side and `..`, absolute paths, symlinks out of the tree, and URLs are
111
+ all refused. HTML renders inside a sandboxed frame that cannot run scripts,
112
+ reach the network, or see the app's session - so an HTML artifact is safe to
113
+ show but will not behave like a live web page.
114
+
115
+ Args:
116
+ path: Workspace-relative file to open (e.g. "reports/coverage.html").
117
+ Mutually exclusive with `content`.
118
+ content: Generated text to show instead of a file, for output that has no
119
+ home on disk. Capped at 128KB - write a file and pass `path` for more.
120
+ content_type: "markdown", "html", or "text". Inferred from the file
121
+ extension when `path` is given; defaults to "text" for `content`.
122
+ mode: "rendered" (default for markdown/HTML) or "source" to show the raw
123
+ text. Plain text is always source - there is nothing to render.
124
+ title: Pane title. Defaults to the file name, or "Artifact".
125
+ placement: "right" (default) to split beside the chat, or "below".
126
+ Ignored when an artifact pane is already open - that one is reused.
127
+ replace_existing: Reuse the one agent artifact pane (default). False opens
128
+ an additional pane, which the user then has to close by hand.
129
+
130
+ Returns:
131
+ A short confirmation of what was opened.
132
+
133
+ Raises:
134
+ ValueError: Bad arguments, or a path outside the workspace.
135
+ RuntimeError: No daemon is running this agent (no UI to open a pane in).
136
+
137
+ Example:
138
+ open_artifact(path="reports/coverage.html", title="Coverage")
139
+ open_artifact(content=summary_md, content_type="markdown", title="Summary")
140
+ """
141
+ if _adapter is None or _event_bus is None:
142
+ raise RuntimeError("open_artifact is not available: no daemon is running this agent.")
143
+
144
+ if path and content is not None:
145
+ raise ValueError("Invalid arguments: pass path or content, not both")
146
+ if not path and content is None:
147
+ raise ValueError("Invalid arguments: pass either path (a workspace file) or content (generated text)")
148
+ if content_type is not None and content_type not in CONTENT_TYPES:
149
+ raise ValueError(f"Invalid content_type '{content_type}': must be one of {', '.join(CONTENT_TYPES)}")
150
+ if mode is not None and mode not in MODES:
151
+ raise ValueError(f"Invalid mode '{mode}': must be one of {', '.join(MODES)}")
152
+ if placement not in PLACEMENTS:
153
+ raise ValueError(f"Invalid placement '{placement}': must be one of {', '.join(PLACEMENTS)}")
154
+
155
+ rel_path = None
156
+ if path:
157
+ rel_path = _resolve_in_workspace(path)
158
+ resolved_type = content_type or _infer_content_type(rel_path)
159
+ default_title = Path(rel_path).name
160
+ else:
161
+ if len(content) > MAX_INLINE_CONTENT:
162
+ raise ValueError(
163
+ f"Invalid content: too large ({len(content)} chars, max {MAX_INLINE_CONTENT}). "
164
+ "Write it to a workspace file and pass path= instead."
165
+ )
166
+ resolved_type = content_type or "text"
167
+ default_title = "Artifact"
168
+
169
+ resolved_mode = "source" if resolved_type == "text" else (mode or "rendered")
170
+
171
+ payload = {
172
+ "session_id": get_current_session_id(),
173
+ "event_type": ARTIFACT_EVENT,
174
+ "artifact_id": AGENT_ARTIFACT_ID if replace_existing else uuid.uuid4().hex[:12],
175
+ "path": rel_path,
176
+ "content": None if rel_path else content,
177
+ "content_type": resolved_type,
178
+ "mode": resolved_mode,
179
+ "title": title or default_title,
180
+ "placement": placement,
181
+ # Drives the pane's "opened by the agent" badge.
182
+ "opened_by": "agent",
183
+ }
184
+ _event_bus.emit("session_event", payload)
185
+
186
+ where = "beside the chat" if placement == "right" else "below the chat"
187
+ return f"Opened {payload['title']} {where}."
@@ -181,7 +181,7 @@ def _build_gitignore_matcher(base_path: Path) -> Optional[pathspec.PathSpec]:
181
181
  Returns:
182
182
  PathSpec matcher or None if no .gitignore files found
183
183
  """
184
- patterns = []
184
+ patterns: list[str] = []
185
185
  current = base_path.resolve()
186
186
 
187
187
  # Walk up the directory tree to find .gitignore files
@@ -191,10 +191,11 @@ def _build_gitignore_matcher(base_path: Path) -> Optional[pathspec.PathSpec]:
191
191
  try:
192
192
  with gitignore_path.open("r", encoding="utf-8") as f:
193
193
  # Parse gitignore patterns (skip empty lines and comments)
194
- for line in f:
195
- line = line.rstrip("\n\r")
196
- if line and not line.startswith("#"):
197
- patterns.append(line)
194
+ lines = [line.rstrip("\n\r") for line in f]
195
+ # Prepended, because the walk runs inward-out and pathspec takes the
196
+ # last matching pattern: a nested .gitignore has to come after the
197
+ # parent it re-includes from.
198
+ patterns[:0] = [line for line in lines if line and not line.startswith("#")]
198
199
  except Exception:
199
200
  pass
200
201
 
@@ -97,6 +97,8 @@ def spawn_job(
97
97
 
98
98
  Returns:
99
99
  Dict with job_id, worker_session_id, parent_session_id, state.
100
+ `worker_session_id` is null for a non-agent executor. `get_job` describes
101
+ how to check on that worker.
100
102
  """
101
103
  from tsugite_daemon.session_runner import get_current_session_id
102
104
 
@@ -159,7 +161,6 @@ def spawn_job(
159
161
 
160
162
  return {
161
163
  "job_id": job.id,
162
- # None for a non-agent executor job (no worker Session is spawned).
163
164
  "worker_session_id": started.id if started else None,
164
165
  "parent_session_id": parent_session_id,
165
166
  "notify_when": job.notify_when,
@@ -176,6 +177,17 @@ def get_job(job_id: str) -> dict:
176
177
  worker/verifier session ids (so you can navigate via `session_status`),
177
178
  state, timestamps, and any error.
178
179
 
180
+ `last_activity_at` is the last time this job's executor saw worker output, or
181
+ the time it started the worker if there has been none. The stamp is throttled
182
+ (one write per 10 seconds on the `cc` executor), so a stamp minutes old on a
183
+ running job means the worker has gone quiet, and one still sitting at the job's
184
+ `created_at` means it never produced anything. `state` reads `running` in every
185
+ one of those cases. A null stamp means this executor does not report liveness.
186
+
187
+ A job on a non-agent executor (`executor="cc"`) runs a CLI in a PTY, so it has
188
+ no `worker_session_id` for `session_status`. Pass its `worker_terminal_id` to
189
+ `pty_capture` to read the worker's recent output.
190
+
179
191
  Args:
180
192
  job_id: Job id (e.g. 'job-4f2a1b3c').
181
193
 
@@ -55,22 +55,31 @@ def _resolve_session_arg(session_id: Optional[str]) -> str:
55
55
  def session_reply(message: str, session_id: Optional[str] = None) -> dict:
56
56
  """Send a follow-up message to an existing session, continuing its conversation.
57
57
 
58
+ A session that is already working has the message held until its next turn.
59
+ A one-shot run that ends without another turn never reads it. The call
60
+ returns straight away.
61
+
58
62
  Args:
59
63
  message: Message to send to the session.
60
64
  session_id: Session to reply to, or "name:<alias>" to address one by its alias.
61
65
  Defaults to the current session; "current" means the same.
62
66
 
63
67
  Returns:
64
- Dict with session_id and the agent's response.
68
+ Dict with session_id, `status` ("delivered" or "queued"), and the agent's
69
+ response when a turn ran.
65
70
  """
66
71
  session_id = _resolve_session_arg(session_id)
67
- # Read here, not in reply_to_session: _call hops to the daemon loop with a fresh
68
- # context, and reply_to_session rebinds the current session to the target.
72
+ # `_call` hops to the daemon loop with a fresh context and `reply_to_session`
73
+ # rebinds the current session to the target, so this read has to happen first.
69
74
  origin = get_current_session_id()
70
75
  metadata = {"from_session": origin} if origin and origin != session_id else None
71
- # revive: an agent addressing a session by id wants that session, finished or not.
72
- result = _call(_session_runner.reply_to_session, session_id, message, metadata=metadata, revive=True, timeout=120)
73
- return {"session_id": session_id, "response": str(result)[:2000]}
76
+ result = _call(_session_runner.reply_or_defer, session_id, message, metadata=metadata, timeout=120)
77
+ reply = result["response"]
78
+ return {
79
+ "session_id": session_id,
80
+ "status": result["status"],
81
+ "response": None if reply is None else str(reply)[:2000],
82
+ }
74
83
 
75
84
 
76
85
  @tool(require_daemon=True)
@@ -0,0 +1,18 @@
1
+ """Usage tracking for token and cost analytics."""
2
+
3
+ from .quota import (
4
+ QuotaReport,
5
+ QuotaWindow,
6
+ collect_quota_reports,
7
+ register_quota_source,
8
+ )
9
+ from .store import UsageStore, get_usage_store
10
+
11
+ __all__ = [
12
+ "QuotaReport",
13
+ "QuotaWindow",
14
+ "UsageStore",
15
+ "collect_quota_reports",
16
+ "get_usage_store",
17
+ "register_quota_source",
18
+ ]