tsugite-cli 0.21.2__tar.gz → 0.22.1__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.1}/AGENTS.md +14 -2
  2. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/CONTRIBUTING.md +20 -1
  3. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/PKG-INFO +1 -1
  4. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/pyproject.toml +1 -1
  5. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_preparation.py +3 -2
  6. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/history_integration.py +7 -1
  7. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/metrics.py +11 -13
  8. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/runner.py +6 -8
  9. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/base.py +2 -4
  10. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/default.md +16 -2
  11. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/run.py +17 -2
  12. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/usage.py +36 -0
  13. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/agent.py +2 -1
  14. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/proxy.py +8 -0
  15. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/subprocess_executor.py +5 -1
  16. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/reconstruction.py +6 -2
  17. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/md_agents.py +2 -2
  18. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/plugins.py +2 -0
  19. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/__init__.py +2 -0
  20. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/agents.py +33 -11
  21. tsugite_cli-0.22.1/tsugite/tools/artifacts.py +187 -0
  22. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/fs.py +6 -5
  23. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/jobs.py +18 -4
  24. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/sessions.py +15 -6
  25. tsugite_cli-0.22.1/tsugite/usage/__init__.py +18 -0
  26. tsugite_cli-0.22.1/tsugite/usage/quota.py +108 -0
  27. tsugite_cli-0.21.2/tsugite/usage/__init__.py +0 -5
  28. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/.gitignore +0 -0
  29. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/LICENSE +0 -0
  30. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/README.md +0 -0
  31. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/__init__.py +0 -0
  32. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_inheritance.py +0 -0
  33. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/__init__.py +0 -0
  34. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/exec_directives.py +0 -0
  35. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/exec_runner.py +0 -0
  36. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/helpers.py +0 -0
  37. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/models.py +0 -0
  38. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/steps.py +0 -0
  39. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/validation.py +0 -0
  40. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_utils.py +0 -0
  41. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/approval.py +0 -0
  42. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/__init__.py +0 -0
  43. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/agent_config.py +0 -0
  44. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/auto_context.py +0 -0
  45. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/delegation.py +0 -0
  46. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/file.py +0 -0
  47. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/inline.py +0 -0
  48. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/storage.py +0 -0
  49. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/url.py +0 -0
  50. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/.gitkeep +0 -0
  51. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/code_searcher.md +0 -0
  52. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/file_searcher.md +0 -0
  53. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/job_verifier.md +0 -0
  54. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/job_worker.md +0 -0
  55. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/onboard.md +0 -0
  56. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_context.py +0 -0
  57. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/.gitkeep +0 -0
  58. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  59. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  60. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  61. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  62. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  63. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  64. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  65. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  66. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  67. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cache.py +0 -0
  68. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cancellation.py +0 -0
  69. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/__init__.py +0 -0
  70. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/agents.py +0 -0
  71. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/attachments.py +0 -0
  72. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/cache.py +0 -0
  73. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/chat.py +0 -0
  74. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/config.py +0 -0
  75. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/daemon.py +0 -0
  76. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/exec.py +0 -0
  77. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/helpers.py +0 -0
  78. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/history.py +0 -0
  79. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/init.py +0 -0
  80. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/models.py +0 -0
  81. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/plugins.py +0 -0
  82. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/render.py +0 -0
  83. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/secrets.py +0 -0
  84. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/skills.py +0 -0
  85. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/tools.py +0 -0
  86. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/validate.py +0 -0
  87. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/workspace.py +0 -0
  88. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/config.py +0 -0
  89. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/console.py +0 -0
  90. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/constants.py +0 -0
  91. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/context.py +0 -0
  92. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/context_block.py +0 -0
  93. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/__init__.py +0 -0
  94. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/content_blocks.py +0 -0
  95. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/executor.py +0 -0
  96. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/executor_registry.py +0 -0
  97. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/memory.py +0 -0
  98. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/record_store.py +0 -0
  99. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/sandbox.py +0 -0
  100. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/state.py +0 -0
  101. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/tools.py +0 -0
  102. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/__init__.py +0 -0
  103. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/base.py +0 -0
  104. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/bus.py +0 -0
  105. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/events.py +0 -0
  106. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/helpers.py +0 -0
  107. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/exceptions.py +0 -0
  108. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/__init__.py +0 -0
  109. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/base.py +0 -0
  110. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/models.py +0 -0
  111. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/registry.py +0 -0
  112. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/sqlite_backend.py +0 -0
  113. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/sqlite_conn.py +0 -0
  114. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/sqlite_schema.py +0 -0
  115. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/ui_events.py +0 -0
  116. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/hooks.py +0 -0
  117. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/interaction.py +0 -0
  118. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/models.py +0 -0
  119. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/options.py +0 -0
  120. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/permissions.py +0 -0
  121. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/prompt_xml.py +0 -0
  122. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/__init__.py +0 -0
  123. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/anthropic.py +0 -0
  124. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/base.py +0 -0
  125. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/model_cache.py +0 -0
  126. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/model_registry.py +0 -0
  127. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/ollama.py +0 -0
  128. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/openai_compat.py +0 -0
  129. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/openrouter.py +0 -0
  130. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/py.typed +0 -0
  131. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/renderer.py +0 -0
  132. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/schemas/__init__.py +0 -0
  133. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/schemas/agent.schema.json +0 -0
  134. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/__init__.py +0 -0
  135. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/backend.py +0 -0
  136. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/env.py +0 -0
  137. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/exec.py +0 -0
  138. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/file.py +0 -0
  139. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/masking.py +0 -0
  140. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/redaction.py +0 -0
  141. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/registry.py +0 -0
  142. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/sqlite.py +0 -0
  143. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/shell_tool_config.py +0 -0
  144. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/skill_discovery.py +0 -0
  145. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/AGENTS.md +0 -0
  146. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/IDENTITY.md +0 -0
  147. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/MEMORY.md +0 -0
  148. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/USER.md +0 -0
  149. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/personas/casual-technical.md +0 -0
  150. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/personas/marvin.md +0 -0
  151. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/personas/minimal.md +0 -0
  152. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/daemon_control.py +0 -0
  153. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/history.py +0 -0
  154. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/http.py +0 -0
  155. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/interactive.py +0 -0
  156. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/notify.py +0 -0
  157. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/schedule.py +0 -0
  158. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/secrets.py +0 -0
  159. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/shell.py +0 -0
  160. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/shell_tools.py +0 -0
  161. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/skills.py +0 -0
  162. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/time.py +0 -0
  163. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tsugite.py +0 -0
  164. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/__init__.py +0 -0
  165. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/base.py +0 -0
  166. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/chat.py +0 -0
  167. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/dispatch.py +0 -0
  168. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/helpers.py +0 -0
  169. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/jsonl.py +0 -0
  170. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/plain.py +0 -0
  171. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_chat.py +0 -0
  172. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_commands.py +0 -0
  173. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_completer.py +0 -0
  174. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_handler.py +0 -0
  175. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui_context.py +0 -0
  176. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui_surfaces.py +0 -0
  177. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/usage/store.py +0 -0
  178. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/user_agent.py +0 -0
  179. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/utils.py +0 -0
  180. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/workspace/__init__.py +0 -0
  181. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/workspace/models.py +0 -0
  182. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/workspace/session.py +0 -0
  183. {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/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.1
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.1"
4
4
  description = "Micro-agent runner for task automation using markdown definitions"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
@@ -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
@@ -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
 
@@ -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
 
@@ -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:
@@ -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:
@@ -156,12 +157,12 @@ You are managing a shared channel. When a user asks for something that would ben
156
157
  - **Inline** - single edit, one-shot read, small calculation. Don't spawn anything.
157
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.
158
159
 
159
- **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`:
160
161
  ```python-exec
161
162
  spawn_job(
162
163
  prompt="Refactor the agent loop to handle nested tool calls",
163
164
  acceptance_criteria=[
164
- "All existing tests still pass",
165
+ "cmd:uv run pytest -q",
165
166
  "Adds at least 2 new tests for nested calls",
166
167
  "No new dependencies",
167
168
  ],
@@ -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
@@ -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
@@ -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:
@@ -8,6 +8,7 @@ import asyncio
8
8
  import fnmatch
9
9
  import ipaddress
10
10
  import logging
11
+ import socket
11
12
  from pathlib import Path
12
13
  from typing import Optional
13
14
 
@@ -46,6 +47,13 @@ def _is_ip_address(host: str) -> bool:
46
47
  ipaddress.ip_address(host)
47
48
  return True
48
49
  except ValueError:
50
+ pass
51
+ # inet_aton accepts the integer, octal and short IPv4 forms that
52
+ # ip_address rejects and getaddrinfo resolves, and rejects hostnames.
53
+ try:
54
+ socket.inet_aton(host)
55
+ return True
56
+ except OSError:
49
57
  return False
50
58
 
51
59
 
@@ -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:
@@ -305,7 +305,7 @@ def extract_tool_directives(content: str) -> List[ToolDirective]:
305
305
  {'url': 'http://example.com'}
306
306
  """
307
307
  directives = []
308
- pattern = r"<!--\s*tsu:tool\s+([^>]+?)\s*-->"
308
+ pattern = r"<!--\s*tsu:tool\s+(.+?)\s*-->"
309
309
 
310
310
  for match in re.finditer(pattern, content):
311
311
  raw_args = match.group(1).strip()
@@ -478,7 +478,7 @@ def extract_step_directives(content: str, include_preamble: bool = True) -> tupl
478
478
  True
479
479
  """
480
480
  steps = []
481
- pattern = r"<!--\s*tsu:step\s+([^>]+?)\s*-->"
481
+ pattern = r"<!--\s*tsu:step\s+(.+?)\s*-->"
482
482
  matches = list(re.finditer(pattern, content))
483
483
 
484
484
  # Extract preamble (content before first step)
@@ -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
@@ -1,5 +1,7 @@
1
1
  """Agent orchestration tools for spawning and managing sub-agents."""
2
2
 
3
+ import os
4
+ import signal
3
5
  from pathlib import Path
4
6
  from typing import Any, Dict, List, Optional
5
7
 
@@ -31,6 +33,13 @@ def _build_subagent_cmd(agent_file: Path, model_override: Optional[str], sandbox
31
33
  return cmd
32
34
 
33
35
 
36
+ def _kill_process_tree(proc) -> None:
37
+ try:
38
+ os.killpg(proc.pid, signal.SIGKILL)
39
+ except ProcessLookupError:
40
+ pass
41
+
42
+
34
43
  def resolve_agent_path(agent_path: str) -> Optional[Path]:
35
44
  """Resolve an agent reference (path or name) to a file. Returns None if missing.
36
45
 
@@ -172,6 +181,7 @@ def spawn_agent(
172
181
  # Set up progress spinner
173
182
  import queue
174
183
  import threading
184
+ import time
175
185
 
176
186
  from ..ui_context import get_progress, get_ui_handler
177
187
 
@@ -186,7 +196,6 @@ def spawn_agent(
186
196
  emit_info_event(f"🚀 Spawning subagent: [cyan]{agent_name}[/cyan]...")
187
197
 
188
198
  try:
189
- # Spawn subprocess with line buffering
190
199
  proc = subprocess.Popen(
191
200
  cmd,
192
201
  stdin=subprocess.PIPE,
@@ -195,18 +204,16 @@ def spawn_agent(
195
204
  text=True,
196
205
  bufsize=1, # Line buffered for real-time output
197
206
  cwd=str(_effective_cwd()),
207
+ start_new_session=True,
198
208
  )
199
209
 
200
- # Write context to stdin then close it
201
210
  proc.stdin.write(context_json)
202
211
  proc.stdin.close()
203
212
 
204
- # Queue for passing lines from reader thread to main thread
205
213
  line_queue = queue.Queue()
206
214
  reader_exception = None
207
215
 
208
216
  def read_stdout():
209
- """Read stdout lines in separate thread and put in queue."""
210
217
  nonlocal reader_exception
211
218
  try:
212
219
  for line in proc.stdout:
@@ -216,15 +223,30 @@ def spawn_agent(
216
223
  reader_exception = e
217
224
  line_queue.put(None)
218
225
 
219
- # Start reader thread
220
226
  reader_thread = threading.Thread(target=read_stdout, daemon=True)
221
227
  reader_thread.start()
222
228
 
223
- # Read JSONL stream and collect events
229
+ # A child that fills the stderr pipe blocks on write(2).
230
+ stderr_text = ""
231
+
232
+ def read_stderr():
233
+ nonlocal stderr_text
234
+ stderr_text = proc.stderr.read()
235
+
236
+ stderr_thread = threading.Thread(target=read_stderr, daemon=True)
237
+ stderr_thread.start()
238
+
224
239
  final_result = None
225
240
  errors = []
241
+ # The child signals EOF by closing stdout, so one that hangs without
242
+ # exiting queues nothing at all, and one stuck in a chatty loop never
243
+ # goes quiet. The deadline is what bounds both.
244
+ deadline = time.monotonic() + timeout
226
245
 
227
246
  while True:
247
+ if time.monotonic() >= deadline:
248
+ _kill_process_tree(proc)
249
+ raise RuntimeError(f"Subagent timed out after {timeout}s")
228
250
  # Try to get line from queue with timeout for periodic updates
229
251
  try:
230
252
  line = line_queue.get(timeout=0.5)
@@ -272,17 +294,17 @@ def spawn_agent(
272
294
 
273
295
  # Wait for completion
274
296
  try:
275
- return_code = proc.wait(timeout=timeout)
297
+ return_code = proc.wait(timeout=max(0.0, deadline - time.monotonic()))
276
298
  except subprocess.TimeoutExpired:
277
- proc.kill()
299
+ _kill_process_tree(proc)
278
300
  raise RuntimeError(f"Subagent timed out after {timeout}s")
279
301
 
280
302
  # Check for failures
281
303
  if return_code != 0:
282
- stderr = proc.stderr.read()
304
+ stderr_thread.join(timeout=1.0)
283
305
  error_msg = f"Subagent failed with exit code {return_code}"
284
- if stderr:
285
- error_msg += f": {stderr}"
306
+ if stderr_text:
307
+ error_msg += f": {stderr_text}"
286
308
  if errors:
287
309
  error_msg += f"\nErrors: {[e['error'] for e in errors]}"
288
310
  raise RuntimeError(error_msg)