Compare commits

...

22 Commits

Author SHA1 Message Date
Patrick Buckley 86b404177b chore: bump version to 0.8.4
- feat: split-pane layout for chat UI (#127)
- fix: enforce CSS min dimensions during split handle drag
- feat: add OpenShell sandbox policy for turnstone-server (#128)
- fix: collector JWT expiry causes silent workstream data wipe (#126)
- fix: auto-titler SSE event + SSE reconnection after restart (#125)
- fix: wire resume_ws through console + expose max_ws in heartbeat (#124)
2026-03-18 18:13:18 -07:00
Patrick Buckley 10165bb8a1 feat: add OpenShell sandbox policy for turnstone-server (#128)
* feat: add OpenShell sandbox policy for turnstone-server

Curated policy for running turnstone-server inside an OpenShell sandbox
with kernel-enforced security boundaries (Landlock, netns, seccomp).

- Filesystem: workdir read-write, /usr+/etc read-only, /tmp+/dev/null
  read-write, Landlock best_effort compatibility
- Network: default-deny with allowlisted LLM APIs (OpenAI, Anthropic),
  Tavily, skills.sh, GitHub (read-only L7), MCP registry (read-only L7),
  Redis localhost, package registries, curated web_fetch domains
- Git: L7-enforced read-only (info/refs + git-upload-pack only)
- Process: privilege drop to sandbox:sandbox
- Inference routing template for credential isolation (real API keys
  never enter the sandbox, resolved at proxy layer)

* fix: address PR #128 review feedback + add integration guide

Review fixes:
- Use python3 (not python) in usage examples to match binary allowlist
- Fix network_policy → network_policies in comment
- Remove /usr/bin/git from github_api (git uses github.com not
  api.github.com; already covered by git_operations policy)
- Remove pip/uv from bash_network_tools (package_registries already
  covers their PyPI access; no need for StackOverflow/Wikipedia reach)
- Restructure routes.yaml so commented blocks are indented under
  routes: key (uncomment without restructuring YAML)

New: docs/openshell.md covering policy customization, inference routing,
domain allowlisting, MCP subprocess inheritance, and the dual-layer
security model.
2026-03-18 18:09:47 -07:00
Patrick Buckley 5d478573cc fix: enforce CSS min dimensions during split handle drag
Drag ratio bounds were hardcoded at 0.1/0.9 which allowed panes to be
resized below their CSS min-width (200px) / min-height (150px), causing
input areas and text to overflow and clip. Now compute bounds dynamically
from the container size and CSS minimums.
2026-03-18 18:08:35 -07:00
Patrick Buckley 1b24e4717f feat: split-pane layout for chat UI (#127)
* feat: split-pane layout for chat UI

Refactor the server UI from a single-pane global-state design to a
multi-pane architecture with per-workstream Pane instances and a binary
layout tree. Each pane has its own SSE connection, message area, input,
and state (busy, approval, streaming).

Phase 1 — Pane class with 25 prototype methods encapsulating all
per-workstream state. Phase 2 — binary split tree (leaf/split nodes)
with recursive flexbox rendering and drag-to-resize handles. Phase 3 —
keyboard shortcuts (Ctrl+\, Ctrl+Shift+\, Ctrl+Shift+W, Ctrl+Alt+Arrow)
and right-click context menu. Phase 4 — layout persistence via
localStorage.

Key design decisions:
- No duplicate workstreams across panes (split refused if no unused ws,
  auto-close redundant pane on ws deletion)
- Max 6 panes to avoid exhausting browser SSE connections
- Viewport guard prevents splitting below min-width/min-height
- Only focused pane refreshes workstream list on SSE reconnect (prevents
  race when multiple panes disconnect simultaneously)
- Tab click focuses existing pane showing that ws in multi-pane mode
- Pointer events on drag handles for mouse + touch support
- Full a11y: ARIA roles/labels, keyboard nav in context menu, focus
  restoration, prefers-reduced-motion coverage

* fix: address PR #127 review feedback

- Add focusin handler so keyboard focus (Tab) updates focusedPaneId
- Context menu skips interactive elements (textarea, input, links,
  buttons) so native copy/paste and link context menus work
- Split handles get ARIA role=separator, aria-orientation, aria-valuenow,
  keyboard resizing (arrow keys, Home/End), and tabindex=0
- Enforce MAX_PANES limit in deserializeLayout to prevent corrupted
  localStorage from creating too many panes/SSE connections
- Update architecture.md to document split-pane layout
2026-03-18 18:03:46 -07:00
Patrick Buckley 9a2db63c07 fix: collector JWT expiry causes silent workstream data wipe (#126)
* fix: collector JWT expiry causes silent workstream data wipe

The console collector baked a one-time JWT snapshot into its httpx
client headers at startup. After 1 hour (JWT expiry), every poll to
server nodes returned 401. The error JSON was silently parsed as valid
empty data, wiping all workstream state while nodes still appeared
reachable — the cluster showed "10 nodes, 0 workstreams."

Root causes fixed:
- Collector: no auth baked into httpx.Client; per-request headers
  from ServiceTokenManager.token (auto-rotating) or static fallback
- Proxy: same pattern — proxy_client/proxy_sse_client created without
  auth headers; _proxy_auth_headers() injects fresh token per-request
- main(): static token snapshot only passed when no token_manager
  exists, preventing stale JWT from being stored anywhere
- _fetch_node: raise_for_status() before .json() so 401s throw
  instead of returning error JSON as "0 workstreams"
- Auth errors (401/403) logged at warning level for operator visibility

* fix: address PR #126 review — type annotation, regression tests, log messages

Tighten token_manager type from Any to ServiceTokenManager | None.
Add two regression tests verifying 401/403 poll responses preserve
existing workstream data and mark nodes unreachable. Fix misleading
log messages: "jwt_minted" → "token_manager_created" since
ServiceTokenManager mints lazily on first .token access.
2026-03-18 15:45:53 -07:00
Patrick Buckley e159837b74 fix: auto-titler SSE event + SSE reconnection after restart (#125)
* fix: auto-titler SSE event + SSE reconnection after restart

_generate_title() now calls self.ui.on_rename() after persisting the
title, so the tab bar, bridge, and console all update in real time.
Also handles multi-part (vision) content and replaces silent except
with log.debug.

SSE onerror handler now parses the workstreams response, replaces the
stale workstreams map, and switches to the first available workstream
if the current ws_id no longer exists (e.g. after server restart).
Previously it retried the stale ws_id forever.

* fix: address PR #125 review — avoid double reconnect + sync tab bar

Return immediately after switchTab/showDashboard on stale ws_id to
prevent scheduling a redundant connectContentSSE via setTimeout.
Always re-render tab bar after replacing the workstreams map so DOM
stays in sync even when currentWsId is still valid.
2026-03-18 15:14:14 -07:00
Patrick Buckley ec3454ee2e fix: wire resume_ws through console + expose max_ws in heartbeat (#124)
* fix: wire resume_ws through console + expose max_ws in heartbeat

Console create_workstream handler now reads resume_ws from the request
body and passes it to CreateWorkstreamMessage on all three dispatch paths
(pool, auto, explicit). Previously resume only worked via channel router
and direct CLI — the console layer never plumbed it through.

Server /health now includes max_ws from WorkstreamManager. Bridge reads
it on startup and includes it in heartbeat metadata so the console's
_pick_best_node gets accurate capacity instead of always defaulting to 10.
Collector also updates max_ws on subsequent heartbeats (not just discovery).

Schemas, Python SDK, TypeScript SDK, and OpenAPI specs updated. Test mocks
fixed for new max_workstreams property access in /health.

* fix: address PR #124 review — resume_ws tests + max_ws fetch on pre-set node_id

Add _fetch_server_metadata() so bridge reads max_ws from /health even
when node_id is pre-set (skipping _fetch_node_id). Without this, heartbeats
would advertise max_ws=10 regardless of actual server config.

Add 3 test cases verifying resume_ws flows through all three console
dispatch paths (directed, pool, auto-select).
2026-03-18 14:24:10 -07:00
renovate[bot] 5cbb832162 chore(deps): lock file maintenance 2026-03-18 13:20:49 -07:00
renovate[bot] 4e4ae2a91d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.11 2026-03-18 13:20:47 -07:00
renovate[bot] c7d0bac638 chore(deps): update astral-sh/setup-uv digest to 37802ad 2026-03-18 13:20:44 -07:00
Patrick Buckley e86305c143 chore: bump version to 0.8.3 2026-03-17 17:06:00 -07:00
Patrick Buckley d0fc42195a chore: remove dead code and fix noisy JWT test warnings
Remove unused methods (ToolSearchManager.should_activate, get_all_tools),
dead attributes (_all_tools, _threshold), unused constant (DEFAULT_INTERVAL),
unused Scenario protocol class, and vestigial parameters (judge._evaluate_single
heuristic, SimEngine.simulate_llm_response turn_number). Lengthen JWT test
secrets to >= 32 bytes to suppress InsecureKeyLengthWarning from PyJWT.
2026-03-17 17:02:25 -07:00
Patrick Buckley 760321f7ee refactor: extract _resolve_capabilities and _without_tool helpers
Extract _resolve_capabilities() shared helper so _get_capabilities()
and _run_agent() use the same config-override logic instead of
duplicating inline. Add _without_tool() module-level helper to
deduplicate the tool-filtering listcomp.
2026-03-17 16:49:11 -07:00
Patrick Buckley 693e51f782 fix: address PR #119 review feedback
Add UI error notification and exc_info logging to run_one() exception
handler so tool failures are visible in the frontend. Apply config.toml
capability overrides when gating web_search in _run_agent(), matching
the pattern used by _get_capabilities().
2026-03-17 16:49:11 -07:00
Patrick Buckley ba07409724 fix: isolate parallel tool exceptions + gate web_search without backend
Two bugs: (1) an uncaught exception in one parallel tool call killed the
entire batch via pool.map(), losing all results including successful ones.
Wrap run_one() in try/except so failures return error strings instead of
propagating. (2) web_search was offered to local models even without a
Tavily API key — the model would attempt it, only to fail at execution
time. Filter web_search from _get_active_tools() and _run_agent() when
neither native support nor Tavily is available.

Closes https://github.com/turnstonelabs/turnstone/issues/117
2026-03-17 16:49:11 -07:00
Patrick Buckley c76a61841e fix: PR #118 round 2 — null-safe parser, docs, consistency
- Null-safe extraction for description, license, and compatibility in
  skill_parser.py — YAML bare keys (e.g. `description:`) no longer
  produce the literal string "None"
- Log warning on skill catalog storage failure instead of silent swallow
- Use `enabled == 1` in list_skills_by_activation for consistency with
  other prompt_templates queries in both storage backends
- Add parser tests for YAML null description, license, and compatibility
- Update governance.md: document runtime config editing on installed
  skills, two-column modal layout, SPDX license dropdown, origin badge
2026-03-17 16:09:06 -07:00
Patrick Buckley 341d2f604f fix: address PR #118 review feedback
- Regenerate OpenAPI snapshots (openapi-console.json) to include license
  and compatibility fields in SkillInfo/CreateSkillRequest/UpdateSkillRequest
- Omit version from create/update payloads when blank so server applies
  default "1.0.0" instead of storing empty string
- Push enabled_only + limit filters into list_skills_by_activation storage
  query (protocol, SQLite, PostgreSQL) instead of loading all rows and
  filtering in Python; session.py now passes enabled_only=True, limit=30
- License length cap ([:128]) was already applied in previous commit
2026-03-17 16:09:06 -07:00
Patrick Buckley 3f7f8495d6 feat: skills modal redesign + runtime config editing for installed skills
Redesigns the create/edit/view skill modal into a two-column spec manifest
layout (Identity/Manifest/Deployment | Skill Content) matching the Agent
Skills spec structure. Installed (readonly) skills can now have their runtime
config (model, temperature, token limits, enabled) edited independently of
the locked spec/content fields.

- Two-column spec layout with section headings (Identity, Manifest, Deployment,
  Skill Content); content textarea uses monospace font and fills the column
- h3 section headings for screen-reader nav; h3 UA stylesheet reset in CSS
- Runtime Config collapsible uses 3-column grid; license field is now a select
  of SPDX identifiers (MIT, Apache-2.0, GPL-3.0, AGPL-3.0, etc.)
- Origin badge (cyan) shows source URL for installed skills in view mode
- server.py: _SKILL_RUNTIME_CONFIG_FIELDS frozenset; readonly skills filter
  updates to config-only fields (spec fields silently dropped); audit action
  distinguishes skill.update.config from skill.update; license field capped
  at 128 chars in both create and update paths
- governance.js: spec fields disabled for readonly; config fields always
  editable; Save button shown for all skills (labeled "Save Config" when
  readonly); collapsible state reset between modal opens prevents state leak;
  esk-allowed-tools disabled state driven by auto_approve not readonly
- Tests: spec-only body on readonly skill → 400; config-only → 200 with
  spec fields unchanged; mixed body → config fields applied, spec dropped
2026-03-17 16:09:06 -07:00
Patrick Buckley dc464ac313 feat: Agent Skills standard compliance + frontend spec fields
Brings skills implementation into full compliance with agentskills.io:

Parser:
- Read `allowed-tools` (hyphenated, standard) only; stored as
  `allowed_tools` internally — no underscore fallback
- Reject consecutive hyphens in skill names
- Extract author/version from standard `metadata:` map with top-level
  fallback; null-safe (no "None" string for bare YAML keys)
- Truncate description at 1024 chars, compatibility at 500 chars (spec
  caps) with log warnings
- Lenient parsing mode (lenient=True) for cross-client import: sanitizes
  names, returns None on skip, malformed-YAML colon-value retry
- Type overloads: strict mode returns ParsedSkill, lenient returns
  ParsedSkill | None

Session:
- `<available-skills>` XML catalog in system messages for
  activation="search" skills (disabled ones filtered out, capped at 30)

Tool rename:
- `load_skill` tool → `skill` (JSON, session preparers/executors,
  approval labels, tests, docs)

Storage (migration 023):
- Add `license` and `compatibility` columns to prompt_templates
- skill_license / compatibility params on create_prompt_template across
  protocol, SQLite, PostgreSQL backends
- Add to SKILL_MUTABLE for update_prompt_template

API + server:
- SkillInfo, CreateSkillRequest, UpdateSkillRequest: license +
  compatibility fields
- Create/update/install endpoints extract and persist both fields
- Install endpoint maps parsed.license + parsed.compatibility from
  imported SKILL.md (previously discarded)
- _skill_to_response() includes both fields

Admin UI:
- Create + edit modals: version, license, compatibility fields
- Readonly (imported) skills: "edit" → "view" button, modal title
  "View Skill", all fields disabled, Save hidden, Cancel → "Close",
  collapsibles auto-expand, focus on Close button
- :disabled CSS for dark-theme modal inputs (bg-highlight, cursor
  not-allowed, dimmed text)
- Fix addEventListener stacking on auto-approve checkboxes → .onchange

SDK: license + compatibility on SkillInfo, CreateSkillRequest,
UpdateSkillRequest TypeScript interfaces

Docs: governance.md, judge.md, tools.md, README, diagram updated
2026-03-17 16:09:06 -07:00
Patrick Buckley 52d59cf7b7 chore: bump version to 0.8.2 2026-03-17 02:20:44 -07:00
Patrick Buckley 2dc885ab4d fix: output guard detects single secret-bearing env lines (#115)
* fix: output guard detects single secret-bearing env lines

The credential leak check required 3+ env-style lines before flagging.
A single AWS_SECRET_ACCESS_KEY=... line was missed. Now flags whenever
any env line has a secret-bearing key name (SECRET, KEY, TOKEN,
PASSWORD, CREDENTIAL), regardless of how many total env lines exist.

* fix: tighten env secret key matching, add tests

Tighten _RE_ENV_SECRET_KEY to word-boundary segments so MONKEY/TURKEY
don't false-positive. Use any() for short-circuit. Add test for single
secret line detection and substring false-positive prevention.
2026-03-17 02:19:22 -07:00
Patrick Buckley 14488f43e0 feat: metacognitive nudge on tool error — search memories for guidance
Add tool_error nudge type that fires when a tool returns an error,
prompting the model to search memories for prior feedback about the
tool or error pattern before retrying.

- Gated on nudges config (respects nudges=false)
- Only fires when memories exist (no noise on fresh workstreams)
- Broad error detection: Error*, *error:*, Command timed out, Unknown tool
- Nudge wording aligned to memory(action='search') convention
- Respects existing cooldown (5 min) and rate limiting
- 4 new tests
2026-03-17 02:06:10 -07:00
66 changed files with 4917 additions and 1564 deletions
+2 -2
View File
@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
uv-version: "0.9.18"
- run: uv lock --check
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
uv-version: "0.9.18"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+1 -1
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.10.10 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.10.11 /uv /usr/local/bin/uv
# System dependencies for psycopg (PostgreSQL client library)
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
+1 -1
View File
@@ -145,7 +145,7 @@ Turnstone includes a built-in governance layer for enterprise deployments — ma
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
- **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md)
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `load_skill` tool for model-driven skill activation
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `skill` tool for model-driven skill activation
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
+49
View File
@@ -0,0 +1,49 @@
# OpenShell inference routing for Turnstone.
#
# When using inference routing, the sandbox process connects to
# https://inference.local instead of the real LLM API. The OpenShell
# proxy intercepts, rewrites credentials, and forwards to the backend.
#
# This keeps real API keys out of the sandbox entirely — the process
# only sees opaque placeholder tokens in its environment.
#
# Usage:
# openshell sandbox run \
# --inference-routes deploy/openshell/routes.yaml \
# ...
#
# Then start turnstone with:
# python3 -m turnstone.server --base-url https://inference.local
#
# CUSTOMIZE: uncomment one of the provider blocks below.
routes:
# --- OpenAI ---
# - name: inference.local
# endpoint: https://api.openai.com/v1
# model: gpt-5
# provider_type: openai
# protocols:
# - openai_chat_completions
# - model_discovery
# api_key_env: OPENAI_API_KEY
# --- Anthropic ---
# - name: inference.local
# endpoint: https://api.anthropic.com
# model: claude-sonnet-4-6
# provider_type: anthropic
# protocols:
# - anthropic_messages
# api_key_env: ANTHROPIC_API_KEY
# --- Local model server (vLLM / llama.cpp) ---
# No secret resolution needed — local servers typically have no auth.
# Omit both api_key and api_key_env to skip credential injection.
# - name: inference.local
# endpoint: http://localhost:8000/v1
# model: meta-llama/Llama-3.1-70B-Instruct
# protocols:
# - openai_chat_completions
# - model_discovery
+333
View File
@@ -0,0 +1,333 @@
# OpenShell sandbox policy for Turnstone AI orchestration platform.
#
# This policy wraps a turnstone-server process (the primary sandbox target).
# The bridge, console, and channel gateway are separate processes that would
# each need their own sandbox with a tailored policy variant.
#
# Usage:
# openshell sandbox run \
# --policy deploy/openshell/turnstone-policy.yaml \
# --workdir /project \
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080
#
# For inference routing (keeps real API keys out of the sandbox):
# openshell sandbox run \
# --policy deploy/openshell/turnstone-policy.yaml \
# --inference-routes deploy/openshell/routes.yaml \
# --workdir /project \
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
# --base-url https://inference.local
#
# Note: inference.local is intercepted by the OpenShell proxy before
# network policy evaluation — no network_policies entry is needed for it.
#
# Customization points (search for "CUSTOMIZE"):
# - OIDC issuer endpoint
# - Redis host/port (if not localhost)
# - MCP HTTP server endpoints
# - Additional tool binaries
# - web_fetch domain allowlist
version: 1
# ---------------------------------------------------------------------------
# Filesystem: Landlock kernel enforcement
# ---------------------------------------------------------------------------
# Static — cannot be changed after sandbox creation.
# include_workdir adds the --workdir path to read_write automatically.
filesystem_policy:
include_workdir: true
read_only:
# Python runtime + installed packages (includes turnstone package)
- /usr
- /lib
- /lib64
# System essentials
- /etc
- /proc
- /dev/urandom
# Turnstone config (read-only — writes go to database)
# CUSTOMIZE: adjust if config lives elsewhere
- /home/sandbox/.config/turnstone
read_write:
# Working directory is added via include_workdir
# Temp files (bash tool scripts, eval workdirs)
- /tmp
# Shell redirections (2>/dev/null)
- /dev/null
# SQLite database (default location is workdir, covered by include_workdir)
# Logs
- /var/log
landlock:
# best_effort: degrade gracefully on kernels without Landlock (< 5.13)
# Change to hard_requirement for production hardened deployments
compatibility: best_effort
# ---------------------------------------------------------------------------
# Process: privilege separation
# ---------------------------------------------------------------------------
process:
run_as_user: sandbox
run_as_group: sandbox
# ---------------------------------------------------------------------------
# Network: per-endpoint, per-binary allowlisting
# ---------------------------------------------------------------------------
# Default-deny. Only listed host:port pairs are reachable.
# Child processes (MCP servers, bash subcommands) inherit the network
# namespace — they cannot bypass the proxy.
network_policies:
# --- LLM API providers ---
openai_api:
name: openai-api
endpoints:
- host: api.openai.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
anthropic_api:
name: anthropic-api
endpoints:
- host: api.anthropic.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Web search fallback (Tavily) ---
tavily_api:
name: tavily-search
endpoints:
- host: api.tavily.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Skill discovery ---
skills_registry:
name: skills-registry
endpoints:
- host: skills.sh
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
github_api:
name: github-api
endpoints:
- host: api.github.com
port: 443
protocol: rest
tls: terminate
enforcement: enforce
access: read-only
- host: raw.githubusercontent.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
mcp_registry:
name: mcp-registry
endpoints:
- host: registry.modelcontextprotocol.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
access: read-only
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- OIDC SSO ---
# CUSTOMIZE: replace with your identity provider's hostname
# oidc_provider:
# name: oidc-provider
# endpoints:
# - host: login.example.com
# port: 443
# binaries:
# - path: /usr/bin/python3*
# - path: /usr/local/bin/python3*
# --- Redis (MQ) ---
# CUSTOMIZE: if Redis is not on localhost, add host + allowed_ips.
# localhost is blocked by default SSRF protection, so we need allowed_ips.
redis:
name: redis-mq
endpoints:
- port: 6379
allowed_ips:
- "127.0.0.1"
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Discord (channel integration) ---
# Uncomment if using turnstone-channel with Discord adapter.
# discord:
# name: discord
# endpoints:
# - host: discord.com
# port: 443
# - host: gateway.discord.gg
# port: 443
# - host: cdn.discordapp.com
# port: 443
# binaries:
# - path: /usr/bin/python3*
# - path: /usr/local/bin/python3*
# --- web_fetch tool: curated domain allowlist ---
#
# This is the hard tradeoff. Turnstone's web_fetch tool lets the LLM
# fetch arbitrary public URLs. OpenShell cannot allow "all HTTPS" —
# every domain must be enumerated.
#
# Strategy: allowlist the domains your workloads actually need.
# The web_fetch tool will return a connection error for unlisted domains,
# which the LLM handles gracefully (it tells the user it can't reach
# that site).
#
# CUSTOMIZE: add domains your workstreams need to fetch from.
web_fetch_common:
name: web-fetch-common
endpoints:
# Documentation sites
- host: "**.readthedocs.io"
port: 443
- host: docs.python.org
port: 443
- host: "**.github.io"
port: 443
# Package registries (metadata lookups)
- host: pypi.org
port: 443
- host: www.npmjs.com
port: 443
# Stack Overflow / reference
- host: stackoverflow.com
port: 443
- host: "**.stackexchange.com"
port: 443
# Wikipedia
- host: "**.wikipedia.org"
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- MCP HTTP servers ---
# CUSTOMIZE: add endpoints for any MCP servers using streamable-http
# transport. stdio-transport MCP servers need no network entry (they
# communicate via stdin/stdout pipes within the sandbox).
# mcp_http_servers:
# name: mcp-http
# endpoints:
# - host: mcp.internal.example.com
# port: 443
# binaries:
# - path: /usr/bin/python3*
# - path: /usr/local/bin/python3*
# --- Bash tool: curl/wget ---
# The bash tool can run curl/wget. These inherit the network namespace
# so they can only reach allowed endpoints. But they need binary entries
# to pass the proxy's identity check.
bash_network_tools:
name: bash-network-tools
endpoints:
# Mirrors web_fetch_common — curl/wget should have the same reach.
- host: "**.readthedocs.io"
port: 443
- host: docs.python.org
port: 443
- host: "**.github.io"
port: 443
- host: pypi.org
port: 443
- host: www.npmjs.com
port: 443
- host: stackoverflow.com
port: 443
- host: "**.stackexchange.com"
port: 443
- host: "**.wikipedia.org"
port: 443
binaries:
- path: /usr/bin/curl
- path: /usr/bin/wget
# --- Package installation ---
# pip install / uv add from the bash tool.
package_registries:
name: package-install
endpoints:
- host: pypi.org
port: 443
- host: files.pythonhosted.org
port: 443
- host: "**.pypi.org"
port: 443
binaries:
- path: /usr/bin/pip*
- path: /usr/local/bin/pip*
- path: /usr/bin/uv
- path: /usr/local/bin/uv
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Git operations ---
# read-only: clone, fetch, pull. No push (L7 enforcement).
git_operations:
name: git-read-only
endpoints:
- host: github.com
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: "/**/info/refs*"
- allow:
method: POST
path: "/**/git-upload-pack"
- host: gitlab.com
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: "/**/info/refs*"
- allow:
method: POST
path: "/**/git-upload-pack"
binaries:
- path: /usr/bin/git
+15 -5
View File
@@ -100,7 +100,7 @@ turnstone/
index.html Single-page app shell (links to CSS and JS)
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
app.js Page-specific client-side JavaScript (SSE, workstreams, tool approval)
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
tools/
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
```
@@ -375,12 +375,19 @@ non-idle background workstreams above the input prompt.
### Web Workstreams
- **Tab bar**: Each workstream renders as a tab with a colored state indicator
(CSS `@keyframes pulse` animation per state).
- **Per-tab SSE**: `connectContentSSE(wsId)` opens
`/v1/api/events?ws_id=<id>` for the active tab's event stream.
(CSS `@keyframes pulse` animation per state). Clicking a tab switches the
focused pane's workstream (or focuses an existing pane showing that ws).
- **Split panes**: The UI supports tiling multiple workstreams side-by-side or
stacked via a binary layout tree. Each `Pane` instance encapsulates its own
SSE connection, message area, input, and state (busy, approval, streaming).
Split via right-click context menu, pane header buttons, or keyboard
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
Layout persisted to `localStorage`.
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
`/v1/api/events?ws_id=<id>` for each pane's event stream independently.
- **Global SSE**: `connectGlobalSSE()` opens `/v1/api/events/global` which
receives `ws_state` broadcasts from all workstreams, used to update tab
indicators without switching.
indicators and pane headers without switching.
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
### Thread Safety
@@ -953,6 +960,9 @@ warns if the summary was truncated.
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
`popstate` listener restores the correct tab or shows the dashboard,
guarded by `_historyNavigation = true` to prevent re-entrant pushState.
- **Pane focus**: `mousedown` and `focusin` events on pane containers update
`focusedPaneId`. Approval shortcuts (y/n/a) apply to the focused pane.
`Ctrl+Alt+Arrow` cycles focus between panes.
### Eval Resilience
@@ -250,13 +250,11 @@ class "MCPClientManager" as MCPMgr {
' ToolSearchManager
class "ToolSearchManager" as ToolSearchMgr {
- _all_tools: list[dict]
- _always_on: list[dict]
- _deferred: list[dict]
- _expanded: dict[str, None]
- _index: BM25Index
--
+ should_activate() → bool
+ get_visible_tools() → list[dict]
+ get_deferred_tools() → list[dict]
+ get_expanded_names() → list[str]
@@ -33,7 +33,7 @@ package "Core Modules" as core #181825 {
}
package "Session Runtime" as runtime #181825 {
rectangle "load_skill tool\nsession.py" as loadtool
rectangle "skill tool\nsession.py" as loadtool
rectangle "set_skill()\nsession.py" as setskill
rectangle "_load_skills()\nsession.py" as loadskills
}
@@ -80,6 +80,7 @@ importui --> install : POST (github source)
' Annotations
note right of parser
YAML frontmatter -> ParsedSkill
allowed-tools (standard) -> allowed_tools (internal)
Anthropic + Hermes tag formats
Name validation (lowercase+hyphens)
end note
+20 -2
View File
@@ -70,7 +70,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
- **Runtime switching**: `/template <name>` to switch, `/template clear` to revert
to defaults, `/template` to show current. Persisted across resume.
- **Model-driven loading**: The `load_skill` built-in tool lets the model
- **Model-driven loading**: The `skill` built-in tool lets the model
discover and activate skills mid-conversation. `search` action finds skills
by query (auto-approved); `load` action activates by name (requires user
approval since it changes session behavior). Main session only.
@@ -83,11 +83,15 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
precedence on name collision. MCP-synced content updates reset `is_default` to
prevent compromised servers from injecting defaults. Admin UI shows origin badge
and disables edit/delete for MCP-sourced skills.
- **Spec fields**: Skills support the full Agent Skills standard frontmatter:
`name`, `description`, `license`, `compatibility`, `metadata` (author, version),
`allowed-tools`. The `license` and `compatibility` fields are preserved on import
and editable in the admin UI. See https://agentskills.io/specification.
- **Security scanning**: Skills are automatically scanned at creation and update
time. The scanner evaluates four risk axes: content risk (command execution,
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
vulnerability risk (prompt injection, insecure credentials), and declared
capability risk (from `allowed_tools`). Results populate the `scan_status`
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
These fields are system-managed and cannot be overwritten via the admin API.
- **Discovery**: External skills can be discovered and installed from registries:
@@ -100,6 +104,20 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
Discovery view has search bar, result cards, and "Import from GitHub" modal.
- SDK: `discover_skills(q)` and `install_skill(source, skill_id=..., url=...)`
on both Python and TypeScript console clients.
- **Runtime config on installed skills**: Installed (readonly) skills can have
their runtime configuration edited — model, temperature, reasoning effort,
token budget, max tokens, agent max turns, auto-approve, allowed tools,
and enabled flag. The server restricts updates to these fields only via
`_SKILL_RUNTIME_CONFIG_FIELDS` filtering; spec/content fields (name,
description, tags, license, compatibility, content, activation) remain
immutable. The admin UI shows "Save Config" instead of "Save" for these
skills. Audit action: `skill.update.config`.
- **Admin UI**: Create/Edit skill modals use a two-column spec manifest layout
(left: Identity / Manifest / Deployment; right: Skill Content editor with
monospace font). Runtime Config is a collapsible 3-column grid below.
License uses an SPDX identifier dropdown (MIT, Apache-2.0, GPL-3.0, etc.).
Installed skills show a cyan origin badge with source URL, spec fields are
disabled, and all collapsible sections auto-expand in view mode.
### Usage Tracking
+1 -1
View File
@@ -299,7 +299,7 @@ four independent risk axes:
obfuscation, download-execute chains, executable URLs from untrusted domains
3. **Vulnerability risk** — prompt injection patterns, insecure credential
handling, third-party content exposure (indirect prompt injection surface)
4. **Declared capability risk** — parsed from the skill's `allowed_tools` field.
4. **Declared capability risk** — parsed from `allowed-tools` in the skill's SKILL.md.
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
Read-only tools are safe.
+288
View File
@@ -0,0 +1,288 @@
# OpenShell Sandbox Integration
Turnstone can run inside an [OpenShell](https://github.com/NVIDIA/OpenShell)
sandbox for kernel-enforced security boundaries around tool execution. OpenShell
provides four layers of defense that Turnstone's application-level safety model
does not cover:
| Layer | Mechanism | What it prevents |
|-------|-----------|------------------|
| Filesystem | Landlock | Writes to `/etc`, `~/.ssh`, system paths |
| Network | Network namespace + seccomp + HTTP CONNECT proxy | Connections to unlisted hosts |
| Process | `setuid` drop + verification | Privilege escalation to root |
| Credentials | Proxy-level secret resolution | API keys in sandbox memory |
Turnstone's own safety layers (human approval, intent judge, tool policies,
output guard) remain active inside the sandbox and handle threats at the semantic
level -- what the LLM *means* to do with its legitimate access.
> See also: [Security and Authentication](security.md),
> [Intent Validation](judge.md), [Governance](governance.md)
---
## Quick Start
```bash
# Run turnstone-server in an OpenShell sandbox
openshell sandbox run \
--policy deploy/openshell/turnstone-policy.yaml \
--workdir /path/to/project \
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080
```
With inference routing (API keys never enter the sandbox):
```bash
openshell sandbox run \
--policy deploy/openshell/turnstone-policy.yaml \
--inference-routes deploy/openshell/routes.yaml \
--workdir /path/to/project \
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
--base-url https://inference.local
```
The `inference.local` hostname is intercepted by the OpenShell proxy before
network policy evaluation -- no network policy entry is needed for it.
---
## Policy Files
### `deploy/openshell/turnstone-policy.yaml`
The main sandbox policy. Covers filesystem, process, and network rules.
### `deploy/openshell/routes.yaml`
Inference routing configuration. Maps `inference.local` to real LLM API
backends. Uncomment and configure the provider(s) you use.
---
## Filesystem Policy
The policy uses Landlock (Linux 5.13+) for kernel-enforced filesystem access
control. Paths are locked at sandbox creation and cannot be changed at runtime.
| Path | Access | Purpose |
|------|--------|---------|
| `--workdir` | read-write | Project files (auto-added via `include_workdir`) |
| `/tmp` | read-write | Bash tool temp scripts, eval workdirs |
| `/dev/null` | read-write | Shell redirections (`2>/dev/null`) |
| `/var/log` | read-write | Log files |
| `/usr`, `/lib`, `/lib64` | read-only | Python runtime, installed packages |
| `/etc` | read-only | System config, SSL certificates |
| `/proc`, `/dev/urandom` | read-only | Process info, entropy |
| `~/.config/turnstone` | read-only | Config file (writes go to database) |
Landlock runs in `best_effort` mode by default -- degrades gracefully on kernels
without Landlock support. Set `compatibility: hard_requirement` for production
hardened deployments.
---
## Network Policy
Default-deny. Only explicitly listed host:port pairs are reachable. All child
processes (MCP servers, bash commands, grep) inherit the network namespace and
cannot bypass the proxy.
### Included endpoints
| Policy | Hosts | Purpose |
|--------|-------|---------|
| `openai_api` | `api.openai.com` | OpenAI LLM API |
| `anthropic_api` | `api.anthropic.com` | Anthropic LLM API |
| `tavily_api` | `api.tavily.com` | Web search fallback |
| `skills_registry` | `skills.sh` | Skill discovery |
| `github_api` | `api.github.com` (read-only L7), `raw.githubusercontent.com` | Skill fetch, GitHub API |
| `mcp_registry` | `registry.modelcontextprotocol.io` (read-only L7) | MCP server discovery |
| `redis` | `127.0.0.1:6379` | Message queue |
| `web_fetch_common` | readthedocs, python docs, GitHub Pages, PyPI, npm, Stack Overflow, Wikipedia | Curated web_fetch domains |
| `bash_network_tools` | Same as `web_fetch_common` | curl/wget from bash tool |
| `package_registries` | `pypi.org`, `files.pythonhosted.org` | pip/uv package installs |
| `git_operations` | `github.com`, `gitlab.com` (L7: clone/fetch only, no push) | Git read-only operations |
### L7 enforcement
Endpoints marked with `protocol: rest` and `tls: terminate` get HTTP-level
inspection. The proxy TLS-terminates using an ephemeral per-sandbox CA, parses
each request, and evaluates method + path against the rules.
The `github_api`, `mcp_registry`, and `git_operations` policies use L7
enforcement:
- **GitHub API / MCP Registry**: `access: read-only` -- only GET, HEAD, OPTIONS
allowed
- **Git operations**: explicit rules allowing only `info/refs` (GET) and
`git-upload-pack` (POST) -- clone and fetch work, push is blocked
### Commented-out sections
The policy includes commented blocks for optional integrations. Uncomment and
configure as needed:
- **OIDC** -- add your identity provider's hostname
- **Discord** -- `discord.com`, `gateway.discord.gg`, `cdn.discordapp.com`
- **MCP HTTP servers** -- any MCP servers using streamable-http transport
---
## Customizing the Domain Allowlist
The `web_fetch` tool lets the LLM fetch arbitrary public URLs, but OpenShell
cannot allow "all HTTPS" -- bare wildcard hosts are rejected by policy
validation. Instead, the policy ships with a curated set of common reference
domains.
To add domains your workloads need:
```yaml
# In turnstone-policy.yaml, under web_fetch_common.endpoints:
- host: docs.example.com
port: 443
# Also add to bash_network_tools.endpoints if curl/wget should reach it:
- host: docs.example.com
port: 443
```
Wildcard patterns are supported:
- `*.example.com` -- matches one subdomain level (e.g. `api.example.com`)
- `**.example.com` -- matches any depth (e.g. `deep.sub.example.com`)
Unlisted domains return connection errors, which the LLM handles gracefully by
telling the user it cannot reach that site.
---
## Inference Routing
Inference routing keeps real API keys completely outside the sandbox. The
sandbox process only sees opaque placeholder tokens in its environment
(`openshell:resolve:env:ANTHROPIC_API_KEY`). The proxy rewrites these to real
credentials on the wire before forwarding to the upstream API.
### Setup
1. Edit `deploy/openshell/routes.yaml` -- uncomment your provider:
```yaml
routes:
# OpenAI
- name: inference.local
endpoint: https://api.openai.com/v1
model: gpt-5
provider_type: openai
protocols:
- openai_chat_completions
- model_discovery
api_key_env: OPENAI_API_KEY
# Or Anthropic
- name: inference.local
endpoint: https://api.anthropic.com
model: claude-sonnet-4-6
provider_type: anthropic
protocols:
- anthropic_messages
api_key_env: ANTHROPIC_API_KEY
```
2. Start with `--inference-routes` and point turnstone at `inference.local`:
```bash
openshell sandbox run \
--inference-routes deploy/openshell/routes.yaml \
--base-url https://inference.local \
...
```
3. When inference routing is active, the `openai_api` and `anthropic_api`
network policies can be removed from the sandbox policy -- the proxy handles
LLM traffic on a separate code path that bypasses OPA entirely.
### Local model servers
For local servers (vLLM, llama.cpp) with no authentication, omit both
`api_key` and `api_key_env` from the route config. No credential resolution
is needed.
---
## MCP Server Subprocesses
MCP servers using stdio transport are spawned as child processes of turnstone.
They automatically inherit all sandbox constraints:
- **Network namespace** -- kernel-level, cannot be bypassed
- **Landlock filesystem** -- kernel-level, cannot be relaxed
- **Seccomp socket filter** -- kernel-level, inherited on fork
No per-subprocess policy entries are needed for these constraints. However, if
an MCP server makes outbound network requests (through the proxy), its binary
must appear in a `binaries[]` entry for the relevant network policy. The proxy
identifies the requesting process via `/proc/<pid>/exe` (not `argv[0]`, which
is spoofable).
Example for a Python-based MCP server that calls an external API:
```yaml
mcp_external_api:
name: mcp-external
endpoints:
- host: api.example.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
```
MCP servers using streamable-http transport are remote -- they need a network
policy entry for their host:port but no binary entry (the Python process making
the HTTP call is already covered by the standard `python3*` binary entries).
---
## Security Model: Which Layer Enforces What
```
OpenShell (infrastructure) Turnstone (application)
───────────────────────────── ──────────────────────────────
Filesystem access Landlock kernel enforcement (no enforcement)
Network egress Netns + seccomp + proxy + OPA SSRF check on web_fetch
Credentials Placeholder injection + proxy Output guard redaction
Privilege level setuid drop + verification (no enforcement)
Tool semantics (no visibility) Heuristic + LLM judge
Tool policies (no visibility) fnmatch admin policies
Prompt injection (no visibility) Output guard detection
Human approval (no visibility) Approval gate + "always"
```
OpenShell constrains what the process can physically reach. Turnstone constrains
what the LLM does with its legitimate access. Neither layer is sufficient alone:
- Without OpenShell: a bash command can `curl` secrets to any endpoint, write to
`/etc/crontab`, or read `~/.ssh/id_rsa` -- all gated only by human approval
- Without Turnstone: the LLM can `rm -rf` the entire workdir, run destructive
commands, or consume prompt injection payloads -- all within the sandbox's
allowed scope
---
## Hardening Checklist
For production deployments:
- [ ] Set `landlock.compatibility: hard_requirement`
- [ ] Enable inference routing (removes API keys from sandbox)
- [ ] Remove `openai_api`/`anthropic_api` network policies when using inference
routing (traffic goes through the router, not direct)
- [ ] Review and trim `web_fetch_common` domains to your actual needs
- [ ] Remove `package_registries` policy if pip/uv installs are not needed
- [ ] Add your OIDC provider endpoint if using SSO
- [ ] Set Redis `allowed_ips` to your actual Redis host if not localhost
- [ ] Consider removing `bash_network_tools` entirely if bash should not have
network access
+5 -5
View File
@@ -492,7 +492,7 @@ data.get("mergedAt") is not None
---
### load_skill
### skill
Discover and activate skills at runtime during a conversation. The model can
search for available skills and load one by name, replacing the current active
@@ -543,7 +543,7 @@ pre-configure skills at workstream creation.
| `watch` | Monitor | No (create) | No | No | `command` |
| `read_resource`| MCP | No | Yes | Yes | `uri` |
| `use_prompt` | MCP | No | Yes | Yes | `name` |
| `load_skill` | Skills | No (load) | No | No | `name` |
| `skill` | Skills | No (load) | No | No | `name` |
| `tool_search`| Search | Yes | No | No | `query` |
---
@@ -591,9 +591,9 @@ CLI flags override the config file:
### How it works
1. **Threshold check**: At session startup, `ToolSearchManager.should_activate()`
counts total tools (built-in + MCP). If the count is below the threshold, tool
search stays off and all tools are sent to the model directly.
1. **Threshold check**: At session startup, if the total tool count (built-in + MCP)
is below the threshold, tool search stays off and all tools are sent to the model
directly.
2. **Partitioning**: When active, tools are split into two sets:
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.8.1"
version = "0.8.4"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+51 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Console API",
"version": "0.7.0",
"version": "0.8.4",
"description": "Cluster-wide visibility and control across all turnstone nodes."
},
"paths": {
@@ -4378,6 +4378,12 @@
"description": "Skill name (replaces default skills)",
"title": "Skill",
"type": "string"
},
"resume_ws": {
"default": "",
"description": "Workstream ID to resume (loads previous conversation)",
"title": "Resume Ws",
"type": "string"
}
},
"title": "ConsoleCreateWsRequest",
@@ -6883,6 +6889,16 @@
"title": "Allowed Tools",
"type": "string"
},
"license": {
"default": "",
"title": "License",
"type": "string"
},
"compatibility": {
"default": "",
"title": "Compatibility",
"type": "string"
},
"scan_status": {
"default": "",
"title": "Scan Status",
@@ -7107,6 +7123,16 @@
"default": "[]",
"title": "Allowed Tools",
"type": "string"
},
"license": {
"default": "",
"title": "License",
"type": "string"
},
"compatibility": {
"default": "",
"title": "Compatibility",
"type": "string"
}
},
"required": [
@@ -7357,6 +7383,30 @@
],
"default": null,
"title": "Allowed Tools"
},
"license": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "License"
},
"compatibility": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Compatibility"
}
},
"title": "UpdateSkillRequest",
+12 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "0.7.0",
"version": "0.8.4",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -1559,6 +1559,11 @@
"title": "Version",
"type": "string"
},
"node_id": {
"default": "",
"title": "Node Id",
"type": "string"
},
"uptime_seconds": {
"default": 0.0,
"title": "Uptime Seconds",
@@ -1569,6 +1574,12 @@
"title": "Model",
"type": "string"
},
"max_ws": {
"default": 10,
"description": "Maximum concurrent workstreams",
"title": "Max Ws",
"type": "integer"
},
"workstreams": {
"$ref": "#/components/schemas/WorkstreamCounts",
"default": {
+7
View File
@@ -187,6 +187,8 @@ export interface SkillInfo {
notify_on_complete: string;
enabled: boolean;
allowed_tools: string;
license: string;
compatibility: string;
resource_count: number;
created: string;
updated: string;
@@ -214,6 +216,8 @@ export interface CreateSkillRequest {
notify_on_complete?: string;
enabled?: boolean;
allowed_tools?: string;
license?: string;
compatibility?: string;
}
export interface UpdateSkillRequest {
@@ -237,6 +241,8 @@ export interface UpdateSkillRequest {
notify_on_complete?: string;
enabled?: boolean;
allowed_tools?: string;
license?: string;
compatibility?: string;
}
export interface ListSkillsResponse {
@@ -396,6 +402,7 @@ export interface ConsoleCreateWsRequest {
model?: string;
initial_message?: string;
skill?: string;
resume_ws?: string;
}
export interface ConsoleCreateWsResponse {
+1
View File
@@ -19,6 +19,7 @@ class TestServerVersioning:
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = []
mock_mgr.max_workstreams = 10
app = create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
+10 -2
View File
@@ -783,6 +783,7 @@ class TestServerAuth:
mock_ws.session = mock_session
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
app = srv_mod.create_app(
workstreams=mock_mgr,
@@ -1001,6 +1002,7 @@ class TestServerLogin:
mock_ws.session = mock_session
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
app = srv_mod.create_app(
workstreams=mock_mgr,
@@ -1369,8 +1371,11 @@ class TestCorsConfigurable:
import turnstone.server as srv_mod
mgr = MagicMock()
mgr.list_all.return_value = []
mgr.max_workstreams = 10
app = srv_mod.create_app(
workstreams=MagicMock(),
workstreams=mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
@@ -1388,8 +1393,11 @@ class TestCorsConfigurable:
import turnstone.server as srv_mod
mgr = MagicMock()
mgr.list_all.return_value = []
mgr.max_workstreams = 10
app = srv_mod.create_app(
workstreams=MagicMock(),
workstreams=mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
+5 -5
View File
@@ -130,7 +130,7 @@ class TestParseScopes:
class TestJWT:
SECRET = "test-secret-key-for-jwt"
SECRET = "test-secret-key-for-jwt-min-32b!"
def test_round_trip(self):
scopes = frozenset({"read", "write"})
@@ -155,7 +155,7 @@ class TestJWT:
def test_invalid_signature(self):
token = create_jwt("user1", frozenset({"read"}), "db", self.SECRET)
assert validate_jwt(token, "wrong-secret") is None
assert validate_jwt(token, "wrong-secret-key-for-jwt-min-32b") is None
def test_malformed_token(self):
assert validate_jwt("not.a.jwt", self.SECRET) is None
@@ -217,7 +217,7 @@ class TestAuthenticateToken:
assert result.scopes == frozenset({"read", "write", "approve"})
def test_jwt_token(self):
secret = "test-secret"
secret = "test-secret-key-for-jwt-min-32b!"
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
cfg = AuthConfig(enabled=True)
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
@@ -304,7 +304,7 @@ class TestCheckRequestScopes:
assert result.has_scope("approve")
def test_jwt_with_scopes(self):
secret = "test"
secret = "test-secret-key-for-jwt-min-32b!"
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
cfg = AuthConfig(enabled=True)
allowed, status, msg, result = check_request(
@@ -319,7 +319,7 @@ class TestCheckRequestScopes:
assert result.user_id == "u1"
def test_jwt_insufficient_scope(self):
secret = "test"
secret = "test-secret-key-for-jwt-min-32b!"
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
cfg = AuthConfig(enabled=True)
allowed, status, msg, _ = check_request(
+85 -1
View File
@@ -3,7 +3,7 @@
import asyncio
import json
import queue
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
@@ -264,6 +264,57 @@ class TestCollectorPolling:
assert q.empty()
assert len(c._nodes["node-a"].workstreams) == 0
def test_poll_401_preserves_workstreams_and_marks_unreachable(self):
"""A 401 from the server must NOT wipe workstream data."""
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
reachable=True,
workstreams={"ws1": {"id": "ws1", "name": "existing", "state": "idle"}},
)
# Mock httpx to return 401
import httpx as _httpx
mock_response = _httpx.Response(
401,
json={"error": "Unauthorized"},
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
)
with patch.object(c._http_client, "get", return_value=mock_response):
c._poll_all_nodes()
# Workstream data must be preserved, node marked unreachable
assert c._nodes["node-a"].reachable is False
assert "ws1" in c._nodes["node-a"].workstreams
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "existing"
def test_poll_403_preserves_workstreams(self):
"""A 403 should also preserve state and mark unreachable."""
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
reachable=True,
workstreams={"ws1": {"id": "ws1", "name": "keep-me", "state": "running"}},
)
import httpx as _httpx
mock_response = _httpx.Response(
403,
json={"error": "Forbidden"},
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
)
with patch.object(c._http_client, "get", return_value=mock_response):
c._poll_all_nodes()
assert c._nodes["node-a"].reachable is False
assert "ws1" in c._nodes["node-a"].workstreams
class TestCollectorEvents:
"""Real-time event handling from cluster channel."""
@@ -1050,6 +1101,39 @@ class TestConsoleWorkstreamCreation:
assert resp.status_code == 200
assert resp.json()["target_node"] == "pool"
def test_create_with_resume_ws_directed(self, client_and_broker, mock_collector):
"""resume_ws is forwarded in directed dispatch."""
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "node-a", "resume_ws": "old-ws-id-123"},
)
assert resp.status_code == 200
msg = json.loads(broker.push_inbound.call_args[0][0])
assert msg["resume_ws"] == "old-ws-id-123"
def test_create_with_resume_ws_pool(self, client_and_broker, mock_collector):
"""resume_ws is forwarded in pool dispatch."""
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "pool", "resume_ws": "old-ws-id-456"},
)
assert resp.status_code == 200
msg = json.loads(broker.push_inbound.call_args[0][0])
assert msg["resume_ws"] == "old-ws-id-456"
def test_create_with_resume_ws_auto(self, client_and_broker, mock_collector):
"""resume_ws is forwarded in auto-select dispatch."""
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"resume_ws": "old-ws-id-789"},
)
assert resp.status_code == 200
msg = json.loads(broker.push_inbound.call_args[0][0])
assert msg["resume_ws"] == "old-ws-id-789"
# ---------------------------------------------------------------------------
# Proxy tests
-4
View File
@@ -182,7 +182,6 @@ class TestErrorHandling:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
MagicMock(),
)
assert result is None
@@ -215,7 +214,6 @@ class TestErrorHandling:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
MagicMock(),
)
assert result is None
@@ -259,7 +257,6 @@ class TestMultiTurnToolUse:
verdict = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
MagicMock(),
)
assert verdict is not None
assert verdict.tier == "llm"
@@ -305,7 +302,6 @@ class TestMultiTurnToolUse:
judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
MagicMock(),
)
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
assert provider.create_completion.call_count == 5
+143 -46
View File
@@ -1,4 +1,4 @@
"""Tests for the load_skill built-in tool."""
"""Tests for the skill built-in tool."""
from __future__ import annotations
@@ -9,25 +9,25 @@ from turnstone.core.tools import BUILTIN_TOOL_NAMES, PRIMARY_KEY_MAP
class TestToolRegistration:
"""Verify load_skill is registered correctly."""
"""Verify skill is registered correctly."""
def test_in_builtin_tool_names(self) -> None:
assert "load_skill" in BUILTIN_TOOL_NAMES
assert "skill" in BUILTIN_TOOL_NAMES
def test_not_agent_tool(self) -> None:
from turnstone.core.tools import AGENT_TOOLS
names = {t["function"]["name"] for t in AGENT_TOOLS}
assert "load_skill" not in names
assert "skill" not in names
def test_not_task_agent_tool(self) -> None:
from turnstone.core.tools import TASK_AGENT_TOOLS
names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
assert "load_skill" not in names
assert "skill" not in names
def test_has_primary_key(self) -> None:
assert PRIMARY_KEY_MAP.get("load_skill") == "name"
assert PRIMARY_KEY_MAP.get("skill") == "name"
# ---------------------------------------------------------------------------
@@ -82,12 +82,12 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
class TestPrepareLoadSkill:
"""Test _prepare_load_skill validation and item dict shape."""
"""Test _prepare_skill validation and item dict shape."""
def test_load_valid(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
assert item["func_name"] == "load_skill"
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
assert item["func_name"] == "skill"
assert item["action"] == "load"
assert item["name"] == "code-review"
assert item["needs_approval"] is True
@@ -96,19 +96,19 @@ class TestPrepareLoadSkill:
def test_load_missing_name(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "load"})
item = session._prepare_skill("call-1", {"action": "load"})
assert "error" in item
assert "name" in item["error"].lower()
assert item["needs_approval"] is False
def test_load_empty_name(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "load", "name": ""})
item = session._prepare_skill("call-1", {"action": "load", "name": ""})
assert "error" in item
def test_search_with_query(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
assert item["action"] == "search"
assert item["query"] == "code review"
assert item["needs_approval"] is False
@@ -116,30 +116,30 @@ class TestPrepareLoadSkill:
def test_search_without_query(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search"})
item = session._prepare_skill("call-1", {"action": "search"})
assert item["action"] == "search"
assert item["query"] == ""
assert item["needs_approval"] is False
def test_invalid_action(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "delete"})
item = session._prepare_skill("call-1", {"action": "delete"})
assert "error" in item
assert "delete" in item["error"]
def test_empty_action(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": ""})
item = session._prepare_skill("call-1", {"action": ""})
assert "error" in item
def test_header_for_load(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
assert "my-skill" in item["header"]
def test_header_for_search(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search", "query": "testing"})
item = session._prepare_skill("call-1", {"action": "search", "query": "testing"})
assert "testing" in item["header"]
@@ -149,7 +149,7 @@ class TestPrepareLoadSkill:
class TestExecLoadSkill:
"""Test _exec_load_skill execution logic."""
"""Test _exec_skill execution logic."""
def test_load_existing_skill(self) -> None:
skills = [
@@ -164,8 +164,8 @@ class TestExecLoadSkill:
session, _, fake_get = _make_session(skills)
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
call_id, result = session._exec_load_skill(item)
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
call_id, result = session._exec_skill(item)
assert call_id == "call-1"
assert "code-review" in result
@@ -177,8 +177,8 @@ class TestExecLoadSkill:
session, _, fake_get = _make_session([])
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
item = session._prepare_load_skill("call-1", {"action": "load", "name": "nope"})
call_id, result = session._exec_load_skill(item)
item = session._prepare_skill("call-1", {"action": "load", "name": "nope"})
call_id, result = session._exec_skill(item)
assert "not found" in result.lower()
assert session._set_skill_called == []
@@ -188,8 +188,8 @@ class TestExecLoadSkill:
session, _, fake_get = _make_session(skills)
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
item = session._prepare_load_skill("call-1", {"action": "load", "name": "test"})
session._exec_load_skill(item)
item = session._prepare_skill("call-1", {"action": "load", "name": "test"})
session._exec_skill(item)
session.ui.on_tool_result.assert_called_once()
@@ -216,10 +216,10 @@ class TestExecLoadSkill:
mock_storage.list_prompt_templates.return_value = skills
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code"})
item = session._prepare_skill("call-1", {"action": "search", "query": "code"})
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
call_id, result = session._exec_load_skill(item)
call_id, result = session._exec_skill(item)
assert "code-review" in result
# docs-writer shouldn't match "code" query
@@ -241,10 +241,10 @@ class TestExecLoadSkill:
mock_storage.list_prompt_templates.return_value = skills
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search"})
item = session._prepare_skill("call-1", {"action": "search"})
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
call_id, result = session._exec_load_skill(item)
call_id, result = session._exec_skill(item)
# Should be limited to 10
assert result.count("skill-") == 10
@@ -254,10 +254,10 @@ class TestExecLoadSkill:
mock_storage.list_prompt_templates.return_value = []
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search", "query": "nonexistent"})
item = session._prepare_skill("call-1", {"action": "search", "query": "nonexistent"})
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
call_id, result = session._exec_load_skill(item)
call_id, result = session._exec_skill(item)
assert "no skills found" in result.lower()
@@ -276,21 +276,21 @@ class TestExecLoadSkill:
mock_storage.list_prompt_templates.return_value = skills
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search", "query": "risky"})
item = session._prepare_skill("call-1", {"action": "search", "query": "risky"})
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
call_id, result = session._exec_load_skill(item)
call_id, result = session._exec_skill(item)
assert "high" in result
def test_search_storage_failure_returns_empty(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search", "query": "test"})
item = session._prepare_skill("call-1", {"action": "search", "query": "test"})
with patch(
"turnstone.core.storage._registry.get_storage", side_effect=RuntimeError("no storage")
):
call_id, result = session._exec_load_skill(item)
call_id, result = session._exec_skill(item)
assert "no skills found" in result.lower()
@@ -307,10 +307,8 @@ class TestExecLoadSkill:
session, _, fake_get = _make_session(skills)
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
item = session._prepare_load_skill(
"call-1", {"action": "load", "name": "disabled-skill"}
)
call_id, result = session._exec_load_skill(item)
item = session._prepare_skill("call-1", {"action": "load", "name": "disabled-skill"})
call_id, result = session._exec_skill(item)
assert "not found" in result.lower()
assert session._set_skill_called == []
@@ -321,8 +319,8 @@ class TestExecLoadSkill:
session._skill_name = "active"
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
item = session._prepare_load_skill("call-1", {"action": "load", "name": "active"})
call_id, result = session._exec_load_skill(item)
item = session._prepare_skill("call-1", {"action": "load", "name": "active"})
call_id, result = session._exec_skill(item)
assert "already active" in result.lower()
assert session._set_skill_called == []
@@ -352,10 +350,10 @@ class TestExecLoadSkill:
mock_storage.list_prompt_templates.return_value = skills
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search"})
item = session._prepare_skill("call-1", {"action": "search"})
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
call_id, result = session._exec_load_skill(item)
call_id, result = session._exec_skill(item)
assert "enabled-skill" in result
assert "disabled-skill" not in result
@@ -375,14 +373,113 @@ class TestExecLoadSkill:
mock_storage.list_prompt_templates.return_value = skills
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
call_id, result = session._exec_load_skill(item)
call_id, result = session._exec_skill(item)
assert "code-review" in result
def test_preparer_load_has_approval_label(self) -> None:
session, _, _ = _make_session()
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
assert item["approval_label"] == "load_skill__my-skill"
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
assert item["approval_label"] == "skill__my-skill"
# ---------------------------------------------------------------------------
# Tests: Skill Catalog Disclosure (Agent Skills standard compliance)
# ---------------------------------------------------------------------------
class TestSkillCatalogDisclosure:
"""Verify <available-skills> catalog appears in system messages."""
def _build_session_with_system_messages(
self,
search_skills: list[dict[str, Any]] | None = None,
) -> Any:
"""Build a session and call _init_system_messages to get dev_parts."""
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
ui = MagicMock()
session.ui = ui
session.model = "test-model"
session._ws_id = "ws-test"
session._node_id = "node-1"
session._skill_name = None
session._skill_content = None
session._skill_resources = {}
session._applied_skill_content = None
session.context_window = 128000
session.messages = []
session._config = {}
session.creative_mode = False
session.instructions = ""
session.system_messages = []
session._agent_system_messages = []
session.reasoning_effort = "medium"
session._pending_nudge = []
session._tool_search = None
session._mcp_client = None
session._notify_on_complete = "{}"
# Memory stubs
session._memory_config = MagicMock()
session._memory_config.fetch_limit = 0
session._user_id = ""
with (
patch(
"turnstone.core.session.list_skills_by_activation",
return_value=search_skills or [],
),
patch.object(session, "_get_visible_memories", return_value=[]),
):
session._init_system_messages()
return session
def test_catalog_present_with_search_skills(self) -> None:
skills = [
{"name": "pdf-processing", "description": "Extract PDF text and forms."},
{"name": "data-analysis", "description": "Analyze datasets."},
]
session = self._build_session_with_system_messages(search_skills=skills)
content = session.system_messages[0]["content"]
assert "<available-skills>" in content
assert "pdf-processing" in content
assert "data-analysis" in content
assert "</available-skills>" in content
def test_catalog_omitted_when_no_search_skills(self) -> None:
session = self._build_session_with_system_messages(search_skills=[])
content = session.system_messages[0]["content"]
assert "<available-skills>" not in content
def test_catalog_capped_at_30(self) -> None:
skills = [{"name": f"skill-{i:03d}", "description": f"Desc {i}"} for i in range(50)]
session = self._build_session_with_system_messages(search_skills=skills)
content = session.system_messages[0]["content"]
# Should include first 30, not all 50
assert "skill-029" in content
assert "skill-030" not in content
def test_catalog_escapes_html(self) -> None:
skills = [
{"name": "xss-test", "description": "Handle <script> & 'quotes'."},
]
session = self._build_session_with_system_messages(search_skills=skills)
content = session.system_messages[0]["content"]
assert "&lt;script&gt;" in content
assert "<script>" not in content.replace("<available-skills>", "").replace(
"</available-skills>", ""
).replace("<skill>", "").replace("</skill>", "").replace("<name>", "").replace(
"</name>", ""
).replace("<description>", "").replace("</description>", "")
def test_catalog_includes_hint(self) -> None:
skills = [{"name": "test", "description": "Test skill."}]
session = self._build_session_with_system_messages(search_skills=skills)
content = session.system_messages[0]["content"]
assert "/skill" in content
+23
View File
@@ -6,6 +6,7 @@ from turnstone.core.metacognition import (
NUDGE_DENIAL,
NUDGE_RESUME,
NUDGE_START,
NUDGE_TOOL_ERROR,
detect_completion,
detect_correction,
format_nudge,
@@ -263,5 +264,27 @@ class TestFormatNudge:
def test_start(self):
assert format_nudge("start") == NUDGE_START
def test_tool_error(self):
assert format_nudge("tool_error") == NUDGE_TOOL_ERROR
def test_invalid(self):
assert format_nudge("invalid") == ""
class TestToolErrorNudge:
def test_fires(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
def test_cooldown(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
assert should_nudge("tool_error", state, message_count=6, memory_count=3) is False
def test_not_on_first_message(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=1, memory_count=3) is False
def test_not_with_zero_memories(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=0) is False
+3 -3
View File
@@ -131,7 +131,7 @@ def authorize_client(storage: SQLiteBackend, oidc_config: OIDCConfig) -> TestCli
)
app.state.oidc_config = oidc_config
app.state.auth_storage = storage
app.state.jwt_secret = "test-jwt-secret"
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
app.state.jwks_data = {"keys": []}
app.state.login_limiter = None
return TestClient(app, raise_server_exceptions=False)
@@ -468,7 +468,7 @@ class TestOIDCCallback:
)
app.state.oidc_config = _make_oidc_config()
app.state.auth_storage = backend
app.state.jwt_secret = "secret"
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
app.state.jwks_data = {"keys": []}
app.state.login_limiter = None
@@ -492,7 +492,7 @@ class TestOIDCCallback:
)
app.state.oidc_config = _make_oidc_config()
app.state.auth_storage = storage
app.state.jwt_secret = "secret"
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
app.state.jwks_data = {"keys": []}
limiter = LoginRateLimiter(max_attempts=1, window_seconds=300)
limiter.record("ip:testclient")
+11
View File
@@ -184,6 +184,17 @@ class TestEnvSecretFalsePositives:
r = evaluate_output("APP_NAME=myapp\nSECRET_KEY=abc123\nAPI_TOKEN=xyz789\nDEBUG=true")
assert "env_file_leak" in r.flags
def test_single_secret_env_line(self) -> None:
"""A single AWS_SECRET_ACCESS_KEY=... line should trigger."""
r = evaluate_output("AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
assert "env_file_leak" in r.flags
assert r.risk_level == "high"
def test_substring_key_no_false_positive(self) -> None:
"""MONKEY=banana should not trigger (KEY is a substring, not a segment)."""
r = evaluate_output("MONKEY=banana\nTURKEY=gobble\nDONKEY=hee-haw")
assert "env_file_leak" not in r.flags
class TestOutputAssessment:
"""Verify OutputAssessment structure."""
+2
View File
@@ -623,6 +623,7 @@ class TestServerHealthMetrics:
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
app = srv_mod.create_app(
workstreams=mock_mgr,
@@ -799,6 +800,7 @@ class TestServerRateLimiting:
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
app = srv_mod.create_app(
workstreams=mock_mgr,
+147
View File
@@ -607,3 +607,150 @@ class TestPruneWorkstreams:
# Config rows should be cleaned up
assert load_workstream_config("orphan_cfg") == {}
assert load_workstream_config("stale_cfg") == {}
# ── Parallel tool exception isolation ────────────────────────────────
class TestParallelToolExceptionIsolation:
"""Bug #117: one tool raising should not kill the entire batch."""
def test_exception_in_one_tool_does_not_kill_batch(self, tmp_db, mock_openai_client):
from unittest.mock import patch
session = ChatSession(
client=mock_openai_client,
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
def succeed(item):
return item["call_id"], "ok"
def fail(item):
raise RuntimeError("boom")
items = [
{
"call_id": "c1",
"func_name": "bash",
"execute": succeed,
"needs_approval": False,
"header": "test",
"preview": "",
},
{
"call_id": "c2",
"func_name": "math",
"execute": fail,
"needs_approval": False,
"header": "test",
"preview": "",
},
]
tool_calls = [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "math", "arguments": "{}"}},
]
with (
patch.object(session, "_prepare_tool", side_effect=items),
patch.object(session, "_evaluate_intent"),
patch.object(session, "_emit_state"),
patch.object(session, "_init_system_messages"),
patch.object(session, "_check_cancelled"),
):
session.ui.approve_tools.return_value = (True, None)
results, _ = session._execute_tools(tool_calls)
assert results[0] == ("c1", "ok")
assert results[1][0] == "c2"
assert "Error executing math" in results[1][1]
assert "boom" in results[1][1]
# ── Web search tool gating ───────────────────────────────────────────
class TestWebSearchGating:
"""Bug #117: web_search should not be offered without a backend."""
def test_web_search_filtered_when_no_backend(self, tmp_db, mock_openai_client):
from unittest.mock import patch
from turnstone.core.providers._protocol import ModelCapabilities
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
caps = ModelCapabilities(supports_web_search=False)
with (
patch.object(session, "_get_capabilities", return_value=caps),
patch("turnstone.core.session.get_tavily_key", return_value=None),
):
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "web_search" not in names
def test_web_search_kept_when_tavily_available(self, tmp_db, mock_openai_client):
from unittest.mock import patch
from turnstone.core.providers._protocol import ModelCapabilities
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
caps = ModelCapabilities(supports_web_search=False)
with (
patch.object(session, "_get_capabilities", return_value=caps),
patch("turnstone.core.session.get_tavily_key", return_value="tvly-test-key"),
):
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "web_search" in names
def test_web_search_kept_when_native_support(self, tmp_db, mock_openai_client):
from unittest.mock import patch
from turnstone.core.providers._protocol import ModelCapabilities
session = ChatSession(
client=mock_openai_client,
model="gpt-5-search-api",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
caps = ModelCapabilities(supports_web_search=True)
with (
patch.object(session, "_get_capabilities", return_value=caps),
patch("turnstone.core.session.get_tavily_key", return_value=None),
):
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "web_search" in names
+3 -3
View File
@@ -74,7 +74,7 @@ class TestSimEngine:
def test_llm_response_returns_content(self, engine):
async def _test():
content, tool_calls = await engine.simulate_llm_response(True, 1)
content, tool_calls = await engine.simulate_llm_response(True)
assert isinstance(content, str)
assert len(content) > 0
assert isinstance(tool_calls, list)
@@ -85,8 +85,8 @@ class TestSimEngine:
async def _test():
e1 = SimEngine(fast_config, rng=random.Random(123))
e2 = SimEngine(fast_config, rng=random.Random(123))
c1, t1 = await e1.simulate_llm_response(True, 1)
c2, t2 = await e2.simulate_llm_response(True, 1)
c1, t1 = await e1.simulate_llm_response(True)
c2, t2 = await e2.simulate_llm_response(True)
assert c1 == c2
assert len(t1) == len(t2)
+316 -6
View File
@@ -18,7 +18,7 @@ description: Automated code review skill
author: Test Author
version: 2.0.0
tags: [python, review, quality]
allowed_tools: [read_file, list_directory]
allowed-tools: [read_file, list_directory]
license: MIT
compatibility: ">=0.7"
---
@@ -187,13 +187,13 @@ Content.
class TestAllowedTools:
"""Verify allowed_tools parsing."""
"""Verify allowed-tools parsing (Agent Skills standard hyphenated field)."""
def test_list_format(self) -> None:
raw = """\
---
name: tools-list
allowed_tools: [bash, read_file]
allowed-tools: [bash, read_file]
---
Content.
@@ -201,11 +201,12 @@ Content.
result = parse_skill_md(raw)
assert result.allowed_tools == ["bash", "read_file"]
def test_csv_format(self) -> None:
def test_space_delimited_format(self) -> None:
"""Standard format per Agent Skills spec."""
raw = """\
---
name: tools-csv
allowed_tools: "bash, read_file, write_file"
name: tools-space
allowed-tools: "bash read_file write_file"
---
Content.
@@ -219,6 +220,19 @@ Content.
name: no-tools
---
Content.
"""
result = parse_skill_md(raw)
assert result.allowed_tools == []
def test_underscore_key_not_read(self) -> None:
"""allowed_tools (underscore) is not a SKILL.md field — ignored by parser."""
raw = """\
---
name: legacy-key
allowed_tools: [bash, read_file]
---
Content.
"""
result = parse_skill_md(raw)
@@ -247,3 +261,299 @@ class TestValidateSkillName:
assert validate_skill_name("HAS-UPPER") is not None
assert validate_skill_name("has space") is not None
assert validate_skill_name("-leading-hyphen") is not None
def test_consecutive_hyphens_rejected(self) -> None:
"""Agent Skills spec: consecutive hyphens not allowed."""
assert validate_skill_name("foo--bar") is not None
assert "consecutive hyphens" in (validate_skill_name("a--b") or "")
# Single hyphens are fine
assert validate_skill_name("foo-bar") is None
# -- Agent Skills Standard Compliance Tests -----------------------------------
class TestStandardAllowedTools:
"""Agent Skills spec: 'allowed-tools' (hyphenated), space-delimited."""
def test_list_format(self) -> None:
raw = """\
---
name: standard-tools
allowed-tools: ["Bash(git:*)", "Bash(jq:*)", "Read"]
---
Content.
"""
result = parse_skill_md(raw)
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
def test_space_delimited(self) -> None:
"""Standard format: space-delimited string."""
raw = """\
---
name: space-tools
allowed-tools: "Bash(git:*) Bash(jq:*) Read"
---
Content.
"""
result = parse_skill_md(raw)
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
def test_mixed_space_comma_delimiters(self) -> None:
raw = """\
---
name: mixed-delim
allowed-tools: "Read, Write Bash"
---
Content.
"""
result = parse_skill_md(raw)
assert result.allowed_tools == ["Read", "Write", "Bash"]
class TestStandardMetadataNesting:
"""Standard puts author/version under metadata map."""
def test_metadata_author(self) -> None:
raw = """\
---
name: nested-author
description: Test skill
metadata:
author: example-org
version: "2.0"
---
Content.
"""
result = parse_skill_md(raw)
assert result.author == "example-org"
assert result.version == "2.0"
def test_top_level_takes_precedence(self) -> None:
raw = """\
---
name: precedence
description: Test skill
author: top-level
version: 1.0.0
metadata:
author: nested
version: "2.0"
---
Content.
"""
result = parse_skill_md(raw)
assert result.author == "top-level"
assert result.version == "1.0.0"
def test_metadata_version_only(self) -> None:
raw = """\
---
name: version-only
description: Test
metadata:
version: "3.5.1"
---
Content.
"""
result = parse_skill_md(raw)
assert result.version == "3.5.1"
assert result.author == ""
def test_null_author_uses_default(self) -> None:
"""YAML null/bare key must not produce the string 'None'."""
raw = """\
---
name: null-author
description: Test
author:
---
Content.
"""
result = parse_skill_md(raw)
assert result.author == ""
assert result.version == "1.0.0"
def test_null_version_uses_default(self) -> None:
raw = """\
---
name: null-version
description: Test
version:
---
Content.
"""
result = parse_skill_md(raw)
assert result.version == "1.0.0"
def test_null_description_falls_back_to_body(self) -> None:
"""YAML null description must not produce 'None' string."""
raw = """\
---
name: null-desc
description:
---
First paragraph here.
"""
result = parse_skill_md(raw)
assert result.description == "First paragraph here."
assert "None" not in result.description
def test_null_license_and_compatibility(self) -> None:
"""YAML null license/compatibility must not produce 'None' string."""
raw = """\
---
name: null-fields
description: Test
license:
compatibility:
---
Content.
"""
result = parse_skill_md(raw)
assert result.license == ""
assert result.compatibility == ""
class TestStandardFieldLengths:
"""Spec caps: description <= 1024, compatibility <= 500."""
def test_description_truncated_at_1024(self) -> None:
long_desc = "x" * 1200
raw = f"""\
---
name: long-desc
description: "{long_desc}"
---
Content.
"""
result = parse_skill_md(raw)
assert len(result.description) == 1024
def test_compatibility_truncated_at_500(self) -> None:
long_compat = "y" * 600
raw = f"""\
---
name: long-compat
description: Short
compatibility: "{long_compat}"
---
Content.
"""
result = parse_skill_md(raw)
assert len(result.compatibility) == 500
def test_short_fields_unchanged(self) -> None:
raw = """\
---
name: short
description: Brief
compatibility: Requires git
---
Content.
"""
result = parse_skill_md(raw)
assert result.description == "Brief"
assert result.compatibility == "Requires git"
class TestLenientMode:
"""Lenient parsing for cross-client skill ingestion."""
def test_invalid_name_sanitized(self) -> None:
raw = """\
---
name: Invalid_Name!
description: A test skill
---
Content.
"""
result = parse_skill_md(raw, lenient=True)
assert result is not None
assert result.name == "invalidname"
def test_unsalvageable_name_returns_none(self) -> None:
raw = """\
---
name: "!!!"
description: A test skill
---
Content.
"""
assert parse_skill_md(raw, lenient=True) is None
def test_missing_description_returns_none(self) -> None:
raw = """\
---
name: no-desc
---
"""
assert parse_skill_md(raw, lenient=True) is None
def test_broken_yaml_returns_none(self) -> None:
raw = """\
---
name: [broken: yaml: {{{
---
Content.
"""
assert parse_skill_md(raw, lenient=True) is None
def test_malformed_yaml_colon_in_description_recovers(self) -> None:
"""Standard recommends retrying unquoted colon values."""
raw = """\
---
name: colon-desc
description: Use this skill when: the user asks about PDFs
---
Content.
"""
result = parse_skill_md(raw, lenient=True)
# The frontmatter library may parse this fine, but if not,
# the retry mechanism should recover.
assert result is not None
assert result.name == "colon-desc"
assert "PDF" in result.description
def test_strict_mode_still_raises(self) -> None:
"""Default strict mode unchanged."""
raw = """\
---
name: Invalid_Name!
description: A test skill
---
Content.
"""
with pytest.raises(ValueError):
parse_skill_md(raw)
def test_consecutive_hyphens_lenient(self) -> None:
raw = """\
---
name: foo--bar
description: A test skill
---
Content.
"""
result = parse_skill_md(raw, lenient=True)
assert result is not None
assert "--" not in result.name
+103 -4
View File
@@ -802,8 +802,8 @@ class TestSkillAPI:
)
assert resp.status_code == 404
def test_update_skill_readonly_rejected(self, api_client, api_storage):
"""Updating a readonly (MCP-sourced) skill returns 403."""
def test_update_skill_readonly_spec_fields_rejected(self, api_client, api_storage):
"""Updating spec fields on a readonly skill returns 400 (filtered to nothing)."""
_create_template(
api_storage,
"s1",
@@ -815,9 +815,65 @@ class TestSkillAPI:
)
resp = api_client.put(
"/v1/api/admin/skills/s1",
json={"description": "hacked"},
json={"description": "hacked", "content": "evil"},
)
assert resp.status_code == 403
assert resp.status_code == 400
assert "runtime config" in resp.json()["error"].lower()
def test_update_skill_readonly_runtime_config_allowed(self, api_client, api_storage):
"""Runtime config fields can be updated on a readonly (installed) skill."""
_create_template(
api_storage,
"s1",
"installed-skill",
"external content",
origin="source",
readonly=True,
)
resp = api_client.put(
"/v1/api/admin/skills/s1",
json={"model": "gpt-5", "temperature": 0.5, "enabled": False},
)
assert resp.status_code == 200
data = resp.json()
assert data["model"] == "gpt-5"
assert data["temperature"] == 0.5
assert data["enabled"] is False
# Spec fields must remain unchanged
assert data["content"] == "external content"
def test_update_skill_readonly_mixed_body_filters_spec(self, api_client, api_storage):
"""When JS sends all fields for a readonly skill, spec fields are silently dropped."""
_create_template(
api_storage,
"s1",
"installed",
"original content",
origin="source",
readonly=True,
)
resp = api_client.put(
"/v1/api/admin/skills/s1",
# Simulate what the browser form submits: every field present
json={
"name": "hacked",
"content": "evil content",
"description": "tampered",
"model": "gpt-5",
"enabled": False,
"token_budget": 50000,
},
)
assert resp.status_code == 200
data = resp.json()
# Config fields updated
assert data["model"] == "gpt-5"
assert data["enabled"] is False
assert data["token_budget"] == 50000
# Spec fields unchanged
assert data["name"] == "installed"
assert data["content"] == "original content"
assert data["description"] == ""
def test_update_skill_recomputes_token_estimate(self, api_client, api_storage):
"""Updating content recomputes token_estimate."""
@@ -1156,6 +1212,49 @@ class TestSkillSessionConfigApplication:
parsed_tools = json.loads(tpl["allowed_tools"])
assert parsed_tools == ["bash", "read_file", "write_file"]
def test_license_compatibility_roundtrip(self, db):
"""Agent Skills spec fields license and compatibility round-trip."""
db.create_prompt_template(
template_id="spec1",
name="spec-fields-skill",
category="general",
content="Spec test.",
skill_license="Apache-2.0",
compatibility="Requires git, docker, jq",
)
tpl = db.get_skill_by_name("spec-fields-skill")
assert tpl is not None
assert tpl["license"] == "Apache-2.0"
assert tpl["compatibility"] == "Requires git, docker, jq"
def test_license_compatibility_default_empty(self, db):
"""license and compatibility default to empty string."""
db.create_prompt_template(
template_id="spec2",
name="no-spec-fields",
category="general",
content="No spec fields.",
)
tpl = db.get_skill_by_name("no-spec-fields")
assert tpl is not None
assert tpl["license"] == ""
assert tpl["compatibility"] == ""
def test_update_license_compatibility(self, db):
"""license and compatibility can be updated."""
db.create_prompt_template(
template_id="spec3",
name="updatable-spec",
category="general",
content="Test.",
)
db.update_prompt_template("spec3", license="MIT")
db.update_prompt_template("spec3", compatibility="Python 3.11+")
tpl = db.get_prompt_template("spec3")
assert tpl is not None
assert tpl["license"] == "MIT"
assert tpl["compatibility"] == "Python 3.11+"
# ---------------------------------------------------------------------------
# 7. Migration behavior tests
-11
View File
@@ -128,17 +128,9 @@ class TestToolSearchManager:
return ToolSearchManager(
all_tools,
always_on_names={"bash", "read_file", "edit_file"},
threshold=5,
max_results=3,
)
def test_should_activate_above_threshold(self, manager):
assert manager.should_activate()
def test_should_not_activate_below_threshold(self, builtin_tools):
mgr = ToolSearchManager(builtin_tools, always_on_names={"bash", "read_file", "edit_file"})
assert not mgr.should_activate()
def test_visible_tools_initially_builtin_only(self, manager):
visible = manager.get_visible_tools()
names = {_tool_name(t) for t in visible}
@@ -201,9 +193,6 @@ class TestToolSearchManager:
names = {_tool_name(t) for t in deferred}
assert "mcp__github__create_issue" not in names
def test_get_all_tools_returns_everything(self, manager, builtin_tools, mcp_tools):
assert len(manager.get_all_tools()) == len(builtin_tools) + len(mcp_tools)
def test_search_tool_definition_format(self, manager):
defn = manager.get_search_tool_definition()
assert defn["type"] == "function"
+1 -1
View File
@@ -112,7 +112,7 @@ class TestToolsMetadata:
"watch": "command",
"read_resource": "uri",
"use_prompt": "name",
"load_skill": "name",
"skill": "name",
}
assert expected == PRIMARY_KEY_MAP
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.8.1"
__version__ = "0.8.4"
+9
View File
@@ -137,6 +137,9 @@ class ConsoleCreateWsRequest(BaseModel):
default="", description="Optional first message sent after creation"
)
skill: str = Field(default="", description="Skill name (replaces default skills)")
resume_ws: str = Field(
default="", description="Workstream ID to resume (loads previous conversation)"
)
class ConsoleCreateWsResponse(BaseModel):
@@ -307,6 +310,8 @@ class SkillInfo(BaseModel):
notify_on_complete: str = "{}"
enabled: bool = True
allowed_tools: str = "[]"
license: str = ""
compatibility: str = ""
scan_status: str = ""
scan_report: str = "{}"
scan_version: str = ""
@@ -337,6 +342,8 @@ class CreateSkillRequest(BaseModel):
notify_on_complete: str = "{}"
enabled: bool = True
allowed_tools: str = "[]"
license: str = ""
compatibility: str = ""
class UpdateSkillRequest(BaseModel):
@@ -360,6 +367,8 @@ class UpdateSkillRequest(BaseModel):
notify_on_complete: str | None = None
enabled: bool | None = None
allowed_tools: str | None = None
license: str | None = None
compatibility: str | None = None
class ListSkillsResponse(BaseModel):
+2
View File
@@ -157,8 +157,10 @@ class McpStatus(BaseModel):
class HealthResponse(BaseModel):
status: str = Field(examples=["ok", "degraded"])
version: str = ""
node_id: str = ""
uptime_seconds: float = 0.0
model: str = ""
max_ws: int = Field(default=10, description="Maximum concurrent workstreams")
workstreams: WorkstreamCounts = WorkstreamCounts()
backend: BackendStatus | None = None
mcp: McpStatus | None = None
+42 -8
View File
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any
import httpx
if TYPE_CHECKING:
from turnstone.core.auth import ServiceTokenManager
from turnstone.mq.broker import RedisBroker
log = logging.getLogger("turnstone.console.collector")
@@ -58,6 +59,7 @@ class ClusterCollector:
max_poll_workers: int = 50,
http_timeout: float = 5.0,
auth_token: str = "",
token_manager: ServiceTokenManager | None = None,
):
self._broker = broker
self._prefix = prefix
@@ -65,16 +67,20 @@ class ClusterCollector:
self._discovery_interval = discovery_interval
self._max_poll_workers = max_poll_workers
self._http_timeout = http_timeout
self._token_manager = token_manager
# Static auth header — only used when no token_manager is present.
# When a token_manager exists, auth is injected per-request via
# extra_headers in _poll_all_nodes to avoid stale JWT expiry.
self._static_auth: dict[str, str] | None = None
if auth_token and token_manager is None:
self._static_auth = {"Authorization": f"Bearer {auth_token}"}
self._lock = threading.Lock()
self._nodes: dict[str, NodeSnapshot] = {}
self._running = False
self._threads: list[threading.Thread] = []
self._poll_pool = ThreadPoolExecutor(max_workers=max_poll_workers)
headers = {}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
self._http_client = httpx.Client(timeout=http_timeout, headers=headers)
self._http_client = httpx.Client(timeout=http_timeout)
# SSE fan-out to browser clients
self._listeners: list[queue.Queue[dict[str, Any]]] = []
@@ -212,6 +218,7 @@ class ClusterCollector:
self._nodes[nid].server_url = meta.get(
"server_url", self._nodes[nid].server_url
)
self._nodes[nid].max_ws = meta.get("max_ws", self._nodes[nid].max_ws)
# Remove nodes whose heartbeats expired
lost = [nid for nid in self._nodes if nid not in active_ids]
@@ -235,6 +242,14 @@ class ClusterCollector:
def _poll_all_nodes(self) -> None:
"""Fetch dashboard data from all known nodes in parallel."""
# Snapshot current auth header for this poll cycle. Per-request
# headers avoid mutating shared client state (thread-safe).
if self._token_manager is not None:
poll_headers: dict[str, str] | None = {
"Authorization": f"Bearer {self._token_manager.token}"
}
else:
poll_headers = self._static_auth
with self._lock:
targets = [
(n.node_id, n.server_url)
@@ -245,25 +260,44 @@ class ClusterCollector:
if not targets:
return
futures = {self._poll_pool.submit(self._fetch_node, nid, url): nid for nid, url in targets}
futures = {
self._poll_pool.submit(self._fetch_node, nid, url, poll_headers): nid
for nid, url in targets
}
for future in as_completed(futures):
nid = futures[future]
try:
dashboard, health = future.result()
self._apply_poll(nid, dashboard, health)
except httpx.HTTPStatusError as exc:
if exc.response.status_code in (401, 403):
log.warning(
"Auth failure polling node %s: HTTP %d", nid, exc.response.status_code
)
else:
log.debug("Failed to poll node %s: HTTP %d", nid, exc.response.status_code)
with self._lock:
if nid in self._nodes:
self._nodes[nid].reachable = False
except Exception:
log.debug("Failed to poll node %s", nid)
with self._lock:
if nid in self._nodes:
self._nodes[nid].reachable = False
def _fetch_node(self, node_id: str, server_url: str) -> tuple[dict[str, Any], dict[str, Any]]:
def _fetch_node(
self,
node_id: str,
server_url: str,
extra_headers: dict[str, str] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Fetch /v1/api/dashboard and /health from a single node."""
base = server_url.rstrip("/")
dash_resp = self._http_client.get(f"{base}/v1/api/dashboard")
dash_resp = self._http_client.get(f"{base}/v1/api/dashboard", headers=extra_headers)
dash_resp.raise_for_status()
dash_data: dict[str, Any] = dash_resp.json()
try:
health_resp = self._http_client.get(f"{base}/health")
health_resp = self._http_client.get(f"{base}/health", headers=extra_headers)
health_data: dict[str, Any] = health_resp.json()
except Exception:
health_data = {}
+58 -18
View File
@@ -371,6 +371,7 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_model = body.get("model", "")
raw_initial_message = body.get("initial_message", "")
raw_skill = body.get("skill", "")
raw_resume_ws = body.get("resume_ws", "")
if not isinstance(raw_node_id, str):
raw_node_id = "" if raw_node_id is None else None
if not isinstance(raw_name, str):
@@ -381,15 +382,20 @@ async def create_workstream(request: Request) -> JSONResponse:
raw_initial_message = "" if raw_initial_message is None else None
if not isinstance(raw_skill, str):
raw_skill = "" if raw_skill is None else None
if not isinstance(raw_resume_ws, str):
raw_resume_ws = "" if raw_resume_ws is None else None
if (
raw_node_id is None
or raw_name is None
or raw_model is None
or raw_initial_message is None
or raw_skill is None
or raw_resume_ws is None
):
return JSONResponse(
{"error": "node_id, name, model, initial_message, and skill must be strings"},
{
"error": "node_id, name, model, initial_message, skill, and resume_ws must be strings"
},
status_code=400,
)
node_id = raw_node_id
@@ -397,6 +403,7 @@ async def create_workstream(request: Request) -> JSONResponse:
model = raw_model[:128]
initial_message = raw_initial_message[:4096]
skill = raw_skill[:256]
resume_ws = raw_resume_ws[:64]
from turnstone.mq.protocol import CreateWorkstreamMessage
@@ -407,6 +414,7 @@ async def create_workstream(request: Request) -> JSONResponse:
model=model,
initial_message=initial_message,
skill=skill,
resume_ws=resume_ws,
)
broker.push_inbound(msg.to_json())
log.debug("Pool dispatch: correlation_id=%s name=%r", msg.correlation_id, name)
@@ -435,6 +443,7 @@ async def create_workstream(request: Request) -> JSONResponse:
target_node=node_id,
initial_message=initial_message,
skill=skill,
resume_ws=resume_ws,
)
broker.push_inbound(msg.to_json(), node_id=node_id)
@@ -670,17 +679,13 @@ async def _proxy_sse(
@asynccontextmanager
async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
# Create async HTTP client for proxy routes
headers: dict[str, str] = {}
token = app.state.proxy_auth_token
if token:
headers["Authorization"] = f"Bearer {token}"
app.state.proxy_client = httpx.AsyncClient(timeout=30, headers=headers)
# Separate client for SSE streams — longer read timeout, shared connection pool
# Create async HTTP clients for proxy routes. Auth headers are NOT baked
# in — _proxy_auth_headers() injects a fresh token per-request so JWTs
# auto-rotate via ServiceTokenManager instead of expiring after 1 hour.
app.state.proxy_client = httpx.AsyncClient(timeout=30)
app.state.proxy_sse_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
limits=httpx.Limits(keepalive_expiry=30),
headers=headers,
)
# Start scheduler if configured
scheduler = getattr(app.state, "scheduler", None)
@@ -2134,6 +2139,24 @@ async def admin_delete_policy(request: Request) -> JSONResponse:
_VALID_ACTIVATIONS = {"named", "default", "search"}
# Fields that may be updated on installed (readonly) skills.
# These are local runtime configuration — not part of the SKILL.md spec —
# so they don't compromise the fidelity of an externally-sourced skill.
_SKILL_RUNTIME_CONFIG_FIELDS = frozenset(
{
"model",
"temperature",
"reasoning_effort",
"max_tokens",
"token_budget",
"agent_max_turns",
"auto_approve",
"allowed_tools",
"enabled",
"notify_on_complete",
}
)
def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], JSONResponse | None]:
"""Parse and validate session config fields from a skill request body.
@@ -2286,6 +2309,8 @@ def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str,
"notify_on_complete": r.get("notify_on_complete", "{}"),
"enabled": r.get("enabled", True),
"allowed_tools": r.get("allowed_tools", "[]"),
"license": r.get("license", ""),
"compatibility": r.get("compatibility", ""),
"scan_status": r.get("scan_status", ""),
"scan_report": r.get("scan_report", "{}"),
"scan_version": r.get("scan_version", ""),
@@ -2370,6 +2395,8 @@ async def admin_create_skill(request: Request) -> JSONResponse:
org_id = str(body.get("org_id", "")).strip()[:64]
author = str(body.get("author", "")).strip()[:256]
version = str(body.get("version", "1.0.0")).strip()[:64]
license_val = str(body.get("license", "")).strip()[:128]
compatibility = str(body.get("compatibility", "")).strip()[:500]
raw_tags = body.get("tags", [])
if isinstance(raw_tags, list):
@@ -2418,6 +2445,8 @@ async def admin_create_skill(request: Request) -> JSONResponse:
tags=tags_str,
version=version,
author=author,
skill_license=license_val,
compatibility=compatibility,
activation=activation,
token_estimate=token_estimate,
**session_fields,
@@ -2456,8 +2485,7 @@ async def admin_update_skill(request: Request) -> JSONResponse:
existing = storage.get_prompt_template(skill_id)
if existing is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
if existing.get("readonly"):
return JSONResponse({"error": "MCP-sourced skills are read-only"}, status_code=403)
is_readonly = bool(existing.get("readonly"))
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
@@ -2497,6 +2525,10 @@ async def admin_update_skill(request: Request) -> JSONResponse:
updates["author"] = str(body["author"]).strip()[:256]
if "version" in body:
updates["version"] = str(body["version"]).strip()[:64]
if "license" in body:
updates["license"] = str(body["license"]).strip()[:128]
if "compatibility" in body:
updates["compatibility"] = str(body["compatibility"]).strip()[:500]
if "tags" in body:
raw_tags = body["tags"]
if isinstance(raw_tags, list):
@@ -2509,6 +2541,13 @@ async def admin_update_skill(request: Request) -> JSONResponse:
tag_str = "[]"
updates["tags"] = tag_str
# Installed (readonly) skills: restrict updates to runtime config only.
# Spec/content fields are locked to preserve external-source fidelity.
if is_readonly:
updates = {k: v for k, v in updates.items() if k in _SKILL_RUNTIME_CONFIG_FIELDS}
if not updates:
return JSONResponse({"error": "No runtime config fields to update"}, status_code=400)
# Snapshot current state for version history before applying update
existing_versions = storage.list_skill_versions(skill_id)
version_int = len(existing_versions) + 1
@@ -2526,7 +2565,7 @@ async def admin_update_skill(request: Request) -> JSONResponse:
record_audit(
storage,
audit_uid,
"skill.update",
"skill.update.config" if is_readonly else "skill.update",
"skill",
skill_id,
updates,
@@ -3203,6 +3242,8 @@ async def admin_skill_install(request: Request) -> JSONResponse:
source_url=pkg_source_url,
version=parsed.version,
author=parsed.author,
skill_license=parsed.license,
compatibility=parsed.compatibility,
activation="named",
token_estimate=token_estimate,
allowed_tools=allowed_tools_str,
@@ -4818,13 +4859,13 @@ def main() -> None:
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
collector_token = collector_token_mgr.token
log.info("console.collector_jwt_minted")
log.info("console.collector_token_manager_created")
collector = ClusterCollector(
broker=broker,
poll_interval=args.poll_interval,
auth_token=collector_token,
auth_token=collector_token if collector_token_mgr is None else "",
token_manager=collector_token_mgr,
)
collector.start()
@@ -4862,8 +4903,7 @@ def main() -> None:
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
proxy_token = proxy_token_mgr.token
log.info("console.proxy_jwt_minted")
log.info("console.proxy_token_manager_created")
from turnstone.core.web_helpers import parse_cors_origins
@@ -4875,7 +4915,7 @@ def main() -> None:
auth_config=auth_config,
jwt_secret=jwt_secret,
auth_storage=auth_storage,
proxy_auth_token=proxy_token,
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
proxy_token_mgr=proxy_token_mgr,
cors_origins=cors_origins,
)
+154 -59
View File
@@ -768,7 +768,7 @@ function _renderGovSkills(items) {
t.resource_count +
" res</span>";
}
var editDisabled = t.readonly ? " disabled" : "";
var editLabel = t.readonly ? "view" : "edit";
var deleteDisabled = "";
html +=
'<div class="admin-row" role="listitem">' +
@@ -794,9 +794,9 @@ function _renderGovSkills(items) {
'<span class="admin-col admin-col-actions">' +
'<button class="admin-btn-action" data-edit-tmpl="' +
escapeHtml(t.template_id) +
'"' +
editDisabled +
">edit</button>" +
'">' +
editLabel +
"</button>" +
'<button class="admin-btn-danger" data-delete-tmpl="' +
escapeHtml(t.template_id) +
'" data-tmpl-name="' +
@@ -870,6 +870,9 @@ function showCreateTemplateModal() {
document.getElementById("skill-description").value = "";
document.getElementById("skill-tags").value = "";
document.getElementById("skill-author").value = "";
document.getElementById("skill-version").value = "";
document.getElementById("skill-license").value = "";
document.getElementById("skill-compatibility").value = "";
document.getElementById("skill-activation").value = "named";
document.getElementById("ctm-content").value = "";
document.getElementById("ctm-variables").textContent = "(none)";
@@ -888,11 +891,9 @@ function showCreateTemplateModal() {
document.getElementById("csk-allowed-tools").value = "";
document.getElementById("csk-allowed-tools").disabled = false;
document.getElementById("csk-enabled").checked = true;
document
.getElementById("csk-auto-approve")
.addEventListener("change", function () {
document.getElementById("csk-allowed-tools").disabled = this.checked;
});
document.getElementById("csk-auto-approve").onchange = function () {
document.getElementById("csk-allowed-tools").disabled = this.checked;
};
document.getElementById("create-template-error").style.display = "none";
// Clear resource list
_pendingResources = [];
@@ -949,31 +950,38 @@ function submitCreateTemplate() {
.filter(Boolean)
: [];
document.getElementById("ctm-submit").disabled = true;
var csVersion = (document.getElementById("skill-version").value || "").trim();
var createBody = {
name: name,
category: document.getElementById("ctm-category").value,
description: (
document.getElementById("skill-description").value || ""
).trim(),
tags: JSON.stringify(tagsArray),
author: (document.getElementById("skill-author").value || "").trim(),
license: (document.getElementById("skill-license").value || "").trim(),
compatibility: (
document.getElementById("skill-compatibility").value || ""
).trim(),
activation: document.getElementById("skill-activation").value,
content: content,
variables: JSON.stringify(varList),
is_default: document.getElementById("ctm-default").checked,
model: document.getElementById("csk-model").value.trim(),
auto_approve: document.getElementById("csk-auto-approve").checked,
temperature: csTemp ? parseFloat(csTemp) : null,
reasoning_effort: document.getElementById("csk-reasoning-effort").value,
max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null,
token_budget: csBudget ? parseInt(csBudget, 10) : 0,
agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null,
allowed_tools: JSON.stringify(csAllowedArr),
enabled: document.getElementById("csk-enabled").checked,
};
if (csVersion) createBody.version = csVersion;
authFetch("/v1/api/admin/skills", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name,
category: document.getElementById("ctm-category").value,
description: (
document.getElementById("skill-description").value || ""
).trim(),
tags: JSON.stringify(tagsArray),
author: (document.getElementById("skill-author").value || "").trim(),
activation: document.getElementById("skill-activation").value,
content: content,
variables: JSON.stringify(varList),
is_default: document.getElementById("ctm-default").checked,
model: document.getElementById("csk-model").value.trim(),
auto_approve: document.getElementById("csk-auto-approve").checked,
temperature: csTemp ? parseFloat(csTemp) : null,
reasoning_effort: document.getElementById("csk-reasoning-effort").value,
max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null,
token_budget: csBudget ? parseInt(csBudget, 10) : 0,
agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null,
allowed_tools: JSON.stringify(csAllowedArr),
enabled: document.getElementById("csk-enabled").checked,
}),
body: JSON.stringify(createBody),
})
.then(function (r) {
if (!r.ok)
@@ -1052,6 +1060,9 @@ function showEditTemplateModal(tmplId) {
}
document.getElementById("etm-tags").value = tagsDisplay;
document.getElementById("etm-author").value = tmpl.author || "";
document.getElementById("etm-version").value = tmpl.version || "";
document.getElementById("etm-license").value = tmpl.license || "";
document.getElementById("etm-compatibility").value = tmpl.compatibility || "";
document.getElementById("etm-activation").value = tmpl.activation || "named";
document.getElementById("etm-content").value = tmpl.content;
_updateVarsDisplay("etm-content", "etm-variables");
@@ -1086,11 +1097,9 @@ function showEditTemplateModal(tmplId) {
document.getElementById("esk-allowed-tools").disabled =
tmpl.auto_approve || false;
document.getElementById("esk-enabled").checked = tmpl.enabled !== false;
document
.getElementById("esk-auto-approve")
.addEventListener("change", function () {
document.getElementById("esk-allowed-tools").disabled = this.checked;
});
document.getElementById("esk-auto-approve").onchange = function () {
document.getElementById("esk-allowed-tools").disabled = this.checked;
};
document.getElementById("edit-template-error").style.display = "none";
// Scan report section
var scanSection = document.getElementById("etm-scan-section");
@@ -1180,12 +1189,91 @@ function showEditTemplateModal(tmplId) {
});
};
}
// Reset collapsible state before applying readonly rules (prevents state leak
// when switching between readonly and editable skills in the same session)
var allDetails = document.querySelectorAll(
"#edit-template-box .admin-details",
);
for (var d = 0; d < allDetails.length; d++) allDetails[d].open = false;
// --- Readonly mode for imported skills ---
var isReadonly = tmpl.readonly || false;
var editTitle = document.getElementById("edit-template-title");
if (editTitle)
editTitle.textContent = isReadonly ? "View Skill" : "Edit Skill";
// Origin badge — show provenance for installed skills
var originBadge = document.getElementById("etm-origin-badge");
if (originBadge) {
if (isReadonly && tmpl.source_url) {
originBadge.textContent = "Installed from \u00a0" + tmpl.source_url;
originBadge.style.display = "inline-flex";
} else if (isReadonly && tmpl.origin && tmpl.origin !== "manual") {
originBadge.textContent = "Installed skill";
originBadge.style.display = "inline-flex";
} else {
originBadge.style.display = "none";
}
}
var submitBtn = document.getElementById("etm-submit");
if (submitBtn) {
submitBtn.style.display = "";
submitBtn.textContent = isReadonly ? "Save Config" : "Save";
}
// Spec/content fields: locked for installed skills (preserve source fidelity)
[
"etm-name",
"etm-category",
"etm-description",
"etm-tags",
"etm-author",
"etm-version",
"etm-license",
"etm-compatibility",
"etm-activation",
"etm-content",
"etm-default",
].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.disabled = isReadonly;
});
// Runtime config fields: always editable (local settings, not part of SKILL.md spec)
[
"esk-model",
"esk-temperature",
"esk-reasoning-effort",
"esk-max-tokens",
"esk-token-budget",
"esk-agent-max-turns",
"esk-auto-approve",
"esk-enabled",
].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.disabled = false;
});
// esk-allowed-tools follows auto_approve state, not readonly state
var allowedToolsEl = document.getElementById("esk-allowed-tools");
if (allowedToolsEl) allowedToolsEl.disabled = tmpl.auto_approve || false;
var cancelBtn = document.querySelector("#edit-template-box .modal-cancel");
if (cancelBtn) cancelBtn.textContent = isReadonly ? "Close" : "Cancel";
// Auto-expand Runtime Config collapsible for installed skills so config is visible
if (isReadonly) {
var details = document.querySelectorAll(
"#edit-template-box .admin-details",
);
for (var d = 0; d < details.length; d++) details[d].open = true;
}
// --- Skill Resources ---
var resSection = document.getElementById("etm-resources-section");
if (resSection) {
_loadSkillResources(tmplId, tmpl.readonly || false);
_loadSkillResources(tmplId, isReadonly);
}
_etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box");
// Focus management
if (isReadonly) {
if (cancelBtn) cancelBtn.focus();
} else {
document.getElementById("etm-name").focus();
}
}
function hideEditTemplateModal() {
@@ -1466,31 +1554,38 @@ function submitEditTemplate() {
.filter(Boolean)
: [];
document.getElementById("etm-submit").disabled = true;
var esVersion = (document.getElementById("etm-version").value || "").trim();
var updateBody = {
name: document.getElementById("etm-name").value.trim(),
category: document.getElementById("etm-category").value,
description: (
document.getElementById("etm-description").value || ""
).trim(),
tags: JSON.stringify(tagsArray),
author: (document.getElementById("etm-author").value || "").trim(),
license: (document.getElementById("etm-license").value || "").trim(),
compatibility: (
document.getElementById("etm-compatibility").value || ""
).trim(),
activation: document.getElementById("etm-activation").value,
content: content,
variables: JSON.stringify(varList),
is_default: document.getElementById("etm-default").checked,
model: document.getElementById("esk-model").value.trim(),
auto_approve: document.getElementById("esk-auto-approve").checked,
temperature: esTemp ? parseFloat(esTemp) : null,
reasoning_effort: document.getElementById("esk-reasoning-effort").value,
max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null,
token_budget: esBudget ? parseInt(esBudget, 10) : 0,
agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null,
allowed_tools: JSON.stringify(esAllowedArr),
enabled: document.getElementById("esk-enabled").checked,
};
if (esVersion) updateBody.version = esVersion;
authFetch("/v1/api/admin/skills/" + id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: document.getElementById("etm-name").value.trim(),
category: document.getElementById("etm-category").value,
description: (
document.getElementById("etm-description").value || ""
).trim(),
tags: JSON.stringify(tagsArray),
author: (document.getElementById("etm-author").value || "").trim(),
activation: document.getElementById("etm-activation").value,
content: content,
variables: JSON.stringify(varList),
is_default: document.getElementById("etm-default").checked,
model: document.getElementById("esk-model").value.trim(),
auto_approve: document.getElementById("esk-auto-approve").checked,
temperature: esTemp ? parseFloat(esTemp) : null,
reasoning_effort: document.getElementById("esk-reasoning-effort").value,
max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null,
token_budget: esBudget ? parseInt(esBudget, 10) : 0,
agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null,
allowed_tools: JSON.stringify(esAllowedArr),
enabled: document.getElementById("esk-enabled").checked,
}),
body: JSON.stringify(updateBody),
})
.then(function (r) {
if (!r.ok)
+170 -91
View File
@@ -848,61 +848,100 @@ window.TURNSTONE_KB_SHORTCUTS = [
<!-- Create Skill Modal -->
<div id="create-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-template-title">
<div id="create-template-box" class="admin-modal admin-modal-wide">
<div id="create-template-box" class="admin-modal admin-modal-wide admin-modal-skill">
<h2 id="create-template-title">Create Skill</h2>
<div id="create-template-error" role="alert" aria-live="assertive"></div>
<label for="ctm-name">Name</label>
<input id="ctm-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
<label for="ctm-category">Category</label>
<select id="ctm-category">
<option value="general">General</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="custom">Custom</option>
</select>
<label for="skill-description">Description</label>
<textarea id="skill-description" rows="2" placeholder="Brief description for discovery"></textarea>
<label for="skill-tags">Tags</label>
<input id="skill-tags" type="text" placeholder="Comma-separated tags">
<label for="skill-author">Author</label>
<input id="skill-author" type="text" placeholder="Author name">
<label for="skill-activation">Activation</label>
<select id="skill-activation">
<option value="named">Named</option>
<option value="default">Default (auto-apply)</option>
<option value="search">Search (BM25 discoverable)</option>
</select>
<label for="ctm-content">Content <span class="label-hint">system message text, use {{model}}, {{ws_id}}, {{node_id}} for placeholders</span></label>
<textarea id="ctm-content" rows="6" placeholder="You are a code reviewer using {{model}}..."></textarea>
<label>Variables <span class="label-hint">auto-detected from content &mdash; available: model, ws_id, node_id</span></label>
<div id="ctm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Set as default for new workstreams</label>
<div class="skill-spec-body">
<div class="skill-spec-col skill-spec-col-meta">
<div class="skill-spec-section">
<h3 class="skill-spec-heading">Identity</h3>
<label for="ctm-name">Name</label>
<input id="ctm-name" type="text" placeholder="e.g. code-review" autocomplete="off">
<label for="ctm-category">Category</label>
<select id="ctm-category">
<option value="general">General</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="custom">Custom</option>
</select>
<label for="skill-description">Description</label>
<textarea id="skill-description" rows="2" placeholder="Brief description for skill discovery"></textarea>
</div>
<div class="skill-spec-section">
<h3 class="skill-spec-heading">Manifest</h3>
<label for="skill-tags">Tags</label>
<input id="skill-tags" type="text" placeholder="python, review, quality">
<label for="skill-author">Author</label>
<input id="skill-author" type="text" placeholder="Author name">
<label for="skill-version">Version</label>
<input id="skill-version" type="text" placeholder="1.0.0">
<label for="skill-license">License</label>
<select id="skill-license">
<option value="">— not specified —</option>
<option value="MIT">MIT</option>
<option value="Apache-2.0">Apache-2.0</option>
<option value="GPL-2.0">GPL-2.0</option>
<option value="GPL-3.0">GPL-3.0</option>
<option value="LGPL-2.1">LGPL-2.1</option>
<option value="LGPL-3.0">LGPL-3.0</option>
<option value="AGPL-3.0">AGPL-3.0</option>
<option value="BSD-2-Clause">BSD-2-Clause</option>
<option value="BSD-3-Clause">BSD-3-Clause</option>
<option value="ISC">ISC</option>
<option value="MPL-2.0">MPL-2.0</option>
<option value="Unlicense">Unlicense</option>
<option value="Proprietary">Proprietary</option>
</select>
<label for="skill-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
<input id="skill-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
</div>
<div class="skill-spec-section">
<h3 class="skill-spec-heading">Deployment</h3>
<label for="skill-activation">Activation <span class="label-hint">how models discover this skill</span></label>
<select id="skill-activation">
<option value="named">Named — explicit /skill invocation</option>
<option value="default">Default — auto-applied to every session</option>
<option value="search">Search — BM25 discoverable</option>
</select>
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Apply to new workstreams by default</label>
</div>
</div>
<div class="skill-spec-col skill-spec-col-content">
<div class="skill-spec-section skill-spec-section-content">
<h3 class="skill-spec-heading">Skill Content <span class="label-hint">system message &mdash; {{model}}, {{ws_id}}, {{node_id}}</span></h3>
<textarea id="ctm-content" class="skill-content-area" placeholder="You are a code reviewer using {{model}}..."></textarea>
<div class="skill-vars-row">
<span class="skill-vars-label">Variables</span>
<div id="ctm-variables" class="skill-vars-display label-hint"></div>
</div>
</div>
</div>
</div>
<details class="admin-details">
<summary>Session Config <span class="label-hint">optional &mdash; applied when skill is selected for a workstream</span></summary>
<label for="csk-model">Model</label>
<input id="csk-model" type="text" placeholder="Default model">
<label for="csk-temperature">Temperature</label>
<input id="csk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default">
<label for="csk-reasoning-effort">Reasoning Effort</label>
<select id="csk-reasoning-effort">
<option value="">System default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<label for="csk-max-tokens">Max Tokens</label>
<input id="csk-max-tokens" type="number" min="1" placeholder="System default">
<label for="csk-token-budget">Token Budget</label>
<input id="csk-token-budget" type="number" min="0" placeholder="0 = unlimited">
<label for="csk-agent-max-turns">Agent Max Turns</label>
<input id="csk-agent-max-turns" type="number" min="1" placeholder="System default">
<summary>Runtime Config <span class="label-hint">model, temperature, token limits</span></summary>
<div class="skill-config-grid">
<div><label for="csk-model">Model</label><input id="csk-model" type="text" placeholder="Default model"></div>
<div><label for="csk-temperature">Temperature</label><input id="csk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default"></div>
<div>
<label for="csk-reasoning-effort">Reasoning Effort</label>
<select id="csk-reasoning-effort">
<option value="">System default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div><label for="csk-max-tokens">Max Tokens</label><input id="csk-max-tokens" type="number" min="1" placeholder="System default"></div>
<div><label for="csk-token-budget">Token Budget</label><input id="csk-token-budget" type="number" min="0" placeholder="0 = unlimited"></div>
<div><label for="csk-agent-max-turns">Agent Max Turns</label><input id="csk-agent-max-turns" type="number" min="1" placeholder="System default"></div>
</div>
<label class="admin-checkbox"><input id="csk-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="csk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
<input id="csk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
<label class="admin-checkbox"><input id="csk-enabled" type="checkbox" checked> Enabled</label>
</details>
<details class="admin-details">
<summary>Resources <span class="label-hint">optional bundled files (scripts, references, assets)</span></summary>
<summary>Resources <span class="label-hint">bundled files (scripts, references, assets)</span></summary>
<div id="ctm-resources-list" role="list" aria-live="polite" aria-label="Pending resources"></div>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
<label for="ctm-res-path">Path</label>
@@ -923,55 +962,95 @@ window.TURNSTONE_KB_SHORTCUTS = [
<!-- Edit Skill Modal -->
<div id="edit-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-template-title">
<div id="edit-template-box" class="admin-modal admin-modal-wide">
<div id="edit-template-box" class="admin-modal admin-modal-wide admin-modal-skill">
<h2 id="edit-template-title">Edit Skill</h2>
<div id="etm-origin-badge" class="skill-origin-badge" style="display:none"></div>
<div id="edit-template-error" role="alert" aria-live="assertive"></div>
<input id="etm-id" type="hidden">
<label for="etm-name">Name</label>
<input id="etm-name" type="text" autocomplete="off">
<label for="etm-category">Category</label>
<select id="etm-category">
<option value="general">General</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="custom">Custom</option>
</select>
<label for="etm-description">Description</label>
<textarea id="etm-description" rows="2" placeholder="Brief description for discovery"></textarea>
<label for="etm-tags">Tags</label>
<input id="etm-tags" type="text" placeholder="Comma-separated tags">
<label for="etm-author">Author</label>
<input id="etm-author" type="text" placeholder="Author name">
<label for="etm-activation">Activation</label>
<select id="etm-activation">
<option value="named">Named</option>
<option value="default">Default (auto-apply)</option>
<option value="search">Search (BM25 discoverable)</option>
</select>
<label for="etm-content">Content</label>
<textarea id="etm-content" rows="6"></textarea>
<label>Variables <span class="label-hint">auto-detected from content &mdash; available: model, ws_id, node_id</span></label>
<div id="etm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Set as default</label>
<div class="skill-spec-body">
<div class="skill-spec-col skill-spec-col-meta">
<div class="skill-spec-section">
<h3 class="skill-spec-heading">Identity</h3>
<label for="etm-name">Name</label>
<input id="etm-name" type="text" autocomplete="off">
<label for="etm-category">Category</label>
<select id="etm-category">
<option value="general">General</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="custom">Custom</option>
</select>
<label for="etm-description">Description</label>
<textarea id="etm-description" rows="2" placeholder="Brief description for skill discovery"></textarea>
</div>
<div class="skill-spec-section">
<h3 class="skill-spec-heading">Manifest</h3>
<label for="etm-tags">Tags</label>
<input id="etm-tags" type="text" placeholder="python, review, quality">
<label for="etm-author">Author</label>
<input id="etm-author" type="text" placeholder="Author name">
<label for="etm-version">Version</label>
<input id="etm-version" type="text" placeholder="1.0.0">
<label for="etm-license">License</label>
<select id="etm-license">
<option value="">— not specified —</option>
<option value="MIT">MIT</option>
<option value="Apache-2.0">Apache-2.0</option>
<option value="GPL-2.0">GPL-2.0</option>
<option value="GPL-3.0">GPL-3.0</option>
<option value="LGPL-2.1">LGPL-2.1</option>
<option value="LGPL-3.0">LGPL-3.0</option>
<option value="AGPL-3.0">AGPL-3.0</option>
<option value="BSD-2-Clause">BSD-2-Clause</option>
<option value="BSD-3-Clause">BSD-3-Clause</option>
<option value="ISC">ISC</option>
<option value="MPL-2.0">MPL-2.0</option>
<option value="Unlicense">Unlicense</option>
<option value="Proprietary">Proprietary</option>
</select>
<label for="etm-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
<input id="etm-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
</div>
<div class="skill-spec-section">
<h3 class="skill-spec-heading">Deployment</h3>
<label for="etm-activation">Activation <span class="label-hint">how models discover this skill</span></label>
<select id="etm-activation">
<option value="named">Named — explicit /skill invocation</option>
<option value="default">Default — auto-applied to every session</option>
<option value="search">Search — BM25 discoverable</option>
</select>
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Apply to new workstreams by default</label>
</div>
</div>
<div class="skill-spec-col skill-spec-col-content">
<div class="skill-spec-section skill-spec-section-content">
<h3 class="skill-spec-heading">Skill Content <span class="label-hint">{{model}}, {{ws_id}}, {{node_id}}</span></h3>
<textarea id="etm-content" class="skill-content-area"></textarea>
<div class="skill-vars-row">
<span class="skill-vars-label">Variables</span>
<div id="etm-variables" class="skill-vars-display label-hint"></div>
</div>
</div>
</div>
</div>
<details class="admin-details">
<summary>Session Config <span class="label-hint">applied when skill is selected for a workstream</span></summary>
<label for="esk-model">Model</label>
<input id="esk-model" type="text" placeholder="Default model">
<label for="esk-temperature">Temperature</label>
<input id="esk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default">
<label for="esk-reasoning-effort">Reasoning Effort</label>
<select id="esk-reasoning-effort">
<option value="">System default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<label for="esk-max-tokens">Max Tokens</label>
<input id="esk-max-tokens" type="number" min="1" placeholder="System default">
<label for="esk-token-budget">Token Budget</label>
<input id="esk-token-budget" type="number" min="0" placeholder="0 = unlimited">
<label for="esk-agent-max-turns">Agent Max Turns</label>
<input id="esk-agent-max-turns" type="number" min="1" placeholder="System default">
<summary>Runtime Config <span class="label-hint">model, temperature, token limits</span></summary>
<div class="skill-config-grid">
<div><label for="esk-model">Model</label><input id="esk-model" type="text" placeholder="Default model"></div>
<div><label for="esk-temperature">Temperature</label><input id="esk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default"></div>
<div>
<label for="esk-reasoning-effort">Reasoning Effort</label>
<select id="esk-reasoning-effort">
<option value="">System default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div><label for="esk-max-tokens">Max Tokens</label><input id="esk-max-tokens" type="number" min="1" placeholder="System default"></div>
<div><label for="esk-token-budget">Token Budget</label><input id="esk-token-budget" type="number" min="0" placeholder="0 = unlimited"></div>
<div><label for="esk-agent-max-turns">Agent Max Turns</label><input id="esk-agent-max-turns" type="number" min="1" placeholder="System default"></div>
</div>
<label class="admin-checkbox"><input id="esk-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="esk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
<input id="esk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
+140
View File
@@ -1166,6 +1166,14 @@
outline: none;
box-shadow: 0 0 0 3px var(--accent-dim);
}
.admin-modal input:disabled, .admin-modal select:disabled, .admin-modal textarea:disabled {
opacity: 0.55;
cursor: not-allowed;
background: var(--bg-highlight);
border-color: var(--border);
color: var(--fg-dim);
}
.admin-modal label.admin-checkbox input:disabled { opacity: 0.4; }
.admin-modal input::placeholder, .admin-modal textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
.admin-modal textarea { resize: vertical; min-height: 40px; }
.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
@@ -1199,6 +1207,138 @@
.admin-details summary .label-hint { font-weight: 400; }
.admin-details label:first-of-type { margin-top: 4px; }
/* ==========================================================================
Skill Spec Modal two-column manifest layout
Left: Identity / Manifest / Deployment | Right: Skill Content
========================================================================== */
.admin-modal-skill { padding: 28px 28px 24px; }
.skill-spec-body {
display: grid;
grid-template-columns: 1fr 1.55fr;
gap: 0;
margin-bottom: 12px;
}
.skill-spec-col-meta {
border-right: 1px solid var(--border);
padding-right: 22px;
}
.skill-spec-col-content {
padding-left: 22px;
display: flex;
flex-direction: column;
}
.skill-spec-section { margin-bottom: 14px; }
.skill-spec-section:last-child { margin-bottom: 0; }
/* h3 used for screen-reader heading structure; reset UA defaults */
h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.skill-spec-heading {
font-family: var(--font-display);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
padding-bottom: 5px;
margin: 14px 0 6px;
border-bottom: 1px solid var(--accent-dim);
}
.skill-spec-section:first-child .skill-spec-heading { margin-top: 0; }
.skill-spec-heading .label-hint {
text-transform: none;
letter-spacing: 0;
font-weight: 400;
font-size: 10px;
opacity: 1;
}
.skill-spec-section-content {
flex: 1;
display: flex;
flex-direction: column;
}
.skill-content-area {
flex: 1;
min-height: 220px;
font-family: var(--font-mono) !important;
font-size: 11.5px !important;
line-height: 1.65 !important;
}
.skill-vars-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
min-height: 18px;
}
.skill-vars-label {
font-family: var(--font-display);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--fg-dim);
white-space: nowrap;
flex-shrink: 0;
}
.skill-vars-display { font-size: 11px; }
.skill-config-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px 14px;
margin: 4px 0 2px;
}
.skill-config-grid > div { min-width: 0; }
/* Origin badge — shown for remotely installed (readonly) skills */
.skill-origin-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--cyan);
background: rgba(103, 232, 249, 0.07);
border: 1px solid rgba(103, 232, 249, 0.18);
border-radius: var(--radius-sm);
padding: 5px 10px;
margin-bottom: 14px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-origin-badge::before {
content: "\2193";
font-size: 11px;
flex-shrink: 0;
}
@media (max-width: 700px) {
.skill-spec-body { grid-template-columns: 1fr; }
.skill-spec-col-meta {
border-right: none;
padding-right: 0;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 16px;
}
.skill-spec-col-content { padding-left: 0; }
.skill-config-grid { grid-template-columns: 1fr 1fr; }
}
.modal-buttons { display: flex; gap: 10px; margin-top: 20px; }
.modal-cancel {
flex: 1;
+1 -2
View File
@@ -975,7 +975,7 @@ class IntentJudge:
"""Daemon thread: run LLM judge for each item and invoke callback."""
for item, h_verdict in zip(items, heuristic_verdicts, strict=True):
try:
llm_verdict = self._evaluate_single(item, messages, h_verdict)
llm_verdict = self._evaluate_single(item, messages)
# Arbitrate: only callback when LLM upgrades the heuristic
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
callback(llm_verdict)
@@ -990,7 +990,6 @@ class IntentJudge:
self,
item: dict[str, Any],
messages: list[dict[str, Any]],
heuristic: IntentVerdict,
) -> IntentVerdict | None:
"""Run LLM judge for a single tool call. Returns verdict or None."""
start = time.monotonic()
+9 -2
View File
@@ -179,10 +179,17 @@ def list_default_skills(org_id: str = "") -> list[dict[str, Any]]:
return []
def list_skills_by_activation(activation: str) -> list[dict[str, Any]]:
def list_skills_by_activation(
activation: str,
*,
enabled_only: bool = False,
limit: int = 0,
) -> list[dict[str, Any]]:
"""Return skills filtered by activation value, ordered by name."""
try:
return get_storage().list_skills_by_activation(activation)
return get_storage().list_skills_by_activation(
activation, enabled_only=enabled_only, limit=limit
)
except Exception:
return []
+10
View File
@@ -44,12 +44,19 @@ NUDGE_START = (
"user's request to find applicable context, preferences, or guidance."
)
NUDGE_TOOL_ERROR = (
"A tool just returned an error. Before retrying, check your memories — "
"the user may have given feedback about this tool or error pattern in a "
"previous session. Use memory(action='search') to find relevant guidance."
)
_NUDGE_MAP: dict[str, str] = {
"correction": NUDGE_CORRECTION,
"denial": NUDGE_DENIAL,
"resume": NUDGE_RESUME,
"completion": NUDGE_COMPLETION,
"start": NUDGE_START,
"tool_error": NUDGE_TOOL_ERROR,
}
# ---------------------------------------------------------------------------
@@ -153,6 +160,9 @@ def should_nudge(
# Start nudge only on first message
if nudge_type == "start" and message_count != 1:
return False
# Tool error nudge only if there are memories to search
if nudge_type == "tool_error" and memory_count == 0:
return False
# Resume/start nudge only if there are memories to recall
if nudge_type in ("resume", "start") and memory_count == 0:
return False
+5 -4
View File
@@ -54,7 +54,10 @@ _RE_CONNECTION_STRING = re.compile(
r"(?:postgresql|mysql|mongodb|redis|amqp)://[^:@\s]+:[^@\s]+@",
)
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
_RE_ENV_SECRET_KEY = re.compile(r"SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL", re.IGNORECASE)
_RE_ENV_SECRET_KEY = re.compile(
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
re.IGNORECASE,
)
# (pattern, redact_label) — ordered most-specific first for redaction.
_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
@@ -218,9 +221,7 @@ def _check_credentials(
risk = "high"
env_lines = _RE_ENV_SECRET_LINE.findall(text)
if len(env_lines) >= 3 and any(
_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines
):
if any(_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines):
_add_flag(flags, "credential_leak")
flags.append("env_file_leak")
ann.append("Output contains .env-style assignments with secret-bearing keys.")
+131 -43
View File
@@ -39,6 +39,7 @@ from turnstone.core.memory import (
get_skill_by_name,
get_workstream_display_name,
list_default_skills,
list_skills_by_activation,
list_structured_memories,
list_workstreams_with_history,
load_messages,
@@ -128,6 +129,11 @@ _MAX_SKILL_CONTENT: int = 32768
_TEMPLATE_VAR_RE = re.compile(r"\{\{(\w+)\}\}")
def _without_tool(tools: list[dict[str, Any]], name: str) -> list[dict[str, Any]]:
"""Return *tools* with the named tool removed."""
return [t for t in tools if t.get("function", {}).get("name") != name]
def _render_template(content: str, context: dict[str, str]) -> str:
"""Replace ``{{variable}}`` placeholders in a single pass.
@@ -341,7 +347,6 @@ class ChatSession:
self._tool_search = ToolSearchManager(
self._tools,
always_on_names=set(BUILTIN_TOOL_NAMES),
threshold=tool_search_threshold,
max_results=tool_search_max_results,
)
# Skill: explicit name overrides is_default skills
@@ -360,11 +365,16 @@ class ChatSession:
def model_alias(self) -> str | None:
return self._model_alias
def _get_capabilities(self) -> ModelCapabilities:
def _resolve_capabilities(
self,
provider: LLMProvider,
model: str,
alias: str | None = None,
) -> ModelCapabilities:
"""Get model capabilities, applying config.toml overrides if present."""
caps = self._provider.get_capabilities(self.model)
if self._registry and self._model_alias:
cfg: ModelConfig = self._registry.get_config(self._model_alias)
caps = provider.get_capabilities(model)
if self._registry and alias:
cfg: ModelConfig = self._registry.get_config(alias)
if cfg.capabilities:
fields = {f.name for f in dataclasses.fields(type(caps))}
overrides = {k: v for k, v in cfg.capabilities.items() if k in fields}
@@ -372,6 +382,10 @@ class ChatSession:
caps = dataclasses.replace(caps, **overrides)
return caps
def _get_capabilities(self) -> ModelCapabilities:
"""Get capabilities for the current model."""
return self._resolve_capabilities(self._provider, self.model, self._model_alias)
def _save_config(self) -> None:
"""Persist LLM-affecting config so resumed workstreams behave identically."""
save_workstream_config(
@@ -512,7 +526,6 @@ class ChatSession:
self._tool_search = ToolSearchManager(
self._tools,
always_on_names=set(BUILTIN_TOOL_NAMES),
threshold=self._tool_search_threshold,
max_results=self._tool_search_max_results,
)
# Restore previously expanded tools that still exist
@@ -615,10 +628,14 @@ class ChatSession:
user_msg = ""
asst_msg = ""
for m in self.messages:
content = m.get("content") or ""
# Handle multi-part content (vision messages)
if isinstance(content, list):
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
if m["role"] == "user" and not user_msg:
user_msg = (m.get("content") or "")[:300]
user_msg = content[:300]
elif m["role"] == "assistant" and not asst_msg:
asst_msg = (m.get("content") or "")[:200]
asst_msg = content[:200]
if user_msg and asst_msg:
break
if not user_msg:
@@ -654,8 +671,9 @@ class ChatSession:
title = raw.split("\n")[0].strip().strip('"').strip("'")
if title:
update_workstream_title(self._ws_id, title[:80])
self.ui.on_rename(title[:80])
except Exception:
pass # Title generation is non-critical
log.debug("Title generation failed for ws=%s", self._ws_id, exc_info=True)
def resume(self, ws_id: str) -> bool:
"""Load messages from a previous workstream and resume it.
@@ -856,6 +874,28 @@ class ChatSession:
)
lines.append("</skill-resources>")
dev_parts.append("\n".join(lines))
# Skill catalog: disclose search-activated skills so the model
# knows they exist (Agent Skills standard progressive disclosure).
try:
search_skills = list_skills_by_activation("search", enabled_only=True, limit=30)
except Exception:
log.warning("session.skill_catalog_failed", exc_info=True)
search_skills = []
if search_skills:
catalog_lines = ["<available-skills>"]
for sk in search_skills[:30]:
sk_name = _html_escape(sk.get("name", ""))
sk_desc = _html_escape(sk.get("description", "")[:200])
catalog_lines.append(
f" <skill><name>{sk_name}</name><description>{sk_desc}</description></skill>"
)
catalog_lines.append("</available-skills>")
catalog_lines.append(
"Additional skills are available. When a task matches a skill "
"description, ask the user to activate it with `/skill <name>`, "
"or use `/skill search <query>` to find relevant skills."
)
dev_parts.append("\n".join(catalog_lines))
if self.instructions:
dev_parts.append("")
dev_parts.append(self.instructions)
@@ -915,19 +955,29 @@ class ChatSession:
- Client-side fallback: send visible tools + synthetic tool_search.
Without tool search: return self._tools unchanged.
Web search gating: ``web_search`` is removed when the model has
no native search support and no Tavily API key is configured.
"""
if self.creative_mode:
return None
if not self._tool_search:
return self._tools
# Check if provider supports native tool search
caps = self._get_capabilities()
if caps.supports_tool_search:
# Provider handles defer_loading — send all tools
return self._tools
# Client-side fallback: visible tools + search tool
visible = self._tool_search.get_visible_tools()
return visible + [self._tool_search.get_search_tool_definition()]
if not self._tool_search:
tools = self._tools
else:
if caps.supports_tool_search:
# Provider handles defer_loading — send all tools
tools = self._tools
else:
# Client-side fallback: visible tools + search tool
visible = self._tool_search.get_visible_tools()
tools = visible + [self._tool_search.get_search_tool_definition()]
# Gate web_search: only include when a backend exists
if not caps.supports_web_search and not get_tavily_key():
tools = _without_tool(tools, "web_search")
return tools
def _get_deferred_names(self) -> frozenset[str] | None:
"""Return names of deferred tools for native provider search, or None."""
@@ -1212,6 +1262,29 @@ class ChatSession:
_tname,
tool_call_id=tc_id,
)
# Metacognitive nudge: check memories on tool error
if (
self._memory_config.nudges
and any(
isinstance(out, str)
and (
out.startswith("Error")
or " error: " in out[:50]
or out.startswith("Command timed out")
or out.startswith("Unknown tool:")
)
for _, out in results
)
and should_nudge(
"tool_error",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
cooldown_secs=self._memory_config.nudge_cooldown,
)
):
self._pending_nudge.append(format_nudge("tool_error"))
self._init_system_messages()
# Inject user feedback from approval prompt (e.g. "y, use full path")
if user_feedback:
self.messages.append({"role": "user", "content": user_feedback})
@@ -1913,7 +1986,7 @@ class ChatSession:
it["func_args"] = {"url": it.get("url", ""), "question": it.get("question", "")}
elif name == "web_search":
it["func_args"] = {"query": it.get("query", ""), "topic": it.get("topic", "")}
elif name == "load_skill":
elif name == "skill":
it["func_args"] = {"action": it.get("action", ""), "name": it.get("name", "")}
elif name == "watch":
it["func_args"] = {
@@ -2034,8 +2107,17 @@ class ChatSession:
return item["call_id"], item["error"]
if item.get("denied"):
return item["call_id"], item.get("denial_msg", "Denied by user")
result: tuple[str, str | list[dict[str, Any]]] = item["execute"](item)
return result
try:
result: tuple[str, str | list[dict[str, Any]]] = item["execute"](item)
return result
except (KeyboardInterrupt, GenerationCancelled):
raise
except Exception as e:
func = item.get("func_name", "unknown")
msg = f"Error executing {func}: {e}"
log.warning("tool_exec.failed", tool=func, error=str(e), exc_info=True)
self.ui.on_error(msg)
return item["call_id"], msg
if len(items) == 1:
results = [run_one(items[0])]
@@ -2180,7 +2262,7 @@ class ChatSession:
"watch": self._prepare_watch,
"read_resource": self._prepare_read_resource,
"use_prompt": self._prepare_use_prompt,
"load_skill": self._prepare_load_skill,
"skill": self._prepare_skill,
}
preparer = preparers.get(func_name)
if not preparer:
@@ -3004,10 +3086,10 @@ class ChatSession:
"limit": max(1, min(limit, 50)),
}
# -- load_skill prepare/execute --------------------------------------------
# -- skill prepare/execute -------------------------------------------------
def _prepare_load_skill(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a load_skill action (load or search)."""
def _prepare_skill(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a skill action (load or search)."""
action = (args.get("action") or "").strip().lower()
if action == "load":
@@ -3015,20 +3097,20 @@ class ChatSession:
if not name:
return {
"call_id": call_id,
"func_name": "load_skill",
"header": "\u2717 load_skill: name is required",
"func_name": "skill",
"header": "\u2717 skill: name is required",
"preview": "",
"needs_approval": False,
"error": "Error: 'name' is required for load action",
}
return {
"call_id": call_id,
"func_name": "load_skill",
"header": f"\u2699 load_skill: {name}",
"func_name": "skill",
"header": f"\u2699 skill: {name}",
"preview": "",
"needs_approval": True,
"approval_label": f"load_skill__{name}",
"execute": self._exec_load_skill,
"approval_label": f"skill__{name}",
"execute": self._exec_skill,
"action": "load",
"name": name,
}
@@ -3037,26 +3119,26 @@ class ChatSession:
query = (args.get("query") or "").strip()
return {
"call_id": call_id,
"func_name": "load_skill",
"func_name": "skill",
"header": f"\u2699 skill search{': ' + query[:80] if query else ''}",
"preview": "",
"needs_approval": False,
"execute": self._exec_load_skill,
"execute": self._exec_skill,
"action": "search",
"query": query,
}
return {
"call_id": call_id,
"func_name": "load_skill",
"header": "\u2717 load_skill: invalid action",
"func_name": "skill",
"header": "\u2717 skill: invalid action",
"preview": "",
"needs_approval": False,
"error": f"Error: action must be 'load' or 'search', got '{action}'",
}
def _exec_load_skill(self, item: dict[str, Any]) -> tuple[str, str]:
"""Execute a load_skill action."""
def _exec_skill(self, item: dict[str, Any]) -> tuple[str, str]:
"""Execute a skill action."""
call_id = item["call_id"]
action = item["action"]
@@ -3065,12 +3147,12 @@ class ChatSession:
skill_data = get_skill_by_name(name)
if not skill_data or not skill_data.get("enabled", True):
msg = f"Error: skill '{name}' not found"
self.ui.on_tool_result(call_id, "load_skill", msg)
self.ui.on_tool_result(call_id, "skill", msg)
return call_id, msg
if self._skill_name == name:
msg = f"Skill '{name}' is already active"
self.ui.on_tool_result(call_id, "load_skill", msg)
self.ui.on_tool_result(call_id, "skill", msg)
return call_id, msg
self.set_skill(name)
@@ -3083,7 +3165,7 @@ class ChatSession:
if scan:
parts.append(f"Security tier: {scan}")
msg = "\n".join(parts)
self.ui.on_tool_result(call_id, "load_skill", msg)
self.ui.on_tool_result(call_id, "skill", msg)
return call_id, msg
# action == "search"
@@ -3093,7 +3175,7 @@ class ChatSession:
rows = get_storage().list_prompt_templates(limit=50)
except Exception:
log.warning("load_skill.search_storage_error", exc_info=True)
log.warning("skill.search_storage_error", exc_info=True)
rows = []
# Filter out disabled skills
@@ -3137,7 +3219,7 @@ class ChatSession:
if not rows:
msg = "No skills found" + (f" matching '{query}'" if query else "")
self.ui.on_tool_result(call_id, "load_skill", msg)
self.ui.on_tool_result(call_id, "skill", msg)
return call_id, msg
lines = [f"Found {len(rows)} skill(s):", ""]
@@ -3159,7 +3241,7 @@ class ChatSession:
lines.append(line)
msg = "\n".join(lines)
self.ui.on_tool_result(call_id, "load_skill", msg)
self.ui.on_tool_result(call_id, "skill", msg)
return call_id, msg
# -- MCP tool prepare/execute ----------------------------------------------
@@ -3648,6 +3730,12 @@ class ChatSession:
agent_client, agent_model, _ = self._registry.resolve(self._registry.agent_model)
agent_provider = self._registry.get_provider(self._registry.agent_model)
# Gate web_search: remove when no backend exists for the agent model
agent_alias = self._registry.agent_model if self._registry else None
agent_caps = self._resolve_capabilities(agent_provider, agent_model, agent_alias)
if not agent_caps.supports_web_search and not get_tavily_key():
tools = _without_tool(tools, "web_search")
# Build extra params for agent calls
agent_extra: dict[str, Any] | None = None
if agent_provider.provider_name == "openai":
+148 -23
View File
@@ -2,19 +2,40 @@
Pure functions, no I/O. Accepts raw SKILL.md text and returns a
:class:`ParsedSkill` dataclass.
Compliant with the Agent Skills specification (https://agentskills.io/specification).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from typing import Any, Literal, overload
import frontmatter
# Name validation: lowercase letters, digits, hyphens, max 64 chars
from turnstone.core.log import get_logger
log = get_logger(__name__)
# Name validation: lowercase letters, digits, hyphens, max 64 chars.
# Note: consecutive hyphens checked separately (not expressible in a
# single character-class regex without a lookahead).
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,62}[a-z0-9]$|^[a-z0-9]$")
# Split allowed-tools on whitespace or commas (standard uses spaces,
# legacy turnstone format uses commas). Tool expressions must not
# contain internal whitespace (e.g. "Bash(git:*)" not "Bash(git: *)").
_LIST_SPLIT_RE = re.compile(r"[\s,]+")
# Malformed YAML recovery: match a bare ``description:`` line whose
# value contains an unquoted colon (the most common cross-client issue).
_BARE_DESC_RE = re.compile(r"^(description:\s*)(.+)$", re.MULTILINE)
# Field length caps from the Agent Skills specification.
_MAX_DESCRIPTION_LEN = 1024
_MAX_COMPATIBILITY_LEN = 500
@dataclass(frozen=True)
class ParsedSkill:
@@ -55,13 +76,39 @@ def _extract_tags(meta: dict[str, Any]) -> list[str]:
return []
def _extract_list(meta: dict[str, Any], key: str) -> list[str]:
"""Extract a list of strings from frontmatter, with fallback."""
val = meta.get(key)
if isinstance(val, list):
return [str(v) for v in val if v]
if isinstance(val, str) and val:
return [v.strip() for v in val.split(",") if v.strip()]
def _extract_str(meta: dict[str, Any], key: str, default: str = "") -> str:
"""Extract a string field, checking top-level then ``metadata.*`` fallback.
Handles YAML ``null`` / bare keys gracefully (returns *default*
rather than the string ``"None"``).
"""
raw = meta.get(key)
val = str(raw).strip() if raw is not None else ""
if val:
return val
# Standard puts author/version under metadata map
nested = meta.get("metadata")
if isinstance(nested, dict):
raw = nested.get(key)
val = str(raw).strip() if raw is not None else ""
if val:
return val
return default
def _extract_list(meta: dict[str, Any], *keys: str) -> list[str]:
"""Extract a list of strings from frontmatter.
Tries each *key* in order (first match wins). String values are
split on whitespace or commas to handle both the Agent Skills
standard (space-delimited) and legacy comma-delimited formats.
"""
for key in keys:
val = meta.get(key)
if isinstance(val, list):
return [str(v) for v in val if v]
if isinstance(val, str) and val:
return [v for v in _LIST_SPLIT_RE.split(val) if v]
return []
@@ -71,22 +118,66 @@ def validate_skill_name(name: str) -> str | None:
return "name is required"
if len(name) > 64:
return f"name exceeds 64 characters ({len(name)})"
if "--" in name:
return "name must not contain consecutive hyphens"
if not _NAME_RE.match(name):
return "name must be lowercase alphanumeric with hyphens (e.g. 'code-review')"
return None
def parse_skill_md(raw: str) -> ParsedSkill:
"""Parse SKILL.md (YAML frontmatter + markdown body).
def _try_parse_frontmatter(raw: str) -> frontmatter.Post:
"""Parse YAML frontmatter with a single malformed-YAML retry.
Handles missing or malformed frontmatter gracefully returns a
``ParsedSkill`` with defaults for any missing fields.
Raises ``ValueError`` if ``name`` is missing or invalid.
The most common cross-client issue is unquoted description values
containing colons (e.g. ``description: Use when: the user asks``).
On initial failure, wrap the description value in quotes and retry.
"""
try:
post = frontmatter.loads(raw)
return frontmatter.loads(raw)
except Exception:
pass # fall through to retry
# Retry: quote the description line
def _quote_desc(m: re.Match[str]) -> str:
prefix = m.group(1)
value = m.group(2).strip()
escaped = value.replace('"', '\\"')
return f'{prefix}"{escaped}"'
fixed = _BARE_DESC_RE.sub(_quote_desc, raw)
if fixed != raw:
try:
return frontmatter.loads(fixed)
except Exception:
pass
raise ValueError("Failed to parse SKILL.md YAML frontmatter")
@overload
def parse_skill_md(raw: str, *, lenient: Literal[False] = ...) -> ParsedSkill: ...
@overload
def parse_skill_md(raw: str, *, lenient: Literal[True]) -> ParsedSkill | None: ...
def parse_skill_md(raw: str, *, lenient: bool = False) -> ParsedSkill | None:
"""Parse SKILL.md (YAML frontmatter + markdown body).
When *lenient* is ``False`` (default strict mode), raises
``ValueError`` on missing/invalid name or unparseable YAML.
When *lenient* is ``True`` (for external import / cross-client
ingestion), logs warnings and returns ``None`` for unskippable
failures instead of raising.
"""
try:
post = _try_parse_frontmatter(raw)
except Exception as exc:
if lenient:
log.warning("skill_parser.yaml_failed", error=str(exc))
return None
raise ValueError(f"Failed to parse SKILL.md frontmatter: {exc}") from exc
meta: dict[str, Any] = dict(post.metadata)
@@ -96,10 +187,20 @@ def parse_skill_md(raw: str) -> ParsedSkill:
name = str(meta.get("name", "")).strip().lower()
name_err = validate_skill_name(name)
if name_err:
raise ValueError(name_err)
if lenient:
log.warning("skill_parser.name_invalid", name=name, error=name_err)
# Try to salvage: strip invalid chars, truncate
sanitized = re.sub(r"[^a-z0-9-]", "", name).strip("-")
sanitized = re.sub(r"-{2,}", "-", sanitized)[:64].strip("-")
if not sanitized or validate_skill_name(sanitized):
return None
name = sanitized
else:
raise ValueError(name_err)
# Description — frontmatter or first paragraph of body
description = str(meta.get("description", "")).strip()
raw_desc = meta.get("description")
description = str(raw_desc).strip() if raw_desc is not None else ""
if not description and body:
first_line = body.split("\n")[0].strip()
# Skip markdown headings
@@ -107,15 +208,39 @@ def parse_skill_md(raw: str) -> ParsedSkill:
first_line = first_line.lstrip("# ").strip()
description = first_line[:256]
if not description and lenient:
log.warning("skill_parser.no_description", name=name)
return None
# Spec caps
if len(description) > _MAX_DESCRIPTION_LEN:
log.warning(
"skill_parser.description_truncated",
name=name,
length=len(description),
)
description = description[:_MAX_DESCRIPTION_LEN]
raw_compat = meta.get("compatibility")
compatibility = str(raw_compat).strip() if raw_compat is not None else ""
if len(compatibility) > _MAX_COMPATIBILITY_LEN:
log.warning(
"skill_parser.compatibility_truncated",
name=name,
length=len(compatibility),
)
compatibility = compatibility[:_MAX_COMPATIBILITY_LEN]
return ParsedSkill(
name=name,
description=description,
content=body,
tags=_extract_tags(meta),
author=str(meta.get("author", "")).strip(),
version=str(meta.get("version", "1.0.0")).strip(),
allowed_tools=_extract_list(meta, "allowed_tools"),
license=str(meta.get("license", "")).strip(),
compatibility=str(meta.get("compatibility", "")).strip(),
author=_extract_str(meta, "author"),
version=_extract_str(meta, "version", default="1.0.0"),
# Standard uses "allowed-tools" (hyphenated); stored internally as allowed_tools
allowed_tools=_extract_list(meta, "allowed-tools"),
license=_extract_str(meta, "license"),
compatibility=compatibility,
raw_frontmatter=meta,
)
+18 -3
View File
@@ -1508,6 +1508,8 @@ class PostgreSQLBackend:
notify_on_complete: str = "{}",
enabled: bool = True,
allowed_tools: str = "[]",
skill_license: str = "",
compatibility: str = "",
) -> None:
# Sync is_default from activation when activation is explicitly set
if activation == "default":
@@ -1540,6 +1542,8 @@ class PostgreSQLBackend:
"activation": activation,
"token_estimate": token_estimate,
"allowed_tools": allowed_tools,
"license": skill_license,
"compatibility": compatibility,
"scan_status": scan_status,
"scan_report": scan_report,
"scan_version": scan_version,
@@ -1679,13 +1683,24 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
def list_skills_by_activation(self, activation: str) -> list[dict[str, Any]]:
def list_skills_by_activation(
self,
activation: str,
*,
enabled_only: bool = False,
limit: int = 0,
) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
q = (
sa.select(prompt_templates)
.where(prompt_templates.c.activation == activation)
.order_by(prompt_templates.c.name)
).fetchall()
)
if enabled_only:
q = q.where(prompt_templates.c.enabled == 1)
if limit > 0:
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [
_row_to_dict(r, "is_default", "readonly", "auto_approve", "enabled") for r in rows
]
+9 -1
View File
@@ -579,6 +579,8 @@ class StorageBackend(Protocol):
notify_on_complete: str = "{}",
enabled: bool = True,
allowed_tools: str = "[]",
skill_license: str = "",
compatibility: str = "",
) -> None:
"""Create a prompt template (skill)."""
...
@@ -617,7 +619,13 @@ class StorageBackend(Protocol):
"""Count prompt templates, optionally filtered by org_id."""
...
def list_skills_by_activation(self, activation: str) -> list[dict[str, Any]]:
def list_skills_by_activation(
self,
activation: str,
*,
enabled_only: bool = False,
limit: int = 0,
) -> list[dict[str, Any]]:
"""Return prompt templates filtered by activation value, ordered by name."""
...
+2
View File
@@ -311,6 +311,8 @@ prompt_templates = sa.Table(
sa.Column("activation", sa.Text, nullable=False, server_default="named"),
sa.Column("token_estimate", sa.Integer, nullable=False, server_default="0"),
sa.Column("allowed_tools", sa.Text, nullable=False, server_default="[]"), # JSON array
sa.Column("license", sa.Text, nullable=False, server_default=""),
sa.Column("compatibility", sa.Text, nullable=False, server_default=""),
sa.Column("scan_status", sa.Text, nullable=False, server_default=""),
sa.Column("scan_report", sa.Text, nullable=False, server_default="{}"), # JSON
sa.Column("installed_at", sa.Text, nullable=False, server_default=""),
+18 -3
View File
@@ -1532,6 +1532,8 @@ class SQLiteBackend:
notify_on_complete: str = "{}",
enabled: bool = True,
allowed_tools: str = "[]",
skill_license: str = "",
compatibility: str = "",
) -> None:
# Sync is_default from activation when activation is explicitly set
if activation == "default":
@@ -1564,6 +1566,8 @@ class SQLiteBackend:
"activation": activation,
"token_estimate": token_estimate,
"allowed_tools": allowed_tools,
"license": skill_license,
"compatibility": compatibility,
"scan_status": scan_status,
"scan_report": scan_report,
"scan_version": scan_version,
@@ -1703,13 +1707,24 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
def list_skills_by_activation(self, activation: str) -> list[dict[str, Any]]:
def list_skills_by_activation(
self,
activation: str,
*,
enabled_only: bool = False,
limit: int = 0,
) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
q = (
sa.select(prompt_templates)
.where(prompt_templates.c.activation == activation)
.order_by(prompt_templates.c.name)
).fetchall()
)
if enabled_only:
q = q.where(prompt_templates.c.enabled == 1)
if limit > 0:
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [
_row_to_dict(r, "is_default", "readonly", "auto_approve", "enabled") for r in rows
]
+2
View File
@@ -54,6 +54,8 @@ SKILL_MUTABLE = frozenset(
"notify_on_complete",
"enabled",
"allowed_tools",
"license",
"compatibility",
"scan_version",
"scan_status",
"scan_report",
@@ -0,0 +1,34 @@
"""Add license and compatibility columns to prompt_templates.
Agent Skills standard (agentskills.io) defines license and compatibility
as optional SKILL.md frontmatter fields. These were parsed but discarded
prior to this migration.
Revision ID: 023
Revises: 022
Create Date: 2026-03-17
"""
import sqlalchemy as sa
from alembic import op
revision = "023"
down_revision = "022"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"prompt_templates",
sa.Column("license", sa.Text, nullable=False, server_default=""),
)
op.add_column(
"prompt_templates",
sa.Column("compatibility", sa.Text, nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("prompt_templates", "compatibility")
op.drop_column("prompt_templates", "license")
-11
View File
@@ -64,15 +64,12 @@ class ToolSearchManager:
all_tools: list[dict[str, Any]],
always_on_names: set[str],
*,
threshold: int = 20,
max_results: int = 5,
) -> None:
self._all_tools = all_tools
self._always_on: list[dict[str, Any]] = []
self._deferred: list[dict[str, Any]] = []
self._deferred_by_name: dict[str, dict[str, Any]] = {}
self._expanded: dict[str, None] = {} # ordered set (preserves discovery order)
self._threshold = threshold
self._max_results = max_results
for tool in all_tools:
@@ -90,10 +87,6 @@ class ToolSearchManager:
# Pre-compute server summary for the search tool description
self._server_hint = _mcp_server_summary(self._deferred)
def should_activate(self) -> bool:
"""Return True if tool search should be active (enough tools)."""
return len(self._all_tools) > self._threshold
def get_visible_tools(self) -> list[dict[str, Any]]:
"""Return always-on tools + any expanded (discovered) tools."""
result = list(self._always_on)
@@ -107,10 +100,6 @@ class ToolSearchManager:
"""Return tools that are currently deferred (not yet discovered)."""
return [t for t in self._deferred if _tool_name(t) not in self._expanded]
def get_all_tools(self) -> list[dict[str, Any]]:
"""Return the full tool list (for native provider modes)."""
return list(self._all_tools)
def search(self, query: str) -> list[dict[str, Any]]:
"""Search deferred tools by query, return top-k matches.
-1
View File
@@ -32,7 +32,6 @@ MAX_WATCHES_PER_WS = 5
MIN_INTERVAL = 10 # seconds
MAX_INTERVAL = 86_400 # 24 hours
DEFAULT_MAX_POLLS = 100
DEFAULT_INTERVAL = 300 # 5 minutes
MAX_OUTPUT_SIZE = 65_536 # truncate stored/dispatched output at 64 KB
# Safe builtins exposed to condition expressions.
+5
View File
@@ -107,6 +107,11 @@ class WorkstreamManager:
self._evictions: int = 0
self._last_evicted: Workstream | None = None
@property
def max_workstreams(self) -> int:
"""Configured maximum concurrent workstreams."""
return self._max_workstreams
@property
def eviction_count(self) -> int:
"""Number of workstreams auto-evicted by ``create()``."""
+25 -2
View File
@@ -85,6 +85,7 @@ class Bridge:
self._approval_timeout = approval_timeout
self._prefix = prefix
self._node_id = node_id # resolved in run() from server /health
self._server_max_ws: int = 10 # resolved in _fetch_node_id() from server /health
self._heartbeat_ttl = heartbeat_ttl
self._started_at = time.time()
self._auth_token = auth_token
@@ -140,6 +141,15 @@ class Bridge:
# -- public entry point --------------------------------------------------
def _fetch_server_metadata(self) -> None:
"""Fetch max_ws from server /health (best-effort, non-blocking)."""
try:
resp = self._http.get("/health")
if resp.status_code == 200:
self._server_max_ws = resp.json().get("max_ws", 10)
except Exception:
log.debug("Failed to fetch server metadata", exc_info=True)
def _fetch_node_id(self) -> str:
"""Retrieve node_id from server /health with capped exponential backoff.
@@ -161,6 +171,7 @@ class Bridge:
data = resp.json()
nid = data.get("node_id", "")
if nid:
self._server_max_ws = data.get("max_ws", 10)
return str(nid)
log.warning("Server /health missing node_id (attempt %d)", attempt)
except SystemExit:
@@ -178,10 +189,18 @@ class Bridge:
"""Block until shutdown (KeyboardInterrupt)."""
if not self._node_id:
self._node_id = self._fetch_node_id()
else:
# node_id was pre-set — still need to fetch max_ws from server
self._fetch_server_metadata()
from turnstone.core.log import ctx_node_id
ctx_node_id.set(self._node_id)
log.info("Bridge starting — node=%s server=%s", self._node_id, self._server_url)
log.info(
"Bridge starting — node=%s server=%s max_ws=%d",
self._node_id,
self._server_url,
self._server_max_ws,
)
self._recover_workstreams()
heartbeat_t = threading.Thread(
@@ -879,7 +898,11 @@ class Bridge:
while self._running:
self._broker.register_node(
self._node_id,
{"server_url": self._server_url, "started": self._started_at},
{
"server_url": self._server_url,
"started": self._started_at,
"max_ws": self._server_max_ws,
},
ttl=self._heartbeat_ttl,
)
time.sleep(self._heartbeat_ttl / 2)
+5
View File
@@ -135,6 +135,7 @@ class AsyncTurnstoneConsole(_BaseClient):
model: str = "",
initial_message: str = "",
skill: str = "",
resume_ws: str = "",
) -> ConsoleCreateWsResponse:
body: dict[str, Any] = {}
if node_id:
@@ -147,6 +148,8 @@ class AsyncTurnstoneConsole(_BaseClient):
body["initial_message"] = initial_message
if skill:
body["skill"] = skill
if resume_ws:
body["resume_ws"] = resume_ws
return await self._request(
"POST",
"/v1/api/cluster/workstreams/new",
@@ -880,6 +883,7 @@ class TurnstoneConsole:
model: str = "",
initial_message: str = "",
skill: str = "",
resume_ws: str = "",
) -> ConsoleCreateWsResponse:
return self._runner.run(
self._async.create_workstream(
@@ -888,6 +892,7 @@ class TurnstoneConsole:
model=model,
initial_message=initial_message,
skill=skill,
resume_ws=resume_ws,
)
)
+1
View File
@@ -990,6 +990,7 @@ async def health(request: Request) -> JSONResponse:
"node_id": getattr(request.app.state, "node_id", ""),
"uptime_seconds": round(time.monotonic() - _metrics.start_time, 2),
"model": _metrics.model,
"max_ws": mgr.max_workstreams,
"workstreams": {"total": len(wss), **states},
"backend": {
"status": "up" if backend_ok else "down",
+1 -3
View File
@@ -66,9 +66,7 @@ class SimEngine:
self._config = config
self._rng = rng or random.Random(config.seed)
async def simulate_llm_response(
self, first_round: bool, turn_number: int
) -> tuple[str, list[dict[str, Any]]]:
async def simulate_llm_response(self, first_round: bool) -> tuple[str, list[dict[str, Any]]]:
"""Simulate an LLM response.
Returns ``(content_text, tool_calls)`` where *tool_calls* may be
-1
View File
@@ -75,7 +75,6 @@ class SimWorkstream:
self._set_state("thinking", correlation_id)
content, tool_calls = await self._engine.simulate_llm_response(
rounds == 0,
self._turn_count,
)
await self._stream_content(content, correlation_id)
+1 -10
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Any
from turnstone.mq.broker import RedisBroker
from turnstone.mq.protocol import SendMessage
@@ -18,15 +18,6 @@ if TYPE_CHECKING:
log = logging.getLogger("turnstone.sim.scenario")
class Scenario(Protocol):
async def run(
self,
cluster: SimCluster,
config: SimConfig,
metrics: MetricsCollector,
) -> None: ...
class SteadyStateScenario:
"""Inject messages at a constant rate for the configured duration."""
@@ -1,5 +1,5 @@
{
"name": "load_skill",
"name": "skill",
"description": "Load or search for skills. Actions: 'load' activates a skill by name (replaces current skill), 'search' finds available skills by query.",
"parameters": {
"type": "object",
+2004 -1045
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -71,7 +71,7 @@
</div>
</div>
<div id="messages" role="log" aria-live="polite" aria-label="Chat messages"></div>
<div id="split-root"></div>
<!-- New workstream modal -->
<div id="new-ws-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="new-ws-title">
@@ -104,12 +104,6 @@
</div>
</div>
<div id="input-area">
<textarea id="input" rows="1" placeholder="Type a message... (Shift+Enter for newline)" aria-label="Message input"></textarea>
<button id="send-btn" onclick="sendMessage()">Send</button>
<button id="stop-btn" onclick="cancelGeneration()" style="display:none" aria-label="Stop generation">&#9632; Stop</button>
</div>
<div id="toast" role="status" aria-live="polite"></div>
<script>
window.TURNSTONE_AUTH_TITLE = "turnstone";
@@ -120,6 +114,12 @@ window.TURNSTONE_KB_SHORTCUTS = [
{ desc: "Close workstream", badge: '<span class="kb-key">Ctrl+W</span>' },
{ desc: "Switch to tab 1\u20139", badge: '<span class="kb-key">Ctrl+1</span>\u2026<span class="kb-key">9</span>' }
]},
{ title: "Split panes", keys: [
{ desc: "Split right", badge: '<span class="kb-key">Ctrl+\\</span>' },
{ desc: "Split down", badge: '<span class="kb-key">Ctrl+Shift+\\</span>' },
{ desc: "Close pane", badge: '<span class="kb-key">Ctrl+Shift+W</span>' },
{ desc: "Cycle pane focus", badge: '<span class="kb-key">Ctrl+Alt+\u2190</span> <span class="kb-key">\u2192</span>' }
]},
{ title: "Tool approval", keys: [
{ desc: "Approve", badge: '<span class="kb-key">y</span> / <span class="kb-key">Enter</span>' },
{ desc: "Deny", badge: '<span class="kb-key">n</span> / <span class="kb-key">Esc</span>' },
+160 -13
View File
@@ -190,10 +190,155 @@
}
#new-tab-btn:hover { background: var(--bg-highlight); color: var(--accent); border-color: var(--accent); }
/* ==========================================================================
Split panes
========================================================================== */
#split-root {
display: flex;
flex: 1;
min-height: 0;
}
.split-container {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
}
.split-horizontal { flex-direction: row; }
.split-vertical { flex-direction: column; }
.split-child {
display: flex;
min-width: 0;
min-height: 0;
}
.split-handle {
flex: 0 0 4px;
background: var(--border);
cursor: col-resize;
transition: background 0.15s;
z-index: 1;
position: relative;
touch-action: none;
}
.split-handle:hover,
.split-handle:active,
.split-handle.dragging { background: var(--accent); }
.split-vertical > .split-handle { cursor: row-resize; }
/* Expand hit area to 12px via pseudoelement */
.split-handle::before {
content: '';
position: absolute;
z-index: 1;
}
.split-horizontal > .split-handle::before {
top: 0; bottom: 0; left: -4px; right: -4px;
}
.split-vertical > .split-handle::before {
left: 0; right: 0; top: -4px; bottom: -4px;
}
/* Pane */
.pane {
display: flex;
flex-direction: column;
flex: 1;
min-width: 200px;
min-height: 150px;
overflow: hidden;
}
.pane.focused { outline: 1px solid var(--accent-dim); outline-offset: -1px; }
.multi-pane .pane.focused .pane-header {
border-bottom-color: var(--accent);
background: var(--bg-highlight);
}
/* Pane header — only visible in multi-pane mode */
.pane-header { display: none; }
.multi-pane .pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 8px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 24px;
}
.pane-ws-name {
font-size: 11px;
color: var(--fg-dim);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pane.focused .pane-ws-name { color: var(--accent); }
.pane-actions { display: flex; gap: 2px; flex-shrink: 0; }
.pane-action-btn {
background: none;
border: none;
color: var(--fg-dim);
font-size: 12px;
cursor: pointer;
padding: 2px 4px;
border-radius: var(--radius-sm);
line-height: 1;
opacity: 0.3;
transition: opacity 0.15s, color 0.1s, background 0.1s;
}
.pane.focused .pane-action-btn { opacity: 0.5; }
.pane-header:hover .pane-action-btn,
.pane-action-btn:focus-visible { opacity: 1; }
.pane-action-btn:hover { color: var(--fg-bright); background: var(--bg-highlight); }
.pane-close-btn:hover { color: var(--red); }
/* Pane context menu */
.pane-ctx-menu {
position: fixed;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
min-width: 200px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 300;
overflow: hidden;
padding: 4px 0;
}
[data-theme="light"] .pane-ctx-menu { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
.pane-ctx-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 7px 14px;
background: none;
border: none;
color: var(--fg);
font: inherit;
font-family: var(--font-display);
font-size: 13px;
cursor: pointer;
text-align: left;
white-space: nowrap;
transition: background 0.1s;
}
.pane-ctx-item:hover:not(:disabled) { background: var(--bg-highlight); }
.pane-ctx-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.pane-ctx-item:disabled { color: var(--fg-dim); opacity: 0.4; cursor: default; }
.pane-ctx-label { flex: 1; }
.pane-ctx-key {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-dim);
flex-shrink: 0;
}
.pane-ctx-sep { height: 1px; background: var(--border-strong); margin: 4px 0; }
/* ==========================================================================
Messages
========================================================================== */
#messages {
.pane-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -461,7 +606,7 @@ body { position: static; }
/* ==========================================================================
Input area
========================================================================== */
#input-area {
.pane-input-area {
padding: 12px 16px;
background: var(--bg-surface);
border-top: 1px solid var(--border-strong);
@@ -469,7 +614,7 @@ body { position: static; }
gap: 8px;
flex-shrink: 0;
}
#input-area textarea {
.pane-input {
flex: 1;
background: var(--bg);
color: var(--fg);
@@ -484,8 +629,8 @@ body { position: static; }
max-height: 200px;
transition: border-color 0.15s, box-shadow 0.15s;
}
#input-area textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
#input-area button {
.pane-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.pane-input-area button {
background: var(--accent);
color: var(--bg);
border: none;
@@ -498,11 +643,11 @@ body { position: static; }
letter-spacing: 0.02em;
transition: filter 0.15s;
}
#input-area button:hover { filter: brightness(1.1); }
#input-area button:disabled { opacity: 0.35; cursor: not-allowed; filter: none; }
#stop-btn { background: var(--red, #c94040); }
#stop-btn:focus-visible { outline: 2px solid var(--fg-bright, #e8ecf4); outline-offset: 2px; }
[data-theme="light"] #stop-btn { color: #fff; }
.pane-input-area button:hover { filter: brightness(1.1); }
.pane-input-area button:disabled { opacity: 0.35; cursor: not-allowed; filter: none; }
.pane-stop { background: var(--red, #c94040); }
.pane-stop:focus-visible { outline: 2px solid var(--fg-bright, #e8ecf4); outline-offset: 2px; }
[data-theme="light"] .pane-stop { color: #fff; }
/* ==========================================================================
Inline approval blocks
@@ -768,7 +913,7 @@ body { position: static; }
/* ==========================================================================
Focus indicators server-specific overrides
========================================================================== */
#input-area textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.pane-input:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.approval-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* ==========================================================================
@@ -1117,11 +1262,13 @@ body { position: static; }
.ws-tab, .ws-tab .tab-close, #new-tab-btn,
.hmenu-item, .dashboard-card,
.approval-btn, .approval-feedback-input,
#plan-buttons button, #input-area button,
#plan-buttons button, .pane-input-area button,
.dashboard-new-btn, .dashboard-input,
#health-indicator, #hamburger-btn,
#mcp-status, .msg-assistant tbody tr,
.msg-assistant .img-placeholder,
#new-ws-cancel, #new-ws-submit,
#new-ws-box input, #new-ws-box select { transition: none; }
#new-ws-box input, #new-ws-box select,
.split-handle, .pane-action-btn,
.pane-ctx-item { transition: none; }
}
Generated
+103 -103
View File
@@ -155,7 +155,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.84.0"
version = "0.86.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -167,9 +167,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" }
sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" },
{ url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" },
]
[[package]]
@@ -431,101 +431,101 @@ wheels = [
[[package]]
name = "coverage"
version = "7.13.4"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[package.optional-dependencies]
@@ -1319,7 +1319,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.28.0"
version = "2.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1331,9 +1331,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/87/eb0abb4ef88ddb95b3c13149384c4c288f584f3be17d6a4f63f8c3e3c226/openai-2.28.0.tar.gz", hash = "sha256:bb7fdff384d2a787fa82e8822d1dd3c02e8cf901d60f1df523b7da03cbb6d48d", size = 670334, upload-time = "2026-03-13T19:56:27.306Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/df122348638885526e53140e9c6b0d844af7312682b3bde9587eebc28b47/openai-2.28.0-py3-none-any.whl", hash = "sha256:79aa5c45dba7fef84085701c235cf13ba88485e1ef4f8dfcedc44fc2a698fc1d", size = 1141218, upload-time = "2026-03-13T19:56:25.46Z" },
{ url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" },
]
[[package]]
@@ -2067,15 +2067,15 @@ wheels = [
[[package]]
name = "sse-starlette"
version = "3.3.2"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" }
sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" },
{ url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" },
]
[[package]]
@@ -2168,7 +2168,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "0.8.1"
version = "0.8.4"
source = { editable = "." }
dependencies = [
{ name = "alembic" },