@team-agent/installer 0.2.2 → 0.2.4

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 (49) hide show
  1. package/package.json +1 -1
  2. package/schemas/team.schema.json +6 -0
  3. package/src/team_agent/abnormal_track.py +253 -0
  4. package/src/team_agent/approvals/runtime_prompts.py +1 -1
  5. package/src/team_agent/cli/commands.py +104 -3
  6. package/src/team_agent/cli/parser.py +10 -1
  7. package/src/team_agent/compiler.py +1 -1
  8. package/src/team_agent/coordinator/lifecycle.py +23 -2
  9. package/src/team_agent/diagnose/orphan_cleanup.py +199 -28
  10. package/src/team_agent/display/__init__.py +31 -0
  11. package/src/team_agent/display/adaptive.py +425 -0
  12. package/src/team_agent/display/backend.py +46 -0
  13. package/src/team_agent/display/close.py +6 -0
  14. package/src/team_agent/display/rebuild.py +102 -0
  15. package/src/team_agent/display/tiling.py +156 -0
  16. package/src/team_agent/display/worker_window.py +4 -0
  17. package/src/team_agent/display/workspace.py +36 -127
  18. package/src/team_agent/idle_predicate.py +200 -0
  19. package/src/team_agent/idle_takeover.py +59 -0
  20. package/src/team_agent/idle_takeover_wiring.py +111 -0
  21. package/src/team_agent/launch/core.py +14 -4
  22. package/src/team_agent/leader/__init__.py +444 -61
  23. package/src/team_agent/lifecycle/operations.py +1 -0
  24. package/src/team_agent/lifecycle/start.py +1 -1
  25. package/src/team_agent/message_store/core.py +38 -11
  26. package/src/team_agent/message_store/leader_notification_log.py +47 -26
  27. package/src/team_agent/message_store/schema.py +8 -2
  28. package/src/team_agent/messaging/delivery.py +336 -1
  29. package/src/team_agent/messaging/leader.py +13 -4
  30. package/src/team_agent/messaging/leader_api_errors.py +216 -0
  31. package/src/team_agent/messaging/leader_panes.py +294 -0
  32. package/src/team_agent/messaging/scheduler.py +12 -0
  33. package/src/team_agent/messaging/send.py +54 -26
  34. package/src/team_agent/messaging/tmux_io.py +202 -33
  35. package/src/team_agent/messaging/tmux_prompt.py +87 -0
  36. package/src/team_agent/messaging/trust_auto_answer.py +52 -0
  37. package/src/team_agent/provider_state/README.md +78 -0
  38. package/src/team_agent/provider_state/__init__.py +86 -0
  39. package/src/team_agent/provider_state/claude.py +86 -0
  40. package/src/team_agent/provider_state/codex.py +84 -0
  41. package/src/team_agent/provider_state/common.py +207 -0
  42. package/src/team_agent/provider_state/registry.py +118 -0
  43. package/src/team_agent/restart/orchestration.py +215 -12
  44. package/src/team_agent/runtime.py +65 -15
  45. package/src/team_agent/sessions/capture.py +65 -15
  46. package/src/team_agent/spec.py +63 -3
  47. package/src/team_agent/status/queries.py +32 -1
  48. package/src/team_agent/wake.py +58 -0
  49. package/src/team_agent/watch/__init__.py +145 -0
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from typing import Any
5
5
 
6
6
  from team_agent.errors import ValidationError
7
+ from team_agent.display.backend import VALID_DISPLAY_BACKENDS
7
8
  from team_agent.permissions import CANONICAL_TOOLS, expand_tools
8
9
  from team_agent.profiles import AUTH_MODES
9
10
  from team_agent.simple_yaml import loads
@@ -27,9 +28,60 @@ def load_yaml(path: Path) -> dict[str, Any]:
27
28
  def load_spec(path: Path) -> dict[str, Any]:
28
29
  spec = load_yaml(path)
