@team-agent/installer 0.2.1 → 0.2.3

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 (39) hide show
  1. package/package.json +1 -1
  2. package/schemas/team.schema.json +6 -0
  3. package/src/team_agent/approvals/runtime_prompts.py +1 -1
  4. package/src/team_agent/cli/commands.py +122 -6
  5. package/src/team_agent/cli/parser.py +42 -1
  6. package/src/team_agent/coordinator/__main__.py +21 -2
  7. package/src/team_agent/coordinator/lifecycle.py +11 -0
  8. package/src/team_agent/diagnose/orphan_cleanup.py +364 -0
  9. package/src/team_agent/events.py +47 -0
  10. package/src/team_agent/launch/core.py +2 -1
  11. package/src/team_agent/leader/__init__.py +273 -60
  12. package/src/team_agent/lifecycle/agents.py +54 -2
  13. package/src/team_agent/lifecycle/operations.py +87 -9
  14. package/src/team_agent/lifecycle/start.py +1 -1
  15. package/src/team_agent/message_store/core.py +8 -7
  16. package/src/team_agent/message_store/leader_notification_log.py +132 -0
  17. package/src/team_agent/message_store/result_watchers.py +144 -1
  18. package/src/team_agent/message_store/schema.py +31 -2
  19. package/src/team_agent/messaging/delivery.py +293 -1
  20. package/src/team_agent/messaging/idle_alerts.py +109 -9
  21. package/src/team_agent/messaging/leader.py +179 -10
  22. package/src/team_agent/messaging/leader_api_errors.py +216 -0
  23. package/src/team_agent/messaging/leader_panes.py +393 -23
  24. package/src/team_agent/messaging/result_delivery.py +219 -4
  25. package/src/team_agent/messaging/results.py +12 -21
  26. package/src/team_agent/messaging/scheduler.py +24 -2
  27. package/src/team_agent/messaging/send.py +21 -26
  28. package/src/team_agent/messaging/tmux_io.py +153 -23
  29. package/src/team_agent/messaging/tmux_prompt.py +87 -0
  30. package/src/team_agent/messaging/trust_auto_answer.py +44 -0
  31. package/src/team_agent/restart/orchestration.py +207 -4
  32. package/src/team_agent/runtime.py +7 -7
  33. package/src/team_agent/rust_core.py +157 -3
  34. package/src/team_agent/sessions/capture.py +65 -15
  35. package/src/team_agent/spec.py +59 -0
  36. package/src/team_agent/state.py +153 -10
  37. package/src/team_agent/status/inbox.py +33 -3
  38. package/src/team_agent/status/queries.py +32 -1
  39. package/src/team_agent/watch/__init__.py +145 -0
@@ -1,24 +1,54 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import datetime, timezone
3
4
  from pathlib import Path
4
5
  from typing import Any
5
6
 
6
7
  from team_agent.message_store import MessageStore
7
8
 
8
9
 
9
- def inbox(workspace: Path, agent_id: str, limit: int = 20) -> dict[str, Any]:
10
+ def _parse_since(since: str | None) -> datetime | None:
11
+ if not since:
12
+ return None
13
+ try:
14
+ dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
15
+ except (ValueError, AttributeError):
16
+ return None
17
+ if dt.tzinfo is None:
18
+ dt = dt.replace(tzinfo=timezone.utc)
19
+ return dt
20
+
21
+
22
+ def _filter_since(rows: list[dict[str, Any]], since: str | None) -> list[dict[str, Any]]:
23
+ cutoff = _parse_since(since)
24
+ if cutoff is None:
25
+ return rows
26
+ filtered: list[dict[str, Any]] = []
27
+ for row in rows:
28
+ ts_raw = str(row.get("created_at") or "")
29
+ ts = _parse_since(ts_raw)
30
+ if ts and ts >= cutoff:
31
+ filtered.append(row)
32
+ return filtered
33
+
34
+
35
+ def inbox(workspace: Path, agent_id: str, limit: int = 20, since: str | None = None) -> dict[str, Any]:
10
36
  rows = MessageStore(workspace).inbox(agent_id, limit=limit)
11
- return {"ok": True, "agent_id": agent_id, "messages": rows}
37
+ rows = _filter_since(rows, since)
38
+ return {"ok": True, "agent_id": agent_id, "messages": rows, "since": since}
12
39
 
13
40
 
14
- def format_inbox(workspace: Path, agent_id: str, limit: int = 20) -> str:
41
+ def format_inbox(workspace: Path, agent_id: str, limit: int = 20, since: str | None = None) -> str:
15
42
  store = MessageStore(workspace)
16
43
  rows = store.inbox(agent_id, limit=limit)
