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.
Files changed (24) hide show
  1. package/README.md +1 -1
  2. package/docs/usage/01-installation.md +1 -1
  3. package/package.json +1 -1
  4. package/payload/mishkan/hooks/post-tool-observe.sh +55 -7
  5. package/payload/mishkan/hooks/pre-tool-trace.sh +40 -6
  6. package/payload/mishkan/observability/README.md +15 -12
  7. package/payload/mishkan/observability/schema.json +18 -1
  8. package/payload/mishkan/observability/watch/pyproject.toml +2 -2
  9. package/payload/mishkan/observability/watch/src/mishkan_watch/__init__.py +1 -1
  10. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +29 -7
  11. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +7 -3
  12. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +42 -13
  13. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +10 -3
  14. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +25 -6
  15. package/payload/mishkan/observability/watchd/pyproject.toml +1 -1
  16. package/payload/mishkan/observability/watchd/src/mishkan_watchd/__init__.py +1 -1
  17. package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +52 -4
  18. package/payload/mishkan/observability/watchd/src/mishkan_watchd/server.py +24 -1
  19. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +38 -64
  20. package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +222 -34
  21. package/payload/mishkan/observability/watchd/tests/test_state.py +502 -4
  22. package/payload/mishkan/scripts/mishkan-ingest.sh +4 -0
  23. package/docs/engineer/profile-readable.md +0 -201
  24. package/docs/engineer/profile.md +0 -754
@@ -36,9 +36,32 @@ class WatchdServer:
36
36
  self.lock = asyncio.Lock()
37
37
  self._heartbeat_task: asyncio.Task | None = None
38
38
 
39
+ @staticmethod
40
+ def _socket_is_live(socket_path: Path) -> bool:
41
+ """Return True if a daemon is already listening on socket_path.
42
+
43
+ Attempts a non-blocking connect and an immediate close. If it
44
+ succeeds the socket has a live owner; if it raises (connection
45
+ refused, file not found, timeout) the socket is stale or absent.
46
+ """
47
+ try:
48
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
49
+ s.settimeout(0.5)
50
+ s.connect(str(socket_path))
51
+ return True
52
+ except (OSError, socket.timeout):
53
+ return False
54
+
39
55
  async def start(self) -> asyncio.AbstractServer:
40
- # Ensure parent dir, remove stale socket.
56
+ # Ensure parent dir exists.
41
57
  self.socket_path.parent.mkdir(parents=True, exist_ok=True)
58
+ # Check liveness BEFORE unlinking: if a daemon is already serving
59
+ # this socket, do not steal it — return a no-op guard server instead.
60
+ # Only unlink a socket whose owner is gone (stale file).
61
+ if self.socket_path.exists() and self._socket_is_live(self.socket_path):
62
+ raise RuntimeError(
63
+ f"mishkan-watchd: daemon already running on {self.socket_path}"
64
+ )
42
65
  try:
43
66
  self.socket_path.unlink()
44
67
  except FileNotFoundError:
@@ -1,18 +1,22 @@
1
- """Graphify daemon source — watch graphify-out/ for scans + queries.
1
+ """Graphify daemon source — stats-only watcher for graphify-out/.
2
2
 
3
3
  Graphify produces deterministic AST graphs under `<project>/graphify-out/`.
4
- This source watches every active session's project for changes in that
5
- directory and synthesises bus events that the Knowledge tab consumes.
6
-
7
- Two event types emitted:
8
-
9
- - graphify_scan : `graph.json` mtime changed a `graphify update <path>`
10
- completed. Payload: project path, node count, edge
11
- count, community count (parsed from manifest.json
12
- when present).
13
- - graphify_query : a new file under `graphify-out/memory/` → a
14
- `graphify query` was saved via `graphify save-result`.
15
- Payload: question, answer excerpt (200 chars).
4
+ This source watches every active session's project for graph.json changes
5
+ and surfaces current node/edge/community counts as state refreshes.
6
+
7
+ STATS-ONLY (as of Fix ③): this source no longer owns scan or query COUNTS.
8
+ Real graphify CLI invocations are detected by post-tool-observe.sh (Bash
9
+ hook, PostToolUse) which emits the authoritative graphify_scan and
10
+ graphify_query events that carry session_id and increment the counters in
11
+ state.py. This source only emits graphify_scan events with stats_only=True
12
+ so state.py updates the size display (nodes/edges/communities) WITHOUT
13
+ incrementing the scan counter. This eliminates:
14
+ - double-counting (hook + tail both firing for one real scan), and
15
+ - the phantom scan on every daemon restart (old last_graph_mtime=0.0 init).
16
+
17
+ The memory/ watcher is also removed: graphify query --save-result is not
18
+ the documented workflow; the hook detects the real `graphify query` CLI
19
+ invocation instead.
16
20
 