29
30
  validate_spec(spec, base_dir=path.parent)
31
+ _emit_load_time_deprecations(spec, path)
30
32
  return spec
31
33
 
32
34
 
35
+ def _emit_load_time_deprecations(spec: dict[str, Any], path: Path) -> None:
36
+ """Stage 7 S7 (2026-05-27): deprecation signals attached to the spec field
37
+ itself must fire when the YAML is read, not lazily inside the trust-prompt
38
+ code path. A user with the deprecated field in team.spec.yaml needs to see
39
+ the warning even when startup never reaches attempt_trust_auto_answer.
40
+
41
+ The leader-panes helper owns the one-shot stderr guard + the structured
42
+ audit event, so we reuse it. EventLog points at the WORKSPACE ROOT (not
43
+ the spec file's directory) so a quick-start layout that stores the spec
44
+ under <workspace>/.team/current/team.spec.yaml still routes the audit
45
+ event into the single canonical <workspace>/.team/logs/events.jsonl
46
+ instead of a doubled <workspace>/.team/current/.team/logs/events.jsonl
47
+ nesting.
48
+ """
49
+ runtime = spec.get("runtime")
50
+ if not isinstance(runtime, dict):
51
+ return
52
+ if not bool(runtime.get("auto_trust_own_workspace")):
53
+ return
54
+ # Local import keeps the spec module free of messaging-layer coupling at
55
+ # import time; only YAMLs that opt into the deprecated field pay the cost.
56
+ from team_agent.events import EventLog
57
+ from team_agent.messaging.leader_panes import _emit_spec_opt_in_deprecation
58
+ _emit_spec_opt_in_deprecation(EventLog(_resolve_workspace_root(path)))
59
+
60
+
61
+ def _resolve_workspace_root(spec_path: Path) -> Path:
62
+ """Find the workspace root that owns this spec.
63
+
64
+ A workspace root is the directory whose `.team/` subdirectory holds the
65
+ runtime state, logs, artifacts, and (for quick-start layouts) the spec
66
+ itself under `.team/current/`. We climb from the spec file's parent
67
+ looking for the first ancestor that has a `.team/` child. If no ancestor
68
+ qualifies (fresh workspace before init, or a spec deliberately placed
69
+ outside any team workspace), we fall back to `spec_path.parent` which is
70
+ the legacy single-layout behaviour.
71
+
72
+ Implementation note: we use real filesystem evidence (`(dir/.team).is_dir()`)
73
+ rather than path-string parsing so the resolver works correctly even when
74
+ workspace paths legitimately contain a `.team` segment.
75
+ """
76
+ direct_parent = spec_path.parent
77
+ if (direct_parent / ".team").is_dir():
78
+ return direct_parent
79
+ for ancestor in direct_parent.parents:
80
+ if (ancestor / ".team").is_dir():
81
+ return ancestor
82
+ return direct_parent
83
+
84
+
33
85
  def validate_spec(spec: dict[str, Any], base_dir: Path | None = None) -> None:
34
86
  messages = _basic_schema_errors(spec)
35
87
  messages.extend(_semantic_errors(spec, base_dir or Path.cwd()))
@@ -182,24 +234,32 @@ def _check_communication(comm: Any, errors: list[str]) -> None:
182
234
 
183
235
 
184
236
  def _check_runtime(runtime: Any, errors: list[str]) -> None:
