420 lines
24 KiB
Python
420 lines
24 KiB
Python
"""Team work on top of messages: tasks, handoffs, pins, the human's availability, connections and restart prompts.
|
|
|
|
Mixed into Harbor (core.py); every method uses Harbor's connection, clock and write helper.
|
|
"""
|
|
import json
|
|
|
|
from .util import (PRIORITY_NAMES, HarborError, first_line, iso, parse_iso, parse_priority, parse_scope, parse_until,
|
|
scopes_overlap, utc)
|
|
|
|
TASK_STATES = ("open", "in_progress", "blocked", "review", "done", "cancelled")
|
|
OPEN_TASK_STATES = ("open", "in_progress", "blocked", "review")
|
|
TASK_MOVES = {
|
|
"open": {"in_progress", "cancelled", "blocked"}, # deferred before anyone starts it
|
|
"in_progress": {"blocked", "open", "done", "cancelled", "review"},
|
|
"blocked": {"in_progress", "open", "cancelled"},
|
|
"review": {"in_progress", "done", "cancelled"},
|
|
"done": {"open"},
|
|
"cancelled": {"open"},
|
|
}
|
|
PRESENCE = ("here", "away", "dnd")
|
|
WORK_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS tasks(
|
|
id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT, scope TEXT, lo INTEGER, hi INTEGER,
|
|
priority INTEGER NOT NULL DEFAULT 2, state TEXT NOT NULL, assignee TEXT, created_by TEXT NOT NULL,
|
|
item_id INTEGER, claim_id INTEGER, due_at TEXT, block_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
CREATE INDEX IF NOT EXISTS tasks_state ON tasks(state, priority, id);
|
|
CREATE TABLE IF NOT EXISTS task_deps(task_id INTEGER NOT NULL, depends_on INTEGER NOT NULL, PRIMARY KEY(task_id, depends_on));
|
|
CREATE TABLE IF NOT EXISTS task_events(id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL, agent TEXT NOT NULL,
|
|
from_state TEXT, to_state TEXT NOT NULL, note TEXT, at TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS handoffs(id INTEGER PRIMARY KEY, agent TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
CREATE INDEX IF NOT EXISTS handoffs_agent ON handoffs(agent, id);
|
|
CREATE TABLE IF NOT EXISTS pins(message_id INTEGER PRIMARY KEY, pinned_by TEXT NOT NULL, note TEXT, pinned_at TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS presence(name TEXT PRIMARY KEY, status TEXT NOT NULL, until TEXT, note TEXT, set_at TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS connections(agent TEXT NOT NULL, channel TEXT NOT NULL, host TEXT NOT NULL DEFAULT '',
|
|
last_at TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(agent, channel, host));
|
|
"""
|
|
|
|
|
|
class WorkMixin:
|
|
# ----------------------------------------------------------------- connections and activity
|
|
def _connect(self, c, agent, channel, host=""):
|
|
now = self.now()
|
|
c.execute("INSERT INTO connections(agent, channel, host, last_at, count) VALUES (?,?,?,?,1) "
|
|
"ON CONFLICT(agent, channel, host) DO UPDATE SET last_at=excluded.last_at, count=count+1",
|
|
(agent, channel, host or "", now))
|
|
c.execute("UPDATE agents SET last_seen=? WHERE name=?", (now, agent))
|
|
|
|
def connect(self, agent, channel, host=""):
|
|
"""Record that agent reached Harbor through channel (mcp, hook, cli, web) from host (codex, kimi, ...)."""
|
|
self._write(lambda c: self._connect(c, agent, channel, host))
|
|
|
|
def activity(self, agent, tool, host="", target=None, channel="hook"):
|
|
"""Agent used a tool: drives the 'working now' indicator, the pulse and the live feed. Hooks report every tool
|
|
call; agents whose app runs no hooks still show up through their Harbor calls (channel cli or mcp) and the
|
|
server's worktree scan (channel files)."""
|
|
now = self.now()
|
|
|
|
def do(c):
|
|
self._connect(c, agent, channel, host)
|
|
c.execute("UPDATE agents SET active_at=?, last_tool=? WHERE name=?", (now, str(tool)[:80] or None, agent))
|
|
self._pulse(c, agent, tools=1)
|
|
self._log_activity(c, agent, tool, target)
|
|
self._write(do)
|
|
|
|
def connections(self):
|
|
out = {}
|
|
for r in self.conn.execute("SELECT * FROM connections ORDER BY agent, last_at DESC"):
|
|
out.setdefault(r["agent"], []).append({**dict(r), "minutes": self.minutes_since(r["last_at"])})
|
|
return out
|
|
|
|
def working(self, agent_row, within=3):
|
|
minutes = self.minutes_since(agent_row.get("active_at"))
|
|
return minutes is not None and minutes < within
|
|
|
|
# ----------------------------------------------------------------- the human's availability
|
|
def set_presence(self, by, origin, status, until=None, note=None):
|
|
if by != self.human or origin != "web":
|
|
raise HarborError(f"only {self.human} sets their availability, from the chat page")
|
|
if status not in PRESENCE:
|
|
raise HarborError(f"status must be one of {', '.join(PRESENCE)}")
|
|
until_at = parse_until(until, utc(self.clock())) if until else None
|
|
note = " ".join((note or "").split())[:300] or None
|
|
now = self.now()
|
|
|
|
def do(c):
|
|
c.execute("INSERT INTO presence(name, status, until, note, set_at) VALUES (?,?,?,?,?) ON CONFLICT(name) DO "
|
|
"UPDATE SET status=excluded.status, until=excluded.until, note=excluded.note, set_at=excluded.set_at",
|
|
(self.human, status, until_at, note, now))
|
|
self._write(do)
|
|
return self.presence()
|
|
|
|
def presence(self):
|
|
row = self.conn.execute("SELECT * FROM presence WHERE name=?", (self.human,)).fetchone()
|
|
if not row:
|
|
return {"name": self.human, "status": "here", "until": None, "note": None, "set_at": None}
|
|
p = dict(row)
|
|
if p["until"] and parse_iso(p["until"]) <= utc(self.clock()):
|
|
p.update(status="here", expired=True)
|
|
return p
|
|
|
|
def presence_line(self):
|
|
p = self.presence()
|
|
if p["status"] == "here":
|
|
return None
|
|
what = {"away": "is away", "dnd": "does not want to be disturbed"}[p["status"]]
|
|
line = f"{self.human} {what}" + (f" until {p['until']}" if p["until"] else "")
|
|
return line + (f": {p['note']}" if p["note"] else "") + " - decide with a coordinator instead of waiting."
|
|
|
|
# ----------------------------------------------------------------- handoffs
|
|
def handoff(self, agent, body):
|
|
self._check_agent(agent, allow_human=False)
|
|
body = (body or "").replace("\r\n", "\n").strip()
|
|
if not body:
|
|
raise HarborError("empty handoff")
|
|
if len(body) > 20000:
|
|
raise HarborError("a handoff is at most 20,000 characters; attach a file to a message instead")
|
|
now = self.now()
|
|
|
|
def do(c):
|
|
hid = c.execute("INSERT INTO handoffs(agent, body, created_at) VALUES (?,?,?)", (agent, body, now)).lastrowid
|
|
self._touch(c, agent)
|
|
return hid
|
|
return {"id": self._write(do), "agent": agent, "created_at": now}
|
|
|
|
def handoffs(self, agent=None, limit=10):
|
|
if agent:
|
|
rows = self.conn.execute("SELECT * FROM handoffs WHERE agent=? ORDER BY id DESC LIMIT ?", (agent, int(limit)))
|
|
else:
|
|
rows = self.conn.execute("SELECT * FROM handoffs ORDER BY id DESC LIMIT ?", (int(limit),))
|
|
return [dict(r) for r in rows]
|
|
|
|
def last_handoff(self, agent):
|
|
rows = self.handoffs(agent, limit=1)
|
|
return rows[0] if rows else None
|
|
|
|
# ----------------------------------------------------------------- pins
|
|
def pin(self, by, message_id, note=None):
|
|
self._check_agent(by)
|
|
if not self.message(message_id):
|
|
raise HarborError(f"no message #{message_id}")
|
|
now = self.now()
|
|
|
|
def do(c):
|
|
c.execute("INSERT INTO pins(message_id, pinned_by, note, pinned_at) VALUES (?,?,?,?) ON CONFLICT(message_id) "
|
|
"DO UPDATE SET note=COALESCE(excluded.note, note)", (int(message_id), by, note, now))
|
|
self._write(do)
|
|
return self.pins()
|
|
|
|
def unpin(self, by, message_id):
|
|
self._check_agent(by)
|
|
self._write(lambda c: c.execute("DELETE FROM pins WHERE message_id=?", (int(message_id),)))
|
|
return self.pins()
|
|
|
|
def pins(self, limit=30):
|
|
rows = self.conn.execute("SELECT p.note pin_note, p.pinned_by, p.pinned_at, m.*, t.topic, t.subject FROM pins p "
|
|
"JOIN messages m ON m.id=p.message_id JOIN threads t ON t.id=m.thread_id "
|
|
"ORDER BY p.pinned_at DESC LIMIT ?", (int(limit),))
|
|
return [self._message_dict(r) for r in rows]
|
|
|
|
def decisions(self, limit=30):
|
|
rows = self.conn.execute(self.MESSAGE_SELECT + " WHERE m.kind='decision' ORDER BY m.id DESC LIMIT ?", (int(limit),))
|
|
return [self._message_dict(r) for r in rows]
|
|
|
|
# ----------------------------------------------------------------- tasks
|
|
def _task_dict(self, row):
|
|
t = dict(row)
|
|
t["priority_name"] = PRIORITY_NAMES.get(t["priority"], str(t["priority"]))
|
|
t["depends_on"] = [r[0] for r in self.conn.execute("SELECT depends_on FROM task_deps WHERE task_id=? ORDER BY 1",
|
|
(t["id"],))]
|
|
t["waiting_on"] = [r[0] for r in self.conn.execute(
|
|
"SELECT d.depends_on FROM task_deps d JOIN tasks x ON x.id=d.depends_on WHERE d.task_id=? AND "
|
|
"x.state NOT IN ('done', 'cancelled') ORDER BY 1", (t["id"],))]
|
|
return t
|
|
|
|
def task(self, task_id):
|
|
row = self.conn.execute("SELECT * FROM tasks WHERE id=?", (int(task_id),)).fetchone()
|
|
if not row:
|
|
raise HarborError(f"no task #{task_id}")
|
|
events = [dict(r) for r in self.conn.execute("SELECT * FROM task_events WHERE task_id=? ORDER BY id",
|
|
(int(task_id),))]
|
|
return self._task_dict(row), events
|
|
|
|
def tasks(self, states=OPEN_TASK_STATES, assignee=None, limit=200):
|
|
query = f"SELECT * FROM tasks WHERE state IN ({','.join('?' * len(states))})"
|
|
args = list(states)
|
|
if assignee:
|
|
query += " AND assignee=?"
|
|
args.append(assignee)
|
|
query += " ORDER BY CASE state WHEN 'in_progress' THEN 0 WHEN 'blocked' THEN 1 WHEN 'review' THEN 2 ELSE 3 END, "
|
|
query += "priority, COALESCE(due_at, '9999'), id LIMIT ?"
|
|
args.append(int(limit))
|
|
return [self._task_dict(r) for r in self.conn.execute(query, args)]
|
|
|
|
def add_task(self, by, title, body=None, scope=None, assignee=None, priority="normal", after=(), due=None,
|
|
origin="cli", notify=True):
|
|
self._check_agent(by, allow_human=(origin == "web"))
|
|
title = " ".join((title or "").split())
|
|
if not title:
|
|
raise HarborError("a task needs a title")
|
|
if assignee:
|
|
self._check_agent(assignee, allow_human=False)
|
|
if assignee != by and by not in self.coordinators:
|
|
raise HarborError("only a coordinator assigns tasks to someone else; leave it unassigned for `next`")
|
|
canonical, lo, hi = parse_scope(scope) if scope else (None, None, None)
|
|
prio = parse_priority(priority)
|
|
due_at = parse_until(due, utc(self.clock())) if due else None
|
|
after = [int(a) for a in (after or [])]
|
|
for dep in after:
|
|
self.task(dep)
|
|
now = self.now()
|
|
|
|
def do(c):
|
|
tid = c.execute("INSERT INTO tasks(title, body, scope, lo, hi, priority, state, assignee, created_by, due_at, "
|
|
"created_at, updated_at) VALUES (?,?,?,?,?,?,'open',?,?,?,?,?)",
|
|
(title, body, canonical, lo, hi, prio, assignee, by, due_at, now, now)).lastrowid
|
|
for dep in after:
|
|
c.execute("INSERT OR IGNORE INTO task_deps(task_id, depends_on) VALUES (?,?)", (tid, dep))
|
|
c.execute("INSERT INTO task_events(task_id, agent, from_state, to_state, note, at) VALUES (?,?,NULL,'open',?,?)",
|
|
(tid, by, body and first_line(body, 200), now))
|
|
self._touch(c, by)
|
|
return tid
|
|
tid = self._write(do)
|
|
if notify and assignee and assignee != by:
|
|
self.send(by, [assignee], f"Task #{tid} is yours: {title}" + (f"\n{body}" if body else ""),
|
|
thread=f"task:{tid}", subject=f"Task: {title}", kind="request",
|
|
importance="high" if prio <= 1 else "normal", trusted_refs={"task": tid}, origin="system")
|
|
return self.task(tid)[0]
|
|
|
|
def _move_task(self, c, task, agent, to_state, note=None, **fields):
|
|
now = self.now()
|
|
sets = ", ".join(["state=?", "updated_at=?"] + [f"{k}=?" for k in fields])
|
|
cur = c.execute(f"UPDATE tasks SET {sets} WHERE id=? AND state=?",
|
|
(to_state, now, *fields.values(), task["id"], task["state"]))
|
|
if cur.rowcount == 0:
|
|
raise HarborError(f"task #{task['id']} changed meanwhile; look at it again")
|
|
c.execute("INSERT INTO task_events(task_id, agent, from_state, to_state, note, at) VALUES (?,?,?,?,?,?)",
|
|
(task["id"], agent, task["state"], to_state, note, now))
|
|
|
|
def _eligible(self, task, agent):
|
|
if task["state"] != "open" or task["assignee"] not in (None, agent) or task["waiting_on"]:
|
|
return False
|
|
if task["scope"]:
|
|
now = self.now()
|
|
for a in self.conn.execute("SELECT * FROM claims WHERE released_at IS NULL AND (expires_at IS NULL OR "
|
|
"expires_at > ?) AND agent != ?", (now, agent)):
|
|
if a["kind"] in ("draft", "naming", "edit", "band") and scopes_overlap(
|
|
(task["scope"], task["lo"], task["hi"]), (a["scope"], a["lo"], a["hi"])):
|
|
if a["kind"] == "band" and task["assignee"] == agent:
|
|
continue # assigned to me explicitly inside someone's band
|
|
return False
|
|
return True
|
|
|
|
def take_task(self, agent, task_id, note=None):
|
|
self._check_agent(agent, allow_human=False)
|
|
task, _ = self.task(task_id)
|
|
if task["state"] != "open":
|
|
raise HarborError(f"task #{task_id} is {task['state']}" + (f" (assigned to {task['assignee']})"
|
|
if task["assignee"] else ""))
|
|
if task["assignee"] not in (None, agent):
|
|
raise HarborError(f"task #{task_id} is assigned to {task['assignee']}")
|
|
if task["waiting_on"]:
|
|
raise HarborError(f"task #{task_id} waits on " + ", ".join(f"#{d}" for d in task["waiting_on"]))
|
|
self.require_running(agent, "take tasks")
|
|
claim_id = None
|
|
if task["scope"]:
|
|
if task["assignee"] == agent and task["created_by"] in self.coordinators and task["created_by"] != agent and task["created_by"] != self.human:
|
|
# a coordinator assigned it explicitly (possibly inside someone else's band): record it as theirs
|
|
claim_id = self.claim(task["created_by"], task["scope"], kind="draft", note=f"task #{task_id}",
|
|
on_behalf_of=agent, override=True)["id"]
|
|
elif task["assignee"] == agent and task["created_by"] == self.human:
|
|
coordinator = next(iter(self.project.coordinators), None)
|
|
claim_id = (self.claim(coordinator, task["scope"], kind="draft", note=f"task #{task_id} (assigned by "
|
|
f"{self.human})", on_behalf_of=agent, override=True)["id"] if coordinator else
|
|
self.claim(agent, task["scope"], kind="draft", note=f"task #{task_id}")["id"])
|
|
else:
|
|
claim_id = self.claim(agent, task["scope"], kind="draft", note=f"task #{task_id}")["id"]
|
|
|
|
def do(c):
|
|
self._move_task(c, task, agent, "in_progress", note, assignee=agent, claim_id=claim_id, block_reason=None)
|
|
now = self.now()
|
|
c.execute("UPDATE agents SET doing=?, doing_at=?, last_seen=? WHERE name=?",
|
|
(f"task #{task_id}: {task['title']}"[:200], now, now, agent))
|
|
try:
|
|
self._write(do)
|
|
except HarborError:
|
|
if claim_id:
|
|
self._write(lambda c: c.execute("UPDATE claims SET released_at=? WHERE id=?", (self.now(), claim_id)))
|
|
raise
|
|
return self.task(task_id)[0]
|
|
|
|
def next_task(self, agent, take=True):
|
|
"""The best open task agent may take (priority, then due time, then age); takes it unless take=False."""
|
|
self._check_agent(agent, allow_human=False)
|
|
if self.stopped(agent):
|
|
return None
|
|
for task in self.tasks(states=("open",), limit=100000):
|
|
if self._eligible(task, agent):
|
|
if not take:
|
|
return task
|
|
try:
|
|
return self.take_task(agent, task["id"])
|
|
except HarborError:
|
|
continue
|
|
return None
|
|
|
|
def update_task(self, agent, task_id, to_state, note=None, origin="cli"):
|
|
self._check_agent(agent, allow_human=(origin == "web"))
|
|
if to_state not in TASK_STATES:
|
|
raise HarborError(f"task state must be one of {', '.join(TASK_STATES)}")
|
|
task, _ = self.task(task_id)
|
|
if to_state == "in_progress" and task["state"] == "open":
|
|
return self.take_task(agent, task_id, note)
|
|
if to_state == "review":
|
|
raise HarborError("a task goes to review by `submit --task`")
|
|
if to_state not in TASK_MOVES[task["state"]]:
|
|
raise HarborError(f"task #{task_id} is {task['state']}; it can move to "
|
|
+ ", ".join(sorted(TASK_MOVES[task["state"]])))
|
|
owner = (agent == task["assignee"] or agent in self.coordinators or
|
|
(agent == task["created_by"] and (not task["assignee"] or task["state"] in ("open", "done", "cancelled"))))
|
|
if not owner:
|
|
raise HarborError(f"task #{task_id} belongs to {task['assignee'] or task['created_by']}")
|
|
if to_state == "in_progress":
|
|
self.require_running(agent, "resume tasks")
|
|
if to_state == "blocked" and not note:
|
|
raise HarborError("say what blocks it (note)")
|
|
fields = {}
|
|
if to_state == "blocked":
|
|
fields["block_reason"] = note
|
|
releasing = to_state in ("open", "done", "cancelled") and task["claim_id"]
|
|
if releasing:
|
|
fields["claim_id"] = None
|
|
assigned = task["created_by"] != task["assignee"] and (task["created_by"] in self.coordinators
|
|
or task["created_by"] == self.human)
|
|
if to_state == "open" and task["state"] in ("in_progress", "blocked") and not assigned:
|
|
fields["assignee"] = None # handed back: anyone may take it with `next`
|
|
# a task a coordinator (or the human) gave to someone stays theirs when they hand it back for later: clearing it
|
|
# would let band routing hand it to the band owner (a load-balanced task inside another agent's band)
|
|
|
|
def do(c):
|
|
if releasing:
|
|
c.execute("UPDATE claims SET released_at=? WHERE id=? AND released_at IS NULL", (self.now(), task["claim_id"]))
|
|
self._move_task(c, task, agent, to_state, note, **fields)
|
|
if to_state in ("done", "cancelled", "open") and task["assignee"]:
|
|
c.execute("UPDATE agents SET doing=NULL WHERE name=? AND doing LIKE ?", (task["assignee"], f"task #{task_id}:%"))
|
|
self._touch(c, agent)
|
|
self._write(do)
|
|
people = {task["created_by"], *self.project.coordinators} - {agent}
|
|
if to_state == "blocked" and people:
|
|
self.send(agent, sorted(people), f"Task #{task_id} ({task['title']}) is blocked: {note}",
|
|
thread=f"task:{task_id}", subject=f"Task: {task['title']}", kind="status", importance="high",
|
|
trusted_refs={"task": int(task_id)}, origin="system")
|
|
elif to_state == "done" and task["created_by"] != agent:
|
|
self.send(agent, [task["created_by"]], f"Task #{task_id} ({task['title']}) is done" + (f": {note}" if note else "."),
|
|
thread=f"task:{task_id}", subject=f"Task: {task['title']}", kind="status",
|
|
trusted_refs={"task": int(task_id)}, origin="system")
|
|
return self.task(task_id)[0]
|
|
|
|
def _task_follows_item(self, item_id, item_state, agent):
|
|
"""Keep a task in step with its review item: landed -> done; sent back -> in progress."""
|
|
row = self.conn.execute("SELECT * FROM tasks WHERE item_id=?", (int(item_id),)).fetchone()
|
|
if not row:
|
|
return
|
|
task = self._task_dict(row)
|
|
target = {"landed": "done", "changes_requested": "in_progress", "withdrawn": "in_progress",
|
|
"rejected": "in_progress", "submitted": "review"}.get(item_state)
|
|
if not target or target == task["state"] or task["state"] not in ("review", "in_progress", "blocked") \
|
|
or target not in TASK_MOVES[task["state"]]:
|
|
return
|
|
|
|
def do(c):
|
|
self._move_task(c, task, agent, target, f"review item #{item_id} {item_state}")
|
|
if target == "done" and task["claim_id"]:
|
|
c.execute("UPDATE claims SET released_at=? WHERE id=? AND released_at IS NULL", (self.now(), task["claim_id"]))
|
|
c.execute("UPDATE agents SET doing=NULL WHERE name=? AND doing LIKE ?", (task["assignee"], f"task #{task['id']}:%"))
|
|
self._write(do)
|
|
|
|
# ----------------------------------------------------------------- restart prompt
|
|
def restart_prompt(self, agent, cmd=None):
|
|
"""A prompt to paste into a fresh session for agent: who it is, where it works, the rules, and the state."""
|
|
from .texts import launcher, protocol
|
|
self._check_agent(agent, allow_human=False)
|
|
cmd = cmd or launcher()
|
|
if not any(a["name"] == agent for a in self.project.agents):
|
|
raise HarborError(f"{agent} is not on this project's roster")
|
|
run = " ".join(cmd)
|
|
spec = next(a for a in self.project.agents if a["name"] == agent)
|
|
lines = [f"You are {agent} on the {self.project.title} team" + (f" ({spec['role']})." if spec["role"] else ".")]
|
|
if spec.get("workdir"):
|
|
lines.append(f"Work in {spec['workdir']}.")
|
|
if spec.get("instructions"):
|
|
lines.append(spec["instructions"].strip())
|
|
lines.append(f"Before anything else run `{run} --as {agent} brief` (or the Harbor MCP tool `brief`) and follow it.")
|
|
lines.append("")
|
|
lines.append(protocol(agent, self.project.name, cmd).strip())
|
|
lines.append("")
|
|
lines.append(f"State from Harbor at {self.now()}:")
|
|
stops = self.stopped(agent)
|
|
lines.append("- " + ("STOPPED (" + ", ".join(f["key"] for f in stops) + "): wait for the human to resume you; "
|
|
"do not start work." if stops else "Not stopped."))
|
|
mine = self.tasks(assignee=agent)
|
|
if mine:
|
|
lines.append("- Your tasks: " + "; ".join(f"#{t['id']} {t['state']} {t['title']}" for t in mine[:8]))
|
|
else:
|
|
nxt = self.next_task(agent, take=False)
|
|
lines.append("- No task in progress." + (f" Next available: #{nxt['id']} {nxt['title']} (take it with "
|
|
"`next`)." if nxt else ""))
|
|
claims = self.active_claims(agent=agent)
|
|
if claims:
|
|
lines.append("- Your claims: " + ", ".join(f"{c['kind']} {c['scope']}" for c in claims[:12]))
|
|
total, urgent = self.unread_count(agent)
|
|
lines.append(f"- Unread messages: {total}" + (f" ({urgent} urgent)" if urgent else ""))
|
|
h = self.last_handoff(agent)
|
|
if h:
|
|
lines.append(f"- Your last handoff ({h['created_at']}):")
|
|
lines += [" " + line for line in h["body"].split("\n")[:40]]
|
|
presence = self.presence_line()
|
|
if presence:
|
|
lines.append("- " + presence)
|
|
return "\n".join(lines)
|