17
21
  Fail-open per the daemon contract. Missing graphify-out/ is silent —
18
22
  many projects don't use Graphify and that's fine.
@@ -34,36 +38,34 @@ def _iso(ts: float | None = None) -> str:
34
38
 
35
39
 
36
40
  class _ProjectWatcher:
37
- """One watcher per project root that has a graphify-out/ dir."""
41
+ """One watcher per project root that has a graphify-out/ dir.
42
+
43
+ Stats-only: emits graphify_scan with stats_only=True when graph.json
44
+ mtime advances. This refreshes the node/edge/community display in the
45
+ Knowledge tab WITHOUT incrementing the scan counter in state.py.
46
+
47
+ The mtime baseline is initialised to the CURRENT mtime of graph.json
48
+ at construction time so an already-built graph does NOT fire a phantom
49
+ stats event on daemon restart. It fires only when the graph is actually
50
+ rebuilt after the daemon started.
51
+ """
38
52
 
39
53
  def __init__(self, project_path: Path, queue: asyncio.Queue[dict[str, Any]]) -> None:
40
54
  self.project_path = project_path
41
55
  self.queue = queue
42
56
  self.graph_dir = project_path / "graphify-out"
43
57
  self.graph_json = self.graph_dir / "graph.json"
44
- self.manifest = self.graph_dir / "manifest.json"
45
- self.memory_dir = self.graph_dir / "memory"
46
- # Start at 0 so the first poll detects the current graph state and
47
- # emits a graphify_scan event — otherwise the TUI shows "no scan"
48
- # forever for projects whose graph predated the daemon. This costs
49
- # one event per project per daemon restart; trivially cheap.
50
- self.last_graph_mtime = 0.0
51
- # Memory queries DO use seen-state to avoid replaying old queries
52
- # on every restart (queries are append-only, replays would spam).
53
- self.seen_memory: set[str] = set()
54
- if self.memory_dir.is_dir():
55
- try:
56
- for f in self.memory_dir.iterdir():
57
- if f.is_file():
58
- self.seen_memory.add(f.name)
59
- except OSError:
60
- pass
58
+ # Initialise to the current mtime so pre-existing graphs do NOT
59
+ # produce a phantom stats event on the first poll.
60
+ try:
61
+ self.last_graph_mtime: float = self.graph_json.stat().st_mtime
62
+ except OSError:
63
+ self.last_graph_mtime = 0.0
61
64
 
62
65
  async def step(self) -> None:
63
66
  if not self.graph_dir.is_dir():
64
67
  return
65
68
  await self._check_graph()
66
- await self._check_memory()
67
69
 
68
70
  async def _check_graph(self) -> None:
69
71
  try:
@@ -84,12 +86,15 @@ class _ProjectWatcher:
84
86
  "tool": None,
85
87
  "outcome": "completed",
86
88
  "payload": {
87
- "op": "scan",
89
+ "op": "stats",
88
90
  "project": str(self.project_path),
89
91
  "nodes": stats.get("nodes"),
90
92
  "edges": stats.get("edges"),
91
93
  "communities": stats.get("communities"),
92
94
  "scanned_at": _iso(mtime),
95
+ # stats_only=True signals state.py to update size fields
96
+ # but NOT increment the scan counter.
97
+ "stats_only": True,
93
98
  },
94
99
  })
95
100
 
@@ -120,37 +125,6 @@ class _ProjectWatcher:
120
125
  "communities": len(communities) if isinstance(communities, list) else None,
121
126
  }