185
- required = {"backend", "display_backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
186
- allowed = required | {
237
+ required = {"backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
238
+ allowed = required | {"display_backend"} | {
187
239
  "dangerous_auto_approve",
188
240
  "auto_attach_leader",
189
241
  "fast",
190
242
  "tick_interval_sec",
191
243
  "push_min_interval_sec",
192
244
  "stuck_timeout_sec",
245
+ # Gap 29 / F3 deprecation (2026-05-26): accept the legacy spec opt-in so
246
+ # YAMLs that still set it validate and the deprecation warning + structured
247
+ # event in messaging/leader_panes.py can fire. The preferred per-session
248
+ # opt-in is the env var TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE; this spec
249
+ # field will be removed in 0.3.0.
250
+ "auto_trust_own_workspace",
193
251
  }
194
252
  _check_keys(runtime, "/runtime", required, allowed, errors)
195
253
  if not isinstance(runtime, dict):
196
254
  return
197
255
  if runtime.get("backend") not in {"tmux", "pty"}:
198
256
  errors.append("/runtime/backend: invalid backend")
199
- if runtime.get("display_backend") not in {"none", "tmux_attach", "iterm", "ghostty", "ghostty_window", "ghostty_workspace"}:
257
+ if "display_backend" in runtime and runtime.get("display_backend") not in VALID_DISPLAY_BACKENDS:
200
258
  errors.append("/runtime/display_backend: invalid display backend")
201
259
  if "dangerous_auto_approve" in runtime and not isinstance(runtime["dangerous_auto_approve"], bool):
202
260
  errors.append("/runtime/dangerous_auto_approve: must be a boolean")
261
+ if "auto_trust_own_workspace" in runtime and not isinstance(runtime["auto_trust_own_workspace"], bool):
262
+ errors.append("/runtime/auto_trust_own_workspace: must be a boolean")
203
263
  _check_list(runtime.get("startup_order"), "/runtime/startup_order", errors)
204
264
 
205
265
 
@@ -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,58 @@
1
+ """Provider-neutral wake layer (Gap 32 §2): decide WHEN to re-read a session
2
+ file, never HOW to parse it. No polling loop, no screen, no provider names.
3
+
4
+ Two cheap, passive sources:
5
+ - a file-change watch (the caller wires FSEvents/inotify/kqueue and calls
6
+ ``on_file_changed``) — naturally per-session (one file per node);
7
+ - an mtime gate — only re-read the tail when the file changed since the last
8
+ classification, or when it has been quiet past the debounce window.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+
16
+ def should_reread(
17
+ *,
18
+ last_mtime: float | None,
19
+ current_mtime: float | None,
20
+ last_classified_mtime: float | None,
21
+ now: float,
22
+ debounce_seconds: float,
23
+ ) -> dict[str, Any]:
24
+ """Return whether a fresh tail-read is warranted and why.
25
+
26
+ A read is warranted when the file changed since we last classified it, or
27
+ when it has been silent longer than the debounce window and we have not yet
28
+ classified at this mtime (so an idle-close that we already saw is not
29
+ re-read forever).
30
+ """
31
+ if current_mtime is None:
32
+ return {"reread": False, "reason": "no_file"}
33
+ if last_classified_mtime is None:
34
+ return {"reread": True, "reason": "never_classified"}
35
+ if current_mtime != last_classified_mtime:
36
+ return {"reread": True, "reason": "file_changed"}
37
+ silent_for = max(0.0, now - current_mtime)
38
+ if silent_for >= debounce_seconds:
39
+ return {"reread": False, "reason": "quiescent_already_classified"}
40
+ return {"reread": False, "reason": "unchanged"}
41
+
42
+
43
+ def on_file_changed(watch_state: dict[str, Any] | None, *, node_id: str, mtime: float) -> dict[str, Any]:
44
+ """Record a file-change wake for a node (the push path)."""
45
+ state = dict(watch_state or {})
46
+ pending = set(state.get("pending") or [])
47
+ pending.add(node_id)
48
+ state["pending"] = sorted(pending)
49
+ state.setdefault("mtimes", {})[node_id] = mtime
50
+ return state
51
+
52
+
53
+ def take_pending(watch_state: dict[str, Any] | None) -> tuple[list[str], dict[str, Any]]:
54
+ """Drain the set of nodes whose files changed since the last drain."""
55
+ state = dict(watch_state or {})
56
+ pending = sorted(state.get("pending") or [])
57
+ state["pending"] = []
58
+ return pending, state
@@ -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"]