mishkan-harness 0.2.4 → 0.2.6
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 +1 -1
- package/docs/usage/01-installation.md +1 -1
- package/package.json +1 -1
- package/payload/mishkan/hooks/post-tool-observe.sh +55 -7
- package/payload/mishkan/hooks/pre-tool-trace.sh +40 -6
- package/payload/mishkan/observability/README.md +15 -12
- package/payload/mishkan/observability/schema.json +18 -1
- package/payload/mishkan/observability/watch/pyproject.toml +2 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/__init__.py +1 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +29 -7
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +7 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +42 -13
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +10 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +25 -6
- package/payload/mishkan/observability/watchd/pyproject.toml +1 -1
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__init__.py +1 -1
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +52 -4
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/server.py +24 -1
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +38 -64
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +222 -34
- package/payload/mishkan/observability/watchd/tests/test_state.py +502 -4
- package/payload/mishkan/scripts/mishkan-ingest.sh +4 -0
- package/docs/engineer/profile-readable.md +0 -201
- package/docs/engineer/profile.md +0 -754
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ MISHKAN turns Claude Code into a standing engineering organisation. Quality and
|
|
|
14
14
|
|
|
15
15
|
It's personal, opinionated infrastructure built around one engineer's standards. To make it yours, replace `docs/engineer/profile.md` and re-sync — nothing else hardcodes the author.
|
|
16
16
|
|
|
17
|
-
> **v0.2.
|
|
17
|
+
> **v0.2.6** — agent fleet, rules, hooks, installer are stable. Cognee knowledge stack ready to bring up locally. Observability stack (watchd + TUI) available as two `uv tool`-installable packages.
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mishkan-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "MISHKAN — a personal advanced SWE R&D harness for Claude Code: 45 biblically-named agents across six teams, deterministic rules + hooks, a shared research pipeline, dependency/supply-chain vetting, and a Cognee-backed knowledge graph. Installs into ~/.claude.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
# all preserved) plus type-specific derived events:
|
|
7
7
|
#
|
|
8
8
|
# - Write/Edit/MultiEdit -> file_change (path + lines_added/removed)
|
|
9
|
-
# - Task ->
|
|
9
|
+
# - Task -> agent_complete (subagent_type + tool_use_id)
|
|
10
|
+
# (agent_spawn is emitted at PreToolUse by
|
|
11
|
+
# pre-tool-trace.sh, when the agent starts)
|
|
12
|
+
# - Bash (graphify ...) -> graphify_query / graphify_scan
|
|
10
13
|
# - Skill -> skill_invoke
|
|
11
14
|
# - ExitPlanMode -> plan (exit + approved + excerpt)
|
|
12
15
|
# - WebFetch / WebSearch -> web_query
|
|
@@ -138,15 +141,19 @@ case "$tool" in
|
|
|
138
141
|
;;
|
|
139
142
|
|
|
140
143
|
Task|Agent)
|
|
144
|
+
# agent_spawn is emitted by pre-tool-trace.sh (PreToolUse) when the task
|
|
145
|
+
# starts. Here at PostToolUse we emit agent_complete so the daemon can
|
|
146
|
+
# decrement the active-agent count. Both events carry tool_use_id as the
|
|
147
|
+
# stable key so state.py can match spawn→complete even when two concurrent
|
|
148
|
+
# agents share the same subagent_type name.
|
|
141
149
|
subagent="$(printf '%s' "$INPUT" | jq -r '.tool_input.subagent_type // empty' 2>/dev/null)"
|
|
142
|
-
desc="$(printf '%s' "$INPUT" | jq -r '.tool_input.description // empty' 2>/dev/null)"
|
|
143
|
-
model="$(printf '%s' "$INPUT" | jq -r '.tool_input.model // empty' 2>/dev/null)"
|
|
144
150
|
agent_id="$(printf '%s' "$INPUT" | jq -r '.tool_response.agentId? // .tool_response.id? // empty' 2>/dev/null)"
|
|
145
|
-
if [ -n "$subagent" ]; then
|
|
151
|
+
if [ -n "$subagent" ] && [ -n "$tool_use_id" ]; then
|
|
146
152
|
payload="$(jq -cn \
|
|
147
|
-
--arg s "$subagent" --arg
|
|
148
|
-
'{subagent_type:$s
|
|
149
|
-
|
|
153
|
+
--arg s "$subagent" --arg tid "$tool_use_id" --arg aid "$agent_id" \
|
|
154
|
+
'{subagent_type:$s, tool_use_id:$tid}
|
|
155
|
+
+ (if $aid=="" then {} else {agentId:$aid} end)')"
|
|
156
|
+
bus_emit "$session" "agent_complete" "$tool" "$outcome" "$payload" "$subagent" "$agent_id"
|
|
150
157
|
fi
|
|
151
158
|
;;
|
|
152
159
|
|
|
@@ -212,6 +219,47 @@ case "$tool" in
|
|
|
212
219
|
bus_emit "$session" "cron_event" "$tool" "$outcome" "$payload"
|
|
213
220
|
;;
|
|
214
221
|
|
|
222
|
+
Bash)
|
|
223
|
+
# Detect graphify CLI invocations so the daemon can count real queries
|
|
224
|
+
# and scans. Only emit on completed calls — errored/blocked calls did
|
|
225
|
+
# not produce a graph result, so they must not count.
|
|
226
|
+
if [ "$outcome" = "completed" ]; then
|
|
227
|
+
cmd="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)"
|
|
228
|
+
project_dir="$(printf '%s' "$INPUT" | jq -r '.tool_input.cwd // empty' 2>/dev/null)"
|
|
229
|
+
[ -z "$project_dir" ] && project_dir="$(pwd 2>/dev/null || printf 'unknown')"
|
|
230
|
+
|
|
231
|
+
# Match graphify query (with or without npx prefix, with or without
|
|
232
|
+
# leading cd; capture the quoted question argument).
|
|
233
|
+
if printf '%s' "$cmd" | grep -qE '(^|&&|;|\|\|)\s*(npx\s+graphify|graphify)\s+.*\bquery\b'; then
|
|
234
|
+
# Extract the quoted question: first double- or single-quoted string
|
|
235
|
+
# after the word "query", or bare word(s) after it. Truncate to 200.
|
|
236
|
+
question="$(printf '%s' "$cmd" | \
|
|
237
|
+
sed -E 's/.*\bquery\b[[:space:]]*//' | \
|
|
238
|
+
sed -E 's/^"([^"]{0,200}).*/\1/;t;s/^'"'"'([^'"'"']{0,200}).*/\1/;t;s/^(.{0,200}).*/\1/' \
|
|
239
|
+
2>/dev/null || true)"
|
|
240
|
+
# Detect traversal flag for query_type.
|
|
241
|
+
query_type=""
|
|
242
|
+
if printf '%s' "$cmd" | grep -qE '\-\-dfs'; then
|
|
243
|
+
query_type="dfs"
|
|
244
|
+
elif printf '%s' "$cmd" | grep -qE '\-\-context'; then
|
|
245
|
+
query_type="context"
|
|
246
|
+
fi
|
|
247
|
+
payload="$(jq -cn \
|
|
248
|
+
--arg q "$question" \
|
|
249
|
+
--arg p "$project_dir" \
|
|
250
|
+
--arg qt "$query_type" \
|
|
251
|
+
'{project:$p}
|
|
252
|
+
+ (if $q=="" then {} else {question:$q} end)
|
|
253
|
+
+ (if $qt=="" then {} else {query_type:$qt} end)' 2>/dev/null)"
|
|
254
|
+
bus_emit "$session" "graphify_query" "$tool" "$outcome" "$payload"
|
|
255
|
+
|
|
256
|
+
elif printf '%s' "$cmd" | grep -qE '(^|&&|;|\|\|)\s*(npx\s+graphify|graphify)\s+.*(update|scan)\b'; then
|
|
257
|
+
payload="$(jq -cn --arg p "$project_dir" '{project:$p}' 2>/dev/null)"
|
|
258
|
+
bus_emit "$session" "graphify_scan" "$tool" "$outcome" "$payload"
|
|
259
|
+
fi
|
|
260
|
+
fi
|
|
261
|
+
;;
|
|
262
|
+
|
|
215
263
|
Workflow)
|
|
216
264
|
wf_name="$(printf '%s' "$INPUT" | jq -r '.tool_input.name // empty' 2>/dev/null)"
|
|
217
265
|
wf_script="$(printf '%s' "$INPUT" | jq -r '.tool_input.scriptPath // empty' 2>/dev/null)"
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# MISHKAN PreToolUse trace hook —
|
|
3
|
-
# post-tool-observe.sh can compute duration_ms per tool call.
|
|
2
|
+
# MISHKAN PreToolUse trace hook — two responsibilities:
|
|
4
3
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# tmpfile
|
|
4
|
+
# 1. Records call-start timestamps so post-tool-observe.sh can compute
|
|
5
|
+
# duration_ms per tool call. Writes one line per (session, tool_use_id)
|
|
6
|
+
# to a tmpfile keyed by session. PostToolUse reads back, diffs, prunes.
|
|
7
|
+
#
|
|
8
|
+
# 2. Emits agent_spawn for Task|Agent tool invocations so the daemon can
|
|
9
|
+
# track live agents from the moment they start (not when they finish).
|
|
10
|
+
# The spawn event carries tool_use_id as the stable correlation key so
|
|
11
|
+
# state.py can pair it with the agent_complete emitted by PostToolUse.
|
|
8
12
|
#
|
|
9
13
|
# Fail-open by contract: any parse / IO / format issue and we exit 0
|
|
10
14
|
# silently. Never blocks a tool call.
|
|
@@ -18,11 +22,11 @@ fi
|
|
|
18
22
|
INPUT="$(cat)"
|
|
19
23
|
|
|
20
24
|
session="$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null)"
|
|
25
|
+
tool="$(printf '%s' "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null)"
|
|
21
26
|
tool_use_id="$(printf '%s' "$INPUT" | jq -r '.tool_use_id // empty' 2>/dev/null)"
|
|
22
27
|
|
|
23
28
|
# tool_use_id may be absent for some tool kinds — synthesize from name+timestamp.
|
|
24
29
|
if [ -z "$tool_use_id" ]; then
|
|
25
|
-
tool="$(printf '%s' "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null)"
|
|
26
30
|
tool_use_id="${tool}-$(date +%s%N 2>/dev/null || date +%s)"
|
|
27
31
|
fi
|
|
28
32
|
|
|
@@ -40,4 +44,34 @@ fi
|
|
|
40
44
|
printf '%s\t%s\n' "$tool_use_id" "$start_ms" \
|
|
41
45
|
>> "${trace_dir}/mishkan-trace-${session}.tmp" 2>/dev/null
|
|
42
46
|
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# agent_spawn — emitted here (PreToolUse) so the daemon sees the agent as
|
|
49
|
+
# active from the START of a Task/Agent call, not after it completes.
|
|
50
|
+
# tool_use_id is the correlation key used by both spawn and complete events.
|
|
51
|
+
# agentId is not available yet at PreToolUse time — that's expected.
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
case "$tool" in
|
|
54
|
+
Task|Agent)
|
|
55
|
+
MISHKAN_HOME_RES="${MISHKAN_HOME:-$HOME/.claude/mishkan}"
|
|
56
|
+
# shellcheck disable=SC1091
|
|
57
|
+
source "${MISHKAN_HOME_RES}/observability/bus.sh" 2>/dev/null || exit 0
|
|
58
|
+
|
|
59
|
+
subagent="$(printf '%s' "$INPUT" | jq -r '.tool_input.subagent_type // empty' 2>/dev/null)"
|
|
60
|
+
desc="$(printf '%s' "$INPUT" | jq -r '.tool_input.description // empty' 2>/dev/null)"
|
|
61
|
+
model="$(printf '%s' "$INPUT" | jq -r '.tool_input.model // empty' 2>/dev/null)"
|
|
62
|
+
|
|
63
|
+
if [ -n "$subagent" ] && [ -n "$tool_use_id" ]; then
|
|
64
|
+
payload="$(jq -cn \
|
|
65
|
+
--arg s "$subagent" \
|
|
66
|
+
--arg d "$desc" \
|
|
67
|
+
--arg m "$model" \
|
|
68
|
+
--arg tid "$tool_use_id" \
|
|
69
|
+
'{subagent_type:$s, tool_use_id:$tid}
|
|
70
|
+
+ (if $d=="" then {} else {description:$d} end)
|
|
71
|
+
+ (if $m=="" then {} else {model:$m} end)' 2>/dev/null)"
|
|
72
|
+
bus_emit "$session" "agent_spawn" "$tool" "started" "$payload" "$subagent" ""
|
|
73
|
+
fi
|
|
74
|
+
;;
|
|
75
|
+
esac
|
|
76
|
+
|
|
43
77
|
exit 0
|
|
@@ -37,23 +37,26 @@ incrementally by the PostToolUse hook and pruned line-by-line.
|
|
|
37
37
|
|
|
38
38
|
| Type | Source | Trigger |
|
|
39
39
|
|---|---|---|
|
|
40
|
-
| `tool_call` | post-tool-observe.sh | every tool call |
|
|
41
|
-
| `file_change` | post-tool-observe.sh | Write / Edit / MultiEdit |
|
|
42
|
-
| `agent_spawn` |
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
40
|
+
| `tool_call` | post-tool-observe.sh (PostToolUse) | every tool call |
|
|
41
|
+
| `file_change` | post-tool-observe.sh (PostToolUse) | Write / Edit / MultiEdit |
|
|
42
|
+
| `agent_spawn` | pre-tool-trace.sh (PreToolUse) | Task / Agent tool starts — carries `tool_use_id` as stable correlation key |
|
|
43
|
+
| `agent_complete` | post-tool-observe.sh (PostToolUse) | Task / Agent tool finishes — carries matching `tool_use_id` |
|
|
44
|
+
| `skill_invoke` | post-tool-observe.sh (PostToolUse) | Skill tool |
|
|
45
|
+
| `plan` | post-tool-observe.sh (PostToolUse) | ExitPlanMode |
|
|
46
|
+
| `web_query` | post-tool-observe.sh (PostToolUse) | WebFetch / WebSearch |
|
|
47
|
+
| `cron_event` | post-tool-observe.sh (PostToolUse) | CronCreate / CronDelete / CronList |
|
|
48
|
+
| `graphify_query` | post-tool-observe.sh (PostToolUse, Bash branch) | `graphify query` CLI invocation detected in Bash tool_input.command |
|
|
49
|
+
| `graphify_scan` (hook) | post-tool-observe.sh (PostToolUse, Bash branch) | `graphify update`/`graphify scan` CLI invocation detected |
|
|
50
|
+
| `graphify_scan` (stats) | graphify_tail daemon source | graph.json mtime advance after daemon start — sets `stats_only=True`, updates node/edge counts only, does NOT increment scan counter |
|
|
51
|
+
| `error` | post-tool-observe.sh (PostToolUse) | outcome=errored OR blocked |
|
|
52
|
+
| `hook_fire` | pre-tool-security.sh (PreToolUse) | Ira allow / deny |
|
|
53
|
+
| `hook_fire` | model-route.py (PreToolUse) | model routing decision |
|
|
50
54
|
|
|
51
55
|
Deferred (next phases of the observability stack):
|
|
52
56
|
|
|
53
57
|
- `token_usage` — Phase 1.5 (session JSONL `usage` block parser)
|
|
54
58
|
- `inter_agent`, `compaction` — Phase 2 (daemon-side session JSONL tail)
|
|
55
|
-
- `worktree_change`, `mcp_server`, `cognee_op`, `
|
|
56
|
-
`workflow_*` — Phase 2/4 (daemon sources)
|
|
59
|
+
- `worktree_change`, `mcp_server`, `cognee_op`, `workflow_*` — Phase 2/4 (daemon sources)
|
|
57
60
|
|
|
58
61
|
## Fail-open contract
|
|
59
62
|
|
|
@@ -67,9 +67,10 @@
|
|
|
67
67
|
"properties": {
|
|
68
68
|
"payload": {
|
|
69
69
|
"type": "object",
|
|
70
|
-
"required": ["subagent_type"],
|
|
70
|
+
"required": ["subagent_type", "tool_use_id"],
|
|
71
71
|
"properties": {
|
|
72
72
|
"subagent_type": {"type": "string"},
|
|
73
|
+
"tool_use_id": {"type": "string", "description": "stable correlation key — matches the agent_complete event for this invocation"},
|
|
73
74
|
"description": {"type": "string"},
|
|
74
75
|
"model": {"type": "string"}
|
|
75
76
|
}
|
|
@@ -77,6 +78,22 @@
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
},
|
|
81
|
+
{
|
|
82
|
+
"if": {"properties": {"type": {"const": "agent_complete"}}},
|
|
83
|
+
"then": {
|
|
84
|
+
"properties": {
|
|
85
|
+
"payload": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"required": ["subagent_type", "tool_use_id"],
|
|
88
|
+
"properties": {
|
|
89
|
+
"subagent_type": {"type": "string"},
|
|
90
|
+
"tool_use_id": {"type": "string", "description": "matches the agent_spawn event for this invocation"},
|
|
91
|
+
"agentId": {"type": "string", "description": "Claude runtime agentId from tool_response, when present"}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
80
97
|
{
|
|
81
98
|
"if": {"properties": {"type": {"const": "hook_fire"}}},
|
|
82
99
|
"then": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mishkan-watch"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "MISHKAN observability TUI — Textual client that reads the mishkan-watchd UNIX socket and renders the
|
|
3
|
+
version = "0.2.6"
|
|
4
|
+
description = "MISHKAN observability TUI — Textual client that reads the mishkan-watchd UNIX socket and renders the 8-tab dashboard."
|
|
5
5
|
readme = "../README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
license = {text = "MIT"}
|
|
@@ -133,17 +133,36 @@ class AgentsTab(Container):
|
|
|
133
133
|
"agents_active": {},
|
|
134
134
|
}
|
|
135
135
|
if etype == "agent_spawn" and sid:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
payload = ev.get("payload") or {}
|
|
137
|
+
name = ev.get("agent") or payload.get("subagent_type")
|
|
138
|
+
# Resolve the stable key: prefer tool_use_id from payload,
|
|
139
|
+
# then top-level subagent_id (older hook schema), then name.
|
|
140
|
+
key = payload.get("tool_use_id") or ev.get("subagent_id") or name
|
|
141
|
+
if name and key:
|
|
142
|
+
agents = sessions[sid].setdefault("agents_active", {})
|
|
143
|
+
# Remove any stale same-named entry under a different key
|
|
144
|
+
# (key-scheme mismatch between snapshot and live delta).
|
|
145
|
+
stale = [k for k, ag in agents.items()
|
|
146
|
+
if ag.get("name") == name and k != key]
|
|
147
|
+
for k in stale:
|
|
148
|
+
del agents[k]
|
|
149
|
+
agents[key] = {
|
|
139
150
|
"name": name,
|
|
140
151
|
"started": ev.get("ts"),
|
|
141
152
|
"status": "running",
|
|
142
153
|
}
|
|
143
154
|
elif etype == "agent_complete" and sid:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
payload = ev.get("payload") or {}
|
|
156
|
+
key = payload.get("tool_use_id") or ev.get("subagent_id") or ev.get("agent")
|
|
157
|
+
if key:
|
|
158
|
+
agents = sessions[sid].get("agents_active", {})
|
|
159
|
+
agents.pop(key, None)
|
|
160
|
+
# Sweep same-named ghosts left by a prior key-scheme mismatch.
|
|
161
|
+
name = ev.get("agent") or (ev.get("payload") or {}).get("subagent_type")
|
|
162
|
+
if name:
|
|
163
|
+
for k in [k for k, ag in agents.items()
|
|
164
|
+
if ag.get("name") == name]:
|
|
165
|
+
del agents[k]
|
|
147
166
|
except Exception:
|
|
148
167
|
return
|
|
149
168
|
|
|
@@ -205,7 +224,10 @@ class AgentsTab(Container):
|
|
|
205
224
|
sess_node.add_leaf(Text("(main only)", style="dim italic"),
|
|
206
225
|
data={"kind": "agent", "sid": sid, "agent": "(main)"})
|
|
207
226
|
else:
|
|
208
|
-
for
|
|
227
|
+
for _key, ag in agents.items():
|
|
228
|
+
# Always display the stored name field, not the dict key
|
|
229
|
+
# (which may be a tool_use_id from the daemon snapshot).
|
|
230
|
+
name = ag.get("name") or _key
|
|
209
231
|
al = Text()
|
|
210
232
|
al.append("● ", style="#00D4AA")
|
|
211
233
|
al.append(name, style="bold")
|
|
@@ -86,12 +86,16 @@ class KnowledgeTab(Container):
|
|
|
86
86
|
etype = ev.get("type")
|
|
87
87
|
g = self._state.setdefault("graphify", {})
|
|
88
88
|
if etype == "graphify_scan":
|
|
89
|
-
g["last_scan_project"] = p.get("project")
|
|
90
|
-
g["last_scan_at"] = p.get("scanned_at") or ev.get("ts")
|
|
91
89
|
g["nodes"] = p.get("nodes")
|
|
92
90
|
g["edges"] = p.get("edges")
|
|
93
91
|
g["communities"] = p.get("communities")
|
|
94
|
-
|
|
92
|
+
# stats_only=True comes from the tail-side periodic size probe
|
|
93
|
+
# (session:null). It updates the size display but is NOT a real
|
|
94
|
+
# scan triggered by a hook — do not increment the scan counter.
|
|
95
|
+
if not p.get("stats_only"):
|
|
96
|
+
g["last_scan_project"] = p.get("project")
|
|
97
|
+
g["last_scan_at"] = p.get("scanned_at") or ev.get("ts")
|
|
98
|
+
g["scans"] = (g.get("scans") or 0) + 1
|
|
95
99
|
elif etype == "graphify_query":
|
|
96
100
|
g["last_query_project"] = p.get("project")
|
|
97
101
|
g["last_query_at"] = ev.get("ts")
|
|
@@ -201,25 +201,48 @@ class LiveTab(Container):
|
|
|
201
201
|
"workflows_active": {},
|
|
202
202
|
}
|
|
203
203
|
if etype == "agent_spawn" and sid:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
payload = ev.get("payload") or {}
|
|
205
|
+
name = ev.get("agent") or payload.get("subagent_type")
|
|
206
|
+
# Resolve the stable key: prefer tool_use_id from payload,
|
|
207
|
+
# then top-level subagent_id (older hook schema), then name.
|
|
208
|
+
key = payload.get("tool_use_id") or ev.get("subagent_id") or name
|
|
209
|
+
if name and key:
|
|
210
|
+
agents = sessions[sid].setdefault("agents_active", {})
|
|
211
|
+
# Avoid a ghost duplicate: if an entry with the same .name
|
|
212
|
+
# already exists under a different key (key-scheme mismatch
|
|
213
|
+
# between snapshot and delta), remove the stale entry first.
|
|
214
|
+
stale = [k for k, ag in agents.items()
|
|
215
|
+
if ag.get("name") == name and k != key]
|
|
216
|
+
for k in stale:
|
|
217
|
+
del agents[k]
|
|
218
|
+
agents[key] = {
|
|
207
219
|
"name": name,
|
|
208
220
|
"started": ev.get("ts"),
|
|
209
221
|
"last_tool": ev.get("tool"),
|
|
210
222
|
"status": "running",
|
|
211
223
|
}
|
|
212
224
|
elif etype == "agent_complete" and sid:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
225
|
+
payload = ev.get("payload") or {}
|
|
226
|
+
key = payload.get("tool_use_id") or ev.get("subagent_id") or ev.get("agent")
|
|
227
|
+
if key:
|
|
228
|
+
agents = sessions[sid].get("agents_active", {})
|
|
229
|
+
agents.pop(key, None)
|
|
230
|
+
# Also sweep any same-named ghost entries left by a prior
|
|
231
|
+
# key-scheme mismatch on spawn.
|
|
232
|
+
name = ev.get("agent") or (ev.get("payload") or {}).get("subagent_type")
|
|
233
|
+
if name:
|
|
234
|
+
for k in [k for k, ag in agents.items()
|
|
235
|
+
if ag.get("name") == name]:
|
|
236
|
+
del agents[k]
|
|
216
237
|
elif etype == "tool_call" and sid:
|
|
217
238
|
name = ev.get("agent")
|
|
218
239
|
if name:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
ag
|
|
222
|
-
|
|
240
|
+
# tool_call carries agent name, not tool_use_id; find by name
|
|
241
|
+
for ag in sessions[sid].get("agents_active", {}).values():
|
|
242
|
+
if ag.get("name") == name:
|
|
243
|
+
ag["last_tool"] = ev.get("tool")
|
|
244
|
+
ag["last_activity"] = ev.get("ts")
|
|
245
|
+
break
|
|
223
246
|
elif etype == "worktree_change":
|
|
224
247
|
p = ev.get("payload") or {}
|
|
225
248
|
path = p.get("path")
|
|
@@ -261,8 +284,10 @@ class LiveTab(Container):
|
|
|
261
284
|
g["nodes"] = p.get("nodes")
|
|
262
285
|
g["edges"] = p.get("edges")
|
|
263
286
|
g["communities"] = p.get("communities")
|
|
264
|
-
|
|
265
|
-
|
|
287
|
+
# stats_only probes update size display but are not real scans
|
|
288
|
+
if not p.get("stats_only"):
|
|
289
|
+
g["scans"] = (g.get("scans") or 0) + 1
|
|
290
|
+
g["last_scan_at"] = p.get("scanned_at") or ev.get("ts")
|
|
266
291
|
else:
|
|
267
292
|
g["queries"] = (g.get("queries") or 0) + 1
|
|
268
293
|
g["last_query_at"] = ev.get("ts")
|
|
@@ -319,8 +344,12 @@ class LiveTab(Container):
|
|
|
319
344
|
for sid, sess in sessions.items():
|
|
320
345
|
if not self._session_matches_filter(sess):
|
|
321
346
|
continue
|
|
322
|
-
for
|
|
347
|
+
for _key, ag in (sess.get("agents_active") or {}).items():
|
|
323
348
|
any_agent = True
|
|
349
|
+
# Always use the stored name field — the key may be a tool_use_id
|
|
350
|
+
# (from daemon snapshot) or a legacy name; the name field is always
|
|
351
|
+
# the human-readable subagent_type.
|
|
352
|
+
name = ag.get("name") or _key
|
|
324
353
|
started = (ag.get("started") or "")[11:19]
|
|
325
354
|
last_tool = ag.get("last_tool") or "-"
|
|
326
355
|
status = ag.get("status") or "running"
|
|
@@ -155,7 +155,7 @@ class UsageTab(Container):
|
|
|
155
155
|
nothing would refresh except on reconnect.
|
|
156
156
|
"""
|
|
157
157
|
etype = ev.get("type")
|
|
158
|
-
sid = ev.get("session_id") or ev.get("sid")
|
|
158
|
+
sid = ev.get("session") or ev.get("session_id") or ev.get("sid")
|
|
159
159
|
sessions = self._state.setdefault("sessions", {})
|
|
160
160
|
|
|
161
161
|
if etype == "token_usage" and sid:
|
|
@@ -234,9 +234,16 @@ class UsageTab(Container):
|
|
|
234
234
|
if sessions:
|
|
235
235
|
best_pct = -1
|
|
236
236
|
for sess in sessions:
|
|
237
|
-
|
|
237
|
+
# last_context_tokens is the most-recent turn's total input
|
|
238
|
+
# footprint (cache_read + cache_write + tokens_in for that
|
|
239
|
+
# turn), added by the daemon alongside Phase-4 token_usage
|
|
240
|
+
# events. It reflects what actually occupies the context window
|
|
241
|
+
# right now. Fall back to the cumulative tokens_in for old
|
|
242
|
+
# snapshots that predate the field — those will still under-
|
|
243
|
+
# report under caching, but gracefully rather than crashing.
|
|
244
|
+
ctx = int(sess.get("last_context_tokens") or sess.get("tokens_in") or 0)
|
|
238
245
|
window, label = _session_context_window(sess)
|
|
239
|
-
pct = int(100 *
|
|
246
|
+
pct = int(100 * ctx / window) if window else 0
|
|
240
247
|
if pct > best_pct:
|
|
241
248
|
best_pct = pct
|
|
242
249
|
worst_label = label
|
|
@@ -239,12 +239,19 @@ class WorkflowsTab(Container):
|
|
|
239
239
|
except Exception:
|
|
240
240
|
return
|
|
241
241
|
await lv.clear()
|
|
242
|
+
# Stable, index-based widget IDs. A run_id or workflow name can contain
|
|
243
|
+
# characters Textual rejects in an id (the "(unknown)" placeholder has
|
|
244
|
+
# parentheses; real names may have dots/spaces). Key list items by
|
|
245
|
+
# render position and map the position back to the real id/name on
|
|
246
|
+
# selection — never feed arbitrary data into a widget id.
|
|
247
|
+
self._wf_ids: list[str] = []
|
|
248
|
+
self._cat_ids: list[str] = []
|
|
242
249
|
# ----- Recent runs (top) ---------------------------------------------
|
|
243
250
|
if self._runs:
|
|
244
251
|
head = Text()
|
|
245
252
|
head.append("RECENT RUNS\n", style="bold #B794F4")
|
|
246
253
|
lv.append(ListItem(Static(head)))
|
|
247
|
-
for run_id, run in list(self._runs.items())[-8:]:
|
|
254
|
+
for i, (run_id, run) in enumerate(list(self._runs.items())[-8:]):
|
|
248
255
|
t = Text()
|
|
249
256
|
mark = "║ " if self._selected == run_id else " "
|
|
250
257
|
t.append(mark, style="#B794F4 bold")
|
|
@@ -257,14 +264,15 @@ class WorkflowsTab(Container):
|
|
|
257
264
|
cost = run.get("cost_usd") or 0.0
|
|
258
265
|
tok = run.get("tokens") or 0
|
|
259
266
|
t.append(f" ${cost:.2f} · {tok/1000:.1f}k tok", style="#B794F4")
|
|
260
|
-
|
|
267
|
+
self._wf_ids.append(run_id)
|
|
268
|
+
lv.append(ListItem(Static(t), id=f"wf-{i}"))
|
|
261
269
|
# ----- Catalogue (bottom) --------------------------------------------
|
|
262
270
|
if self._catalogue:
|
|
263
271
|
head = Text()
|
|
264
272
|
head.append("AVAILABLE", style="bold #00D4AA")
|
|
265
273
|
head.append(f" ({len(self._catalogue)} workflows)", style="dim italic")
|
|
266
274
|
lv.append(ListItem(Static(head)))
|
|
267
|
-
for entry in self._catalogue:
|
|
275
|
+
for i, entry in enumerate(self._catalogue):
|
|
268
276
|
name = entry.get("name", "?")
|
|
269
277
|
desc = entry.get("description") or ""
|
|
270
278
|
mark = "║ " if self._cat_selected == name else " "
|
|
@@ -273,7 +281,8 @@ class WorkflowsTab(Container):
|
|
|
273
281
|
t.append(name[:30], style="bold")
|
|
274
282
|
if desc:
|
|
275
283
|
t.append(f"\n {desc[:60]}", style="dim")
|
|
276
|
-
|
|
284
|
+
self._cat_ids.append(name)
|
|
285
|
+
lv.append(ListItem(Static(t), id=f"cat-{i}"))
|
|
277
286
|
elif not self._runs:
|
|
278
287
|
lv.append(ListItem(Static(Text(
|
|
279
288
|
"(no workflows installed and no runs yet)\n"
|
|
@@ -285,11 +294,21 @@ class WorkflowsTab(Container):
|
|
|
285
294
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
286
295
|
node_id = event.item.id or ""
|
|
287
296
|
if node_id.startswith("wf-"):
|
|
288
|
-
|
|
297
|
+
try:
|
|
298
|
+
idx = int(node_id[3:])
|
|
299
|
+
except ValueError:
|
|
300
|
+
return
|
|
301
|
+
wf_ids = getattr(self, "_wf_ids", [])
|
|
302
|
+
self._selected = wf_ids[idx] if 0 <= idx < len(wf_ids) else None
|
|
289
303
|
self._cat_selected = None
|
|
290
304
|
self._render_tree()
|
|
291
305
|
elif node_id.startswith("cat-"):
|
|
292
|
-
|
|
306
|
+
try:
|
|
307
|
+
idx = int(node_id[4:])
|
|
308
|
+
except ValueError:
|
|
309
|
+
return
|
|
310
|
+
cat_ids = getattr(self, "_cat_ids", [])
|
|
311
|
+
self._cat_selected = cat_ids[idx] if 0 <= idx < len(cat_ids) else None
|
|
293
312
|
self._selected = None
|
|
294
313
|
self._render_tree()
|
|
295
314
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mishkan-watchd"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.6"
|
|
4
4
|
description = "MISHKAN observability daemon — aggregates event bus + filesystem polls into a UNIX-socket snapshot+delta stream for mishkan-watch."
|
|
5
5
|
readme = "../README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -165,10 +165,34 @@ def _clear_pid() -> None:
|
|
|
165
165
|
pass
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def _socket_is_live(socket_path: Path) -> bool:
|
|
169
|
+
"""Return True if a daemon is already listening on socket_path."""
|
|
170
|
+
try:
|
|
171
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
172
|
+
s.settimeout(0.5)
|
|
173
|
+
s.connect(str(socket_path))
|
|
174
|
+
return True
|
|
175
|
+
except (OSError, socket.timeout):
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
168
179
|
def _cmd_start(args: argparse.Namespace) -> int:
|
|
180
|
+
# Check for a live daemon before writing the PID file or binding.
|
|
181
|
+
# The server.start() also performs this check, but the early exit here
|
|
182
|
+
# avoids overwriting a valid PID file with our own PID.
|
|
183
|
+
if args.socket.exists() and _socket_is_live(args.socket):
|
|
184
|
+
print("mishkan-watchd: daemon already running, nothing to do", file=sys.stderr)
|
|
185
|
+
return 0
|
|
169
186
|
_write_pid()
|
|
170
187
|
try:
|
|
171
188
|
asyncio.run(_run(args.log_dir, args.projects_dir, args.socket))
|
|
189
|
+
except RuntimeError as e:
|
|
190
|
+
# server.start() raises RuntimeError when a live daemon is detected
|
|
191
|
+
# after the PID file was written (narrow race). Clean up and exit 0
|
|
192
|
+
# — another daemon is running, which is the desired state.
|
|
193
|
+
_clear_pid()
|
|
194
|
+
print(f"mishkan-watchd: {e}", file=sys.stderr)
|
|
195
|
+
return 0
|
|
172
196
|
finally:
|
|
173
197
|
_clear_pid()
|
|
174
198
|
return 0
|
|
@@ -176,17 +200,41 @@ def _cmd_start(args: argparse.Namespace) -> int:
|
|
|
176
200
|
|
|
177
201
|
def _cmd_stop(_args: argparse.Namespace) -> int:
|
|
178
202
|
try:
|
|
179
|
-
|
|
180
|
-
except
|
|
203
|
+
pid_text = DEFAULT_PID.read_text().strip()
|
|
204
|
+
except FileNotFoundError:
|
|
181
205
|
print("mishkan-watchd: no PID file (daemon not running?)", file=sys.stderr)
|
|
206
|
+
return 0
|
|
207
|
+
except Exception as e:
|
|
208
|
+
print(f"mishkan-watchd: cannot read PID file: {e}", file=sys.stderr)
|
|
182
209
|
return 1
|
|
210
|
+
|
|
183
211
|
try:
|
|
184
|
-
|
|
185
|
-
|
|
212
|
+
pid = int(pid_text)
|
|
213
|
+
except ValueError:
|
|
214
|
+
print("mishkan-watchd: malformed PID file, clearing", file=sys.stderr)
|
|
215
|
+
_clear_pid()
|
|
216
|
+
return 1
|
|
217
|
+
|
|
218
|
+
# Verify the PID is actually a live mishkan-watchd process before killing.
|
|
219
|
+
try:
|
|
220
|
+
# os.kill with signal 0 checks existence without sending a signal.
|
|
221
|
+
os.kill(pid, 0)
|
|
186
222
|
except ProcessLookupError:
|
|
187
223
|
_clear_pid()
|
|
188
224
|
print("mishkan-watchd: process not found, cleared stale PID", file=sys.stderr)
|
|
225
|
+
return 0
|
|
226
|
+
except PermissionError:
|
|
227
|
+
# Process exists but is owned by another user — not ours to kill.
|
|
228
|
+
print(f"mishkan-watchd: PID {pid} exists but is not owned by this user", file=sys.stderr)
|
|
229
|
+
_clear_pid()
|
|
189
230
|
return 1
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
os.kill(pid, signal.SIGTERM)
|
|
234
|
+
print(f"mishkan-watchd: sent SIGTERM to {pid}", file=sys.stderr)
|
|
235
|
+
except ProcessLookupError:
|
|
236
|
+
_clear_pid()
|
|
237
|
+
print("mishkan-watchd: process vanished before SIGTERM, cleared PID", file=sys.stderr)
|
|
190
238
|
return 0
|
|
191
239
|
|
|
192
240
|
|