122
127
 
123
- async def _check_memory(self) -> None:
124
- if not self.memory_dir.is_dir():
125
- return
126
- try:
127
- entries = [f for f in self.memory_dir.iterdir() if f.is_file()]
128
- except OSError:
129
- return
130
- for f in entries:
131
- if f.name in self.seen_memory:
132
- continue
133
- self.seen_memory.add(f.name)
134
- payload = {"op": "query", "project": str(self.project_path), "file": f.name}
135
- try:
136
- data = json.loads(f.read_text())
137
- if isinstance(data, dict):
138
- payload["question"] = (data.get("question") or "")[:200]
139
- answer = data.get("answer") or ""
140
- payload["answer_excerpt"] = answer[:200] if isinstance(answer, str) else ""
141
- payload["query_type"] = data.get("type") or data.get("query_type")
142
- except Exception:
143
- pass
144
- await self.queue.put({
145
- "ts": _iso(),
146
- "session": None,
147
- "project": str(self.project_path),
148
- "type": "graphify_query",
149
- "tool": None,
150
- "outcome": "completed",
151
- "payload": payload,
152
- })
153
-
154
128
 
155
129
  def _decode_project(p: str) -> str:
156
130
  """Decode Claude Code's encoded project dir name into an absolute path.
@@ -32,6 +32,20 @@ def _now() -> str:
32
32
  # a hook fires for a brand-new session before its first JSONL flush.
33
33
  _PENDING_TTL_S = 15.0
34
34
 
35
+ # A workflow run that has received no workflow_* event for this many seconds
36
+ # is transitioned to "stale" in the snapshot. The Workflow tool returns
37
+ # immediately and reports completion via task-notification (not a tool call),
38
+ # so workflow_complete is never emitted by any hook — a TTL is the only
39
+ # durable way to clear lingering "running" entries.
40
+ WORKFLOW_STALE_TTL_S = 900
41
+
42
+ # A session_stop from session_discover is ignored when the session is "busy":
43
+ # either it still has active agents, or a real bus event was applied within
44
+ # this window. Chosen comfortably above session_discover's 60 s active_window
45
+ # so a session whose transcript goes quiet while a subagent is writing to its
46
+ # own nested JSONL is not torn down mid-run.
47
+ SESSION_KEEPALIVE_S = 90.0
48
+
35
49
 
36
50
  @dataclass
37
51
  class AgentState:
@@ -62,6 +76,10 @@ class WorkflowState:
62
76
  spend_usd: float = 0.0
63
77
  tokens_total: int = 0
64
78
  started: str = ""
79
+ # Monotonic timestamp of the last workflow_* event for this run. Used by
80
+ # the stale-sweep in to_snapshot() to transition runs that have received
81
+ # no activity for WORKFLOW_STALE_TTL_S to status "stale".
82
+ last_activity_mono: float = field(default_factory=monotonic)
65
83
 
66
84
 
67
85
  @dataclass
@@ -77,6 +95,18 @@ class SessionState:
77
95
  cache_read: int = 0
78
96
  cache_write: int = 0
79
97
  cost_estimate_usd: float = 0.0
98
+ # Most-recent turn's input footprint (SET, not accumulated). Equals
99
+ # cache_read + cache_write + tokens_in from the last token_usage event.
100
+ # This is what the TUI Usage gauge should divide by the context window to
101
+ # get a meaningful fill fraction; the cumulative tokens_in alone reads ~0%
102
+ # under prompt caching because cache_read tokens are not billed as input.
103
+ last_context_tokens: int = 0
104
+ # Monotonic timestamp of the last bus event applied to this session.
105
+ # Used by the session_stop guard to keep a busy session (active agents or
106
+ # recent activity) alive when session_discover declares it stale because
107
+ # its parent transcript has not been written to (subagent writes go to
108
+ # nested subagents/agent-*.jsonl, leaving the parent file quiet).
109
+ last_event_mono: float = 0.0
80
110
 
81
111
 
82
112
  @dataclass
@@ -163,15 +193,42 @@ class HarnessState:
163
193
  def apply(self, event: dict[str, Any]) -> None:
164
194
  """Apply a bus-format event to the state.
