mishkan-harness 0.2.0 → 0.2.3
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.
- package/README.md +108 -172
- package/bin/mishkan.js +677 -16
- package/docs/design/MISHKAN_decisions.md +450 -8
- package/docs/design/MISHKAN_harness_design.md +1 -33
- package/docs/design/MISHKAN_observability.md +13 -9
- package/docs/research/graphify-token-saving-poc.md +108 -0
- package/docs/usage/09-workflows.md +74 -4
- package/docs/usage/10-observability.md +37 -13
- package/docs/usage/11-graphify.md +264 -0
- package/docs/usage/12-skill-discovery.md +294 -0
- package/docs/usage/README.md +5 -3
- package/package.json +1 -1
- package/payload/install/settings.hooks.json +24 -0
- package/payload/mishkan/AGENT_SPEC.md +25 -7
- package/payload/mishkan/agents/ahikam.md +4 -2
- package/payload/mishkan/agents/aholiab.md +4 -2
- package/payload/mishkan/agents/asaph.md +5 -2
- package/payload/mishkan/agents/baruch.md +19 -2
- package/payload/mishkan/agents/benaiah.md +4 -2
- package/payload/mishkan/agents/bezalel.md +4 -2
- package/payload/mishkan/agents/caleb.md +4 -2
- package/payload/mishkan/agents/deborah.md +4 -2
- package/payload/mishkan/agents/elasah.md +4 -2
- package/payload/mishkan/agents/eliashib.md +4 -2
- package/payload/mishkan/agents/ezra.md +4 -2
- package/payload/mishkan/agents/hanun.md +5 -2
- package/payload/mishkan/agents/hiram.md +5 -2
- package/payload/mishkan/agents/hizkiah.md +5 -2
- package/payload/mishkan/agents/huldah.md +4 -2
- package/payload/mishkan/agents/huram.md +4 -2
- package/payload/mishkan/agents/hushai.md +5 -2
- package/payload/mishkan/agents/igal.md +4 -2
- package/payload/mishkan/agents/ira.md +5 -2
- package/payload/mishkan/agents/jahaziel.md +5 -2
- package/payload/mishkan/agents/jakin.md +4 -2
- package/payload/mishkan/agents/jehonathan.md +5 -2
- package/payload/mishkan/agents/jehoshaphat.md +4 -2
- package/payload/mishkan/agents/joab.md +5 -2
- package/payload/mishkan/agents/joah.md +5 -2
- package/payload/mishkan/agents/maaseiah.md +4 -2
- package/payload/mishkan/agents/meremoth.md +5 -2
- package/payload/mishkan/agents/meshullam.md +5 -2
- package/payload/mishkan/agents/nathan.md +5 -2
- package/payload/mishkan/agents/nehemiah.md +4 -2
- package/payload/mishkan/agents/obed.md +4 -2
- package/payload/mishkan/agents/oholiab.md +5 -2
- package/payload/mishkan/agents/palal.md +5 -2
- package/payload/mishkan/agents/phinehas.md +4 -2
- package/payload/mishkan/agents/rehum.md +4 -2
- package/payload/mishkan/agents/salma.md +5 -2
- package/payload/mishkan/agents/seraiah.md +4 -2
- package/payload/mishkan/agents/shallum.md +5 -2
- package/payload/mishkan/agents/shaphan.md +4 -2
- package/payload/mishkan/agents/shemaiah.md +4 -2
- package/payload/mishkan/agents/shevna.md +5 -2
- package/payload/mishkan/agents/uriah.md +5 -2
- package/payload/mishkan/agents/zaccur.md +4 -2
- package/payload/mishkan/agents/zadok.md +5 -2
- package/payload/mishkan/agents/zerubbabel.md +4 -2
- package/payload/mishkan/commands/code-graph.md +35 -0
- package/payload/mishkan/commands/eval-baruch.md +57 -0
- package/payload/mishkan/commands/mishkan-org-reference.md +58 -0
- package/payload/mishkan/commands/mishkan-skills-misses.md +47 -0
- package/payload/mishkan/commands/mishkan-skills-reindex.md +33 -0
- package/payload/mishkan/commands/skills.md +35 -0
- package/payload/mishkan/commands/sprint-close.md +3 -1
- package/payload/mishkan/hooks/pre-tool-knowledge-route.sh +231 -0
- package/payload/mishkan/hooks/pre-tool-task-skill-route.sh +122 -0
- package/payload/mishkan/hooks/session-start-skill-index.sh +35 -0
- package/payload/mishkan/observability/schema.json +1 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/__main__.py +84 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/app.py +64 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/org_data.py +79 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/skills_data.py +267 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +33 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +118 -9
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +102 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/org.py +139 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/skills.py +187 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +377 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +199 -27
- package/payload/mishkan/observability/watch/src/mishkan_watch/theme.tcss +78 -4
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +36 -6
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/bus_tail.py +16 -3
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +220 -0
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/worktree_poll.py +13 -2
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +157 -3
- package/payload/mishkan/observability/watchd/tests/test_state.py +6 -2
- package/payload/mishkan/org/org.json +141 -0
- package/payload/mishkan/scripts/pre-commit-eval-gate.sh +50 -0
- package/payload/mishkan/scripts/skill-discovery-indexer.py +462 -0
- package/payload/mishkan/scripts/skill-discovery-misses.py +173 -0
- package/payload/mishkan/scripts/skill-discovery-router.py +457 -0
- package/payload/mishkan/scripts/skill-discovery-smoke.sh +156 -0
- package/payload/mishkan/skills/graphify-query-craft/SKILL.md +162 -0
- package/payload/mishkan/skills/mishkan-init/SKILL.md +15 -0
- package/payload/mishkan/skills/skill-discovery/SKILL.md +120 -0
- package/payload/mishkan/templates/case-node.schema.json +1 -0
- package/payload/mishkan/templates/observability-log.schema.json +1 -0
- package/payload/mishkan/workflows/README.md +78 -13
- package/payload/mishkan/workflows/chosheb-feature-ship.js +75 -0
- package/payload/mishkan/workflows/migdal-dr-drill.js +94 -0
- package/payload/mishkan/workflows/migdal-infra-change.js +67 -0
- package/payload/mishkan/workflows/mishkan-blast-radius.js +144 -0
- package/payload/mishkan/workflows/mishkan-init.js +58 -0
- package/payload/mishkan/workflows/mishkan-knowledge-gap-discovery.js +121 -0
- package/payload/mishkan/workflows/mishkan-standards-rollout.js +153 -0
- package/payload/mishkan/workflows/mishmar-security-gate.js +83 -0
- package/payload/mishkan/workflows/panim-ds-rollout.js +83 -0
- package/payload/mishkan/workflows/sefer-release-notes.js +85 -0
- package/payload/mishkan/workflows/yasad-data-migration-wave.js +78 -0
- package/payload/mishkan/workflows/yasad-schema-evolution.js +76 -0
- package/payload/mishkan/templates/user-CLAUDE.md +0 -62
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Tab 7 — Usage Overview.
|
|
2
|
+
|
|
3
|
+
Aggregates token consumption, cost, request counts, and context-window
|
|
4
|
+
estimates across all sessions. Provides the detailed breakdown the
|
|
5
|
+
status bar can't fit.
|
|
6
|
+
|
|
7
|
+
Layout (left → right):
|
|
8
|
+
- TOTALS card : harness-wide tokens / cost / requests
|
|
9
|
+
- SESSIONS : per-session breakdown table
|
|
10
|
+
- DETAIL : selected session deep-dive (agents, top tools, recent activity)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
|
+
from textual.containers import Container, Horizontal
|
|
19
|
+
from textual.widgets import DataTable, Static
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Per-model context window (tokens). Authoritative as of 2026-06 — verified
|
|
23
|
+
# against the live Claude model catalogue (shared/models.md). Sonnet 4.6 and
|
|
24
|
+
# Opus 4.x both ship with 1M context windows at standard pricing; Haiku 4.5
|
|
25
|
+
# stays at 200K. The MISHKAN model-routing YAML keeps most write-heavy
|
|
26
|
+
# specialists on Sonnet and orchestration on Opus, so the dominant ceiling for
|
|
27
|
+
# a session is 1M — not 200K. Pinning the bar at 200K made the "context used"
|
|
28
|
+
# gauge read 5× higher than reality on Sonnet-routed sessions.
|
|
29
|
+
_CONTEXT_WINDOW_BY_MODEL = {
|
|
30
|
+
"opus": 1_000_000,
|
|
31
|
+
"sonnet": 1_000_000,
|
|
32
|
+
"haiku": 200_000,
|
|
33
|
+
}
|
|
34
|
+
# Fallback when no agent attribution is available. Sonnet is the default tier
|
|
35
|
+
# in the routing YAML (defaults.unlisted_agent), so 1M is the honest ceiling
|
|
36
|
+
# for an un-attributed session.
|
|
37
|
+
_DEFAULT_CONTEXT_WINDOW = 1_000_000
|
|
38
|
+
|
|
39
|
+
# Bytes-per-token approximation is irrelevant here; tokens are emitted
|
|
40
|
+
# verbatim by the daemon's token_usage source.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _session_context_window(sess: dict) -> tuple[int, str]:
|
|
44
|
+
"""Pick the right context window for a session.
|
|
45
|
+
|
|
46
|
+
Heuristic: walk the session's active agents, look up each agent's tier in
|
|
47
|
+
the routing-derived per-agent map (populated by the daemon when it sees
|
|
48
|
+
the model field on agent events), and take the max. An Opus + Sonnet
|
|
49
|
+
session has a 1M ceiling either way; an all-Haiku session caps at 200K.
|
|
50
|
+
|
|
51
|
+
Returns (window_tokens, label) where label is a short tier string for
|
|
52
|
+
display ("opus", "sonnet", "haiku", or "mixed" when multiple tiers run).
|
|
53
|
+
"""
|
|
54
|
+
agents = sess.get("agents_active") or {}
|
|
55
|
+
tiers: set[str] = set()
|
|
56
|
+
for ag in agents.values():
|
|
57
|
+
m = (ag.get("model") or "").lower()
|
|
58
|
+
if "opus" in m:
|
|
59
|
+
tiers.add("opus")
|
|
60
|
+
elif "sonnet" in m:
|
|
61
|
+
tiers.add("sonnet")
|
|
62
|
+
elif "haiku" in m:
|
|
63
|
+
tiers.add("haiku")
|
|
64
|
+
if not tiers:
|
|
65
|
+
return _DEFAULT_CONTEXT_WINDOW, "sonnet?"
|
|
66
|
+
if len(tiers) == 1:
|
|
67
|
+
t = next(iter(tiers))
|
|
68
|
+
return _CONTEXT_WINDOW_BY_MODEL.get(t, _DEFAULT_CONTEXT_WINDOW), t
|
|
69
|
+
# Mixed: the limiting factor is whichever tier is loaded heaviest, but
|
|
70
|
+
# for a "context used" gauge the user cares about the smallest ceiling
|
|
71
|
+
# any active agent is bounded by. Haiku in the mix caps the harness at 200K.
|
|
72
|
+
window = min(_CONTEXT_WINDOW_BY_MODEL.get(t, _DEFAULT_CONTEXT_WINDOW) for t in tiers)
|
|
73
|
+
return window, "mixed(" + "+".join(sorted(tiers)) + ")"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fmt_count(n: int | float) -> str:
|
|
77
|
+
n = int(n or 0)
|
|
78
|
+
if n >= 1_000_000:
|
|
79
|
+
return f"{n/1_000_000:.2f}M"
|
|
80
|
+
if n >= 1_000:
|
|
81
|
+
return f"{n/1_000:.1f}k"
|
|
82
|
+
return str(n)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fmt_money(d: float) -> str:
|
|
86
|
+
if not d:
|
|
87
|
+
return "$0.00"
|
|
88
|
+
return f"${d:.2f}" if d < 100 else f"${d:.0f}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class UsageTab(Container):
|
|
92
|
+
DEFAULT_CSS = ""
|
|
93
|
+
|
|
94
|
+
def __init__(self, **kwargs) -> None:
|
|
95
|
+
super().__init__(**kwargs)
|
|
96
|
+
# We hold a *mutable copy* of the snapshot state and patch it on every
|
|
97
|
+
# token_usage delta. The daemon's broadcast cadence is one delta per
|
|
98
|
+
# bus event, but a fresh snapshot only arrives on client connect /
|
|
99
|
+
# reconnect — without local accumulation the Usage tab numbers stayed
|
|
100
|
+
# frozen between snapshots even though the status bar moved (the bar
|
|
101
|
+
# accumulates into the app's `_totals` dict). Mirror the same shape
|
|
102
|
+
# here so this tab is live.
|
|
103
|
+
self._state: dict[str, Any] = {"sessions": {}}
|
|
104
|
+
self._selected_sid: str | None = None
|
|
105
|
+
# Throttle re-renders to ~4 Hz max. The bus can emit many deltas per
|
|
106
|
+
# second; rendering on every one costs CPU and never produces a frame
|
|
107
|
+
# the human can read. We dirty-flag instead and the on_mount tick
|
|
108
|
+
# repaints if anything changed since the last paint.
|
|
109
|
+
self._dirty = False
|
|
110
|
+
|
|
111
|
+
def compose(self) -> ComposeResult:
|
|
112
|
+
with Horizontal(id="usage-row"):
|
|
113
|
+
with Container(id="usage-totals"):
|
|
114
|
+
yield Static("", id="usage-totals-body")
|
|
115
|
+
with Container(id="usage-sessions"):
|
|
116
|
+
yield Static("SESSIONS", classes="panel-title")
|
|
117
|
+
yield DataTable(id="usage-sessions-table",
|
|
118
|
+
cursor_type="row", zebra_stripes=False)
|
|
119
|
+
with Container(id="usage-detail"):
|
|
120
|
+
yield Static("", id="usage-detail-body")
|
|
121
|
+
|
|
122
|
+
def on_mount(self) -> None:
|
|
123
|
+
try:
|
|
124
|
+
t = self.query_one("#usage-sessions-table", DataTable)
|
|
125
|
+
t.add_columns("session", "project", "model", "in", "out", "cached", "$")
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
# 250 ms repaint tick — flushes whatever deltas accumulated since the
|
|
129
|
+
# last paint. Without this the tab depends on snapshot frames for
|
|
130
|
+
# visible motion, which only arrive on (re)connect; deltas update
|
|
131
|
+
# `_state` silently and the user sees a frozen panel.
|
|
132
|
+
self.set_interval(0.25, self._tick)
|
|
133
|
+
self._render_all()
|
|
134
|
+
|
|
135
|
+
def _tick(self) -> None:
|
|
136
|
+
if self._dirty:
|
|
137
|
+
self._dirty = False
|
|
138
|
+
self._render_all()
|
|
139
|
+
|
|
140
|
+
# ----- snapshot / event application --------------------------------------
|
|
141
|
+
|
|
142
|
+
def apply_snapshot(self, state: dict[str, Any]) -> None:
|
|
143
|
+
# Replace local mirror with the authoritative snapshot. Any deltas
|
|
144
|
+
# that landed between the snapshot's generation and its arrival are
|
|
145
|
+
# already folded in by the daemon.
|
|
146
|
+
self._state = dict(state or {})
|
|
147
|
+
self._state.setdefault("sessions", {})
|
|
148
|
+
self._render_all()
|
|
149
|
+
|
|
150
|
+
def apply_event(self, ev: dict[str, Any]) -> None:
|
|
151
|
+
"""Patch our local state mirror so the totals move between snapshots.
|
|
152
|
+
|
|
153
|
+
Without this, the Usage tab is read-only between snapshot frames —
|
|
154
|
+
the daemon broadcasts deltas (not snapshots) on every bus event, so
|
|
155
|
+
nothing would refresh except on reconnect.
|
|
156
|
+
"""
|
|
157
|
+
etype = ev.get("type")
|
|
158
|
+
sid = ev.get("session_id") or ev.get("sid")
|
|
159
|
+
sessions = self._state.setdefault("sessions", {})
|
|
160
|
+
|
|
161
|
+
if etype == "token_usage" and sid:
|
|
162
|
+
p = ev.get("payload") or {}
|
|
163
|
+
sess = sessions.setdefault(sid, {})
|
|
164
|
+
sess["tokens_in"] = int(sess.get("tokens_in") or 0) + int(p.get("tokens_in") or 0)
|
|
165
|
+
sess["tokens_out"] = int(sess.get("tokens_out") or 0) + int(p.get("tokens_out") or 0)
|
|
166
|
+
sess["cache_read"] = int(sess.get("cache_read") or 0) + int(p.get("cache_read") or 0)
|
|
167
|
+
sess["cache_write"] = int(sess.get("cache_write") or 0) + int(p.get("cache_write") or 0)
|
|
168
|
+
sess["cost_estimate_usd"] = float(sess.get("cost_estimate_usd") or 0.0) \
|
|
169
|
+
+ float(p.get("cost_estimate_usd") or 0.0)
|
|
170
|
+
self._dirty = True
|
|
171
|
+
elif etype == "agent_spawn" and sid:
|
|
172
|
+
sess = sessions.setdefault(sid, {})
|
|
173
|
+
agents = sess.setdefault("agents_active", {})
|
|
174
|
+
name = ev.get("agent") or (ev.get("payload") or {}).get("name") or "?"
|
|
175
|
+
model = (ev.get("payload") or {}).get("model") or ""
|
|
176
|
+
ag = agents.setdefault(name, {})
|
|
177
|
+
if model:
|
|
178
|
+
ag["model"] = model
|
|
179
|
+
self._dirty = True
|
|
180
|
+
elif etype == "agent_complete" and sid:
|
|
181
|
+
sess = sessions.setdefault(sid, {})
|
|
182
|
+
agents = sess.setdefault("agents_active", {})
|
|
183
|
+
name = ev.get("agent") or (ev.get("payload") or {}).get("name")
|
|
184
|
+
if name and name in agents:
|
|
185
|
+
agents.pop(name, None)
|
|
186
|
+
self._dirty = True
|
|
187
|
+
elif etype == "tool_call" and sid:
|
|
188
|
+
sess = sessions.setdefault(sid, {})
|
|
189
|
+
recent = sess.setdefault("recent_events", [])
|
|
190
|
+
# Keep a bounded tail so the "TOP TOOLS" panel has signal.
|
|
191
|
+
recent.append({"type": "tool_call", "tool": ev.get("tool")})
|
|
192
|
+
if len(recent) > 200:
|
|
193
|
+
del recent[: len(recent) - 200]
|
|
194
|
+
self._dirty = True
|
|
195
|
+
elif etype in ("session_start", "session_stop"):
|
|
196
|
+
self._dirty = True
|
|
197
|
+
|
|
198
|
+
# ----- renderers ---------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _render_all(self) -> None:
|
|
201
|
+
self._render_totals()
|
|
202
|
+
self._render_sessions()
|
|
203
|
+
self._render_detail()
|
|
204
|
+
|
|
205
|
+
def _render_totals(self) -> None:
|
|
206
|
+
try:
|
|
207
|
+
panel = self.query_one("#usage-totals-body", Static)
|
|
208
|
+
except Exception:
|
|
209
|
+
return
|
|
210
|
+
sessions_map = self._state.get("sessions") or {}
|
|
211
|
+
sessions = list(sessions_map.values())
|
|
212
|
+
tin = sum(int(s.get("tokens_in") or 0) for s in sessions)
|
|
213
|
+
tout = sum(int(s.get("tokens_out") or 0) for s in sessions)
|
|
214
|
+
cr = sum(int(s.get("cache_read") or 0) for s in sessions)
|
|
215
|
+
cw = sum(int(s.get("cache_write") or 0) for s in sessions)
|
|
216
|
+
cost = sum(float(s.get("cost_estimate_usd") or 0.0) for s in sessions)
|
|
217
|
+
agents_running = sum(len(s.get("agents_active") or {}) for s in sessions)
|
|
218
|
+
tool_calls = 0
|
|
219
|
+
for s in sessions:
|
|
220
|
+
for ev in (s.get("recent_events") or []):
|
|
221
|
+
if ev.get("type") == "tool_call":
|
|
222
|
+
tool_calls += 1
|
|
223
|
+
|
|
224
|
+
# Context "used" gauge: pick the worst (most-loaded) session, divide
|
|
225
|
+
# by *its own* context window. Sonnet 4.6 and Opus 4.x carry 1M; Haiku
|
|
226
|
+
# 4.5 carries 200K. A session running 600k tokens on Sonnet is 60%
|
|
227
|
+
# full; the same 600k on Haiku is "over the cap" (which the daemon
|
|
228
|
+
# would normally have already compacted around). Using a single 200k
|
|
229
|
+
# default for everyone (the old behaviour) pinned the bar red on any
|
|
230
|
+
# Sonnet session crossing 160k — a false alarm by 5×.
|
|
231
|
+
used_pct = 0
|
|
232
|
+
worst_label = "—"
|
|
233
|
+
worst_window = _DEFAULT_CONTEXT_WINDOW
|
|
234
|
+
if sessions:
|
|
235
|
+
best_pct = -1
|
|
236
|
+
for sess in sessions:
|
|
237
|
+
tokens_in = int(sess.get("tokens_in") or 0)
|
|
238
|
+
window, label = _session_context_window(sess)
|
|
239
|
+
pct = int(100 * tokens_in / window) if window else 0
|
|
240
|
+
if pct > best_pct:
|
|
241
|
+
best_pct = pct
|
|
242
|
+
worst_label = label
|
|
243
|
+
worst_window = window
|
|
244
|
+
used_pct = min(100, max(0, best_pct))
|
|
245
|
+
|
|
246
|
+
t = Text()
|
|
247
|
+
t.append("HARNESS USAGE\n", style="bold #B794F4")
|
|
248
|
+
t.append("\n")
|
|
249
|
+
t.append("TOKENS\n", style="bold dim")
|
|
250
|
+
t.append(f" in {_fmt_count(tin):>10}\n", style="cyan")
|
|
251
|
+
t.append(f" out {_fmt_count(tout):>10}\n", style="cyan")
|
|
252
|
+
t.append(f" cache {_fmt_count(cr):>10}", style="dim")
|
|
253
|
+
t.append(f" read\n", style="dim")
|
|
254
|
+
t.append(f" {_fmt_count(cw):>10}", style="dim")
|
|
255
|
+
t.append(f" write\n", style="dim")
|
|
256
|
+
t.append("\n")
|
|
257
|
+
t.append("COST\n", style="bold dim")
|
|
258
|
+
t.append(f" {_fmt_money(cost)}\n", style="#68D391 bold")
|
|
259
|
+
t.append("\n")
|
|
260
|
+
t.append("CONTEXT (worst sess.)\n", style="bold dim")
|
|
261
|
+
bar = "█" * (used_pct // 5) + "░" * (20 - used_pct // 5)
|
|
262
|
+
bar_color = "#FC8181" if used_pct > 80 else ("#F6AD55" if used_pct > 60 else "#00D4AA")
|
|
263
|
+
t.append(f" {bar}", style=bar_color)
|
|
264
|
+
t.append(f" {used_pct}%\n", style="dim")
|
|
265
|
+
# Show the actual ceiling we used — 1M for opus/sonnet, 200k for haiku,
|
|
266
|
+
# honest "?" when the session has no agent attribution yet.
|
|
267
|
+
cap_str = f"{worst_window // 1000}k" if worst_window < 1_000_000 else "1M"
|
|
268
|
+
t.append(f" cap {cap_str} ({worst_label})\n", style="dim italic")
|
|
269
|
+
t.append("\n")
|
|
270
|
+
t.append("REQUESTS\n", style="bold dim")
|
|
271
|
+
t.append(f" tool_calls {tool_calls:>6}\n", style="white")
|
|
272
|
+
t.append(f" agents now {agents_running:>6}\n", style="white")
|
|
273
|
+
panel.update(t)
|
|
274
|
+
|
|
275
|
+
def _render_sessions(self) -> None:
|
|
276
|
+
try:
|
|
277
|
+
table = self.query_one("#usage-sessions-table", DataTable)
|
|
278
|
+
except Exception:
|
|
279
|
+
return
|
|
280
|
+
table.clear()
|
|
281
|
+
sessions = (self._state.get("sessions") or {})
|
|
282
|
+
# Sort by tokens_in desc so the heaviest is on top.
|
|
283
|
+
rows = sorted(sessions.items(),
|
|
284
|
+
key=lambda kv: int((kv[1].get("tokens_in") or 0)),
|
|
285
|
+
reverse=True)
|
|
286
|
+
for sid, sess in rows[:15]:
|
|
287
|
+
proj = sess.get("project") or "unknown"
|
|
288
|
+
# Decode encoded form.
|
|
289
|
+
if proj.startswith("-"):
|
|
290
|
+
proj = proj.replace("-", "/")
|
|
291
|
+
tin = int(sess.get("tokens_in") or 0)
|
|
292
|
+
tout = int(sess.get("tokens_out") or 0)
|
|
293
|
+
cr = int(sess.get("cache_read") or 0)
|
|
294
|
+
cost = float(sess.get("cost_estimate_usd") or 0.0)
|
|
295
|
+
_, model_label = _session_context_window(sess)
|
|
296
|
+
table.add_row(
|
|
297
|
+
Text(sid[:8] + "…", style="cyan"),
|
|
298
|
+
Text(proj[-30:], style="dim"),
|
|
299
|
+
Text(model_label[:10], style="#B794F4"),
|
|
300
|
+
Text(_fmt_count(tin), style="cyan"),
|
|
301
|
+
Text(_fmt_count(tout), style="cyan"),
|
|
302
|
+
Text(_fmt_count(cr), style="dim"),
|
|
303
|
+
Text(_fmt_money(cost), style="#68D391"),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def on_data_table_row_selected(self, event) -> None:
|
|
307
|
+
# Pick the session id from the first column of the selected row.
|
|
308
|
+
try:
|
|
309
|
+
table = self.query_one("#usage-sessions-table", DataTable)
|
|
310
|
+
row = table.get_row(event.row_key)
|
|
311
|
+
sid_text = str(row[0])
|
|
312
|
+
sid_prefix = sid_text.rstrip("…").strip()
|
|
313
|
+
sessions = self._state.get("sessions") or {}
|
|
314
|
+
for sid in sessions:
|
|
315
|
+
if sid.startswith(sid_prefix):
|
|
316
|
+
self._selected_sid = sid
|
|
317
|
+
self._render_detail()
|
|
318
|
+
return
|
|
319
|
+
except Exception:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
def _render_detail(self) -> None:
|
|
323
|
+
try:
|
|
324
|
+
panel = self.query_one("#usage-detail-body", Static)
|
|
325
|
+
except Exception:
|
|
326
|
+
return
|
|
327
|
+
sessions = self._state.get("sessions") or {}
|
|
328
|
+
sid = self._selected_sid
|
|
329
|
+
# Default to the heaviest session if none selected.
|
|
330
|
+
if not sid or sid not in sessions:
|
|
331
|
+
if sessions:
|
|
332
|
+
sid = max(sessions, key=lambda k: int(sessions[k].get("tokens_in") or 0))
|
|
333
|
+
else:
|
|
334
|
+
panel.update(Text("(no session)", style="dim italic"))
|
|
335
|
+
return
|
|
336
|
+
sess = sessions[sid]
|
|
337
|
+
t = Text()
|
|
338
|
+
t.append("DETAIL\n", style="bold #B794F4")
|
|
339
|
+
t.append(f" session {sid[:12]}…\n", style="cyan")
|
|
340
|
+
proj = sess.get("project") or "unknown"
|
|
341
|
+
if proj.startswith("-"):
|
|
342
|
+
proj = proj.replace("-", "/")
|
|
343
|
+
t.append(f" project {proj}\n", style="dim")
|
|
344
|
+
t.append("\n")
|
|
345
|
+
# Per-agent breakdown
|
|
346
|
+
agents = sess.get("agents_active") or {}
|
|
347
|
+
t.append("AGENTS ACTIVE\n", style="bold dim")
|
|
348
|
+
if not agents:
|
|
349
|
+
t.append(" (none)\n", style="dim italic")
|
|
350
|
+
else:
|
|
351
|
+
for name, ag in list(agents.items())[:10]:
|
|
352
|
+
last = ag.get("last_tool") or "-"
|
|
353
|
+
tin = _fmt_count(int(ag.get("tokens_in") or 0))
|
|
354
|
+
tout = _fmt_count(int(ag.get("tokens_out") or 0))
|
|
355
|
+
t.append(f" ● {name:14}", style="#00D4AA")
|
|
356
|
+
t.append(f" in {tin:>5}", style="cyan")
|
|
357
|
+
t.append(f" out {tout:>5}", style="cyan")
|
|
358
|
+
t.append(f" {last}\n", style="dim")
|
|
359
|
+
t.append("\n")
|
|
360
|
+
# Top tools from recent events
|
|
361
|
+
tool_count: dict[str, int] = {}
|
|
362
|
+
for ev in (sess.get("recent_events") or []):
|
|
363
|
+
if ev.get("type") == "tool_call":
|
|
364
|
+
tool = ev.get("tool") or "?"
|
|
365
|
+
tool_count[tool] = tool_count.get(tool, 0) + 1
|
|
366
|
+
if tool_count:
|
|
367
|
+
t.append("TOP TOOLS\n", style="bold dim")
|
|
368
|
+
top = sorted(tool_count.items(), key=lambda kv: -kv[1])[:8]
|
|
369
|
+
for tool, count in top:
|
|
370
|
+
t.append(f" {tool:14} {count:>4}\n", style="white")
|
|
371
|
+
panel.update(t)
|
|
372
|
+
|
|
373
|
+
# ----- API expected by app.py -------------------------------------------
|
|
374
|
+
|
|
375
|
+
def set_project_filter(self, project: Any) -> None:
|
|
376
|
+
# Usage tab is informational across all sessions — filter is a no-op.
|
|
377
|
+
return
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"""Tab 3 — Workflows.
|
|
2
2
|
|
|
3
3
|
2-panel layout per §7.3:
|
|
4
|
-
- WORKFLOW LIST (left, 30%) —
|
|
5
|
-
- PHASE TREE (right, 70%) — phases + agent fan-out
|
|
4
|
+
- WORKFLOW LIST (left, 30%) — static catalogue + recent runs
|
|
5
|
+
- PHASE TREE (right, 70%) — phases + agent fan-out OR catalog detail
|
|
6
|
+
|
|
7
|
+
The list panel shows BOTH:
|
|
8
|
+
- Live runs (top) — recent workflow invocations with status
|
|
9
|
+
- Available catalogue (below) — workflows installed on the system,
|
|
10
|
+
with name, description, whenToUse — clickable to read full meta.
|
|
6
11
|
|
|
7
12
|
Tracks workflow_start / workflow_phase / workflow_agent_call /
|
|
8
13
|
workflow_agent_result / workflow_complete events emitted by the
|
|
@@ -11,7 +16,10 @@ in post-tool-observe.sh.
|
|
|
11
16
|
"""
|
|
12
17
|
from __future__ import annotations
|
|
13
18
|
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
14
21
|
from collections import OrderedDict
|
|
22
|
+
from pathlib import Path
|
|
15
23
|
from typing import Any, Optional
|
|
16
24
|
|
|
17
25
|
from rich.text import Text
|
|
@@ -21,6 +29,97 @@ from textual.widgets import ListItem, ListView, Static, Tree
|
|
|
21
29
|
from textual.widgets.tree import TreeNode
|
|
22
30
|
|
|
23
31
|
|
|
32
|
+
# Locations to scan for installed workflow scripts. First hit wins per name.
|
|
33
|
+
_WORKFLOW_PATHS = [
|
|
34
|
+
Path.home() / ".claude" / "mishkan" / "workflows",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _scan_catalogue() -> list[dict[str, Any]]:
|
|
39
|
+
"""Parse meta blocks from every .js workflow on disk.
|
|
40
|
+
|
|
41
|
+
Extracts: name, description, whenToUse, phases (titles only). The meta
|
|
42
|
+
block is required by the workflow contract (`export const meta = {...}`)
|
|
43
|
+
so a missing one is a malformed workflow — silently skipped.
|
|
44
|
+
|
|
45
|
+
Repo-mode fallback: walk up from this file looking for
|
|
46
|
+
payload/mishkan/workflows/ in case mishkan-watch runs from a source
|
|
47
|
+
checkout without the runtime payload installed.
|
|
48
|
+
"""
|
|
49
|
+
paths = list(_WORKFLOW_PATHS)
|
|
50
|
+
here = Path(__file__).resolve()
|
|
51
|
+
for parent in here.parents:
|
|
52
|
+
c = parent / "payload" / "mishkan" / "workflows"
|
|
53
|
+
if c.is_dir():
|
|
54
|
+
paths.append(c)
|
|
55
|
+
break
|
|
56
|
+
if parent == parent.parent:
|
|
57
|
+
break
|
|
58
|
+
seen: set[str] = set()
|
|
59
|
+
out: list[dict[str, Any]] = []
|
|
60
|
+
for d in paths:
|
|
61
|
+
if not d.is_dir():
|
|
62
|
+
continue
|
|
63
|
+
for js in sorted(d.glob("*.js")):
|
|
64
|
+
try:
|
|
65
|
+
txt = js.read_text(encoding="utf-8", errors="replace")
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
meta = _extract_meta(txt)
|
|
69
|
+
if not meta or not meta.get("name"):
|
|
70
|
+
continue
|
|
71
|
+
if meta["name"] in seen:
|
|
72
|
+
continue
|
|
73
|
+
seen.add(meta["name"])
|
|
74
|
+
meta["_path"] = str(js)
|
|
75
|
+
out.append(meta)
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Accept both " and ' quoting — JS allows both in literal strings.
|
|
80
|
+
_META_KEY_RE = re.compile(
|
|
81
|
+
r'(name|description|whenToUse|title|detail)\s*:\s*'
|
|
82
|
+
r'(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\')'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_meta(txt: str) -> dict[str, Any]:
|
|
87
|
+
"""Best-effort extract of the `export const meta = {...}` literal.
|
|
88
|
+
|
|
89
|
+
The contract guarantees meta is a pure literal — no computed values —
|
|
90
|
+
so a key/value regex over the meta block is enough. We don't run a
|
|
91
|
+
full JS parser; we just lift the four fields we render.
|
|
92
|
+
"""
|
|
93
|
+
m = re.search(r'export\s+const\s+meta\s*=\s*\{', txt)
|
|
94
|
+
if not m:
|
|
95
|
+
return {}
|
|
96
|
+
# Naive brace match — meta blocks don't contain strings with unmatched
|
|
97
|
+
# braces in practice; if they do we just truncate, which is fine for
|
|
98
|
+
# display purposes.
|
|
99
|
+
start = m.end()
|
|
100
|
+
depth = 1
|
|
101
|
+
i = start
|
|
102
|
+
while i < len(txt) and depth > 0:
|
|
103
|
+
c = txt[i]
|
|
104
|
+
if c == '{':
|
|
105
|
+
depth += 1
|
|
106
|
+
elif c == '}':
|
|
107
|
+
depth -= 1
|
|
108
|
+
i += 1
|
|
109
|
+
block = txt[start:i]
|
|
110
|
+
out: dict[str, Any] = {}
|
|
111
|
+
for key, dq, sq in _META_KEY_RE.findall(block):
|
|
112
|
+
val = dq or sq
|
|
113
|
+
# JS escape unescape for the common cases.
|
|
114
|
+
val = val.replace('\\"', '"').replace("\\'", "'").replace("\\n", " ").replace("\\\\", "\\")
|
|
115
|
+
out.setdefault(key, val)
|
|
116
|
+
# Phase titles (best-effort): every `title: "..."` or `title: '...'`.
|
|
117
|
+
titles = re.findall(r'\{\s*title:\s*[\'"]([^\'"]+)[\'"]', block)
|
|
118
|
+
if titles:
|
|
119
|
+
out["phase_titles"] = titles
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
24
123
|
class WorkflowsTab(Container):
|
|
25
124
|
DEFAULT_CSS = ""
|
|
26
125
|
|
|
@@ -29,6 +128,10 @@ class WorkflowsTab(Container):
|
|
|
29
128
|
# run_id -> { name, started, phases: {phase: [agent_call dicts]}, ... }
|
|
30
129
|
self._runs: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
|
31
130
|
self._selected: Optional[str] = None
|
|
131
|
+
# Static catalogue parsed from disk at mount.
|
|
132
|
+
self._catalogue: list[dict[str, Any]] = []
|
|
133
|
+
# Currently-shown catalogue entry (selected name) for the detail panel.
|
|
134
|
+
self._cat_selected: Optional[str] = None
|
|
32
135
|
|
|
33
136
|
def compose(self) -> ComposeResult:
|
|
34
137
|
with Horizontal(id="workflows-row"):
|
|
@@ -39,9 +142,20 @@ class WorkflowsTab(Container):
|
|
|
39
142
|
yield Static("PHASE TREE", classes="panel-title")
|
|
40
143
|
yield Tree("(no workflow selected)", id="workflows-tree")
|
|
41
144
|
|
|
42
|
-
def on_mount(self) -> None:
|
|
43
|
-
|
|
145
|
+
async def on_mount(self) -> None:
|
|
146
|
+
try:
|
|
147
|
+
self._catalogue = _scan_catalogue()
|
|
148
|
+
except Exception:
|
|
149
|
+
self._catalogue = []
|
|
150
|
+
await self._render_list()
|
|
44
151
|
self._render_tree()
|
|
152
|
+
# Re-render once after mount completes — when the tab is created
|
|
153
|
+
# before TabbedContent settles its layout, the first ListView paint
|
|
154
|
+
# can land before ListItem rows have a size computed and the items
|
|
155
|
+
# show as zero-height (visible as "header only, no rows"). A delayed
|
|
156
|
+
# repaint guarantees the second pass runs with the final container
|
|
157
|
+
# dimensions.
|
|
158
|
+
self.set_timer(0.1, self._re_render_list)
|
|
45
159
|
|
|
46
160
|
# ----- snapshot / event application --------------------------------------
|
|
47
161
|
|
|
@@ -50,16 +164,16 @@ class WorkflowsTab(Container):
|
|
|
50
164
|
for sess in (state.get("sessions") or {}).values():
|
|
51
165
|
for ev in (sess.get("recent_events") or []):
|
|
52
166
|
self._ingest_event(ev)
|
|
53
|
-
self._render_list()
|
|
54
167
|
self._render_tree()
|
|
168
|
+
self.call_later(self._re_render_list)
|
|
55
169
|
|
|
56
170
|
def apply_event(self, ev: dict[str, Any]) -> None:
|
|
57
171
|
if not self._is_workflow_event(ev):
|
|
58
172
|
return
|
|
59
173
|
self._ingest_event(ev)
|
|
60
|
-
self._render_list()
|
|
61
174
|
if self._selected:
|
|
62
175
|
self._render_tree()
|
|
176
|
+
self.call_later(self._re_render_list)
|
|
63
177
|
|
|
64
178
|
def _is_workflow_event(self, ev: dict[str, Any]) -> bool:
|
|
65
179
|
return (ev.get("type") or "").startswith("workflow_")
|
|
@@ -116,35 +230,67 @@ class WorkflowsTab(Container):
|
|
|
116
230
|
|
|
117
231
|
# ----- renderers ---------------------------------------------------------
|
|
118
232
|
|
|
119
|
-
def
|
|
233
|
+
async def _re_render_list(self) -> None:
|
|
234
|
+
await self._render_list()
|
|
235
|
+
|
|
236
|
+
async def _render_list(self) -> None:
|
|
120
237
|
try:
|
|
121
238
|
lv = self.query_one("#workflows-listview", ListView)
|
|
122
239
|
except Exception:
|
|
123
240
|
return
|
|
124
|
-
lv.clear()
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
241
|
+
await lv.clear()
|
|
242
|
+
# ----- Recent runs (top) ---------------------------------------------
|
|
243
|
+
if self._runs:
|
|
244
|
+
head = Text()
|
|
245
|
+
head.append("RECENT RUNS\n", style="bold #B794F4")
|
|
246
|
+
lv.append(ListItem(Static(head)))
|
|
247
|
+
for run_id, run in list(self._runs.items())[-8:]:
|
|
248
|
+
t = Text()
|
|
249
|
+
mark = "║ " if self._selected == run_id else " "
|
|
250
|
+
t.append(mark, style="#B794F4 bold")
|
|
251
|
+
t.append(run["name"][:24], style="bold")
|
|
252
|
+
t.append(f" [{run['status']}]", style="dim")
|
|
253
|
+
phases_done = sum(1 for items in run["phases"].values()
|
|
254
|
+
if items and all(a.get("status") != "running" for a in items))
|
|
255
|
+
phases_total = max(1, len(run["phases"]) or 1)
|
|
256
|
+
t.append(f"\n {phases_done}/{phases_total} phases", style="dim")
|
|
257
|
+
cost = run.get("cost_usd") or 0.0
|
|
258
|
+
tok = run.get("tokens") or 0
|
|
259
|
+
t.append(f" ${cost:.2f} · {tok/1000:.1f}k tok", style="#B794F4")
|
|
260
|
+
lv.append(ListItem(Static(t), id=f"wf-{run_id}"))
|
|
261
|
+
# ----- Catalogue (bottom) --------------------------------------------
|
|
262
|
+
if self._catalogue:
|
|
263
|
+
head = Text()
|
|
264
|
+
head.append("AVAILABLE", style="bold #00D4AA")
|
|
265
|
+
head.append(f" ({len(self._catalogue)} workflows)", style="dim italic")
|
|
266
|
+
lv.append(ListItem(Static(head)))
|
|
267
|
+
for entry in self._catalogue:
|
|
268
|
+
name = entry.get("name", "?")
|
|
269
|
+
desc = entry.get("description") or ""
|
|
270
|
+
mark = "║ " if self._cat_selected == name else " "
|
|
271
|
+
t = Text()
|
|
272
|
+
t.append(mark, style="#00D4AA bold")
|
|
273
|
+
t.append(name[:30], style="bold")
|
|
274
|
+
if desc:
|
|
275
|
+
t.append(f"\n {desc[:60]}", style="dim")
|
|
276
|
+
lv.append(ListItem(Static(t), id=f"cat-{name}"))
|
|
277
|
+
elif not self._runs:
|
|
278
|
+
lv.append(ListItem(Static(Text(
|
|
279
|
+
"(no workflows installed and no runs yet)\n"
|
|
280
|
+
"Install via `npx mishkan-harness install`,\n"
|
|
281
|
+
"or run a workflow from the main session.",
|
|
282
|
+
style="dim italic",
|
|
283
|
+
))))
|
|
143
284
|
|
|
144
285
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
145
286
|
node_id = event.item.id or ""
|
|
146
287
|
if node_id.startswith("wf-"):
|
|
147
288
|
self._selected = node_id[3:]
|
|
289
|
+
self._cat_selected = None
|
|
290
|
+
self._render_tree()
|
|
291
|
+
elif node_id.startswith("cat-"):
|
|
292
|
+
self._cat_selected = node_id[4:]
|
|
293
|
+
self._selected = None
|
|
148
294
|
self._render_tree()
|
|
149
295
|
|
|
150
296
|
def _render_tree(self) -> None:
|
|
@@ -154,8 +300,34 @@ class WorkflowsTab(Container):
|
|
|
154
300
|
return
|
|
155
301
|
tree.clear()
|
|
156
302
|
root = tree.root
|
|
303
|
+
# Catalogue detail mode — shows description, whenToUse, phases.
|
|
304
|
+
if self._cat_selected:
|
|
305
|
+
entry = next((e for e in self._catalogue if e.get("name") == self._cat_selected), None)
|
|
306
|
+
if entry:
|
|
307
|
+
rl = Text()
|
|
308
|
+
rl.append(entry.get("name", ""), style="bold #00D4AA")
|
|
309
|
+
rl.append(" (catalogue)", style="dim")
|
|
310
|
+
root.label = rl
|
|
311
|
+
root.expand()
|
|
312
|
+
desc = entry.get("description") or "(no description)"
|
|
313
|
+
root.add_leaf(Text(f"description: {desc}", style="white"))
|
|
314
|
+
wtu = entry.get("whenToUse")
|
|
315
|
+
if wtu:
|
|
316
|
+
root.add_leaf(Text(f"when to use: {wtu}", style="#F6AD55"))
|
|
317
|
+
phases = entry.get("phase_titles") or []
|
|
318
|
+
if phases:
|
|
319
|
+
pn = root.add(Text(f"phases ({len(phases)})", style="bold dim"))
|
|
320
|
+
pn.expand()
|
|
321
|
+
for i, ph in enumerate(phases, 1):
|
|
322
|
+
pn.add_leaf(Text(f" {i}. {ph}", style="white"))
|
|
323
|
+
path = entry.get("_path") or ""
|
|
324
|
+
if path:
|
|
325
|
+
root.add_leaf(Text(f"source: {path}", style="dim italic"))
|
|
326
|
+
else:
|
|
327
|
+
root.label = Text("(workflow not in catalogue)", style="dim italic")
|
|
328
|
+
return
|
|
157
329
|
if not self._selected or self._selected not in self._runs:
|
|
158
|
-
root.label = Text("(
|
|
330
|
+
root.label = Text("(select a workflow run or a catalogue entry)", style="dim italic")
|
|
159
331
|
return
|
|
160
332
|
run = self._runs[self._selected]
|
|
161
333
|
rl = Text()
|