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.
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/AGENTS.md +14 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/CONTRIBUTING.md +20 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/PKG-INFO +1 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/pyproject.toml +1 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/metrics.py +11 -13
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/runner.py +6 -8
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/default.md +14 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/usage.py +36 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/agent.py +2 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/subprocess_executor.py +5 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/reconstruction.py +6 -2
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/plugins.py +2 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/__init__.py +2 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/agents.py +8 -0
- tsugite_cli-0.22.0/tsugite/tools/artifacts.py +187 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/fs.py +6 -5
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/jobs.py +13 -1
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/sessions.py +15 -6
- tsugite_cli-0.22.0/tsugite/usage/__init__.py +18 -0
- tsugite_cli-0.22.0/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.0}/.gitignore +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/LICENSE +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/README.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_inheritance.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_preparation.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/exec_directives.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/exec_runner.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/history_integration.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/steps.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_runner/validation.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/agent_utils.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/approval.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/agent_config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/auto_context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/delegation.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/file.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/inline.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/storage.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/attachments/url.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/.gitkeep +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/code_searcher.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/file_searcher.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/job_verifier.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/job_worker.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_agents/onboard.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/.gitkeep +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cache.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cancellation.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/agents.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/attachments.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/cache.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/chat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/daemon.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/exec.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/history.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/init.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/plugins.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/render.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/run.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/secrets.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/skills.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/tools.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/validate.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/cli/workspace.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/console.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/constants.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/context_block.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/content_blocks.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/executor.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/executor_registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/memory.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/proxy.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/record_store.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/sandbox.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/state.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/core/tools.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/bus.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/events.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/events/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/exceptions.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/sqlite_backend.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/sqlite_conn.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/sqlite_schema.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/history/ui_events.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/hooks.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/interaction.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/md_agents.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/options.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/permissions.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/prompt_xml.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/anthropic.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/model_cache.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/model_registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/ollama.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/openai_compat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/providers/openrouter.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/py.typed +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/renderer.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/schemas/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/schemas/agent.schema.json +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/backend.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/env.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/exec.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/file.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/masking.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/redaction.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/registry.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/secrets/sqlite.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/shell_tool_config.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/skill_discovery.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/AGENTS.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/IDENTITY.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/MEMORY.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/USER.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/personas/casual-technical.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/personas/marvin.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/templates/personas/minimal.md +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/daemon_control.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/history.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/http.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/interactive.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/notify.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/schedule.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/secrets.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/shell.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/shell_tools.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/skills.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tools/time.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/tsugite.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/base.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/chat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/dispatch.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/helpers.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/jsonl.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/plain.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_chat.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_commands.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_completer.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui/repl_handler.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui_context.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/ui_surfaces.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/usage/store.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/user_agent.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/utils.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/workspace/__init__.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/workspace/models.py +0 -0
- {tsugite_cli-0.21.2 → tsugite_cli-0.22.0}/tsugite/workspace/session.py +0 -0
- {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
|
|
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
|
```
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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>
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
#
|
|
68
|
-
#
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
return {
|
|
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
|
+
]
|