mishkan-harness 0.2.5 → 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 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.5** — 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.
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.5",
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 -> agent_spawn (subagent_type + agentId)
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 d "$desc" --arg m "$model" \
148
- '{subagent_type:$s} + (if $d=="" then {} else {description:$d} end) + (if $m=="" then {} else {model:$m} end)')"
149
- bus_emit "$session" "agent_spawn" "$tool" "$outcome" "$payload" "$subagent" "$agent_id"
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 — records call-start timestamps so
3
- # post-tool-observe.sh can compute duration_ms per tool call.
2
+ # MISHKAN PreToolUse trace hook — two responsibilities:
4
3
  #
5
- # Writes one line per (session, tool_use_id) pair to a tmpfile keyed by
6
- # session. The PostToolUse hook reads back, diffs against now, and the
7
- # tmpfile is pruned by the PostToolUse handler after consumption.
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` | post-tool-observe.sh | Task / Agent tool |
43
- | `skill_invoke` | post-tool-observe.sh | Skill tool |
44
- | `plan` | post-tool-observe.sh | ExitPlanMode |
45
- | `web_query` | post-tool-observe.sh | WebFetch / WebSearch |
46
- | `cron_event` | post-tool-observe.sh | CronCreate / CronDelete / CronList |
47
- | `error` | post-tool-observe.sh | outcome=errored OR blocked |
48
- | `hook_fire` | pre-tool-security.sh | Ira allow / deny |
49
- | `hook_fire` | model-route.py | model routing decision |
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`, `graphify_*`,
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "mishkan-watch"
3
- version = "0.2.5"
3
+ version = "0.2.6"
4
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"
@@ -1,3 +1,3 @@
1
1
  """MISHKAN observability TUI — mishkan-watch."""
2
2
 
3
- __version__ = "0.2.5"
3
+ __version__ = "0.2.6"
@@ -133,17 +133,36 @@ class AgentsTab(Container):
133
133
  "agents_active": {},
134
134
  }
135
135
  if etype == "agent_spawn" and sid:
136
- name = ev.get("agent") or (ev.get("payload") or {}).get("subagent_type")
137
- if name:
138
- sessions[sid].setdefault("agents_active", {})[name] = {
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
- name = ev.get("agent")
145
- if name:
146
- sessions[sid].get("agents_active", {}).pop(name, None)
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 name, ag in agents.items():
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
- g["scans"] = (g.get("scans") or 0) + 1
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
- name = ev.get("agent") or (ev.get("payload") or {}).get("subagent_type")
205
- if name:
206
- sessions[sid].setdefault("agents_active", {})[name] = {
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
- name = ev.get("agent")
214
- if name:
215
- sessions[sid].get("agents_active", {}).pop(name, None)
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
- ag = sessions[sid].get("agents_active", {}).get(name)
220
- if ag:
221
- ag["last_tool"] = ev.get("tool")
222
- ag["last_activity"] = ev.get("ts")
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
- g["scans"] = (g.get("scans") or 0) + 1
265
- g["last_scan_at"] = p.get("scanned_at") or ev.get("ts")
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 name, ag in (sess.get("agents_active") or {}).items():
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
- tokens_in = int(sess.get("tokens_in") or 0)
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 * tokens_in / window) if window else 0
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
- lv.append(ListItem(Static(t), id=f"wf-{run_id}"))
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
- lv.append(ListItem(Static(t), id=f"cat-{name}"))
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
- self._selected = node_id[3:]
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
- self._cat_selected = node_id[4:]
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.2.5"
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"
@@ -1,3 +1,3 @@
1
1
  """MISHKAN observability daemon — mishkan-watchd."""
2
2
 
3
- __version__ = "0.2.5"
3
+ __version__ = "0.2.6"
@@ -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
- pid = int(DEFAULT_PID.read_text().strip())
180
- except Exception:
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
- os.kill(pid, signal.SIGTERM)
185
- print(f"mishkan-watchd: sent SIGTERM to {pid}", file=sys.stderr)
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