shikiru-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
shikiru/ui/compact.py
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
"""Compact shared readouts for usage, resources and task activity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol
|
|
7
|
+
|
|
8
|
+
from rich.cells import cell_len
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from shikiru.agents.contracts import (
|
|
14
|
+
BILLING_MODE_API,
|
|
15
|
+
BILLING_MODE_SUBSCRIPTION,
|
|
16
|
+
COST_BASIS_API_EQUIVALENT,
|
|
17
|
+
COST_BASIS_BILLED,
|
|
18
|
+
COST_BASIS_UNKNOWN,
|
|
19
|
+
)
|
|
20
|
+
from shikiru.domain.escalation import escalation_reason_label
|
|
21
|
+
from shikiru.domain.levels import UsageLevel
|
|
22
|
+
from shikiru.domain.tasks import EscalationReason, Role
|
|
23
|
+
from shikiru.ui.gauge import Gauge
|
|
24
|
+
from shikiru.ui.glyphs import GLYPH_CURRENT_STAGE, GLYPH_ESCALATED, GLYPH_STOPPED
|
|
25
|
+
from shikiru.ui.keys import append_key_hint
|
|
26
|
+
from shikiru.ui.section import Row, grey, labelled_rows, observed_text, wrap_groups
|
|
27
|
+
from shikiru.ui.size import format_size
|
|
28
|
+
from shikiru.ui.tokens import (
|
|
29
|
+
ALERT_COLOR,
|
|
30
|
+
AMBER,
|
|
31
|
+
PRIMARY_TEXT_COLOR,
|
|
32
|
+
SAGE,
|
|
33
|
+
SECONDARY_TEXT_COLOR,
|
|
34
|
+
STEEL_BLUE,
|
|
35
|
+
STYLE_BOLD,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
from datetime import datetime, timedelta
|
|
41
|
+
|
|
42
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_BOLD_PRIMARY = f"{STYLE_BOLD} {PRIMARY_TEXT_COLOR}"
|
|
46
|
+
_BOLD_STEEL = f"{STYLE_BOLD} {STEEL_BLUE}"
|
|
47
|
+
_BOLD_SAGE = f"{STYLE_BOLD} {SAGE}"
|
|
48
|
+
_BOLD_AMBER = f"{STYLE_BOLD} {AMBER}"
|
|
49
|
+
_BOLD_ALERT = f"{STYLE_BOLD} {ALERT_COLOR}"
|
|
50
|
+
_FREE_SPACE_STYLES = {UsageLevel.WARN: _BOLD_AMBER, UsageLevel.ALERT: _BOLD_ALERT}
|
|
51
|
+
GIB_FORMAT = ".1f"
|
|
52
|
+
CPU_DECIMALS_BELOW_PCT = 10
|
|
53
|
+
CPU_DECIMAL_FORMAT = ".1f"
|
|
54
|
+
CPU_INTEGER_FORMAT = ".0f"
|
|
55
|
+
BYTES_PER_GIB = 1024**3
|
|
56
|
+
BYTES_PER_MIB = 1024**2
|
|
57
|
+
MIB_FORMAT = ".0f"
|
|
58
|
+
SESSION_COST_FORMAT = ".2f"
|
|
59
|
+
COUNT_SHORT_SCALE = 1_000
|
|
60
|
+
COUNT_SHORT_MILLION = COUNT_SHORT_SCALE * COUNT_SHORT_SCALE
|
|
61
|
+
COUNT_SHORT_PRECISION = 1
|
|
62
|
+
SECONDS_PER_MINUTE = 60
|
|
63
|
+
MINUTES_PER_HOUR = 60
|
|
64
|
+
SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR
|
|
65
|
+
HOURS_PER_DAY = 24
|
|
66
|
+
FOCUS_ATTENTION_MAX_HEIGHT = 2
|
|
67
|
+
ACTION_HINT = "Ctrl+P actions"
|
|
68
|
+
USAGE_TOTALS_LABEL = "Totals"
|
|
69
|
+
USAGE_TOKENS_LABEL = "Tokens"
|
|
70
|
+
USAGE_COVERAGE_LABEL = "Coverage"
|
|
71
|
+
USAGE_BILLING_LABEL = "Billing"
|
|
72
|
+
USAGE_LABEL_WIDTH = len(USAGE_COVERAGE_LABEL)
|
|
73
|
+
USAGE_BY_AGENT_LABEL = "By agent"
|
|
74
|
+
USAGE_LIMITS_LABEL = "Limits"
|
|
75
|
+
USAGE_API_TOTAL_WORD = "API"
|
|
76
|
+
USAGE_BILLED_WORD = "billed"
|
|
77
|
+
USAGE_API_BILLED_WORD = "API billed"
|
|
78
|
+
USAGE_API_UNREPORTED_WORD = "API unreported"
|
|
79
|
+
USAGE_API_EQUIVALENT_WORD = "API-equivalent"
|
|
80
|
+
USAGE_ESTIMATE_PREFIX = "est. "
|
|
81
|
+
USAGE_SUBSCRIPTION_WORD = "subscription"
|
|
82
|
+
USAGE_UNKNOWN_WORD = "unknown"
|
|
83
|
+
USAGE_REPORTED_WORD = "reported"
|
|
84
|
+
USAGE_LEGACY_WORD = "legacy"
|
|
85
|
+
USAGE_WITHOUT_TOKENS_WORD = "without tokens"
|
|
86
|
+
USAGE_TOKENS_WORD = "tokens"
|
|
87
|
+
USAGE_TOKENS_IN_LABEL = "in"
|
|
88
|
+
USAGE_TOKENS_OUT_LABEL = "out"
|
|
89
|
+
USAGE_CACHE_READ_LABEL = "cache r"
|
|
90
|
+
USAGE_CACHE_WRITE_LABEL = "w"
|
|
91
|
+
USAGE_TOKENS_UNKNOWN = "tokens unknown"
|
|
92
|
+
USAGE_COST_UNKNOWN = "cost unknown"
|
|
93
|
+
USAGE_RESETS_WORD = "resets"
|
|
94
|
+
RESOURCE_FAILED = "failed"
|
|
95
|
+
RESOURCE_UNAVAILABLE = "unavailable"
|
|
96
|
+
RESOURCE_STALE = "stale"
|
|
97
|
+
RESOURCE_EMPTY = "empty"
|
|
98
|
+
RESOURCE_AVAILABLE = "available"
|
|
99
|
+
RESOURCE_STATE_UNKNOWN = "sample state unknown"
|
|
100
|
+
STATUS_ALIGNMENT_MIN_GAP = 1
|
|
101
|
+
ROLE_LABELS = {
|
|
102
|
+
Role.LEADER.value: "Leader",
|
|
103
|
+
Role.IMPLEMENTER.value: "Implementer",
|
|
104
|
+
Role.HELPER.value: "Helper",
|
|
105
|
+
}
|
|
106
|
+
MILESTONE_DONE = "done"
|
|
107
|
+
MILESTONE_CURRENT = "current"
|
|
108
|
+
MILESTONE_LATER = "later"
|
|
109
|
+
MILESTONE_SKIPPED = "skipped"
|
|
110
|
+
MILESTONE_UNKNOWN = "unknown"
|
|
111
|
+
MILESTONE_REWORK = "rework"
|
|
112
|
+
MILESTONE_STOPPED = "stopped"
|
|
113
|
+
MILESTONE_GAP = " "
|
|
114
|
+
MILESTONE_GLYPH_GAP = " "
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _RecordedUsage(Protocol):
|
|
118
|
+
@property
|
|
119
|
+
def session_count(self) -> int: ...
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def input_tokens(self) -> int: ...
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def output_tokens(self) -> int: ...
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def cache_read_tokens(self) -> int: ...
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def cache_write_tokens(self) -> int: ...
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def reported_sessions(self) -> int: ...
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def legacy_token_sessions(self) -> int: ...
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def unknown_token_sessions(self) -> int: ...
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def billed_cost_usd(self) -> float | None: ...
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def billed_cost_sessions(self) -> int: ...
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def api_billed_cost_usd(self) -> float | None: ...
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def api_billed_cost_sessions(self) -> int: ...
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def api_cost_unreported_sessions(self) -> int: ...
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def api_equivalent_cost_usd(self) -> float | None: ...
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def api_equivalent_cost_sessions(self) -> int: ...
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def unknown_billing_sessions(self) -> int: ...
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def subscription_sessions(self) -> int: ...
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def subscription_agents(self) -> tuple[str, ...]: ...
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _AgentUsageSummary(Protocol):
|
|
174
|
+
@property
|
|
175
|
+
def agent(self) -> str: ...
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def input_tokens(self) -> int: ...
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def output_tokens(self) -> int: ...
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def cache_read_tokens(self) -> int: ...
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def cache_write_tokens(self) -> int: ...
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def run_count(self) -> int: ...
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def usage_reported_runs(self) -> int: ...
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def legacy_token_runs(self) -> int: ...
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def billed_cost_usd(self) -> float | None: ...
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def billed_cost_runs(self) -> int: ...
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def api_equivalent_cost_usd(self) -> float | None: ...
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def api_equivalent_cost_runs(self) -> int: ...
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def unknown_cost_runs(self) -> int: ...
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _QuotaWindow(Protocol):
|
|
215
|
+
@property
|
|
216
|
+
def name(self) -> str: ...
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def used_pct(self) -> float: ...
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def resets_in(self) -> str: ...
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def observed_ago(self) -> str: ...
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _AgentQuotas(Protocol):
|
|
229
|
+
@property
|
|
230
|
+
def agent(self) -> str: ...
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def quotas(self) -> Sequence[_QuotaWindow]: ...
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _ResourceSample(Protocol):
|
|
237
|
+
@property
|
|
238
|
+
def state(self) -> str | None: ...
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def host_cpu_pct(self) -> float: ...
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def host_ram_used_pct(self) -> float: ...
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def free_ram_gb(self) -> float: ...
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def host_ram_used_bytes(self) -> int | None: ...
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def host_ram_total_bytes(self) -> int | None: ...
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def instance_cpu_pct(self) -> float | None: ...
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def instance_rss_gb(self) -> float | None: ...
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def shikiru_cpu_pct(self) -> float | None: ...
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def shikiru_rss_gb(self) -> float | None: ...
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def agents_cpu_pct(self) -> float | None: ...
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def agents_rss_gb(self) -> float | None: ...
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def shikiru_process_tree_empty(self) -> bool | None: ...
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def agents_process_tree_empty(self) -> bool | None: ...
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def sampled_ago(self) -> str | None: ...
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def error(self) -> str | None: ...
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class _AttentionDecision(Protocol):
|
|
287
|
+
@property
|
|
288
|
+
def escalation_id(self) -> str: ...
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def headline(self) -> str: ...
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def reason(self) -> EscalationReason | None: ...
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class _AttentionGroup(Protocol):
|
|
298
|
+
@property
|
|
299
|
+
def task_id(self) -> str | None: ...
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def reasons(self) -> tuple[str, ...]: ...
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def escalations(self) -> tuple[_AttentionDecision, ...]: ...
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _HeldTaskState(Protocol):
|
|
309
|
+
@property
|
|
310
|
+
def task_id(self) -> str: ...
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def holds(self) -> tuple[str, ...]: ...
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def operator_owned_roles(self) -> tuple[str, ...]: ...
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class _AttentionSummary(Protocol):
|
|
320
|
+
@property
|
|
321
|
+
def engine_up(self) -> bool | None: ...
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def held_tasks(self) -> int | None: ...
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def holds(self) -> tuple[str, ...] | None: ...
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def operator_owned(self) -> tuple[str, ...] | None: ...
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def held_task_states(self) -> tuple[_HeldTaskState, ...] | None: ...
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class _Attention(Protocol):
|
|
337
|
+
@property
|
|
338
|
+
def groups(self) -> tuple[_AttentionGroup, ...]: ...
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def decisions(self) -> int: ...
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _TaskSession(Protocol):
|
|
345
|
+
@property
|
|
346
|
+
def role(self) -> str: ...
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def agent(self) -> str: ...
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def model(self) -> str: ...
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def effort(self) -> str | None: ...
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def current_lineage(self) -> bool: ...
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def input_tokens(self) -> int: ...
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def output_tokens(self) -> int: ...
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def cache_read_tokens(self) -> int: ...
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def cache_write_tokens(self) -> int: ...
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def usage_reported(self) -> bool | None: ...
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def legacy_token_totals(self) -> bool: ...
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def billing_mode(self) -> str: ...
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def cost_usd(self) -> float | None: ...
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def cost_basis(self) -> str: ...
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def legacy_cost_recorded(self) -> bool: ...
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class _TaskDetail(Protocol):
|
|
392
|
+
@property
|
|
393
|
+
def milestones(self) -> tuple[_TaskMilestone, ...]: ...
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class _TaskMilestone(Protocol):
|
|
397
|
+
@property
|
|
398
|
+
def stage(self) -> str: ...
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def state(self) -> str: ...
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@dataclass(frozen=True)
|
|
405
|
+
class ProjectUsageMetric:
|
|
406
|
+
"""Render the Home token bar."""
|
|
407
|
+
|
|
408
|
+
usage: _RecordedUsage
|
|
409
|
+
|
|
410
|
+
def render_home(self, width: int | None = None) -> Text:
|
|
411
|
+
"""Render the Home token bar, wrapping only between its groups within `width`."""
|
|
412
|
+
return _home_project_usage_text(self.usage, width)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _known_token_sessions(usage: _RecordedUsage) -> int:
|
|
416
|
+
return usage.reported_sessions + usage.legacy_token_sessions
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _home_project_usage_text(usage: _RecordedUsage, width: int | None) -> Text:
|
|
420
|
+
groups = [*_headline_groups(usage), *_home_coverage_texts(usage)]
|
|
421
|
+
return wrap_groups(tuple(groups), width)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _headline_groups(usage: _RecordedUsage) -> list[Text]:
|
|
425
|
+
groups = [
|
|
426
|
+
_count_text(usage.session_count, "session", _BOLD_PRIMARY),
|
|
427
|
+
_home_token_text(usage),
|
|
428
|
+
]
|
|
429
|
+
if usage.api_billed_cost_sessions and usage.api_billed_cost_usd is not None:
|
|
430
|
+
groups.append(_billed_total(usage.api_billed_cost_usd, USAGE_API_TOTAL_WORD))
|
|
431
|
+
return groups
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _billed_total(amount: float, word: str) -> Text:
|
|
435
|
+
return Text.assemble(
|
|
436
|
+
(_cost_text(amount), _BOLD_SAGE),
|
|
437
|
+
(f" {word}", SECONDARY_TEXT_COLOR),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _cost_text(amount: float) -> str:
|
|
442
|
+
return f"${amount:{SESSION_COST_FORMAT}}"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _home_token_text(usage: _RecordedUsage) -> Text:
|
|
446
|
+
if not _known_token_sessions(usage):
|
|
447
|
+
return Text("tokens unknown", style=SECONDARY_TEXT_COLOR)
|
|
448
|
+
total = compact_count(usage.input_tokens + usage.output_tokens)
|
|
449
|
+
return Text.assemble((total, _BOLD_STEEL), (" tokens", SECONDARY_TEXT_COLOR))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _home_coverage_texts(usage: _RecordedUsage) -> list[Text]:
|
|
453
|
+
labels: list[str] = []
|
|
454
|
+
if _known_token_sessions(usage):
|
|
455
|
+
if usage.legacy_token_sessions:
|
|
456
|
+
labels.append(_count_label(usage.legacy_token_sessions, "legacy session"))
|
|
457
|
+
if usage.unknown_token_sessions:
|
|
458
|
+
labels.append(f"{_count_label(usage.unknown_token_sessions, 'session')} without tokens")
|
|
459
|
+
if usage.api_cost_unreported_sessions:
|
|
460
|
+
labels.append(f"{_count_label(usage.api_cost_unreported_sessions, 'cost')} unreported")
|
|
461
|
+
if usage.unknown_billing_sessions:
|
|
462
|
+
labels.append(f"{_count_label(usage.unknown_billing_sessions, 'billing mode')} unknown")
|
|
463
|
+
return [Text(label, style=SECONDARY_TEXT_COLOR) for label in labels]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@dataclass(frozen=True)
|
|
467
|
+
class ProjectUsageTotals:
|
|
468
|
+
"""Render recorded usage as labelled Totals, Tokens, Coverage and Billing rows."""
|
|
469
|
+
|
|
470
|
+
usage: _RecordedUsage
|
|
471
|
+
|
|
472
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
473
|
+
"""Wrap each row at its group separators within the current width."""
|
|
474
|
+
del console
|
|
475
|
+
yield labelled_rows(_totals_rows(self.usage), options.max_width, USAGE_LABEL_WIDTH)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _totals_rows(usage: _RecordedUsage) -> list[Row]:
|
|
479
|
+
rows = [
|
|
480
|
+
(USAGE_TOTALS_LABEL, (*_headline_groups(usage), *_all_mode_billed_groups(usage))),
|
|
481
|
+
(USAGE_TOKENS_LABEL, tuple(_token_detail_groups(usage))),
|
|
482
|
+
(USAGE_COVERAGE_LABEL, tuple(_coverage_groups(usage))),
|
|
483
|
+
]
|
|
484
|
+
billing_lines = [tuple(_billing_groups(usage)), tuple(_estimate_groups(usage))]
|
|
485
|
+
populated = [groups for groups in billing_lines if groups]
|
|
486
|
+
rows.extend(
|
|
487
|
+
(USAGE_BILLING_LABEL if index == 0 else "", groups)
|
|
488
|
+
for index, groups in enumerate(populated)
|
|
489
|
+
)
|
|
490
|
+
return [(grey(label), groups) for label, groups in rows if groups]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _all_mode_billed_groups(usage: _RecordedUsage) -> list[Text]:
|
|
494
|
+
amount = usage.billed_cost_usd
|
|
495
|
+
differs = (
|
|
496
|
+
usage.billed_cost_sessions != usage.api_billed_cost_sessions
|
|
497
|
+
or amount != usage.api_billed_cost_usd
|
|
498
|
+
)
|
|
499
|
+
if differs and usage.billed_cost_sessions and amount:
|
|
500
|
+
return [_billed_total(amount, USAGE_BILLED_WORD)]
|
|
501
|
+
return []
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _token_detail_groups(usage: _RecordedUsage) -> list[Text]:
|
|
505
|
+
if not _known_token_sessions(usage):
|
|
506
|
+
return []
|
|
507
|
+
groups = [
|
|
508
|
+
_token_amount(USAGE_TOKENS_IN_LABEL, usage.input_tokens),
|
|
509
|
+
_token_amount(USAGE_TOKENS_OUT_LABEL, usage.output_tokens),
|
|
510
|
+
]
|
|
511
|
+
if usage.cache_read_tokens or usage.cache_write_tokens:
|
|
512
|
+
groups.append(_token_amount(USAGE_CACHE_READ_LABEL, usage.cache_read_tokens))
|
|
513
|
+
groups.append(_token_amount(USAGE_CACHE_WRITE_LABEL, usage.cache_write_tokens))
|
|
514
|
+
return groups
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _token_amount(label: str, count: int) -> Text:
|
|
518
|
+
return Text.assemble((f"{label} ", SECONDARY_TEXT_COLOR), (compact_count(count), _BOLD_STEEL))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _coverage_groups(usage: _RecordedUsage) -> list[Text]:
|
|
522
|
+
partial = usage.legacy_token_sessions or usage.unknown_token_sessions
|
|
523
|
+
if not _known_token_sessions(usage) or not partial:
|
|
524
|
+
return []
|
|
525
|
+
return _counted_groups(
|
|
526
|
+
(usage.reported_sessions, USAGE_REPORTED_WORD),
|
|
527
|
+
(usage.legacy_token_sessions, USAGE_LEGACY_WORD),
|
|
528
|
+
(usage.unknown_token_sessions, USAGE_WITHOUT_TOKENS_WORD),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _billing_groups(usage: _RecordedUsage) -> list[Text]:
|
|
533
|
+
return _counted_groups(
|
|
534
|
+
(usage.subscription_sessions, USAGE_SUBSCRIPTION_WORD),
|
|
535
|
+
(usage.api_billed_cost_sessions, USAGE_API_BILLED_WORD),
|
|
536
|
+
(usage.api_cost_unreported_sessions, USAGE_API_UNREPORTED_WORD),
|
|
537
|
+
(usage.unknown_billing_sessions, USAGE_UNKNOWN_WORD),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _estimate_groups(usage: _RecordedUsage) -> list[Text]:
|
|
542
|
+
amount = usage.api_equivalent_cost_usd
|
|
543
|
+
if not usage.api_equivalent_cost_sessions or amount is None:
|
|
544
|
+
return []
|
|
545
|
+
sessions = _count_label(usage.api_equivalent_cost_sessions, "session")
|
|
546
|
+
return [_estimate_text(amount, f" {USAGE_API_EQUIVALENT_WORD} ({sessions})")]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _estimate_text(amount: float, qualifier: str = "") -> Text:
|
|
550
|
+
return Text.assemble(
|
|
551
|
+
(f"{USAGE_ESTIMATE_PREFIX}{_cost_text(amount)}", AMBER),
|
|
552
|
+
(qualifier, SECONDARY_TEXT_COLOR),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _counted_groups(*counts: tuple[int, str]) -> list[Text]:
|
|
557
|
+
return [_counted(count, label) for count, label in counts if count]
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _counted(count: int, label: str) -> Text:
|
|
561
|
+
return Text.assemble((str(count), _BOLD_PRIMARY), (f" {label}", SECONDARY_TEXT_COLOR))
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def compact_count(count: int) -> str:
|
|
565
|
+
if count >= COUNT_SHORT_MILLION:
|
|
566
|
+
return f"{count / COUNT_SHORT_MILLION:.{COUNT_SHORT_PRECISION}f}M"
|
|
567
|
+
if count >= COUNT_SHORT_SCALE:
|
|
568
|
+
return f"{count / COUNT_SHORT_SCALE:.{COUNT_SHORT_PRECISION}f}k"
|
|
569
|
+
return f"{count:,}"
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _count_label(count: int, noun: str) -> str:
|
|
573
|
+
return f"{count} {_plural(count, noun)}"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _count_text(count: int, noun: str, count_style: str) -> Text:
|
|
577
|
+
return Text.assemble(
|
|
578
|
+
(str(count), count_style),
|
|
579
|
+
(f" {_plural(count, noun)}", SECONDARY_TEXT_COLOR),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _plural(count: int, noun: str) -> str:
|
|
584
|
+
return noun if count == 1 else f"{noun}s"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@dataclass(frozen=True)
|
|
588
|
+
class ResourceMetric:
|
|
589
|
+
"""Render one width-wrapping host, Shikiru and agent observation."""
|
|
590
|
+
|
|
591
|
+
sample: _ResourceSample | None
|
|
592
|
+
|
|
593
|
+
def render(self, *, width: int | None = None) -> Text:
|
|
594
|
+
if self.sample is None:
|
|
595
|
+
return Text("Resources unavailable · no sample recorded")
|
|
596
|
+
state = self.sample.state
|
|
597
|
+
if state == RESOURCE_FAILED:
|
|
598
|
+
return _resource_state_line(RESOURCE_FAILED, self.sample)
|
|
599
|
+
if state == RESOURCE_UNAVAILABLE:
|
|
600
|
+
return _resource_state_line(RESOURCE_UNAVAILABLE, self.sample)
|
|
601
|
+
groups = (
|
|
602
|
+
_host_resource_text(self.sample),
|
|
603
|
+
_process_resource_text(
|
|
604
|
+
"Shikiru",
|
|
605
|
+
self.sample.shikiru_cpu_pct,
|
|
606
|
+
self.sample.shikiru_rss_gb,
|
|
607
|
+
self.sample.shikiru_process_tree_empty,
|
|
608
|
+
),
|
|
609
|
+
_process_resource_text(
|
|
610
|
+
"Agents",
|
|
611
|
+
self.sample.agents_cpu_pct,
|
|
612
|
+
self.sample.agents_rss_gb,
|
|
613
|
+
self.sample.agents_process_tree_empty,
|
|
614
|
+
),
|
|
615
|
+
)
|
|
616
|
+
metrics = wrap_groups(groups, width)
|
|
617
|
+
if state == RESOURCE_STALE:
|
|
618
|
+
age = _observed_suffix(self.sample.sampled_ago)
|
|
619
|
+
error = f" · {self.sample.error}" if self.sample.error else ""
|
|
620
|
+
return Text.assemble(
|
|
621
|
+
(f"Stale{age}{error}", AMBER),
|
|
622
|
+
"\n",
|
|
623
|
+
metrics,
|
|
624
|
+
)
|
|
625
|
+
if state not in {RESOURCE_AVAILABLE, RESOURCE_EMPTY}:
|
|
626
|
+
return Text.assemble(
|
|
627
|
+
(f"{RESOURCE_STATE_UNKNOWN}\n", SECONDARY_TEXT_COLOR),
|
|
628
|
+
metrics,
|
|
629
|
+
)
|
|
630
|
+
return metrics
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _observed_suffix(sampled_ago: str | None) -> str:
|
|
634
|
+
return f" · {observed_text(sampled_ago)}" if sampled_ago else ""
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _resource_state_line(state: str, sample: _ResourceSample) -> Text:
|
|
638
|
+
error = f" · {sample.error}" if sample.error else ""
|
|
639
|
+
age = _observed_suffix(sample.sampled_ago)
|
|
640
|
+
style = ALERT_COLOR if state == RESOURCE_FAILED else SECONDARY_TEXT_COLOR
|
|
641
|
+
return Text(f"Resources {state}{error}{age}", style=style)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _process_resource_text(
|
|
645
|
+
label: str,
|
|
646
|
+
cpu_pct: float | None,
|
|
647
|
+
rss_gb: float | None,
|
|
648
|
+
process_tree_empty: bool | None,
|
|
649
|
+
) -> Text:
|
|
650
|
+
if cpu_pct is None or rss_gb is None:
|
|
651
|
+
if process_tree_empty:
|
|
652
|
+
return Text.assemble(
|
|
653
|
+
(label, _BOLD_PRIMARY),
|
|
654
|
+
(" process tree is empty", SECONDARY_TEXT_COLOR),
|
|
655
|
+
)
|
|
656
|
+
return Text(f"{label} CPU/RSS unavailable", style=SECONDARY_TEXT_COLOR)
|
|
657
|
+
return _resource_group_text(
|
|
658
|
+
label,
|
|
659
|
+
cpu_pct,
|
|
660
|
+
Text.assemble((" RSS", PRIMARY_TEXT_COLOR), (f" {format_gib(rss_gb)} GiB", STEEL_BLUE)),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _host_resource_text(sample: _ResourceSample) -> Text:
|
|
665
|
+
used = sample.host_ram_used_bytes
|
|
666
|
+
total = sample.host_ram_total_bytes
|
|
667
|
+
if used is None or total is None:
|
|
668
|
+
memory = Text(" RAM used/total unavailable", style=SECONDARY_TEXT_COLOR)
|
|
669
|
+
else:
|
|
670
|
+
memory = Text.assemble(
|
|
671
|
+
(" RAM", PRIMARY_TEXT_COLOR),
|
|
672
|
+
(
|
|
673
|
+
f" {format_gib(used / BYTES_PER_GIB)}/{format_gib(total / BYTES_PER_GIB)} GiB",
|
|
674
|
+
STEEL_BLUE,
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
return _resource_group_text("Host", sample.host_cpu_pct, memory)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def format_gib(gib: float) -> str:
|
|
681
|
+
"""Format a GiB amount with one decimal and no unit."""
|
|
682
|
+
return f"{gib:{GIB_FORMAT}}"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def format_memory(memory_bytes: int) -> str:
|
|
686
|
+
"""Format bytes as GiB with one decimal from one GiB up, otherwise as whole MiB."""
|
|
687
|
+
if memory_bytes >= BYTES_PER_GIB:
|
|
688
|
+
return f"{format_gib(memory_bytes / BYTES_PER_GIB)} GiB"
|
|
689
|
+
return f"{memory_bytes / BYTES_PER_MIB:{MIB_FORMAT}} MiB"
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def free_space_text(free_bytes: int, total_bytes: int, level: UsageLevel | None) -> Text:
|
|
693
|
+
"""Render `free of total` as sizes, coloured by `level` and ending in `‼` at warn and alert."""
|
|
694
|
+
style = _FREE_SPACE_STYLES.get(level) if level else None
|
|
695
|
+
text = Text.assemble(
|
|
696
|
+
(format_size(free_bytes), style or _BOLD_STEEL),
|
|
697
|
+
(" of ", SECONDARY_TEXT_COLOR),
|
|
698
|
+
(format_size(total_bytes), _BOLD_STEEL),
|
|
699
|
+
)
|
|
700
|
+
if style:
|
|
701
|
+
text.append(f" {GLYPH_ESCALATED}", style=style)
|
|
702
|
+
return text
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def format_cpu_pct(cpu_pct: float) -> str:
|
|
706
|
+
"""Format a CPU percentage with one decimal below ten percent, otherwise as an integer."""
|
|
707
|
+
number_format = CPU_DECIMAL_FORMAT if cpu_pct < CPU_DECIMALS_BELOW_PCT else CPU_INTEGER_FORMAT
|
|
708
|
+
return f"{cpu_pct:{number_format}}%"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _resource_group_text(label: str, cpu_pct: float, memory: Text) -> Text:
|
|
712
|
+
text = Text.assemble(
|
|
713
|
+
(label, _BOLD_PRIMARY),
|
|
714
|
+
(" CPU", PRIMARY_TEXT_COLOR),
|
|
715
|
+
(f" {format_cpu_pct(cpu_pct)}", STEEL_BLUE),
|
|
716
|
+
)
|
|
717
|
+
text.append(memory)
|
|
718
|
+
return text
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def render_resource_metric(sample: _ResourceSample | None, *, width: int | None = None) -> Text:
|
|
722
|
+
"""Render `sample` without starting another resource observation."""
|
|
723
|
+
return ResourceMetric(sample).render(width=width)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def attention_status_text(
|
|
727
|
+
attention: _Attention,
|
|
728
|
+
summary: _AttentionSummary,
|
|
729
|
+
*,
|
|
730
|
+
action_hint: str,
|
|
731
|
+
selected_group_index: int = 0,
|
|
732
|
+
selected_escalation_id: str | None = None,
|
|
733
|
+
not_escalated_task_id: str | None = None,
|
|
734
|
+
width: int | None = None,
|
|
735
|
+
) -> Text:
|
|
736
|
+
"""Render the Home status strip from current attention and observed workspace state."""
|
|
737
|
+
if not_escalated_task_id is not None:
|
|
738
|
+
status = _not_escalated_status(
|
|
739
|
+
not_escalated_task_id, attention.decisions, action_hint, width
|
|
740
|
+
)
|
|
741
|
+
if attention.groups:
|
|
742
|
+
_append_engine_state(status, summary)
|
|
743
|
+
_append_held_task_states(status, summary)
|
|
744
|
+
else:
|
|
745
|
+
_append_held_task_states(status, summary, inline=True)
|
|
746
|
+
return status
|
|
747
|
+
if attention.groups:
|
|
748
|
+
group = attention.groups[selected_group_index]
|
|
749
|
+
status = _attention_group_status(
|
|
750
|
+
group,
|
|
751
|
+
action_hint=action_hint,
|
|
752
|
+
selected_escalation_id=selected_escalation_id,
|
|
753
|
+
width=width,
|
|
754
|
+
)
|
|
755
|
+
_append_engine_state(status, summary)
|
|
756
|
+
_append_held_task_states(status, summary)
|
|
757
|
+
return status
|
|
758
|
+
status = Text("✓ No decisions needed", style=SAGE)
|
|
759
|
+
_append_held_task_states(status, summary, inline=True)
|
|
760
|
+
return status
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def display_escalation_headline(
|
|
764
|
+
task_id: str | None,
|
|
765
|
+
headline: str,
|
|
766
|
+
reason: EscalationReason | None = None,
|
|
767
|
+
) -> str:
|
|
768
|
+
"""Drop an already-present leading task identity and name the reason in words.
|
|
769
|
+
|
|
770
|
+
The stored headline is never changed; only its exact `step · reason:` segment is shown
|
|
771
|
+
with the reason's Operator-facing label.
|
|
772
|
+
"""
|
|
773
|
+
shown = headline if task_id is None else headline.removeprefix(f"{task_id} · ")
|
|
774
|
+
if reason is None:
|
|
775
|
+
return shown
|
|
776
|
+
return shown.replace(f" · {reason.value}:", f" · {escalation_reason_label(reason)}:", 1)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@dataclass(frozen=True)
|
|
780
|
+
class FocusAttentionStatus:
|
|
781
|
+
"""Render a cached Home attention projection against the current terminal width."""
|
|
782
|
+
|
|
783
|
+
attention: _Attention
|
|
784
|
+
summary: _AttentionSummary
|
|
785
|
+
action_hint: str
|
|
786
|
+
selected_group_index: int = 0
|
|
787
|
+
selected_escalation_id: str | None = None
|
|
788
|
+
not_escalated_task_id: str | None = None
|
|
789
|
+
|
|
790
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
791
|
+
"""Align the existing status and action hint during Rich's current-width render."""
|
|
792
|
+
del console
|
|
793
|
+
yield attention_status_text(
|
|
794
|
+
self.attention,
|
|
795
|
+
self.summary,
|
|
796
|
+
action_hint=self.action_hint,
|
|
797
|
+
selected_group_index=self.selected_group_index,
|
|
798
|
+
selected_escalation_id=self.selected_escalation_id,
|
|
799
|
+
not_escalated_task_id=self.not_escalated_task_id,
|
|
800
|
+
width=options.max_width,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _attention_group_status(
|
|
805
|
+
group: _AttentionGroup,
|
|
806
|
+
*,
|
|
807
|
+
action_hint: str,
|
|
808
|
+
selected_escalation_id: str | None,
|
|
809
|
+
width: int | None,
|
|
810
|
+
) -> Text:
|
|
811
|
+
if group.escalations:
|
|
812
|
+
decision = next(
|
|
813
|
+
(item for item in group.escalations if item.escalation_id == selected_escalation_id),
|
|
814
|
+
group.escalations[0],
|
|
815
|
+
)
|
|
816
|
+
headline = display_escalation_headline(group.task_id, decision.headline, decision.reason)
|
|
817
|
+
status = Text.assemble(
|
|
818
|
+
("! ", AMBER),
|
|
819
|
+
(decision.escalation_id, f"{STYLE_BOLD} {AMBER}"),
|
|
820
|
+
(" · ", PRIMARY_TEXT_COLOR),
|
|
821
|
+
(group.task_id, _BOLD_PRIMARY) if group.task_id else ("Project", PRIMARY_TEXT_COLOR),
|
|
822
|
+
(f" · {headline}", PRIMARY_TEXT_COLOR),
|
|
823
|
+
)
|
|
824
|
+
_append_aligned_hint(status, action_hint, width)
|
|
825
|
+
else:
|
|
826
|
+
fault = next(
|
|
827
|
+
(reason for reason in group.reasons if reason.startswith("engine fault")),
|
|
828
|
+
None,
|
|
829
|
+
)
|
|
830
|
+
if fault is not None:
|
|
831
|
+
identity = f"{group.task_id or 'Project'} · {fault}"
|
|
832
|
+
status = Text.assemble(("! ", ALERT_COLOR), (identity, ALERT_COLOR))
|
|
833
|
+
else:
|
|
834
|
+
status = Text.assemble(("! ", AMBER))
|
|
835
|
+
append_key_hint(
|
|
836
|
+
status,
|
|
837
|
+
f"{group.task_id or 'Project'} · {' · '.join(group.reasons)} · {action_hint}",
|
|
838
|
+
AMBER,
|
|
839
|
+
)
|
|
840
|
+
return status
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _not_escalated_status(
|
|
844
|
+
task_id: str,
|
|
845
|
+
other_decisions: int,
|
|
846
|
+
action_hint: str,
|
|
847
|
+
width: int | None,
|
|
848
|
+
) -> Text:
|
|
849
|
+
status = Text.assemble((task_id, _BOLD_PRIMARY), (" · No decision needed", PRIMARY_TEXT_COLOR))
|
|
850
|
+
if other_decisions:
|
|
851
|
+
noun = "decision" if other_decisions == 1 else "decisions"
|
|
852
|
+
status.append(f" · {other_decisions} {noun} on other tasks", style=SECONDARY_TEXT_COLOR)
|
|
853
|
+
_append_aligned_hint(status, action_hint, width)
|
|
854
|
+
return status
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _append_aligned_hint(status: Text, action_hint: str, width: int | None) -> None:
|
|
858
|
+
gap = max(
|
|
859
|
+
width - cell_len(status.plain) - cell_len(action_hint)
|
|
860
|
+
if width is not None
|
|
861
|
+
else STATUS_ALIGNMENT_MIN_GAP,
|
|
862
|
+
STATUS_ALIGNMENT_MIN_GAP,
|
|
863
|
+
)
|
|
864
|
+
status.append(" " * gap)
|
|
865
|
+
append_key_hint(status, action_hint, SECONDARY_TEXT_COLOR)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _aligned_line(left: RenderableType, right: RenderableType) -> Table:
|
|
869
|
+
line = Table.grid(padding=(0, 0), expand=True)
|
|
870
|
+
line.add_column(ratio=1)
|
|
871
|
+
line.add_column(justify="right")
|
|
872
|
+
line.add_row(left, right)
|
|
873
|
+
return line
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _append_held_task_states(
|
|
877
|
+
status: Text,
|
|
878
|
+
summary: _AttentionSummary,
|
|
879
|
+
*,
|
|
880
|
+
inline: bool = False,
|
|
881
|
+
) -> None:
|
|
882
|
+
held_task_states = summary.held_task_states
|
|
883
|
+
if held_task_states is None:
|
|
884
|
+
if summary.operator_owned:
|
|
885
|
+
_append_status_line(
|
|
886
|
+
status,
|
|
887
|
+
f"@ Operator owns {', '.join(summary.operator_owned)}",
|
|
888
|
+
STEEL_BLUE,
|
|
889
|
+
inline=inline,
|
|
890
|
+
)
|
|
891
|
+
if summary.held_tasks:
|
|
892
|
+
_append_status_line(
|
|
893
|
+
status,
|
|
894
|
+
f"= Hold details unavailable for {summary.held_tasks} tasks",
|
|
895
|
+
AMBER,
|
|
896
|
+
inline=inline,
|
|
897
|
+
)
|
|
898
|
+
return
|
|
899
|
+
if summary.held_tasks and not held_task_states:
|
|
900
|
+
_append_status_line(status, "= Hold details unavailable", AMBER, inline=inline)
|
|
901
|
+
if summary.operator_owned and not held_task_states:
|
|
902
|
+
_append_status_line(
|
|
903
|
+
status,
|
|
904
|
+
"@ Operator-owned task details unavailable",
|
|
905
|
+
STEEL_BLUE,
|
|
906
|
+
inline=inline,
|
|
907
|
+
)
|
|
908
|
+
for held_task in held_task_states:
|
|
909
|
+
_append_task_hold_state(status, held_task, inline=inline)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _append_task_hold_state(
|
|
913
|
+
status: Text,
|
|
914
|
+
held_task: _HeldTaskState,
|
|
915
|
+
*,
|
|
916
|
+
inline: bool,
|
|
917
|
+
) -> None:
|
|
918
|
+
owner = (
|
|
919
|
+
f"Operator owns {', '.join(role.title() for role in held_task.operator_owned_roles)}"
|
|
920
|
+
if held_task.operator_owned_roles
|
|
921
|
+
else "held"
|
|
922
|
+
)
|
|
923
|
+
holds = ", ".join(held_task.holds) if held_task.holds else "hold details unavailable"
|
|
924
|
+
_append_status_line(
|
|
925
|
+
status,
|
|
926
|
+
f"{'@' if held_task.operator_owned_roles else '='} {held_task.task_id} · {owner} · {holds}",
|
|
927
|
+
STEEL_BLUE if held_task.operator_owned_roles else AMBER,
|
|
928
|
+
inline=inline,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _append_engine_state(
|
|
933
|
+
status: Text,
|
|
934
|
+
summary: _AttentionSummary,
|
|
935
|
+
) -> None:
|
|
936
|
+
if summary.engine_up is False:
|
|
937
|
+
_append_status_line(status, "○ Engine stopped", SECONDARY_TEXT_COLOR)
|
|
938
|
+
elif summary.engine_up is None:
|
|
939
|
+
_append_status_line(
|
|
940
|
+
status,
|
|
941
|
+
"○ Engine status unavailable",
|
|
942
|
+
SECONDARY_TEXT_COLOR,
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _append_status_line(status: Text, line: str, style: str, *, inline: bool = False) -> None:
|
|
947
|
+
status.append(" · " if inline else "\n")
|
|
948
|
+
status.append(line, style=style)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
@dataclass(frozen=True)
|
|
952
|
+
class AgentUsageRow:
|
|
953
|
+
"""Render one compact role and session-usage pair."""
|
|
954
|
+
|
|
955
|
+
session: _TaskSession
|
|
956
|
+
role_state: str
|
|
957
|
+
|
|
958
|
+
def render(self) -> Group:
|
|
959
|
+
session = self.session
|
|
960
|
+
role = ROLE_LABELS.get(session.role, session.role.title())
|
|
961
|
+
identity = f"{session.agent}/{session.model}"
|
|
962
|
+
tokens = _session_token_summary(session)
|
|
963
|
+
charge, charge_style = _compact_session_charge(session)
|
|
964
|
+
details = Text.assemble(
|
|
965
|
+
(identity, STEEL_BLUE),
|
|
966
|
+
(f": {tokens}", SECONDARY_TEXT_COLOR),
|
|
967
|
+
(f" · {charge}", charge_style),
|
|
968
|
+
)
|
|
969
|
+
return Group(
|
|
970
|
+
_aligned_line(
|
|
971
|
+
Text(role, style=SECONDARY_TEXT_COLOR),
|
|
972
|
+
Text(self.role_state, style=SECONDARY_TEXT_COLOR),
|
|
973
|
+
),
|
|
974
|
+
details,
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _session_token_summary(session: _TaskSession) -> str:
|
|
979
|
+
if session.usage_reported is False:
|
|
980
|
+
return "session tokens unknown"
|
|
981
|
+
total = compact_count(session.input_tokens + session.output_tokens)
|
|
982
|
+
if session.usage_reported is True:
|
|
983
|
+
return f"session-wide {total} tok"
|
|
984
|
+
if session.legacy_token_totals:
|
|
985
|
+
return f"session-wide {total} tok legacy"
|
|
986
|
+
return "session tokens unknown"
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _session_cost_text(session: _TaskSession) -> tuple[str, str]:
|
|
990
|
+
if session.cost_usd is None:
|
|
991
|
+
if session.billing_mode == BILLING_MODE_SUBSCRIPTION:
|
|
992
|
+
return "no per-task charge reported", AMBER
|
|
993
|
+
if session.billing_mode == BILLING_MODE_API:
|
|
994
|
+
return "API charge unreported", SECONDARY_TEXT_COLOR
|
|
995
|
+
return "cost unknown", SECONDARY_TEXT_COLOR
|
|
996
|
+
amount = f"${session.cost_usd:{SESSION_COST_FORMAT}}"
|
|
997
|
+
if session.cost_basis == COST_BASIS_BILLED:
|
|
998
|
+
label = f"billed {amount}"
|
|
999
|
+
style = SAGE
|
|
1000
|
+
elif session.cost_basis == COST_BASIS_API_EQUIVALENT:
|
|
1001
|
+
label = f"estimate {amount}"
|
|
1002
|
+
style = AMBER
|
|
1003
|
+
else:
|
|
1004
|
+
label = f"amount {amount} · basis unknown"
|
|
1005
|
+
style = SECONDARY_TEXT_COLOR
|
|
1006
|
+
return label, style
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _compact_session_charge(session: _TaskSession) -> tuple[str, str]:
|
|
1010
|
+
if session.billing_mode == BILLING_MODE_SUBSCRIPTION:
|
|
1011
|
+
charge, style = "Subscription", SAGE
|
|
1012
|
+
elif session.billing_mode == BILLING_MODE_API:
|
|
1013
|
+
if session.cost_usd is None:
|
|
1014
|
+
charge, style = "API charge unreported", SECONDARY_TEXT_COLOR
|
|
1015
|
+
elif session.cost_basis == COST_BASIS_BILLED:
|
|
1016
|
+
charge, style = f"API ${session.cost_usd:{SESSION_COST_FORMAT}}", SAGE
|
|
1017
|
+
elif session.cost_basis == COST_BASIS_API_EQUIVALENT:
|
|
1018
|
+
charge, style = f"API estimate ${session.cost_usd:{SESSION_COST_FORMAT}}", AMBER
|
|
1019
|
+
else:
|
|
1020
|
+
charge, style = "API amount basis unknown", SECONDARY_TEXT_COLOR
|
|
1021
|
+
elif session.cost_usd is None:
|
|
1022
|
+
charge, style = "billing unknown", SECONDARY_TEXT_COLOR
|
|
1023
|
+
elif session.cost_basis == COST_BASIS_BILLED:
|
|
1024
|
+
charge, style = f"reported ${session.cost_usd:{SESSION_COST_FORMAT}}", SAGE
|
|
1025
|
+
elif session.cost_basis == COST_BASIS_API_EQUIVALENT:
|
|
1026
|
+
charge, style = f"API estimate ${session.cost_usd:{SESSION_COST_FORMAT}}", AMBER
|
|
1027
|
+
elif session.cost_basis == COST_BASIS_UNKNOWN:
|
|
1028
|
+
charge, style = "amount basis unknown", SECONDARY_TEXT_COLOR
|
|
1029
|
+
else:
|
|
1030
|
+
charge, style = _session_cost_text(session)
|
|
1031
|
+
return charge, style
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@dataclass(frozen=True)
|
|
1035
|
+
class AgentUsageRows:
|
|
1036
|
+
"""Render one row of runs, tokens and cost per agent."""
|
|
1037
|
+
|
|
1038
|
+
usages: Sequence[_AgentUsageSummary]
|
|
1039
|
+
|
|
1040
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
1041
|
+
"""Wrap each row at its group separators within the current width."""
|
|
1042
|
+
del console
|
|
1043
|
+
rows = [
|
|
1044
|
+
(Text(usage.agent, style=_BOLD_PRIMARY), _agent_groups(usage)) for usage in self.usages
|
|
1045
|
+
]
|
|
1046
|
+
label_width = max(cell_len(label.plain) for label, _ in rows)
|
|
1047
|
+
yield labelled_rows(rows, options.max_width, label_width)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _agent_groups(usage: _AgentUsageSummary) -> tuple[Text, ...]:
|
|
1051
|
+
return (
|
|
1052
|
+
_count_text(usage.run_count, "run", _BOLD_PRIMARY),
|
|
1053
|
+
*_agent_token_groups(usage),
|
|
1054
|
+
*_agent_cost_groups(usage),
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _agent_token_groups(usage: _AgentUsageSummary) -> list[Text]:
|
|
1059
|
+
known_runs = usage.usage_reported_runs + usage.legacy_token_runs
|
|
1060
|
+
if not known_runs:
|
|
1061
|
+
return [grey(USAGE_TOKENS_UNKNOWN)]
|
|
1062
|
+
groups = [
|
|
1063
|
+
Text.assemble(
|
|
1064
|
+
(compact_count(usage.input_tokens + usage.output_tokens), _BOLD_STEEL),
|
|
1065
|
+
(f" {USAGE_TOKENS_WORD}", SECONDARY_TEXT_COLOR),
|
|
1066
|
+
)
|
|
1067
|
+
]
|
|
1068
|
+
if usage.run_count > known_runs:
|
|
1069
|
+
groups.append(grey(f"{usage.run_count - known_runs} {USAGE_WITHOUT_TOKENS_WORD}"))
|
|
1070
|
+
return groups
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _agent_cost_groups(usage: _AgentUsageSummary) -> list[Text]:
|
|
1074
|
+
groups: list[Text] = []
|
|
1075
|
+
if usage.billed_cost_runs and usage.billed_cost_usd is not None:
|
|
1076
|
+
groups.append(_billed_total(usage.billed_cost_usd, USAGE_BILLED_WORD))
|
|
1077
|
+
if usage.api_equivalent_cost_runs and usage.api_equivalent_cost_usd is not None:
|
|
1078
|
+
groups.append(_estimate_text(usage.api_equivalent_cost_usd))
|
|
1079
|
+
if usage.unknown_cost_runs:
|
|
1080
|
+
label = USAGE_COST_UNKNOWN
|
|
1081
|
+
groups.append(grey(f"{usage.unknown_cost_runs} {label}" if groups else label))
|
|
1082
|
+
return groups
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@dataclass(frozen=True)
|
|
1086
|
+
class AgentQuotaLimits:
|
|
1087
|
+
"""Render each agent's quota windows as gauge rows with their reset countdowns."""
|
|
1088
|
+
|
|
1089
|
+
usages: Sequence[_AgentQuotas]
|
|
1090
|
+
warn_pct: int
|
|
1091
|
+
alert_pct: int
|
|
1092
|
+
|
|
1093
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
1094
|
+
"""Wrap each window row within the current width and note a shared observation age."""
|
|
1095
|
+
del console
|
|
1096
|
+
windows = [(usage.agent, quota) for usage in self.usages for quota in usage.quotas]
|
|
1097
|
+
observed = {quota.observed_ago for _, quota in windows}
|
|
1098
|
+
shared = len(observed) == 1
|
|
1099
|
+
rows: list[Row] = []
|
|
1100
|
+
previous_agent: str | None = None
|
|
1101
|
+
for agent, quota in windows:
|
|
1102
|
+
label = Text(agent, style=_BOLD_PRIMARY) if agent != previous_agent else Text()
|
|
1103
|
+
previous_agent = agent
|
|
1104
|
+
gauge = Gauge(quota.name, quota.used_pct, self.warn_pct, self.alert_pct).render()
|
|
1105
|
+
gauge.append(f" {USAGE_RESETS_WORD} {quota.resets_in}", style=SECONDARY_TEXT_COLOR)
|
|
1106
|
+
if not shared:
|
|
1107
|
+
gauge.append(f" · {observed_text(quota.observed_ago)}", style=SECONDARY_TEXT_COLOR)
|
|
1108
|
+
rows.append((label, (gauge,)))
|
|
1109
|
+
label_width = max(cell_len(label.plain) for label, _ in rows)
|
|
1110
|
+
yield labelled_rows(rows, options.max_width, label_width)
|
|
1111
|
+
if shared:
|
|
1112
|
+
yield grey(observed_text(next(iter(observed))))
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
@dataclass(frozen=True)
|
|
1116
|
+
class AgentUsageDetails:
|
|
1117
|
+
"""Render session token, cost basis and source evidence below a compact row."""
|
|
1118
|
+
|
|
1119
|
+
session: _TaskSession
|
|
1120
|
+
|
|
1121
|
+
def render(self) -> Group:
|
|
1122
|
+
session = self.session
|
|
1123
|
+
if session.usage_reported is False:
|
|
1124
|
+
tokens = "Token values unknown"
|
|
1125
|
+
elif session.usage_reported is None and not session.legacy_token_totals:
|
|
1126
|
+
tokens = "Token provenance unknown"
|
|
1127
|
+
else:
|
|
1128
|
+
provenance = "legacy recorded · " if session.legacy_token_totals else "reported · "
|
|
1129
|
+
tokens = (
|
|
1130
|
+
f"{provenance}input {session.input_tokens:,} · output {session.output_tokens:,} · "
|
|
1131
|
+
f"cache read {session.cache_read_tokens:,} · "
|
|
1132
|
+
f"cache write {session.cache_write_tokens:,}"
|
|
1133
|
+
)
|
|
1134
|
+
cost = _session_cost_text(session)[0]
|
|
1135
|
+
cost_basis = f"Cost basis · {session.cost_basis} · {cost}"
|
|
1136
|
+
if session.legacy_cost_recorded:
|
|
1137
|
+
cost_basis = f"{cost_basis} · legacy record"
|
|
1138
|
+
lines = [
|
|
1139
|
+
Text(f"Tokens · {tokens}", style=SECONDARY_TEXT_COLOR),
|
|
1140
|
+
Text(cost_basis, style=SECONDARY_TEXT_COLOR),
|
|
1141
|
+
]
|
|
1142
|
+
if session.effort:
|
|
1143
|
+
lines.append(Text(f"Effort · {session.effort}", style=SECONDARY_TEXT_COLOR))
|
|
1144
|
+
return Group(*lines)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
@dataclass(frozen=True)
|
|
1148
|
+
class StageActivity:
|
|
1149
|
+
"""Render the recorded workflow milestones of one task."""
|
|
1150
|
+
|
|
1151
|
+
task: _TaskDetail
|
|
1152
|
+
|
|
1153
|
+
def render(self, *, glyphs_only: bool = False) -> Text:
|
|
1154
|
+
"""Return the milestone row, as bare glyphs when `glyphs_only`."""
|
|
1155
|
+
line = Text()
|
|
1156
|
+
for index, milestone in enumerate(self.task.milestones):
|
|
1157
|
+
if index:
|
|
1158
|
+
line.append(MILESTONE_GLYPH_GAP if glyphs_only else MILESTONE_GAP)
|
|
1159
|
+
marker, style = _milestone_marker(milestone.state)
|
|
1160
|
+
line.append(marker if glyphs_only else f"{marker} {milestone.stage}", style=style)
|
|
1161
|
+
if not glyphs_only and milestone.state in {
|
|
1162
|
+
MILESTONE_SKIPPED,
|
|
1163
|
+
MILESTONE_REWORK,
|
|
1164
|
+
MILESTONE_UNKNOWN,
|
|
1165
|
+
MILESTONE_STOPPED,
|
|
1166
|
+
}:
|
|
1167
|
+
line.append(f" {milestone.state}", style=style)
|
|
1168
|
+
return line
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
_MILESTONE_MARKERS = {
|
|
1172
|
+
MILESTONE_DONE: ("✓", SAGE),
|
|
1173
|
+
MILESTONE_CURRENT: (GLYPH_CURRENT_STAGE, STEEL_BLUE),
|
|
1174
|
+
MILESTONE_LATER: ("·", SECONDARY_TEXT_COLOR),
|
|
1175
|
+
MILESTONE_SKIPPED: ("—", SECONDARY_TEXT_COLOR),
|
|
1176
|
+
MILESTONE_REWORK: ("!", AMBER),
|
|
1177
|
+
MILESTONE_STOPPED: (GLYPH_STOPPED, ALERT_COLOR),
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _milestone_marker(state: str) -> tuple[str, str]:
|
|
1182
|
+
return _MILESTONE_MARKERS.get(state, ("?", AMBER))
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def duration_text(duration: timedelta) -> str:
|
|
1186
|
+
elapsed = duration.total_seconds()
|
|
1187
|
+
if elapsed < 0:
|
|
1188
|
+
return "future timestamp"
|
|
1189
|
+
total_seconds = int(elapsed)
|
|
1190
|
+
if total_seconds < SECONDS_PER_MINUTE:
|
|
1191
|
+
return f"{total_seconds}s"
|
|
1192
|
+
if total_seconds < SECONDS_PER_HOUR:
|
|
1193
|
+
return f"{total_seconds // SECONDS_PER_MINUTE}m"
|
|
1194
|
+
hours = total_seconds // SECONDS_PER_HOUR
|
|
1195
|
+
minutes = (total_seconds % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE
|
|
1196
|
+
return f"{hours}h {minutes}m" if minutes else f"{hours}h"
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def recorded_age(timestamp: datetime, now: datetime) -> str:
|
|
1200
|
+
elapsed = (now - timestamp).total_seconds()
|
|
1201
|
+
if elapsed < 0:
|
|
1202
|
+
return "future timestamp"
|
|
1203
|
+
seconds = int(elapsed)
|
|
1204
|
+
if seconds < SECONDS_PER_MINUTE:
|
|
1205
|
+
return f"{seconds}s ago"
|
|
1206
|
+
if seconds < SECONDS_PER_HOUR:
|
|
1207
|
+
return f"{seconds // SECONDS_PER_MINUTE}m ago"
|
|
1208
|
+
hours = seconds // SECONDS_PER_HOUR
|
|
1209
|
+
return f"{hours}h ago" if hours < HOURS_PER_DAY else f"{hours // HOURS_PER_DAY}d ago"
|