165
195
 
166
- Authority gate: session_discover is the source of truth for alive
167
- sessions. Only sessions confirmed by session_discover (via
168
- session_start) are eligible to have events applied. Events for
169
- unknown session_ids are buffered briefly (_PENDING_TTL_S) and
170
- flushed if confirmation arrives; otherwise dropped.
171
-
172
- This kills the phantom-session bug at its root: bus_tail can no
173
- longer resurrect stopped or never-alive sessions, no matter what
174
- old hook events surface from disk.
196
+ Authority gate two tiers:
197
+
198
+ Tier 1 (session_discover path): session_discover emits session_start
199
+ when it observes an active JSONL file (transcript mtime < 60 s). That
200
+ adds the session to _confirmed_alive and flushes any buffered events.
201
+ session_stop is the stop authority; it removes from _confirmed_alive and
202
+ tombstones the session in _stopped_recently.
203
+
204
+ Tier 2 (live-event path): bus_tail seeks to EOF on startup — there is no
205
+ historical replay. Any event that arrives for a session_id that is not
206
+ tombstoned (_stopped_recently) is therefore a genuine live signal. On the
207
+ first such event for an unconfirmed session we confirm it immediately,
208
+ create the SessionState, and flush any buffered events, then fall through
209
+ to apply the triggering event. This fixes the agent-only session blindspot:
210
+ during an agent run the parent transcript is quiet (subagent writes go to
211
+ nested subagents/agent-*.jsonl), so session_discover never confirms the
212
+ session via mtime; but bus events (agent_spawn, tool_call, token_usage)
213
+ stream in live, and tier 2 picks them up on the first one.
214
+
215
+ Tombstone resurrection: because bus_tail seeks to EOF there is no
216
+ historical replay, so a fresh event for a tombstoned session is genuine
217
+ new activity — the session was spuriously stopped by session_discover
218
+ when its parent transcript went quiet during an agent run, not actually
219
+ dead. The tombstone branch un-tombstones, re-confirms, and falls through
220
+ so the triggering event is applied. A truly-ended session produces no new
221
+ events, so it stays tombstoned naturally. Lagging hook events (the
222
+ original phantom-resurrection concern) are distinguished by the busy-guard
223
+ on session_stop: a session that was genuinely busy was never tombstoned in
224
+ the first place. This branch therefore only fires for sessions that were
225
+ spuriously stopped while quiet-but-active (the quiet-then-active pattern).
226
+
227
+ Liveness is refreshed by bus activity (last_event_mono). A session_stop
228
+ from session_discover is ignored when the session is busy (agents_active
229
+ non-empty OR last_event_mono within SESSION_KEEPALIVE_S); session_discover
230
+ re-polls and re-emits session_stop, so idle sessions are still cleaned up
231
+ normally.
175
232
  """
176
233
  try:
177
234
  self._sweep_pending()
@@ -185,19 +242,39 @@ class HarnessState:
185
242
  self._ensure_session(session_id, event.get("project") or "unknown")
186
243
  self._flush_pending(session_id)
187
244
  elif etype == "session_stop":
188
- # Honoured below; remove from confirmed + tombstone.
245
+ # Busy-guard handled below; fall through.
189
246
  pass
190
247
  elif session_id in self._stopped_recently:
191
- # Stopped session drop silently. Tombstone window handles
192
- # lagging hook events for sessions whose stop just propagated.
193
- return
248
+ # A live event for a tombstoned session. bus_tail seeks to
249
+ # EOF (no historical replay), so this is genuine new activity
250
+ # — the session was spuriously stopped by session_discover
251
+ # when its parent transcript went quiet during an agent run,
252
+ # not actually dead. Un-tombstone and re-confirm so its
253
+ # agents/tokens reappear. (A truly-ended session produces no
254
+ # new events, so it stays tombstoned.)
255
+ try:
256
+ self._stopped_recently.remove(session_id)
257
+ except ValueError:
258
+ pass
259
+ self._confirmed_alive.add(session_id)
260
+ self._ensure_session(session_id, event.get("project") or "unknown")
261
+ self._flush_pending(session_id)
262
+ # fall through (NO return) so this event is applied below
194
263
  elif session_id not in self._confirmed_alive:
195
- # Unknown session. Buffer with TTL if session_discover
196
- # confirms within _PENDING_TTL_S we replay in order;
197
- # otherwise the sweep drops it.
198
- buf = self._pending.setdefault(session_id, deque(maxlen=200))
199
- buf.append((monotonic(), event))
200
- return
264
+ # bus_tail seeks to EOF (no historical replay), so a live
265
+ # event for a non-tombstoned, unconfirmed session is a
266
+ # genuine alive signal. Confirm on this first event rather
267
+ # than waiting for session_discover's transcript-mtime poll
268
+ # — which never fires for an agent-only session whose parent
269
+ # transcript stays quiet while subagents write to their own
270
+ # nested JSONL files. The _stopped_recently tombstone check
271
+ # above still blocks resurrection of recently-stopped sessions.
272
+ self._confirmed_alive.add(session_id)
273
+ self._ensure_session(session_id, event.get("project") or "unknown")
274
+ self._flush_pending(session_id)
275
+ # Fall through (NO return) so this event reaches the
276
+ # dispatch below and agents_active / tokens / etc. populate
277
+ # immediately.
201
278
 
202
279
  if etype == "agent_spawn":
203
280
  self._on_agent_spawn(session_id, event)
@@ -217,10 +294,29 @@ class HarnessState:
217
294
  self._on_cognee_op(event)
218
295
  elif etype in ("graphify_scan", "graphify_query"):
219
296
  self._on_graphify(event)
297
+ elif etype in ("workflow_start", "workflow_update"):
298
+ self._on_workflow_event(session_id, event)
220
299
  elif etype == "session_start":
221
300
  pass # ensured above
222
301
  elif etype == "session_stop":
223
302
  if session_id:
303
+ sess = self.sessions.get(session_id)
304
+ busy = (
305
+ sess is not None
306
+ and (
307
+ bool(sess.agents_active)
308
+ or (monotonic() - sess.last_event_mono) < SESSION_KEEPALIVE_S
309
+ )
310
+ )
311
+ if busy:
312
+ # Session still has running agents or received a real
313
+ # bus event recently — transcript staleness from
314
+ # session_discover does not reflect true liveness here
315
+ # (subagent writes go to nested JSONL files). Ignore
316
+ # this stop; session_discover will re-emit on the next
317
+ # poll cycle and the guard re-evaluates then.
318
+ return
319
+ # Genuinely idle: no active agents and no recent bus events.
224
320
  self.sessions.pop(session_id, None)
225
321
  self._confirmed_alive.discard(session_id)
226
322
  self._pending.pop(session_id, None)
@@ -230,6 +326,10 @@ class HarnessState:
230
326
 
231
327
  if session_id and session_id in self.sessions:
232
328
  self.sessions[session_id].recent_events.append(event)
329
+ # Stamp liveness so the session_stop busy-guard has a fresh
330
+ # monotonic reference. Any real bus event refreshes the window,
331
+ # keeping a session alive through a transcript-quiet subagent run.
332
+ self.sessions[session_id].last_event_mono = monotonic()
233
333
  except Exception:
234
334
  return # fail-open on any state error
235
335
 
@@ -299,11 +399,15 @@ class HarnessState:
299
399
  if not sid:
300
400
  return
301
401
  sess = self.sessions[sid]
302
- agent_name = event.get("agent") or (event.get("payload") or {}).get("subagent_type")
303
- if not agent_name:
304
- return
305
402
  payload = event.get("payload") or {}
306
- sess.agents_active[agent_name] = AgentState(
403
+ # Key by tool_use_id so two concurrent agents of the same subagent_type
404
+ # do not collide. Fall back to agent_name when tool_use_id is absent
405
+ # (e.g. replayed legacy events that predate the new schema).
406
+ key = payload.get("tool_use_id") or event.get("agent") or payload.get("subagent_type")
407
+ if not key:
408
+ return
409
+ agent_name = (event.get("agent") or payload.get("subagent_type") or key)
410
+ sess.agents_active[key] = AgentState(
307
411
  name=agent_name,
308
412
  started=event.get("ts") or _now(),
309
413
  last_tool=event.get("tool"),
@@ -317,10 +421,13 @@ class HarnessState:
317
421
  sess = self.sessions.get(sid)
318
422
  if not sess:
319
423
  return
320
- agent_name = event.get("agent")
321
- if not agent_name:
424
+ payload = event.get("payload") or {}
425
+ # Mirror _on_agent_spawn: pop by tool_use_id first, fall back to
426
+ # agent field for legacy events.
427
+ key = payload.get("tool_use_id") or event.get("agent")
428
+ if not key:
322
429
  return
323
- sess.agents_active.pop(agent_name, None)
430
+ sess.agents_active.pop(key, None)
324
431
 
325
432
  def _on_tool_call(self, sid: Optional[str], event: dict[str, Any]) -> None:
326
433
  if not sid:
@@ -341,6 +448,52 @@ class HarnessState:
341
448
  sess.cache_read += int(p.get("cache_read") or 0)
342
449
  sess.cache_write += int(p.get("cache_write") or 0)
343
450
  sess.cost_estimate_usd += float(p.get("cost_estimate_usd") or 0.0)
451
+ # SET (do not accumulate): the per-turn context footprint that the
452
+ # Usage gauge should display. Under prompt caching the vast majority
453
+ # of the context lives in cache_read, not tokens_in, so cumulative
454
+ # tokens_in alone would read ~0% of the context window.
455
+ sess.last_context_tokens = (
456
+ int(p.get("cache_read") or 0)
457
+ + int(p.get("cache_write") or 0)
458
+ + int(p.get("tokens_in") or 0)
459
+ )
460
+
461
+ def _on_workflow_event(self, sid: Optional[str], event: dict[str, Any]) -> None:
462
+ """Record a workflow_start or workflow_update into the owning session.
463
+
464
+ Stamps last_activity_mono on every call so the stale-sweep in
465
+ to_snapshot() has a fresh monotonic reference to work from.
466
+ workflow_complete is never emitted (the Workflow tool is fire-and-forget
467
+ from the hook's perspective); TTL-based staling in to_snapshot() is the
468
+ durable path to clearing "running" entries.
469
+ """
470
+ if not sid:
471
+ return
472
+ sess = self.sessions.get(sid)
473
+ if not sess:
474
+ return
475
+ p = event.get("payload") or {}
476
+ run_id = p.get("run_id") or p.get("workflow_id")
477
+ if not run_id:
478
+ return
479
+ etype = event.get("type")
480
+ if etype == "workflow_start":
481
+ sess.workflows_active[run_id] = WorkflowState(
482
+ name=p.get("name") or p.get("scriptPath") or run_id,
483
+ run_id=run_id,
484
+ phase="running",
485
+ started=event.get("ts") or _now(),
486
+ last_activity_mono=monotonic(),
487
+ )
488
+ elif etype == "workflow_update":
489
+ wf = sess.workflows_active.get(run_id)
490
+ if wf:
491
+ wf.phase = p.get("phase") or wf.phase
492
+ wf.phases_total = int(p.get("phases_total") or wf.phases_total)
493
+ wf.phases_done = int(p.get("phases_done") or wf.phases_done)
494
+ wf.spend_usd = float(p.get("spend_usd") or wf.spend_usd)
495
+ wf.tokens_total = int(p.get("tokens_total") or wf.tokens_total)
496
+ wf.last_activity_mono = monotonic()
344
497
 
345
498
  def _on_worktree(self, event: dict[str, Any]) -> None:
346
499
  p = event.get("payload") or {}
@@ -390,24 +543,39 @@ class HarnessState:
390
543
  def _on_graphify(self, event: dict[str, Any]) -> None:
391
544
  """Aggregate Graphify scan/query events into a single rollup.
392
545
 
393
- We don't break this down per-project here — the TUI cards show the
394
- most recent scan's totals + a query counter, which is what a single
395
- rollup needs. The snapshot serializer flattens this back to a dict
396
- so the existing TUI Knowledge tab can read it directly.
546
+ Event source split (as of Fix ③):
547
+ - graphify_scan / graphify_query COUNTS come from the Bash hook
548
+ (post-tool-observe.sh), which detects real CLI invocations.
549
+ These events carry session_id so they also appear in session
550
+ recent_events correctly.
551
+ - graphify_tail is STATS-ONLY: it reads graph.json node/edge counts
552
+ and emits graphify_scan events that carry nodes/edges/communities
553
+ but whose 'stats_only' flag is set. Those update the size stats
554
+ WITHOUT incrementing the scan counter. This prevents double-counting
555
+ and eliminates the phantom-scan-on-restart from the old mtime=0 init.
556
+
557
+ The snapshot serializer flattens this to a dict for the TUI
558
+ Knowledge tab to read directly.
397
559
  """
398
560
  p = event.get("payload") or {}
399
561
  etype = event.get("type")
400
562
  g = self.graphify
401
563
  if etype == "graphify_scan":
564
+ # Always update node/edge/community stats when present —
565
+ # both hook events and graphify_tail stats events carry these.
402
566
  if p.get("nodes") is not None:
403
567
  g.nodes = int(p["nodes"])
404
568
  if p.get("edges") is not None:
405
569
  g.edges = int(p["edges"])
406
570
  if p.get("communities") is not None:
407
571
  g.communities = int(p["communities"])
408
- g.scans += 1
409
- g.last_scan_project = p.get("project")
410
- g.last_scan_at = p.get("scanned_at") or event.get("ts")
572
+ # Only increment the scan counter for real invocations (hook events).
573
+ # graphify_tail stats events set stats_only=True to signal they are
574
+ # size refreshes, not new scan invocations.
575
+ if not p.get("stats_only"):
576
+ g.scans += 1
577
+ g.last_scan_project = p.get("project")
578
+ g.last_scan_at = p.get("scanned_at") or event.get("ts")
411
579
  elif etype == "graphify_query":
412
580
  g.queries += 1
413
581
  g.last_query_project = p.get("project")
@@ -419,6 +587,25 @@ def _dc_to_dict(dc) -> dict[str, Any]:
419
587
  return {k: v for k, v in dc.__dict__.items()}
420
588
 
421
589
 
590
+ def _workflow_dict(wf: WorkflowState) -> dict[str, Any]:
591
+ """Serialise a WorkflowState, substituting "stale" for any non-terminal
592
+ status whose last_activity_mono is older than WORKFLOW_STALE_TTL_S.
593
+
594
+ The live WorkflowState object is never mutated here — the substitution is
595
+ snapshot-only so the in-memory record stays authoritative if a late event
596
+ arrives for the same run_id.
597
+ """
598
+ d = _dc_to_dict(wf)
599
+ # last_activity_mono is an internal float; strip it from the wire shape.
600
+ d.pop("last_activity_mono", None)
601
+ terminal = {"stale", "completed", "failed", "cancelled"}
602
+ if d.get("phase") not in terminal:
603
+ age = monotonic() - wf.last_activity_mono
604
+ if age > WORKFLOW_STALE_TTL_S:
605
+ d["phase"] = "stale"
606
+ return d
607
+
608
+
422
609
  def _session_dict(s: SessionState) -> dict[str, Any]:
423
610
  # Truncate recent_events for the snapshot frame so the daemon doesn't
424
611
  # send 200 events × N sessions on connect — that easily exceeds the
@@ -432,11 +619,12 @@ def _session_dict(s: SessionState) -> dict[str, Any]:
432
619
  "project": s.project,
433
620
  "started": s.started,
434
621
  "agents_active": {k: _dc_to_dict(v) for k, v in s.agents_active.items()},
435
- "workflows_active": {k: _dc_to_dict(v) for k, v in s.workflows_active.items()},
622
+ "workflows_active": {k: _workflow_dict(v) for k, v in s.workflows_active.items()},
436
623
  "recent_events": recent,
437
624
  "tokens_in": s.tokens_in,
438
625
  "tokens_out": s.tokens_out,
439
626
  "cache_read": s.cache_read,
440
627
  "cache_write": s.cache_write,
441
628
  "cost_estimate_usd": s.cost_estimate_usd,
629
+ "last_context_tokens": s.last_context_tokens,
442
630
  }