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.
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/AGENTS.md +14 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/CONTRIBUTING.md +20 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/PKG-INFO +1 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/pyproject.toml +1 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_preparation.py +3 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/history_integration.py +7 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/metrics.py +11 -13
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/runner.py +6 -8
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/base.py +2 -4
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/default.md +16 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/run.py +17 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/usage.py +36 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/agent.py +2 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/proxy.py +8 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/subprocess_executor.py +5 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/reconstruction.py +6 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/md_agents.py +2 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/plugins.py +2 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/__init__.py +2 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/agents.py +33 -11
- tsugite_cli-0.22.1/tsugite/tools/artifacts.py +187 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/fs.py +6 -5
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/jobs.py +18 -4
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/sessions.py +15 -6
- tsugite_cli-0.22.1/tsugite/usage/__init__.py +18 -0
- tsugite_cli-0.22.1/tsugite/usage/quota.py +108 -0
- tsugite_cli-0.21.2/tsugite/usage/__init__.py +0 -5
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/.gitignore +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/LICENSE +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/README.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_inheritance.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/exec_directives.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/exec_runner.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/steps.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_runner/validation.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/agent_utils.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/approval.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/agent_config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/auto_context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/delegation.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/file.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/inline.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/storage.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/attachments/url.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/.gitkeep +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/code_searcher.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/file_searcher.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/job_verifier.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/job_worker.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_agents/onboard.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/.gitkeep +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cache.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cancellation.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/agents.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/attachments.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/cache.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/chat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/daemon.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/exec.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/history.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/init.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/plugins.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/render.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/secrets.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/skills.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/tools.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/validate.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/cli/workspace.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/console.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/constants.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/context_block.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/content_blocks.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/executor.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/executor_registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/memory.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/record_store.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/sandbox.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/state.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/core/tools.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/bus.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/events.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/events/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/exceptions.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/sqlite_backend.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/sqlite_conn.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/sqlite_schema.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/history/ui_events.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/hooks.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/interaction.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/options.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/permissions.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/prompt_xml.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/anthropic.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/model_cache.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/model_registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/ollama.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/openai_compat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/providers/openrouter.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/py.typed +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/renderer.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/schemas/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/schemas/agent.schema.json +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/backend.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/env.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/exec.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/file.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/masking.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/redaction.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/secrets/sqlite.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/shell_tool_config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/skill_discovery.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/AGENTS.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/IDENTITY.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/MEMORY.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/USER.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/personas/casual-technical.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/personas/marvin.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/templates/personas/minimal.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/daemon_control.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/history.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/http.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/interactive.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/notify.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/schedule.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/secrets.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/shell.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/shell_tools.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/skills.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tools/time.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/tsugite.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/chat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/dispatch.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/jsonl.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/plain.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_chat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_commands.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_completer.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui/repl_handler.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui_context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/ui_surfaces.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/usage/store.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/user_agent.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/utils.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/workspace/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/workspace/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.1}/tsugite/workspace/session.py +0 -0
- {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
|
|
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.
|
|
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.
|
|
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
|
```
|
|
@@ -119,8 +119,9 @@ class AgentPreparer:
|
|
|
119
119
|
placeholders: Dict[str, str] = {}
|
|
120
120
|
modified = content
|
|
121
121
|
for d in directives:
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
event_bus.emit(InfoEvent(message=
|
|
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
|
-
#
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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. --
|
|
141
|
-
2.
|
|
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
|
-
|
|
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>
|
|
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+(
|
|
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+(
|
|
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
|
-
#
|
|
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=
|
|
297
|
+
return_code = proc.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
276
298
|
except subprocess.TimeoutExpired:
|
|
277
|
-
proc
|
|
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
|
-
|
|
304
|
+
stderr_thread.join(timeout=1.0)
|
|
283
305
|
error_msg = f"Subagent failed with exit code {return_code}"
|
|
284
|
-
if
|
|
285
|
-
error_msg += f": {
|
|
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)
|