agent-memory-brain 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
  2. agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
  3. agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
  4. agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
  6. devmem/__init__.py +3 -0
  7. devmem/ado.py +433 -0
  8. devmem/agent_colors.py +74 -0
  9. devmem/cli.py +500 -0
  10. devmem/db.py +1069 -0
  11. devmem/dispatch.py +478 -0
  12. devmem/embed.py +111 -0
  13. devmem/filelock.py +53 -0
  14. devmem/git_sync.py +261 -0
  15. devmem/guard.py +214 -0
  16. devmem/hook_log.py +49 -0
  17. devmem/ingest/__init__.py +1 -0
  18. devmem/ingest/claude.py +87 -0
  19. devmem/ingest/codex.py +175 -0
  20. devmem/ingest/runner.py +318 -0
  21. devmem/ingest/subagents.py +250 -0
  22. devmem/maintenance.py +801 -0
  23. devmem/mcp_server.py +542 -0
  24. devmem/messages.py +684 -0
  25. devmem/panel/__init__.py +1 -0
  26. devmem/panel/app.py +971 -0
  27. devmem/panel/state.py +358 -0
  28. devmem/panel/static/base.css +27 -0
  29. devmem/panel/static/brain.css +473 -0
  30. devmem/panel/static/brain.js +1118 -0
  31. devmem/panel/static/brain3d.js +600 -0
  32. devmem/panel/static/chat.js +18 -0
  33. devmem/panel/static/common.js +208 -0
  34. devmem/panel/static/site.css +508 -0
  35. devmem/panel/static/tokens.css +19 -0
  36. devmem/panel/templates/ado.html +62 -0
  37. devmem/panel/templates/agents.html +35 -0
  38. devmem/panel/templates/audit.html +19 -0
  39. devmem/panel/templates/base.html +42 -0
  40. devmem/panel/templates/brain.html +101 -0
  41. devmem/panel/templates/maintenance.html +32 -0
  42. devmem/panel/templates/memory.html +71 -0
  43. devmem/panel/templates/message_thread.html +49 -0
  44. devmem/panel/templates/messages.html +8 -0
  45. devmem/panel/templates/overview.html +109 -0
  46. devmem/panel/templates/project.html +48 -0
  47. devmem/panel/templates/projects.html +21 -0
  48. devmem/panel/templates/session.html +49 -0
  49. devmem/panel/templates/sessions.html +67 -0
  50. devmem/panel/templates/subagent.html +27 -0
  51. devmem/procs.py +10 -0
  52. devmem/projects.py +17 -0
  53. devmem/secrets.py +9 -0
  54. devmem/skills.py +105 -0