44
+ rows = _filter_since(rows, since)
17
45
  result_counts = store.result_counts()
18
46
  note = "final results are not in inbox; use team-agent collect"
19
47
  if result_counts.get("uncollected", 0):
20
48
  note += f" ({result_counts['uncollected']} uncollected result(s) pending)"
21
49
  if not rows:
50
+ if since:
51
+ return f"{agent_id}: no messages since {since}\n{note}"
22
52
  return f"{agent_id}: no messages\n{note}"
23
53
  lines = [
24
54
  f"{row['created_at']} {row['sender']} -> {row['recipient']} {row['status']}: {row['content']}"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from datetime import datetime
4
5
  from pathlib import Path
5
6
  from typing import Any
6
7
 
@@ -11,6 +12,24 @@ from team_agent.status.compact import compact_status
11
12
  from team_agent.status.constants import PENDING_DELIVERY_STATUSES
12
13
 
13
14
 
15
+ def _interacted_marker(first_send_at: Any) -> str:
16
+ """C3 (cr verdict, 2026-05-27): render the persisted first_send_at as a
17
+ user-visible status field. Valid ISO 8601 UTC strings pass through; any
18
+ other shape (None, empty string, 0, False, corrupt garbage) renders as
19
+ the literal "never" so the operator sees a consistent classification
20
+ instead of leaking raw garbage into status output. Restart enforces
21
+ strict typing separately (corrupt values fail the operation); status is
22
+ a read-only surface and tolerantly degrades to "never" rather than
23
+ failing the status command."""
24
+ if isinstance(first_send_at, str) and first_send_at:
25
+ try:
26
+ datetime.fromisoformat(first_send_at)
27
+ except (ValueError, TypeError):
28
+ return "never"
29
+ return first_send_at
30
+ return "never"
31
+
32
+
14
33
  def status(workspace: Path, as_json: bool = False, *, compact: bool = False) -> dict[str, Any]:
15
34
  from team_agent.runtime import (
16
35
  _capture_missing_sessions,
@@ -31,12 +50,24 @@ def status(workspace: Path, as_json: bool = False, *, compact: bool = False) ->
31
50
  save_runtime_state(workspace, state)
32
51
  session_name = state.get("session_name")
33
52
  tmux_exists = _tmux_session_exists(session_name) if session_name else False
53
+ # C3 (cr verdict): enrich each worker entry with an explicit `interacted`
54
+ # field derived from the persisted first_send_at. The original entry
55
+ # passes through unchanged so any pre-existing field (including raw
56
+ # first_send_at) stays visible.
57
+ enriched_agents: dict[str, Any] = {}
58
+ for aid, raw in (state.get("agents") or {}).items():
59
+ if isinstance(raw, dict):
60
+ entry = dict(raw)
61
+ entry["interacted"] = _interacted_marker(raw.get("first_send_at"))
62
+ else:
63
+ entry = raw
64
+ enriched_agents[aid] = entry
34
65
  result = {
35
66
  "team": state.get("leader", {}).get("id", "leader"),
36
67
  "session_name": session_name,
37
68
  "tmux_session_present": tmux_exists,
38
69
  "leader_receiver": state.get("leader_receiver", {}),
39
- "agents": state.get("agents", {}),
70
+ "agents": enriched_agents,
40
71
  "agent_health": store.agent_health(),
41
72
  "tasks": state.get("tasks", []),
42
73
  "messages": store.message_counts(),
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from team_agent.message_store import MessageStore
10
+ from team_agent.paths import logs_dir, runtime_dir
11
+ from team_agent.status.queries import result_summary_from_row
12
+
13
+
14
+ @dataclass
15
+ class WatchCursor:
16
+ event_offset: int = 0
17
+ seen_result_ids: set[str] = field(default_factory=set)
18
+ initialized: bool = False
19
+ archive_signature: tuple[int, int] | None = None
20
+
21
+
22
+ ROTATION_MARKER = "[watch] log rotated; archived segment events.jsonl.1 not replayed — historical replay deferred to a future --replay flag"
23
+
24
+
25
+ def run_watch(
26
+ workspace: Path,
27
+ *,
28
+ team: str | None = None,
29
+ interval: float = 0.5,
30
+ output: Callable[[str], None] = print,
31
+ sleep: Callable[[float], None] = time.sleep,
32
+ ) -> None:
33
+ cursor = WatchCursor()
34
+ while True:
35
+ for line in collect_watch_lines(workspace, cursor, team=team):
36
+ output(line)
37
+ sleep(interval)
38
+
39
+
40
+ def collect_watch_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
41
+ lines = _collect_event_lines(workspace, cursor, team=team)
42
+ lines.extend(_collect_result_lines(workspace, cursor, team=team))
43
+ return lines
44
+
45
+
46
+ def render_event_line(event: dict[str, Any]) -> str | None:
47
+ kind = event.get("event")
48
+ if kind == "result_received":
49
+ return _result_line(event.get("agent_id"), event.get("summary"))
50
+ if kind in {"leader_receiver.injected", "leader_receiver.submitted"}:
51
+ return f"leader_receiver.injected: {_message_snippet(event)} -> {_recipient(event)}"
52
+ if kind == "send.failed":
53
+ return f"send.failed: {_recipient(event)} reason={_clean(event.get('reason') or event.get('error') or '-')}"
54
+ if kind == "leader_receiver.rebind_required":
55
+ pane = event.get("old_pane_id") or event.get("pane_id") or event.get("target") or "-"
56
+ reason = event.get("reason") or event.get("rediscovery_status") or "-"
57
+ return f"leader_receiver.rebind_required: pane={pane} reason={_clean(reason)}"
58
+ if kind == "leader.api_error":
59
+ error_class = event.get("error_class") or "Unknown"
60
+ provider = event.get("provider") or "-"
61
+ snippet = _clean(event.get("matched_pattern_snippet") or event.get("snippet") or "-")
62
+ return f"leader.api_error: {error_class} provider={provider} snippet={snippet}"
63
+ return None
64
+
65
+
66
+ def _collect_event_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
67
+ path = logs_dir(workspace) / "events.jsonl"
68
+ archive_signature = _archive_signature(path.with_name("events.jsonl.1"))
69
+ lines: list[str] = []
70
+ if not cursor.initialized:
71
+ cursor.archive_signature = archive_signature
72
+ cursor.initialized = True
73
+ elif archive_signature and archive_signature != cursor.archive_signature:
74
+ lines.append(ROTATION_MARKER)
75
+ cursor.archive_signature = archive_signature
76
+ cursor.event_offset = 0
77
+ if not path.exists():
78
+ return lines
79
+ size = path.stat().st_size
80
+ if cursor.event_offset > size:
81
+ if ROTATION_MARKER not in lines:
82
+ lines.append(ROTATION_MARKER)
83
+ cursor.event_offset = 0
84
+ with path.open("r", encoding="utf-8") as handle:
85
+ handle.seek(cursor.event_offset)
86
+ for raw in handle:
87
+ try:
88
+ event = json.loads(raw)
89
+ except json.JSONDecodeError:
90
+ continue
91
+ if team and _event_team_id(event) != team:
92
+ continue
93
+ rendered = render_event_line(event)
94
+ if rendered:
95
+ lines.append(rendered)
96
+ cursor.event_offset = handle.tell()
97
+ return lines
98
+
99
+
100
+ def _collect_result_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
101
+ if not (runtime_dir(workspace) / "team.db").exists():
102
+ return []
103
+ store = MessageStore(workspace)
104
+ lines: list[str] = []
105
+ for row in store.latest_results(limit=20, owner_team_id=team):
106
+ result_id = str(row.get("result_id") or "")
107
+ if not result_id or result_id in cursor.seen_result_ids:
108
+ continue
109
+ cursor.seen_result_ids.add(result_id)
110
+ summary = result_summary_from_row(row) or {}
111
+ lines.append(_result_line(summary.get("agent_id"), summary.get("summary")))
112
+ return lines
113
+
114
+
115
+ def _result_line(agent_id: Any, summary: Any) -> str:
116
+ return f"result_received: {agent_id or '-'} -> {_clean(summary or '-')[:80]}"
117
+
118
+
119
+ def _message_snippet(event: dict[str, Any]) -> str:
120
+ message_id = str(event.get("message_id") or event.get("msg_id") or "-")
121
+ return message_id[:12] if message_id != "-" else "-"
122
+
123
+
124
+ def _recipient(event: dict[str, Any]) -> str:
125
+ return str(event.get("recipient") or event.get("to") or event.get("target") or "-")
126
+
127
+
128
+ def _clean(value: Any) -> str:
129
+ return " ".join(str(value).split())
130
+
131
+
132
+ def _event_team_id(event: dict[str, Any]) -> str | None:
133
+ value = event.get("team_id") or event.get("owner_team_id") or event.get("team")
134
+ return str(value) if value else None
135
+
136
+
137
+ def _archive_signature(path: Path) -> tuple[int, int] | None:
138
+ try:
139
+ stat = path.stat()
140
+ except FileNotFoundError:
141
+ return None
142
+ return (stat.st_size, stat.st_mtime_ns)
143
+
144
+
145
+ __all__ = ["ROTATION_MARKER", "WatchCursor", "collect_watch_lines", "render_event_line", "run_watch"]