devmem/messages.py ADDED
@@ -0,0 +1,684 @@
1
+ """Messages between agents and the subagent feed, shared by the MCP tools, the log scanner and the panel."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ import sqlite3
7
+ import zlib
8
+ from datetime import UTC, datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .db import now, parse_ts
13
+ from .secrets import SECRET
14
+
15
+ log = logging.getLogger("devmem.messages")
16
+
17
+ KINDS = ("message", "question", "finding", "event")
18
+ BODY_MAX, SUBJECT_MAX, NAME_MAX = 8000, 200, 100
19
+ RATE_PER_MIN, RETENTION_DAYS, STALE_S = 30, 30, 600
20
+ STOP_S = 2 * 3600 # no write for this long and not finished: the agent counts as stopped, gets a "(no report)" event and its thread closes
21
+ USER = "user"
22
+ EVENTS_TO = "#agents" # Part A events go to a channel, so they only reach inboxes of agents that posted there
23
+ LIVE_TO = "#live"
24
+ LIVE_ROWS_LIMIT = 200
25
+ _UNREAD_BY_USER = "NOT EXISTS(SELECT 1 FROM message_reads mr WHERE mr.message_id=m.id AND mr.reader='user')"
26
+ FRIENDLY_NAMES = {"silent-failure-hunter": "bug hunter", "code-reviewer": "code reviewer", "pr-test-analyzer": "test reviewer",
27
+ "general-purpose": "worker", "Explore": "explorer", "Plan": "planner"}
28
+
29
+
30
+ def friendly_name(value: str | None) -> str:
31
+ short = (value or "subagent").rsplit(":", 1)[-1]
32
+ return FRIENDLY_NAMES.get(short, short)
33
+
34
+
35
+ def _plain(text: str) -> str:
36
+ text = re.sub(r"!\[([^]]*)\]\([^)]*\)|\[([^]]+)\]\([^)]*\)", lambda m: m.group(1) or m.group(2) or "", text)
37
+ text = re.sub(r"[`*_>#]", "", text)
38
+ return " ".join(text.split())
39
+
40
+
41
+ def _cut(text: str, size: int = 80) -> str:
42
+ text = _plain(text)
43
+ return text if len(text) <= size else text[:size - 1].rstrip() + "…"
44
+
45
+
46
+ def _sentence(text: str, size: int = 70) -> str:
47
+ plain = _plain(text)
48
+ sentence = re.split(r"(?<=[.!?])\s+", plain, maxsplit=1)[0]
49
+ return _cut(sentence, size)
50
+
51
+
52
+ def report_outcome(text: str) -> tuple[str, bool]:
53
+ """Return a terse deterministic report result and whether it found a problem."""
54
+ plain = _plain(text)
55
+ if re.search(r"\b(no (?:issues|bugs)|nothing found|LGTM|all pass(?:ed)?)\b", plain, re.I): return "no issues found", False
56
+ count = re.search(r"\b(\d+)\s+(findings?|issues?|bugs?|test gaps?)\b", plain, re.I)
57
+ if count:
58
+ amount, noun = int(count.group(1)), count.group(2).lower()
59
+ noun = "test gaps" if "gap" in noun else ("bugs" if "bug" in noun else "issues")
60
+ severity = re.search(r"\(([^)]*(?:critical|high)[^)]*)\)", plain, re.I)
61
+ return f"{amount} {noun} found" + (f" ({_cut(severity.group(1), 38)})" if severity else ""), amount > 0
62
+ passed = re.search(r"\b(\d+)\s+passed\s*/\s*(\d+)\s+failed\b", plain, re.I)
63
+ if passed:
64
+ good, bad = map(int, passed.groups())
65
+ return ("no issues found" if not bad else f"{bad} failed ({good} passed)"), bool(bad)
66
+ severities = re.findall(r"\b(\d+)\s+(critical|high|important|medium|low)\b", plain, re.I)
67
+ if severities:
68
+ amount = sum(int(number) for number, _ in severities)
69
+ detail = ", ".join(f"{number} {level.lower()}" for number, level in severities)
70
+ return f"{amount} issues found ({_cut(detail, 38)})", amount > 0
71
+ headings = re.findall(r"(?im)^\s*(?:#+\s*)?(?:critical|high|important)[^\n]*\n((?:\s*(?:[-*]|\d+[.)])\s+[^\n]+\n?)+)", text)
72
+ found = sum(len(re.findall(r"(?m)^\s*(?:[-*]|\d+[.)])\s+", block)) for block in headings)
73
+ if found: return f"{found} issues found", True
74
+ return _sentence(text), False
75
+
76
+
77
+ def headline_for(row: dict[str, Any], agent_type: str | None = None) -> tuple[str, str]:
78
+ """One plain-language feed line and its status icon."""
79
+ kind = row.get("sender_kind")
80
+ who = friendly_name(agent_type or row.get("sender")) if row.get("sender_declared") and kind != "codex" else {"user": "You", "claude": "Claude", "codex": "Codex"}.get(kind, friendly_name(agent_type or row.get("sender")))
81
+ finish = lambda text, icon: (_cut(text, 80), icon)
82
+ if row.get("kind") == "event" and row.get("agent_id"):
83
+ subject = row.get("subject") or ""
84
+ if subject.startswith("started:"):
85
+ return finish(f"{who}: started — {_cut(subject.removeprefix('started:').strip(), 72)}", "▶")
86
+ if subject.startswith("dispatched "):
87
+ return finish(f"Claude: {_cut(subject, 72)}", "▶")
88
+ if subject.startswith("stopped "): return finish(f"{who}: stopped without a report", "■")
89
+ outcome, findings = report_outcome(row.get("body") or "")
90
+ if (row.get("body") or "").startswith("(no report: agent stopped)"): return finish(f"{who}: stopped without a report", "■")
91
+ return finish(f"{who}: {_cut(outcome, max(8, 80 - len(who) - 2))}", "⚠" if findings else "✓")
92
+ subject = row.get("subject") or _sentence(row.get("body") or "", 72)
93
+ icon = "?" if row.get("kind") == "question" else "✉"
94
+ if re.match(re.escape(who) + r"\b", subject, re.I): return finish(subject, icon) # already names the speaker
95
+ if row.get("kind") == "question": return finish(f"{who} asks: {_cut(subject, max(8, 80 - len(who) - 7))}", "?")
96
+ return finish(f"{who}: {_cut(subject, max(8, 80 - len(who) - 2))}", "✉")
97
+
98
+
99
+ def _rows(conn, sql: str, params=()) -> list[dict[str, Any]]:
100
+ return [dict(row) for row in conn.execute(sql, params).fetchall()]
101
+
102
+
103
+ def guard(*texts: str | None) -> None:
104
+ if any(text and SECRET.search(text) for text in texts):
105
+ raise ValueError("message rejected: it looks like it contains a secret (key, token or password); nothing was stored")
106
+
107
+
108
+ def _name(value: str | None, field: str) -> str:
109
+ value = (value or "").strip()
110
+ if not value or len(value) > NAME_MAX: raise ValueError(f"{field} must be 1-{NAME_MAX} characters")
111
+ return value
112
+
113
+
114
+ def post(conn, body: str, *, sender: str, sender_kind: str, to: str = "*", subject: str | None = None, kind: str = "message",
115
+ reply_to: int | None = None, project: str | None = None, declared: bool = False) -> int:
116
+ """Store one message and return its id; a reply joins its parent's thread and answers an open question."""
117
+ if kind not in KINDS: raise ValueError("kind must be " + ", ".join(KINDS))
118
+ if not isinstance(body, str) or not body.strip(): raise ValueError("body is required")
119
+ if len(body) > BODY_MAX: raise ValueError(f"body exceeds {BODY_MAX} characters; put long material in a file and post its path")
120
+ if subject is not None and len(subject) > SUBJECT_MAX: raise ValueError(f"subject exceeds {SUBJECT_MAX} characters")
121
+ sender, to = _name(sender, "sender"), _name(to, "to")
122
+ speaker = friendly_name(sender) if declared and sender_kind != "codex" else {"user": "You", "claude": "Claude", "codex": "Codex"}.get(sender_kind, friendly_name(sender))
123
+ derived_subject = subject is None
124
+ if derived_subject: subject = SECRET.sub("[redacted]", _cut(f"{speaker}: {_sentence(body, 72)}", SUBJECT_MAX))
125
+ guard(body, None if derived_subject else subject) # before anything is written: a rejected secret must never reach the DB
126
+ if conn.execute("SELECT COUNT(*) FROM messages WHERE sender=? AND agent_id IS NULL AND datetime(created_at) >= datetime('now','-60 seconds')",
127
+ (sender,)).fetchone()[0] >= RATE_PER_MIN:
128
+ raise ValueError(f"rate limit: at most {RATE_PER_MIN} posts per minute per sender")
129
+ parent = None
130
+ if reply_to is not None:
131
+ parent = conn.execute("SELECT * FROM messages WHERE id=?", (reply_to,)).fetchone()
132
+ if not parent: raise ValueError(f"message {reply_to} does not exist")
133
+ cur = conn.execute("""INSERT INTO messages(thread_id,reply_to,project,sender,sender_kind,sender_declared,recipient,kind,subject,body,status,created_at)
134
+ VALUES(?,?,?,?,?,?,?,?,?,?,'open',?)""", (parent["thread_id"] if parent else None, reply_to, project if project is not None else (parent["project"] if parent else None),
135
+ sender, sender_kind, int(declared), to, kind, subject, body, now()))
136
+ message_id = cur.lastrowid
137
+ if parent is None: conn.execute("UPDATE messages SET thread_id=id WHERE id=?", (message_id,))
138
+ elif parent["kind"] == "question" and parent["status"] == "open":
139
+ conn.execute("UPDATE messages SET status='answered' WHERE id=?", (reply_to,))
140
+ return message_id
141
+
142
+
143
+ def _with_thread(conn, where: str, params=(), order="m.id", limit: int | None = None) -> list[dict[str, Any]]:
144
+ sql = f"""SELECT m.*, r.status thread_status, r.subject thread_subject FROM messages m LEFT JOIN messages r ON r.id=m.thread_id
145
+ WHERE {where} ORDER BY {order}""" + (" LIMIT ?" if limit else "")
146
+ return _rows(conn, sql, (*params, limit) if limit else params)
147
+
148
+
149
+ def mark_read(conn, ids, reader: str) -> None:
150
+ conn.executemany("INSERT OR IGNORE INTO message_reads(message_id,reader,read_at) VALUES(?,?,?)", [(i, reader, now()) for i in ids])
151
+
152
+
153
+ def inbox(conn, reader: str, unread_only: bool = True, limit: int = 20) -> list[dict[str, Any]]:
154
+ """Messages to the reader, to '*', or to channels the reader posted in; oldest first, then marked read."""
155
+ rows = _with_thread(conn, """m.sender != :me AND (m.recipient=:me OR m.recipient='*' OR
156
+ (m.recipient=:live AND (m.sender_kind='user' OR EXISTS(SELECT 1 FROM room_connections c WHERE c.agent_key=m.sender))
157
+ AND EXISTS(SELECT 1 FROM room_connections rc WHERE rc.agent_key=:me AND m.id>rc.since_id)) OR
158
+ (m.recipient LIKE '#%' AND m.recipient NOT IN (:events,:live) AND m.recipient IN
159
+ (SELECT recipient FROM messages WHERE sender=:me AND recipient LIKE '#%' AND recipient NOT IN (:events,:live))))
160
+ AND (:everything OR NOT EXISTS(SELECT 1 FROM message_reads mr WHERE mr.message_id=m.id AND mr.reader=:me))""",
161
+ {"me": reader, "live": LIVE_TO, "events": EVENTS_TO, "everything": int(not unread_only)}, order="m.id DESC")[:max(1, min(limit, 100))]
162
+ rows.reverse()
163
+ # fix(2026-10-28): only an unread check consumes messages; re-reading history (unread_only=False) must not mark everything read.
164
+ if unread_only: mark_read(conn, [row["id"] for row in rows], reader)
165
+ return rows
166
+
167
+
168
+ def live_rows(conn, hours: int = 2) -> list[dict[str, Any]]:
169
+ """Recent room timeline, excluding private agent-to-agent mail."""
170
+ rows = _rows(conn, """SELECT * FROM messages m WHERE datetime(m.created_at)>=datetime('now', ?)
171
+ AND NOT (m.sender_kind IN ('claude','subagent','codex') AND m.recipient NOT IN ('*','user') AND m.recipient NOT LIKE '#%')
172
+ ORDER BY m.id DESC LIMIT ?""", (f"-{hours} hours", LIVE_ROWS_LIMIT))
173
+ rows.reverse()
174
+ for row in rows:
175
+ if row["sender_kind"] != "user" or row["recipient"] != "#live":
176
+ row["delivery"] = []
177
+ continue
178
+ reads = [read[0] for read in conn.execute("SELECT reader FROM message_reads WHERE message_id=? AND reader!=?", (row["id"], USER))]
179
+ read_set = set(reads)
180
+ delivery = [{"state": "received", "key": reader} for reader in reads]
181
+ waiting = conn.execute("SELECT agent_key FROM room_connections WHERE since_id<?", (row["id"],)).fetchall()
182
+ delivery.extend({"state": "waiting", "key": member[0]} for member in waiting if member[0] not in read_set)
183
+ row["delivery"] = delivery
184
+ return rows
185
+
186
+
187
+ def thread(conn, thread_id: int) -> list[dict[str, Any]]:
188
+ return _with_thread(conn, "m.thread_id=?", (thread_id,))
189
+
190
+
191
+ def last_id(conn, thread_id: int) -> int:
192
+ return conn.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE thread_id=?", (thread_id,)).fetchone()[0]
193
+
194
+
195
+ def newer(conn, thread_id: int, after_id: int) -> list[dict[str, Any]]:
196
+ return _with_thread(conn, "m.thread_id=? AND m.id>?", (thread_id, after_id))
197
+
198
+
199
+ def wait_baseline(conn, thread_id: int, reader: str) -> int:
200
+ """Default after_id for message_wait: the reader's own newest message in the thread, else the thread root.
201
+ So a reply that landed before the first wait (or between two waits) is still returned."""
202
+ row = conn.execute("SELECT MAX(id) FROM messages WHERE thread_id=? AND sender=?", (thread_id, reader)).fetchone()
203
+ return row[0] if row and row[0] else thread_id
204
+
205
+
206
+ def close(conn, thread_id: int) -> int:
207
+ return conn.execute("UPDATE messages SET status='closed',closed_at=? WHERE thread_id=? AND status!='closed'", (now(), thread_id)).rowcount
208
+
209
+
210
+ def reopen(conn, thread_id: int) -> int:
211
+ """A reopened question that already has a reply goes back to answered, everything else to open."""
212
+ return conn.execute("""UPDATE messages SET closed_at=NULL, status=CASE WHEN kind='question' AND EXISTS(SELECT 1 FROM messages c WHERE c.reply_to=messages.id)
213
+ THEN 'answered' ELSE 'open' END WHERE thread_id=? AND status='closed'""", (thread_id,)).rowcount
214
+
215
+
216
+ def prune(conn, days: int = RETENTION_DAYS) -> int:
217
+ """Delete closed threads whose newest message and close time are both older than `days`."""
218
+ window = f"-{int(days)} days"
219
+ deleted = conn.execute("""DELETE FROM messages WHERE thread_id IN (SELECT r.id FROM messages r WHERE r.id=r.thread_id AND r.status='closed'
220
+ AND datetime(COALESCE(r.closed_at,r.created_at)) < datetime('now',?)
221
+ AND NOT EXISTS(SELECT 1 FROM messages m WHERE m.thread_id=r.id AND (m.status!='closed' OR datetime(m.created_at) >= datetime('now',?))))""",
222
+ (window, window)).rowcount
223
+ _prune_subagents(conn)
224
+ return deleted
225
+
226
+
227
+ def _prune_subagents(conn) -> None:
228
+ """An agent whose spawn thread is gone loses its timeline; its row stays as a tombstone (spawn_msg_id=-1) so the scanner
229
+ never re-posts it, until its log file is gone as well."""
230
+ for (aid,) in conn.execute("""SELECT agent_id FROM subagents s WHERE s.spawn_msg_id > 0
231
+ AND NOT EXISTS(SELECT 1 FROM messages m WHERE m.id=s.spawn_msg_id)""").fetchall():
232
+ conn.execute("DELETE FROM subagent_tools WHERE agent_id=?", (aid,)); conn.execute("DELETE FROM subagent_texts WHERE agent_id=?", (aid,))
233
+ conn.execute("UPDATE subagents SET spawn_msg_id=-1,report_msg_id=COALESCE(report_msg_id,0),task=NULL,last_text=NULL WHERE agent_id=?", (aid,))
234
+ for aid, source in conn.execute("SELECT agent_id,source_file FROM subagents WHERE spawn_msg_id=-1").fetchall():
235
+ if not source or not Path(source).exists():
236
+ conn.execute("DELETE FROM subagent_tools WHERE agent_id=?", (aid,)); conn.execute("DELETE FROM subagents WHERE agent_id=?", (aid,))
237
+
238
+
239
+ def first_line(row: dict[str, Any], size: int = 140) -> str:
240
+ text = row.get("subject") or next((line for line in (row.get("body") or "").splitlines() if line.strip()), "")
241
+ text = " ".join(text.split())
242
+ return text if len(text) <= size else text[:size - 1] + "…"
243
+
244
+
245
+ def feed(conn, limit: int = 20) -> list[dict[str, Any]]:
246
+ rows = _with_thread(conn, "1=1", order="datetime(m.created_at) DESC, m.id DESC", limit=limit)
247
+ read = {row[0] for row in conn.execute(f"SELECT message_id FROM message_reads WHERE reader='user' AND message_id IN ({','.join('?' * len(rows))})",
248
+ [row["id"] for row in rows])} if rows else set()
249
+ keys = chat_keys(conn, _roots(conn, [row["thread_id"] for row in rows]))
250
+ out = []
251
+ for row in rows:
252
+ who, text = _preview(conn, row)
253
+ agent = conn.execute("SELECT agent_type,agent_kind,model FROM subagents WHERE agent_id=?", (row["agent_id"],)).fetchone() if row["agent_id"] else None
254
+ headline, icon = headline_for(row, agent_name(dict(agent)) if agent else None)
255
+ out.append({"id": row["id"], "thread_id": row["thread_id"], "sender": row["sender"], "sender_kind": row["sender_kind"],
256
+ "declared": bool(row["sender_declared"]), "recipient": row["recipient"], "kind": row["kind"], "line": first_line(row),
257
+ "thread_status": row["thread_status"], "project": row["project"], "agent_id": row["agent_id"], "created_at": row["created_at"],
258
+ "unread": row["id"] not in read and row["sender_kind"] != "user" and row["kind"] != "event",
259
+ "chat": keys.get(row["thread_id"]), "who": who, "text": text, "headline": headline, "icon": icon, "mine": row["sender_kind"] == "user",
260
+ "hue": None if who in ("You", "Claude") else hue(who)})
261
+ for agent in _rows(conn, "SELECT * FROM subagents WHERE spawn_msg_id > 0 AND report_msg_id IS NULL"):
262
+ if subagent_status(agent) != "running": continue
263
+ spawn = conn.execute("SELECT thread_id FROM messages WHERE id=?", (agent["spawn_msg_id"],)).fetchone()
264
+ if not spawn: continue
265
+ tool = conn.execute("SELECT name,arg FROM subagent_tools WHERE agent_id=? ORDER BY source_offset DESC LIMIT 1", (agent["agent_id"],)).fetchone()
266
+ steps = conn.execute("SELECT COUNT(*) FROM subagent_tools WHERE agent_id=?", (agent["agent_id"],)).fetchone()[0]
267
+ detail = (tool["name"] + (" " + tool["arg"] if tool["arg"] else "")) if tool else "working"
268
+ if steps: detail += f" ({steps} steps)"
269
+ who = agent_name(agent)
270
+ out.append({"id": "live:" + agent["agent_id"], "thread_id": spawn["thread_id"], "sender": agent["agent_type"] or "subagent", "sender_kind": "subagent",
271
+ "declared": False, "recipient": EVENTS_TO, "kind": "event", "line": detail, "thread_status": "open", "project": agent["project"],
272
+ "agent_id": agent["agent_id"], "created_at": agent["last_activity_at"] or agent["started_at"], "unread": False,
273
+ "chat": keys.get(spawn["thread_id"]), "who": who, "text": detail, "headline": _cut(f"{who}: working — {detail}"), "icon": "▶", "mine": False,
274
+ "hue": hue(who)})
275
+ out.sort(key=lambda item: parse_ts(item["created_at"]) or _EPOCH, reverse=True)
276
+ return out[:limit]
277
+
278
+
279
+ def unread_count(conn) -> int:
280
+ """Unread messages for the user; Part A events are traffic, not mail, so they never count."""
281
+ return conn.execute(f"SELECT COUNT(*) FROM messages m WHERE m.kind!='event' AND m.sender_kind!='user' AND {_UNREAD_BY_USER}").fetchone()[0]
282
+
283
+
284
+ def badge(conn, since: str | None = None) -> int:
285
+ """Open agent questions in open threads, counted once per thread."""
286
+ try:
287
+ return conn.execute(f"""SELECT COUNT(DISTINCT m.thread_id) FROM messages m JOIN messages r ON r.id=m.thread_id
288
+ WHERE m.kind='question' AND m.status='open' AND m.sender_kind!='user' AND r.status!='closed'
289
+ AND (? IS NULL OR datetime(m.created_at)>=datetime(?))""", (since, since)).fetchone()[0]
290
+ except sqlite3.OperationalError as exc:
291
+ if "no such table" not in str(exc): log.exception("messages badge failed")
292
+ return 0 # a pre-v9 database (read-only connection) has no message tables
293
+
294
+
295
+ def snapshot(conn, limit: int = 20, since: str | None = None) -> dict[str, Any]:
296
+ return {"items": feed(conn, limit), "unread": unread_count(conn), "badge": badge(conn, since), "sig": signature(conn)}
297
+
298
+
299
+ def threads(conn, project: str = "", agent: str = "", kind: str = "", status: str = "", question: bool = False, limit: int = 200) -> list[dict[str, Any]]:
300
+ where, params = ["r.id=r.thread_id"], []
301
+ if project: where.append("r.project=?"); params.append(project)
302
+ if status: where.append("r.status=?"); params.append(status)
303
+ if agent: where.append("EXISTS(SELECT 1 FROM messages a WHERE a.thread_id=r.id AND (a.sender=? OR a.recipient=?))"); params += [agent, agent]
304
+ if kind: where.append("EXISTS(SELECT 1 FROM messages k WHERE k.thread_id=r.id AND k.kind=?)"); params.append(kind)
305
+ if question: where.append("EXISTS(SELECT 1 FROM messages q WHERE q.thread_id=r.id AND q.kind='question')")
306
+ rows = _rows(conn, f"""SELECT r.*, COUNT(m.id) messages, MAX(m.created_at) last_at, SUM(m.kind='question') questions,
307
+ SUM(m.kind!='event' AND m.sender_kind!='user' AND {_UNREAD_BY_USER}) unread
308
+ FROM messages r JOIN messages m ON m.thread_id=r.id WHERE {' AND '.join(where)}
309
+ GROUP BY r.id ORDER BY datetime(MAX(m.created_at)) DESC, r.id DESC LIMIT ?""", (*params, limit))
310
+ for row in rows: row["line"] = first_line(row)
311
+ return rows
312
+
313
+
314
+ def facets(conn) -> dict[str, list[str]]:
315
+ return {"projects": [r[0] for r in conn.execute("SELECT DISTINCT project FROM messages WHERE project IS NOT NULL ORDER BY project")],
316
+ "agents": [r[0] for r in conn.execute("SELECT sender FROM messages UNION SELECT recipient FROM messages WHERE recipient NOT IN ('*') ORDER BY 1")]}
317
+
318
+
319
+ # ---------------------------------------------------------------- subagents (Part A)
320
+
321
+ def subagent_status(row: dict[str, Any]) -> str:
322
+ """finished (meta or a terminal last record), stopped (silent for STOP_S), stale (silent for STALE_S) or running."""
323
+ if row.get("finished") or row.get("last_terminal"): return "finished"
324
+ last = parse_ts(row.get("last_activity_at") or row.get("started_at"))
325
+ idle = (datetime.now(UTC) - last).total_seconds() if last else float("inf")
326
+ return "stopped" if idle > STOP_S else "stale" if idle > STALE_S else "running"
327
+
328
+
329
+ def subagent(conn, agent_id: str) -> dict[str, Any] | None:
330
+ row = conn.execute("SELECT * FROM subagents WHERE agent_id=?", (agent_id,)).fetchone()
331
+ if not row: return None
332
+ result = dict(row)
333
+ result["status"] = subagent_status(result)
334
+ result["report"] = result["last_text"] if result["status"] == "finished" else None
335
+ spawn = conn.execute("SELECT thread_id FROM messages WHERE id=?", (result["spawn_msg_id"],)).fetchone() if (result["spawn_msg_id"] or 0) > 0 else None
336
+ result["thread_id"] = spawn[0] if spawn else None # None once the thread was pruned: no link to a missing page
337
+ result["tools"] = _rows(conn, "SELECT ts,name,arg FROM subagent_tools WHERE agent_id=? ORDER BY source_offset", (agent_id,))
338
+ result["texts"] = _rows(conn, "SELECT ts,role,body FROM subagent_texts WHERE agent_id=? ORDER BY source_offset", (agent_id,))
339
+ result["tokens"] = conn.execute("SELECT COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens),0) FROM usage_events WHERE source_file=?",
340
+ (result["source_file"],)).fetchone()[0] if result["source_file"] else 0
341
+ return result
342
+
343
+
344
+ def session_subagents(conn, session_ids, max_age_s: int = 3600, per_session: int = 6) -> dict[str, list[dict[str, Any]]]:
345
+ """Recent subagents per parent session, each with its latest tool call, for the Agents panel."""
346
+ ids = [s for s in dict.fromkeys(session_ids) if s]
347
+ if not ids: return {}
348
+ rows = _rows(conn, f"""SELECT s.agent_id,s.session_id,s.handoff_session_id,s.agent_type,s.agent_kind,s.model,s.description,s.started_at,s.last_activity_at,s.finished,s.last_terminal,
349
+ (SELECT name FROM subagent_tools t WHERE t.agent_id=s.agent_id ORDER BY source_offset DESC LIMIT 1) last_tool,
350
+ (SELECT arg FROM subagent_tools t WHERE t.agent_id=s.agent_id ORDER BY source_offset DESC LIMIT 1) last_arg
351
+ FROM subagents s WHERE (s.session_id IN ({','.join('?' * len(ids))}) OR s.handoff_session_id IN ({','.join('?' * len(ids))}))
352
+ AND datetime(COALESCE(s.last_activity_at,s.started_at)) >= datetime('now',?)
353
+ ORDER BY datetime(COALESCE(s.last_activity_at,s.started_at)) DESC""", (*ids, *ids, f"-{max_age_s} seconds"))
354
+ out: dict[str, list[dict[str, Any]]] = {}
355
+ for row in rows:
356
+ row["status"] = subagent_status(row)
357
+ row["agent_type"] = agent_name(row)
358
+ group = out.setdefault(row["handoff_session_id"] or row["session_id"], [])
359
+ if len(group) < per_session: group.append(row)
360
+ return out
361
+
362
+
363
+ # ---------------------------------------------------------------- chat view
364
+
365
+ ATTACH_BEFORE, ATTACH_AFTER = timedelta(minutes=5), timedelta(minutes=15)
366
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
367
+
368
+
369
+ def hue(name: str) -> int:
370
+ """Stable color per member name, shared by the chat page and the Brain panel."""
371
+ return zlib.crc32(name.encode()) % 360
372
+
373
+
374
+ def short_type(agent_type: str | None) -> str:
375
+ return (agent_type or "subagent").rsplit(":", 1)[-1]
376
+
377
+
378
+ def agent_name(row: dict[str, Any]) -> str:
379
+ if row.get("agent_kind") == "codex":
380
+ model = row.get("model") or ""
381
+ return "Codex" + (f" ({model})" if model else "")
382
+ return friendly_name(row.get("agent_type"))
383
+
384
+
385
+ def _in(column: str, values: list) -> tuple[str, list]:
386
+ return f"{column} IN ({','.join('?' * len(values))})", list(values)
387
+
388
+
389
+ def chat_keys(conn, roots: list[dict[str, Any]]) -> dict[int, str]:
390
+ """Chat for each thread root: s:<session> for a subagent's thread, or for a message_post thread started in the project of exactly one
391
+ Claude session active at that time; c:#channel for other channel threads; t:<thread id> for the rest."""
392
+ keys: dict[int, str] = {}
393
+ agents = [r["agent_id"] for r in roots if r.get("agent_id")]
394
+ sessions: dict[str, tuple[str | None, str | None, str]] = {}
395
+ for start in range(0, len(agents), 500):
396
+ where, params = _in("agent_id", agents[start:start + 500])
397
+ sessions.update({row[0]: (row[1], row[2], row[3]) for row in conn.execute(
398
+ f"SELECT agent_id,session_id,handoff_session_id,agent_kind FROM subagents WHERE {where}", params)})
399
+ pending: dict[str, list[dict[str, Any]]] = {}
400
+ for r in roots:
401
+ agent = sessions.get(r.get("agent_id"))
402
+ if agent and agent[1]: keys[r["id"]] = "s:" + agent[1]
403
+ elif agent and agent[2] == "codex": keys[r["id"]] = "d:" + r["agent_id"]
404
+ elif agent and agent[0]: keys[r["id"]] = "s:" + agent[0]
405
+ elif r.get("project") and not r.get("agent_id"): pending.setdefault(r["project"], []).append(r)
406
+ for project, rows in pending.items():
407
+ times = [t for t in (parse_ts(r["created_at"]) for r in rows) if t]
408
+ if not times: continue
409
+ since = (min(times) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
410
+ windows = [(sid, parse_ts(a), parse_ts(b)) for sid, a, b in conn.execute(
411
+ """SELECT session_id,MIN(ts),MAX(ts) FROM usage_events WHERE agent='claude' AND project=? AND ts >= ? AND session_id IS NOT NULL
412
+ AND NOT EXISTS(SELECT 1 FROM ingest_offsets io WHERE io.source_file=usage_events.source_file AND io.context LIKE '%devmem_spawned%') GROUP BY session_id""", (project, since))]
413
+ for r in rows:
414
+ if r.get("sender_kind") == "codex": continue
415
+ at = parse_ts(r["created_at"])
416
+ match = [sid for sid, a, b in windows if at and a and b and a - ATTACH_BEFORE <= at <= b + ATTACH_AFTER]
417
+ if len(match) == 1: keys[r["id"]] = "s:" + match[0]
418
+ codex_runs = [(row[0], parse_ts(row[1]), parse_ts(row[2]), row[3] or "", row[4]) for row in conn.execute(
419
+ "SELECT agent_id,started_at,last_activity_at,handoff_id,handoff_session_id FROM subagents WHERE agent_kind='codex' AND project=?", (project,))]
420
+ for r in rows:
421
+ if r.get("sender_kind") != "codex": continue
422
+ at, sender = parse_ts(r["created_at"]), r.get("sender") or ""
423
+ # fix(2026-10-28): two runs within the window left Codex's own posts in separate chats; the handoff template signs posts with the task id
424
+ # (prefix match: ids stored before the full-slug fix were cut at the first hyphen)
425
+ match = [run for run in codex_runs if run[3] and (sender == run[3] or sender.startswith(run[3] + "-"))]
426
+ if len(match) != 1: match = [run for run in codex_runs if at and run[1] and run[2] and run[1] - ATTACH_BEFORE <= at <= run[2] + ATTACH_AFTER]
427
+ if len(match) == 1: keys[r["id"]] = ("s:" + match[0][4]) if match[0][4] else "d:" + match[0][0]
428
+ for r in roots:
429
+ if r["id"] not in keys:
430
+ channel = r.get("recipient") or ""
431
+ keys[r["id"]] = "c:" + channel if channel.startswith("#") and channel != EVENTS_TO else f"t:{r['id']}"
432
+ return keys
433
+
434
+
435
+ def _roots(conn, thread_ids=None) -> list[dict[str, Any]]:
436
+ if thread_ids is None: return _rows(conn, "SELECT id,agent_id,project,recipient,sender_kind,created_at,subject,body FROM messages WHERE id=thread_id")
437
+ ids = list(dict.fromkeys(thread_ids))
438
+ if not ids: return []
439
+ where, params = _in("id", ids)
440
+ return _rows(conn, f"SELECT id,agent_id,project,recipient,sender_kind,created_at,subject,body FROM messages WHERE id=thread_id AND {where}", params)
441
+
442
+
443
+ def _session_agents(conn, session_id: str) -> list[dict[str, Any]]:
444
+ rows = _rows(conn, """SELECT * FROM subagents WHERE (session_id=? OR handoff_session_id=?) AND spawn_msg_id > 0
445
+ ORDER BY datetime(COALESCE(started_at,last_activity_at)), agent_id""", (session_id, session_id))
446
+ seen: dict[str, int] = {}
447
+ for row in rows:
448
+ base = agent_name(row); seen[base] = seen.get(base, 0) + 1
449
+ row["name"] = base if seen[base] == 1 else f"{base} {seen[base]}" # two reviewers of one type stay apart
450
+ row["status"] = subagent_status(row)
451
+ return rows
452
+
453
+
454
+ def _later(a, b) -> bool:
455
+ return (parse_ts(a) or _EPOCH) > (parse_ts(b) or _EPOCH)
456
+
457
+
458
+ def _member_of(m: dict[str, Any], by_type: dict[str, dict], session: bool) -> str:
459
+ if m["sender_kind"] == "user": return USER
460
+ if session and m["sender_kind"] == "claude" and not m["sender_declared"]: return "parent"
461
+ agent = by_type.get(m["sender"]) or by_type.get(short_type(m["sender"]))
462
+ return "agent:" + agent["agent_id"] if agent and m["sender_kind"] == "subagent" else "x:" + m["sender"]
463
+
464
+
465
+ def chat(conn, key: str) -> dict[str, Any] | None:
466
+ """Every bubble of one chat, oldest first: tasks, remarks, tool-call groups, reports, posted messages and user replies."""
467
+ kind, _, ident = key.partition(":")
468
+ if kind not in ("s", "t", "c", "d") or not ident: return None
469
+ roots = _roots(conn, [int(ident)] if kind == "t" and ident.isdigit() else None)
470
+ keys = chat_keys(conn, roots)
471
+ thread_ids = [r["id"] for r in roots if keys.get(r["id"]) == key]
472
+ if kind == "s": agents = _session_agents(conn, ident)
473
+ elif kind == "d":
474
+ agent = conn.execute("SELECT * FROM subagents WHERE agent_id=? AND agent_kind='codex' AND spawn_msg_id > 0", (ident,)).fetchone()
475
+ agents = [dict(agent)] if agent else []
476
+ for a in agents: a["name"], a["status"] = agent_name(a), subagent_status(a)
477
+ else: agents = []
478
+ if not thread_ids and not agents: return None
479
+ where, params = _in("m.thread_id", thread_ids) if thread_ids else ("0", [])
480
+ items_m = _with_thread(conn, where, params)
481
+ by_id = {a["agent_id"]: a for a in agents}
482
+ by_type: dict[str, dict] = {}
483
+ for a in agents: by_type.setdefault(a["agent_type"] or "subagent", a); by_type.setdefault(short_type(a["agent_type"]), a)
484
+ members: dict[str, dict[str, Any]] = {USER: {"name": "You", "kind": "user", "hue": None}}
485
+ if kind == "s": members["parent"] = {"name": "Claude", "kind": "claude", "hue": None}
486
+ for a in agents:
487
+ members["agent:" + a["agent_id"]] = {"name": a["name"], "kind": "subagent", "hue": hue(a["name"]), "agent_id": a["agent_id"], "status": a["status"],
488
+ "description": a["description"], "type": a["agent_type"]}
489
+ items: list[tuple[tuple, dict[str, Any]]] = []
490
+
491
+ def add(phase: int, item: dict[str, Any]) -> None:
492
+ items.append(((parse_ts(item["at"]) or _EPOCH, phase, len(items)), item))
493
+
494
+ reports: dict[str, str] = {}
495
+ for m in items_m:
496
+ agent = by_id.get(m["agent_id"]) if m["kind"] == "event" else None
497
+ headline, icon = headline_for(m, agent_name(agent) if agent else None)
498
+ base = {"key": f"m:{m['id']}", "type": "bubble", "at": m["created_at"], "message_id": m["id"], "thread_id": m["thread_id"], "agent_id": m["agent_id"],
499
+ "headline": headline, "icon": icon}
500
+ if agent and m["id"] == agent["spawn_msg_id"]:
501
+ if agent.get("agent_kind") == "codex" and not agent.get("handoff_session_id"):
502
+ add(0, {**base, "who": "agent:" + agent["agent_id"], "text": m["body"], "label": "task", "agent_id": agent["agent_id"]})
503
+ else:
504
+ add(0, {**base, "who": "parent", "to": "@" + agent["name"], "text": m["body"], "label": "task", "agent_id": agent["agent_id"]})
505
+ elif agent and m["id"] == agent["report_msg_id"]:
506
+ reports[agent["agent_id"]] = m["body"]
507
+ add(2, {**base, "who": "agent:" + agent["agent_id"], "text": m["body"], "label": "report" if agent.get("ended") != "stopped" else "stopped"})
508
+ else:
509
+ who = _member_of(m, by_type, kind == "s")
510
+ if who.startswith("x:") and who not in members:
511
+ members[who] = {"name": m["sender"], "kind": m["sender_kind"], "hue": hue(m["sender"]), "declared": bool(m["sender_declared"])}
512
+ to = m["recipient"] if m["recipient"] not in ("*", EVENTS_TO) else None
513
+ add(1, {**base, "who": who, "to": to, "text": m["body"], "subject": m["subject"] if m["id"] == m["thread_id"] else None,
514
+ "label": m["kind"] if m["kind"] in ("question", "finding") else None, "status": m["status"] if m["kind"] == "question" else None})
515
+ for a in agents:
516
+ aid, member = a["agent_id"], "agent:" + a["agent_id"]
517
+ timeline = [(r["source_offset"], 0, r) for r in _rows(conn, "SELECT id,ts,role,body,source_offset FROM subagent_texts WHERE agent_id=?", (aid,))]
518
+ timeline += [(r["source_offset"], 1, r) for r in _rows(conn, "SELECT id,ts,name,arg,source_offset FROM subagent_tools WHERE agent_id=?", (aid,))]
519
+ timeline.sort(key=lambda entry: (entry[0], entry[1]))
520
+ report, group = (reports.get(aid) or "").strip(), None
521
+ for _, is_tool, r in timeline:
522
+ if is_tool:
523
+ if group is None:
524
+ group = {"key": f"g:{aid}:{r['id']}", "type": "tools", "who": member, "agent_id": aid, "at": r["ts"], "tools": []}
525
+ add(1, group)
526
+ group["tools"].append({"name": r["name"], "arg": r["arg"]})
527
+ if r["ts"]: group["at"] = r["ts"]
528
+ continue
529
+ group = None
530
+ body = r["body"].rstrip("…")
531
+ if r["role"] == "agent" and report and report.startswith(body): continue # the closing text is already the report bubble
532
+ add(1, {"key": f"x:{r['id']}", "type": "bubble", "at": r["ts"], "agent_id": aid, "text": r["body"],
533
+ **({"who": "parent", "to": "@" + a["name"]} if r["role"] == "parent" else {"who": member})})
534
+ # A tool group sorts by its last call, so a growing group moves down like a typing indicator; re-key after the groups filled in.
535
+ items = [((parse_ts(item["at"]) or _EPOCH, order[1], order[2]), item) for order, item in items]
536
+ items.sort(key=lambda pair: pair[0])
537
+ out = [item for _, item in items]
538
+ for item in out:
539
+ item["mine"] = item["who"] == USER
540
+ if item["type"] == "tools": item["count"] = len(item["tools"])
541
+ headlines = [item for item in out if item.get("message_id")]
542
+ for a in agents:
543
+ if a["status"] != "running": continue
544
+ tool_rows = _rows(conn, "SELECT name,arg FROM subagent_tools WHERE agent_id=? ORDER BY source_offset", (a["agent_id"],))
545
+ last = tool_rows[-1] if tool_rows else None
546
+ tool = (last["name"] + (" " + last["arg"] if last["arg"] else "")) if last else "working"
547
+ if tool_rows: tool += f" ({len(tool_rows)} steps)"
548
+ name = agent_name(a)
549
+ headlines.append({"key": "live:" + a["agent_id"], "type": "live", "at": a["last_activity_at"] or a["started_at"], "who": "agent:" + a["agent_id"],
550
+ "agent_id": a["agent_id"], "icon": "▶", "headline": _cut(f"{name}: working — {tool}"),
551
+ "text": "\n".join(t["name"] + (" " + t["arg"] if t["arg"] else "") for t in tool_rows)})
552
+ headlines.sort(key=lambda item: parse_ts(item["at"]) or _EPOCH)
553
+ title_parts, project = _chat_title_parts(conn, key, roots, thread_ids, agents)
554
+ title = " · ".join(title_parts)
555
+ statuses = {m["thread_status"] for m in items_m if m["id"] == m["thread_id"]}
556
+ return {"key": key, "kind": kind, "title": title, "title_parts": title_parts, "project": project, "members": members, "items": out, "headlines": headlines, "thread_ids": thread_ids,
557
+ "status": "open" if "open" in statuses else (statuses.pop() if len(statuses) == 1 else "mixed" if statuses else None),
558
+ "target": _target_from(out, items_m, by_id, members)}
559
+
560
+
561
+ def _chat_title_parts(conn, key: str, roots, thread_ids, agents) -> tuple[list[str], str | None]:
562
+ """The chat title as separate parts, so the panel can lay them out without re-splitting text."""
563
+ kind, _, ident = key.partition(":")
564
+ mine = [r for r in roots if r["id"] in thread_ids]
565
+ project = next((r["project"] for r in mine if r["project"]), None) or next((a["project"] for a in agents if a["project"]), None)
566
+ if kind == "s":
567
+ if not project:
568
+ row = conn.execute("SELECT project FROM usage_events WHERE session_id=? AND project IS NOT NULL LIMIT 1", (ident,)).fetchone()
569
+ project = row[0] if row else None
570
+ return [Path(project).name if project else "Session", ident[:8]], project
571
+ if kind == "d":
572
+ agent = next(iter(agents), None) or conn.execute("SELECT * FROM subagents WHERE agent_id=?", (ident,)).fetchone()
573
+ agent = dict(agent) if agent else {}
574
+ project = agent.get("project") or project
575
+ return ["Codex", Path(project).name if project else "Project", first_line({"body": agent.get("task") or ""}, 80)], project
576
+ if kind == "c": return [ident], project
577
+ root = mine[0] if mine else {}
578
+ return [first_line(root, 80) if root else f"Thread {ident}"], project
579
+
580
+
581
+ def _target_from(items, items_m, by_id, members) -> dict[str, Any] | None:
582
+ """Who a user message goes to: the newest open question from an agent, else the last agent that spoke."""
583
+ question = next((m for m in reversed(items_m) if m["kind"] == "question" and m["status"] == "open" and m["sender_kind"] != "user"), None)
584
+ if question: return {"reply_to": question["id"], "to": question["sender"], "name": question["sender"], "why": "open question"}
585
+ for item in reversed(items):
586
+ if item["type"] != "bubble" or item["who"] == USER: continue
587
+ agent = by_id.get(item.get("agent_id"))
588
+ if item["who"].startswith("agent:") and agent:
589
+ reply = item.get("message_id") or agent["report_msg_id"] or agent["spawn_msg_id"]
590
+ if reply and reply > 0: return {"reply_to": reply, "to": agent["agent_type"] or "subagent", "name": agent["name"], "why": "last to speak"}
591
+ elif item.get("message_id"):
592
+ m = next(x for x in items_m if x["id"] == item["message_id"])
593
+ if m["sender_kind"] == "system": continue
594
+ if item["who"] == "parent" and agent: continue # a task bubble: the parent has no inbox name here; keep looking
595
+ return {"reply_to": m["id"], "to": m["sender"], "name": members.get(item["who"], {}).get("name", m["sender"]), "why": "last to speak"}
596
+ agent_messages = [m for m in items_m if m["sender_kind"] != "system"]
597
+ if agent_messages:
598
+ last = agent_messages[-1]
599
+ return {"reply_to": last["id"], "to": last["sender"] if last["sender_kind"] != "user" else "*", "name": last["sender"], "why": "newest message"}
600
+ return None
601
+
602
+
603
+ def chats(conn, project: str = "", agent: str = "", kind: str = "", status: str = "", question: bool = False, limit: int = 200) -> list[dict[str, Any]]:
604
+ """The chat list: one row per chat, newest first, with a preview of its last bubble and its unread count."""
605
+ rows = threads(conn, project, agent, kind, status, question, limit=5000)
606
+ keys = chat_keys(conn, rows)
607
+ grouped: dict[str, dict[str, Any]] = {}
608
+ for t in rows:
609
+ c = grouped.setdefault(keys[t["id"]], {"key": keys[t["id"]], "kind": keys[t["id"]][0], "threads": [], "last_at": t["last_at"], "unread": 0,
610
+ "questions": 0, "open": False})
611
+ c["threads"].append(t["id"]); c["unread"] += t["unread"] or 0; c["questions"] += t["questions"] or 0; c["open"] |= t["status"] == "open"
612
+ if _later(t["last_at"], c["last_at"]): c["last_at"] = t["last_at"]
613
+ out = []
614
+ for c in list(grouped.values()):
615
+ where, params = _in("thread_id", c["threads"])
616
+ last = conn.execute(f"SELECT * FROM messages WHERE {where} ORDER BY datetime(created_at) DESC, id DESC LIMIT 1", params).fetchone()
617
+ c["who"], c["preview"] = _preview(conn, dict(last)) if last else ("", "")
618
+ agent = conn.execute("SELECT agent_type,agent_kind,model FROM subagents WHERE agent_id=?", (last["agent_id"],)).fetchone() if last and last["agent_id"] else None
619
+ c["headline"] = headline_for(dict(last), agent_name(dict(agent)) if agent else None)[0] if last else ""
620
+ ident = c["key"][2:]
621
+ agent_rows = []
622
+ if c["kind"] == "s":
623
+ row = conn.execute("""SELECT t.ts,t.body,t.role,s.agent_type FROM subagent_texts t JOIN subagents s ON s.agent_id=t.agent_id
624
+ WHERE s.session_id=? ORDER BY datetime(t.ts) DESC, t.id DESC LIMIT 1""", (ident,)).fetchone()
625
+ if row and _later(row["ts"], c["last_at"]):
626
+ c["last_at"], c["who"], c["preview"] = row["ts"], ("Claude" if row["role"] == "parent" else short_type(row["agent_type"])), first_line({"body": row["body"]}, 90)
627
+ active = conn.execute("SELECT MAX(last_activity_at) FROM subagents WHERE session_id=?", (ident,)).fetchone()[0]
628
+ if active and _later(active, c["last_at"]): c["last_at"] = active
629
+ c["running"] = conn.execute("SELECT COUNT(*) FROM subagents WHERE session_id=? AND report_msg_id IS NULL AND spawn_msg_id > 0", (ident,)).fetchone()[0]
630
+ agent_rows = _rows(conn, "SELECT * FROM subagents WHERE session_id=? AND spawn_msg_id > 0", (ident,))
631
+ elif c["threads"]:
632
+ where_agents, params_agents = _in("m.thread_id", c["threads"])
633
+ agent_rows = _rows(conn, f"SELECT DISTINCT s.* FROM subagents s JOIN messages m ON m.agent_id=s.agent_id WHERE {where_agents}", params_agents)
634
+ unanswered = conn.execute(f"SELECT COUNT(*) FROM messages m WHERE {where} AND m.kind='question' AND m.status='open' AND m.recipient='user'", params).fetchone()[0]
635
+ if agent_rows:
636
+ states = [subagent_status(row) for row in agent_rows]
637
+ done, working = sum(state == "finished" for state in states), sum(state == "running" for state in states)
638
+ c["running"] = working
639
+ parts = [f"{len(states)} agent" + ("s" if len(states) != 1 else "")]
640
+ if done: parts.append(f"{done} done")
641
+ if working: parts.append(f"{working} working")
642
+ if unanswered: parts.append(f"{unanswered} question for you")
643
+ c["state_parts"] = parts
644
+ else: c["state_parts"] = ["1 thread"] + ([f"{unanswered} question for you"] if unanswered else [])
645
+ c["state"] = " · ".join(c["state_parts"])
646
+ c["title_parts"], c["project"] = _chat_title_parts(conn, c["key"], _roots(conn, c["threads"]), c["threads"], [])
647
+ c["title"] = " · ".join(c["title_parts"])
648
+ c["hue"] = hue(c["title"])
649
+ out.append(c)
650
+ out.sort(key=lambda c: parse_ts(c["last_at"]) or _EPOCH, reverse=True)
651
+ return out[:limit]
652
+
653
+
654
+ def _preview(conn, m: dict[str, Any]) -> tuple[str, str]:
655
+ if m["kind"] == "event" and m["agent_id"]:
656
+ agent = conn.execute("SELECT agent_type,spawn_msg_id FROM subagents WHERE agent_id=?", (m["agent_id"],)).fetchone()
657
+ if agent and agent["spawn_msg_id"] == m["id"]: return "Claude", "@" + short_type(agent["agent_type"]) + " " + first_line({"body": m["body"]}, 90)
658
+ return short_type(m["sender"]), first_line({"body": m["body"]}, 90)
659
+ return ("You" if m["sender_kind"] == "user" else m["sender"]), first_line({"body": m["body"]}, 90)
660
+
661
+
662
+ def chat_post(conn, key: str, body: str) -> int:
663
+ """Post the user's message into a chat: a reply to the chat's target, so an agent waiting in message_wait gets it."""
664
+ current = chat(conn, key)
665
+ if not current: raise LookupError("chat not found")
666
+ target = current["target"]
667
+ if not target: raise LookupError("this chat has no message to reply to")
668
+ message_id = post(conn, body, sender=USER, sender_kind="user", to=target["to"] if target["to"] != USER else "*", reply_to=target["reply_to"])
669
+ mark_read(conn, [message_id], USER)
670
+ from .dispatch import enqueue
671
+ enqueue(conn, message_id, key)
672
+ return message_id
673
+
674
+
675
+ def mark_chat_read(conn, current: dict[str, Any]) -> None:
676
+ ids = [item["message_id"] for item in current["items"] if item.get("message_id")]
677
+ mark_read(conn, ids, USER)
678
+
679
+
680
+ def signature(conn) -> list[int]:
681
+ """Changes whenever a bubble could appear or change: new or re-statused messages, new tool calls or remarks, agents ending."""
682
+ return list(conn.execute("""SELECT (SELECT COALESCE(MAX(id),0) FROM messages), (SELECT COUNT(*) FROM messages WHERE status!='open'),
683
+ (SELECT COALESCE(MAX(id),0) FROM subagent_tools), (SELECT COALESCE(MAX(id),0) FROM subagent_texts),
684
+ (SELECT COUNT(*) FROM subagents WHERE report_msg_id IS NOT NULL)""").fetchone())