@team-agent/installer 0.2.3 → 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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/team_agent/abnormal_track.py +253 -0
  3. package/src/team_agent/compiler.py +1 -1
  4. package/src/team_agent/coordinator/lifecycle.py +20 -2
  5. package/src/team_agent/display/__init__.py +31 -0
  6. package/src/team_agent/display/adaptive.py +425 -0
  7. package/src/team_agent/display/backend.py +46 -0
  8. package/src/team_agent/display/close.py +6 -0
  9. package/src/team_agent/display/rebuild.py +102 -0
  10. package/src/team_agent/display/tiling.py +156 -0
  11. package/src/team_agent/display/worker_window.py +4 -0
  12. package/src/team_agent/display/workspace.py +36 -127
  13. package/src/team_agent/idle_predicate.py +200 -0
  14. package/src/team_agent/idle_takeover.py +59 -0
  15. package/src/team_agent/idle_takeover_wiring.py +111 -0
  16. package/src/team_agent/launch/core.py +13 -4
  17. package/src/team_agent/leader/__init__.py +444 -61
  18. package/src/team_agent/message_store/core.py +30 -4
  19. package/src/team_agent/message_store/leader_notification_log.py +47 -26
  20. package/src/team_agent/messaging/delivery.py +45 -2
  21. package/src/team_agent/messaging/leader_panes.py +115 -21
  22. package/src/team_agent/messaging/send.py +33 -0
  23. package/src/team_agent/messaging/tmux_io.py +49 -10
  24. package/src/team_agent/messaging/trust_auto_answer.py +11 -3
  25. package/src/team_agent/provider_state/README.md +78 -0
  26. package/src/team_agent/provider_state/__init__.py +86 -0
  27. package/src/team_agent/provider_state/claude.py +86 -0
  28. package/src/team_agent/provider_state/codex.py +84 -0
  29. package/src/team_agent/provider_state/common.py +207 -0
  30. package/src/team_agent/provider_state/registry.py +118 -0
  31. package/src/team_agent/restart/orchestration.py +9 -9
  32. package/src/team_agent/runtime.py +62 -12
  33. package/src/team_agent/spec.py +4 -3
  34. package/src/team_agent/wake.py +58 -0
@@ -11,9 +11,20 @@ from __future__ import annotations
11
11
 
12
12
  from contextlib import closing
13
13
  from datetime import datetime, timedelta, timezone
14
+ import sqlite3
15
+ import time
14
16
  from typing import Any
15
17
 
16
18
 
19
+ def _sqlite_locked(exc: sqlite3.OperationalError) -> bool:
20
+ message = str(exc).lower()
21
+ return (
22
+ "database is locked" in message
23
+ or "database table is locked" in message
24
+ or "database schema is locked" in message
25
+ )
26
+
27
+
17
28
  def claim_leader_notification_delivery(
18
29
  store: Any,
19
30
  *,
@@ -28,32 +39,42 @@ def claim_leader_notification_delivery(
28
39
  rowcount=0 means a prior row exists for (result_id, leader_session_uuid); SELECT
29
40
  it and return so the caller can decide to suppress (same envelope_hash) or surface
30
41
  legitimate-duplicate (different envelope_hash)."""
31
- now = datetime.now(timezone.utc).isoformat()
32
- with closing(store.connect()) as conn:
33
- with conn:
34
- cur = conn.execute(
35
- "insert or ignore into leader_notification_log("
36
- " result_id, leader_session_uuid, notified_message_id, notified_at,"
37
- " leader_pane_id_at_notify, envelope_content_hash, owner_team_id"
38
- ") values (?, ?, ?, ?, ?, ?, ?)",
39
- (
40
- result_id, leader_session_uuid, proposed_message_id, now,
41
- pane_id, envelope_hash, owner_team_id,
42
- ),
43
- )
44
- if cur.rowcount == 1:
45
- return {
46
- "status": "claimed_by_you",
47
- "notified_message_id": proposed_message_id,
48
- "notified_at": now,
49
- "envelope_content_hash": envelope_hash,
50
- }
51
- row = conn.execute(
52
- "select notified_message_id, notified_at, envelope_content_hash, "
53
- "leader_pane_id_at_notify from leader_notification_log "
54
- "where result_id = ? and leader_session_uuid = ?",
55
- (result_id, leader_session_uuid),
56
- ).fetchone()
42
+ delay = 0.05
43
+ row = None
44
+ for attempt in range(6):
45
+ now = datetime.now(timezone.utc).isoformat()
46
+ try:
47
+ with closing(store.connect()) as conn:
48
+ with conn:
49
+ cur = conn.execute(
50
+ "insert or ignore into leader_notification_log("
51
+ " result_id, leader_session_uuid, notified_message_id, notified_at,"
52
+ " leader_pane_id_at_notify, envelope_content_hash, owner_team_id"
53
+ ") values (?, ?, ?, ?, ?, ?, ?)",
54
+ (
55
+ result_id, leader_session_uuid, proposed_message_id, now,
56
+ pane_id, envelope_hash, owner_team_id,
57
+ ),
58
+ )
59
+ if cur.rowcount == 1:
60
+ return {
61
+ "status": "claimed_by_you",
62
+ "notified_message_id": proposed_message_id,
63
+ "notified_at": now,
64
+ "envelope_content_hash": envelope_hash,
65
+ }
66
+ row = conn.execute(
67
+ "select notified_message_id, notified_at, envelope_content_hash, "
68
+ "leader_pane_id_at_notify from leader_notification_log "
69
+ "where result_id = ? and leader_session_uuid = ?",
70
+ (result_id, leader_session_uuid),
71
+ ).fetchone()
72
+ break
73
+ except sqlite3.OperationalError as exc:
74
+ if not _sqlite_locked(exc) or attempt == 5:
75
+ raise
76
+ time.sleep(delay)
77
+ delay *= 2
57
78
  if row is None:
58
79
  # Should not happen (INSERT OR IGNORE returned 0 → row must exist), but be defensive.
59
80
  return {"status": "claimed_by_you", "notified_message_id": proposed_message_id,
@@ -15,6 +15,40 @@ from pathlib import Path
15
15
  from typing import Any
16
16
 
17
17
 
18
+ def _tmux_pane_width(target: str) -> dict[str, Any]:
19
+ """Query the tmux pane width (display columns) for ``target``.
20
+
21
+ Live wiring seam for the trust-prompt truncation matcher: returns
22
+ ``{"ok": True, "pane_width": <int>}`` on success or
23
+ ``{"ok": False, "error": "..."}`` on any failure / timeout / unparseable
24
+ output. Fail-safe by design: NEVER returns a default width. Callers must
25
+ treat failure as "no boundary signal" and let the workspace matcher fall
26
+ back to exact equality, so a hard-truncated prompt is never auto-answered
27
+ on guesswork.
28
+ """
29
+ from team_agent.messaging.deps import run_cmd
30
+ try:
31
+ proc = run_cmd(
32
+ ["tmux", "display-message", "-p", "-t", str(target), "-F", "#{pane_width}"],
33
+ timeout=2,
34
+ )
35
+ except Exception as exc: # pragma: no cover - defensive; tmux not present, timeout, etc.
36
+ return {"ok": False, "error": f"tmux_query_failed:{exc.__class__.__name__}"}
37
+ if getattr(proc, "returncode", 1) != 0:
38
+ err = (getattr(proc, "stderr", "") or "").strip().splitlines()
39
+ return {"ok": False, "error": err[0] if err else "tmux_query_nonzero"}
40
+ text = (getattr(proc, "stdout", "") or "").strip()
41
+ if not text:
42
+ return {"ok": False, "error": "empty_output"}
43
+ try:
44
+ width = int(text.splitlines()[0].strip())
45
+ except (ValueError, IndexError):
46
+ return {"ok": False, "error": "unparseable_output"}
47
+ if width <= 0:
48
+ return {"ok": False, "error": "non_positive_width"}
49
+ return {"ok": True, "pane_width": width}
50
+
51
+
18
52
  # Spark MEDIUM sweep #3 (2026-05-26): retry_needed bounded backoff. Each entry is
19
53
  # the delay (seconds) BEFORE the attempt with that number runs; attempt 1 was the
20
54
  # original delivery, attempt 2 fires 5s after retry_needed, attempt 3 fires 15s
@@ -85,12 +119,21 @@ def _deliver_pending_message(
85
119
  # Bypassed entirely when opt-out (default) — the existing failed envelope
86
120
  # is preserved.
87
121
  from team_agent.messaging.leader_panes import attempt_trust_auto_answer
122
+ pane_target = injection.get("pane_id") or target
123
+ # Live wiring: query the tmux pane width now and hand it to the trust
124
+ # matcher via state["pane_width"]. On failure we leave pane_width
125
+ # absent so the matcher falls back to exact equality (fail-safe — a
126
+ # right-edge truncated prefix is never auto-answered on guesswork).
127
+ width_query = _tmux_pane_width(pane_target)
128
+ trust_state = dict(state) if isinstance(state, dict) else {}
129
+ if width_query.get("ok"):
130
+ trust_state["pane_width"] = width_query["pane_width"]
88
131
  answer = attempt_trust_auto_answer(
89
132
  workspace,
90
- injection.get("pane_id") or target,
133
+ pane_target,
91
134
  injection.get("pane_capture_tail") or "",
92
135
  EventLog(workspace),
93
- state=state,
136
+ state=trust_state,
94
137
  )
95
138
  if answer.get("answered"):
96
139
  # Spark MEDIUM #4 (2026-05-26): replace the fixed 0.3s sleep with a
@@ -392,6 +392,9 @@ def _broadcast_ambiguous_candidates(
392
392
  team_id=team_id,
393
393
  uuid_prefix=_uuid_prefix(owner_identity),
394
394
  debounce_bucket=bucket,
395
+ # C16/C22: two or more live candidates remain; each must explicitly claim
396
+ # with --confirm, so the broadcast carries the closed-enum lease reason.
397
+ reason="force_confirm_required",
395
398
  )
396
399
  for candidate in candidates:
397
400
  pane_id = str(candidate.get("pane_id") or "")
@@ -560,7 +563,8 @@ def attempt_trust_auto_answer(
560
563
  reason="pane_id_missing",
561
564
  )
562
565
  return {"ok": False, "answered": False, "reason": "pane_id_missing"}
563
- if not _capture_tail_references_workspace(pane_capture_tail, workspace):
566
+ pane_width = state.get("pane_width") if isinstance(state, dict) else None
567
+ if not _capture_tail_references_workspace(pane_capture_tail, workspace, pane_width):
564
568
  event_log.write(
565
569
  "leader_panes.trust_auto_answer_refused",
566
570
  pane_id=pane_id,
@@ -568,9 +572,15 @@ def attempt_trust_auto_answer(
568
572
  reason="workspace_dir_mismatch",
569
573
  )
570
574
  return {"ok": False, "answered": False, "reason": "workspace_dir_mismatch"}
575
+ # Round-5 (post Round-1..4 withdrawal): Codex's trust prompt already
576
+ # highlights `1. Yes, continue` as the default choice; a plain Enter
577
+ # accepts it. Sending the digit `1` first creates a stray `1` keystroke
578
+ # buffered as input once Codex hooks up its keyboard handler, which
579
+ # later becomes a real user turn that competes with the brief paste.
580
+ # Drop the digit; submit Enter only.
571
581
  answer = _tmux_inject_text(
572
582
  str(pane_id),
573
- "1",
583
+ "",
574
584
  "Enter",
575
585
  f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
576
586
  attempts=1,
@@ -653,44 +663,128 @@ def _reset_spec_opt_in_deprecation_state() -> None:
653
663
  _SPEC_OPT_IN_DEPRECATION_WARNED = False
654
664
 
655
665
 
656
- def _capture_tail_references_workspace(tail: str, workspace: Path) -> bool:
657
- """Spark MEDIUM #5: a raw substring match accepted '/repo' inside
658
- '/repo-backup' and rejected symlinked / trailing-slash spellings. We now
659
- canonicalize the workspace via Path.resolve, parse candidate absolute paths
660
- out of the prompt tail (one token per line after stripping codex box-drawing
661
- glyphs), canonicalize each candidate the same way, and only return True on
662
- boundary-safe canonical equality."""
666
+ def _capture_tail_references_workspace(tail: str, workspace: Path, pane_width: int | None = None) -> bool:
667
+ """Decide whether the Codex trust-prompt tail names the worker's own
668
+ workspace cwd. The runtime cwd is the source of truth; the prompt path is a
669
+ consistency guard. Match cases (one converged helper per token):
670
+
671
+ - exact canonical equality (the unchanged baseline);
672
+ - mid-ellipsis ``head…tail`` / ``head...tail`` where head is a prefix of
673
+ the runtime cwd and tail is its suffix;
674
+ - hard right-edge truncation: the canonical runtime cwd starts with the
675
+ canonical captured path AND the captured token reaches the capture
676
+ line's right boundary (pane_width).
677
+
678
+ Without a pane_width signal, prefix matching is forbidden — the captured
679
+ path is treated as a complete token and must exactly equal the runtime cwd
680
+ (this is what stops ``/repo`` from sliding into ``/repo-backup``).
681
+ """
663
682
  if not tail:
664
683
  return False
665
684
  workspace_canonical = _canonicalize_path(workspace)
666
685
  if not workspace_canonical:
667
686
  return False
668
- for candidate in _candidate_paths_from_prompt(tail):
669
- if _canonicalize_path(Path(candidate)) == workspace_canonical:
687
+ for token, source_line in _candidate_path_lines_from_prompt(tail):
688
+ if _workspace_matches_token(workspace_canonical, token, source_line, pane_width):
670
689
  return True
671
690
  return False
672
691
 
673
692
 
674
- _PATH_LINE_RE = re.compile(r"(/[\w\-./~+@]+)")
693
+ _PATH_LINE_RE = re.compile(r"(/[\w\-./~+@…]+)")
694
+ _ELLIPSIS_TOKENS = ("…", "...")
675
695
 
676
696
 
677
- def _candidate_paths_from_prompt(tail: str) -> list[str]:
678
- """Pull every absolute-path-shaped token out of the prompt's tail. Codex
679
- renders the trust prompt's directory inside box-drawing glyphs and on its
680
- own line; strip leading/trailing whitespace and glyph noise before matching."""
681
- paths: list[str] = []
697
+ def _candidate_path_lines_from_prompt(tail: str) -> list[tuple[str, str]]:
698
+ """Pull (path_token, source_line) pairs out of the prompt's tail. The
699
+ source line is the line AFTER stripping Codex box-drawing glyphs, so the
700
+ matcher can locate the token's end column relative to the visible width."""
701
+ pairs: list[tuple[str, str]] = []
702
+ seen: set[tuple[str, str]] = set()
682
703
  for raw_line in tail.splitlines():
683
704
  line = raw_line.strip()
684
- # Codex draws box-glyph prefixes (▌ ▎ │) that need to be stripped.
685
705
  for glyph in ("▌", "▎", "│"):
686
706
  line = line.lstrip(glyph).strip()
687
707
  if not line:
688
708
  continue
689
709
  for match in _PATH_LINE_RE.finditer(line):
690
710
  token = match.group(1).rstrip("/")
691
- if token and token not in paths:
692
- paths.append(token)
693
- return paths
711
+ if not token:
712
+ continue
713
+ key = (token, line)
714
+ if key in seen:
715
+ continue
716
+ seen.add(key)
717
+ pairs.append(key)
718
+ return pairs
719
+
720
+
721
+ def _candidate_paths_from_prompt(tail: str) -> list[str]:
722
+ """Backwards-compatible token-only view (kept for any external callers)."""
723
+ out: list[str] = []
724
+ for token, _line in _candidate_path_lines_from_prompt(tail):
725
+ if token not in out:
726
+ out.append(token)
727
+ return out
728
+
729
+
730
+ def _workspace_matches_token(
731
+ workspace_canonical: str,
732
+ token: str,
733
+ source_line: str,
734
+ pane_width: int | None,
735
+ ) -> bool:
736
+ """The converged trust-prompt match logic.
737
+
738
+ Order matters:
739
+ 1. exact canonical equality;
740
+ 2. mid-ellipsis head/tail match;
741
+ 3. right-edge hard truncation (prefix + boundary-reached).
742
+ A captured token that does NOT reach the line's right boundary is treated
743
+ as a complete short path and must equal the runtime cwd exactly.
744
+ """
745
+ # 1. Exact canonical equality.
746
+ captured_canonical = _canonicalize_path(Path(token))
747
+ if not captured_canonical:
748
+ return False
749
+ if captured_canonical == workspace_canonical:
750
+ return True
751
+ # 2. Mid-ellipsis: split on … or ..., require head ⊑ workspace and workspace ⊐ tail.
752
+ for ellipsis in _ELLIPSIS_TOKENS:
753
+ if ellipsis in token:
754
+ head, _, tail_part = token.partition(ellipsis)
755
+ head_canonical = _canonicalize_path(Path(head)) if head.startswith("/") else head
756
+ if not head_canonical or not tail_part:
757
+ return False
758
+ return (
759
+ workspace_canonical.startswith(head_canonical)
760
+ and workspace_canonical.endswith(tail_part)
761
+ )
762
+ # 3. Right-edge hard truncation: prefix + boundary.
763
+ if not _token_reaches_right_edge(token, source_line, pane_width):
764
+ # No boundary signal → captured must be a complete token; exact already
765
+ # failed → mismatch (this rejects /repo vs /repo-backup both ways).
766
+ return False
767
+ return (
768
+ workspace_canonical == captured_canonical
769
+ or workspace_canonical.startswith(captured_canonical + "/")
770
+ or workspace_canonical.startswith(captured_canonical)
771
+ )
772
+
773
+
774
+ def _token_reaches_right_edge(token: str, source_line: str, pane_width: int | None) -> bool:
775
+ """The token reaches the capture line's right boundary iff the line is wide
776
+ enough to be at pane capacity AND the token sits flush against the line's
777
+ end. Without a pane_width we cannot prove truncation — return False so the
778
+ caller falls back to exact-equality (this is the C/repo vs C/repo-backup
779
+ safeguard)."""
780
+ if not pane_width or pane_width <= 0:
781
+ return False
782
+ rstripped = source_line.rstrip()
783
+ if not rstripped.endswith(token):
784
+ return False
785
+ # Allow a one-column tolerance for trailing whitespace stripped from the
786
+ # raw capture; the line must be at pane capacity to count as hard-cut.
787
+ return len(rstripped) >= max(1, pane_width - 1)
694
788
 
695
789
 
696
790
  def _canonicalize_path(p: Path | str) -> str:
@@ -77,6 +77,7 @@ def _send_message_unlocked(
77
77
  return gate
78
78
  owner_team_id = team_state_key(state)
79
79
  leader_id = _leader_id(state, spec)
80
+ _flag_rebind_required_when_unbound_plain_shell_leader(workspace, state, spec, sender, leader_id, event_log)
80
81
 
81
82
  if isinstance(target, list):
82
83
  if watch_result:
@@ -134,6 +135,38 @@ def _send_message_unlocked(
134
135
  )
135
136
 
136
137
 
138
+ def _flag_rebind_required_when_unbound_plain_shell_leader(
139
+ workspace: Path,
140
+ state: dict[str, Any],
141
+ spec: dict[str, Any],
142
+ sender: str,
143
+ leader_id: str,
144
+ event_log: EventLog,
145
+ ) -> None:
146
+ # Gap 39 C5: a leader send from a plain shell (no $TMUX_PANE) must never self-bind
147
+ # the caller as the leader receiver. When the lease is fully unbound, flag a
148
+ # rebind_required so the message stays queued and the operator knows to reconnect
149
+ # from a real tmux leader pane. Only fires for an unbound lease + no caller pane.
150
+ import os
151
+ from team_agent.messaging.deps import _leader_receiver_is_direct
152
+ if not _is_leader_sender(sender, leader_id):
153
+ return
154
+ if isinstance(state.get("team_owner"), dict) and state["team_owner"].get("pane_id"):
155
+ return
156
+ if _leader_receiver_is_direct(state.get("leader_receiver")):
157
+ return
158
+ if os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE"):
159
+ return
160
+ event_log.write(
161
+ "leader_receiver.rebind_required",
162
+ reason="not_in_tmux_pane",
163
+ old_pane_id=(state.get("leader_receiver") or {}).get("pane_id"),
164
+ new_pane_id=None,
165
+ team_id=team_state_key(state),
166
+ recovery_action="run team-agent claim-leader --confirm from the leader's tmux pane",
167
+ )
168
+
169
+
137
170
  def _send_single_message_unlocked(
138
171
  workspace: Path,
139
172
  state: dict[str, Any],
@@ -32,6 +32,49 @@ def _tmux_inject_text(
32
32
  *,
33
33
  bypass_non_input_gate: bool = False,
34
34
  ) -> dict[str, Any]:
35
+ # Round-5 follow-up: empty-text Enter path (used by trust auto-answer to
36
+ # accept Codex's default `1. Yes, continue` choice with a plain Enter).
37
+ # tmux rejects set-buffer / paste-buffer of an empty string, so the
38
+ # buffer-paste route would leave the trust prompt stuck. Issue
39
+ # `send-keys -t <target> <submit_key>` directly and bypass the buffer
40
+ # path entirely.
41
+ if text == "":
42
+ proc = run_cmd(["tmux", "send-keys", "-t", target, submit_key], timeout=10)
43
+ if proc.returncode != 0:
44
+ return {
45
+ "ok": False,
46
+ "stage": "send-keys",
47
+ "error": proc.stderr.strip() or "tmux send-keys failed",
48
+ "attempts": [
49
+ {
50
+ "attempt": 1,
51
+ "submitted": False,
52
+ "verification": "send_keys_failed",
53
+ "submit_key": submit_key,
54
+ }
55
+ ],
56
+ "verification": "send_keys_failed",
57
+ }
58
+ return {
59
+ "ok": True,
60
+ "stage": "submitted",
61
+ "visible": True,
62
+ "submitted": True,
63
+ "verification": "empty_text_send_keys",
64
+ "submit_verification": f"{submit_key}_sent_direct",
65
+ "turn_verification": "not_required",
66
+ "attempts": [
67
+ {
68
+ "attempt": 1,
69
+ "submitted": True,
70
+ "verification": "empty_text_send_keys",
71
+ "submit_key": submit_key,
72
+ }
73
+ ],
74
+ "submit_attempts": [
75
+ {"attempt": 1, "submitted": True, "verification": "send_keys"}
76
+ ],
77
+ }
35
78
  token_match = re.search(r"\[team-agent-token:([^\]]+)\]", text)
36
79
  token = token_match.group(1) if token_match else ""
37
80
  attempt_log: list[dict[str, Any]] = []
@@ -134,6 +177,11 @@ def _tmux_inject_text(
134
177
  "submit_attempts": submit.get("attempts"),
135
178
  }
136
179
  submit_verification = _leader_submit_verification(submit.get("verification"), verification, submit_key)
180
+ # Gap 42: paste+submit success is authoritative for delivery. The post-submit
181
+ # turn-boundary probe is observation metadata only, never a delivery gate — a
182
+ # busy / compacting recipient that has not yet shown a new prompt marker is
183
+ # still a successful delivery. Real paste/submit failures are caught and
184
+ # returned above; this point is only reached after submit reported ok.
137
185
  turn_visible, turn_verification, turn_capture = _wait_for_leader_new_turn(
138
186
  target,
139
187
  text,
@@ -142,16 +190,7 @@ def _tmux_inject_text(
142
190
  timeout=2.0,
143
191
  )
144
192
  if not turn_visible:
145
- return {
146
- "ok": False,
147
- "stage": "turn-boundary-verification",
148
- "error": f"leader turn boundary not verified: {turn_verification}",
149
- "attempts": attempt_log,
150
- "verification": verification,
151
- "submit_verification": submit_verification,
152
- "turn_verification": turn_verification,
153
- "submit_attempts": submit.get("attempts"),
154
- }
193
+ turn_verification = "not_yet_observed"
155
194
  return {
156
195
  "ok": True,
157
196
  "stage": "submitted",
@@ -18,14 +18,22 @@ def retry_injection_after_trust_auto_answer(
18
18
  buffer_name: str,
19
19
  provider: str,
20
20
  ) -> dict[str, Any]:
21
- from team_agent.messaging.delivery import _wait_for_trust_prompt_dismissal
21
+ from team_agent.messaging.delivery import _tmux_pane_width, _wait_for_trust_prompt_dismissal
22
22
  from team_agent.messaging.leader_panes import attempt_trust_auto_answer
23
+ pane_target = injection.get("pane_id") or target
24
+ # Live wiring: query tmux pane width now and pass via state["pane_width"]
25
+ # (symmetric with _deliver_pending_message). Fail-safe on query failure —
26
+ # leave pane_width absent so the matcher falls back to exact equality.
27
+ width_query = _tmux_pane_width(pane_target)
28
+ trust_state = dict(state) if isinstance(state, dict) else {}
29
+ if width_query.get("ok"):
30
+ trust_state["pane_width"] = width_query["pane_width"]
23
31
  answer = attempt_trust_auto_answer(
24
32
  workspace,
25
- injection.get("pane_id") or target,
33
+ pane_target,
26
34
  injection.get("pane_capture_tail") or "",
27
35
  event_log,
28
- state=state,
36
+ state=trust_state,
29
37
  )
30
38
  if not answer.get("answered"):
31
39
  return injection
@@ -0,0 +1,78 @@
1
+ # Adding a provider idle/turn-state adapter
2
+
3
+ Gap 32 decides every node's idle/working/abnormal state from a deterministic
4
+ FILE FACT — the provider's own session-log/rollout turn-lifecycle records — never
5
+ from the pane screen. The predicate, abnormal track, and wake layers are
6
+ **provider-neutral and reused unchanged**. To support a brand-new CLI you fill the
7
+ small checklist below; you do not touch any neutral module.
8
+
9
+ ## What you add (only two places)
10
+
11
+ 1. `src/team_agent/provider_state/<provider>.py` — a thin reader that translates
12
+ that CLI's session records into normalized lifecycle facts.
13
+ 2. one entry in `src/team_agent/provider_state/registry.py` — pure infra DATA.
14
+
15
+ Everything else (`idle_predicate.py`, `abnormal_track.py`, `wake.py`,
16
+ `idle_takeover.py`) is provider-neutral and must stay free of provider names
17
+ (there is a grep test, C6).
18
+
19
+ ## The checklist
20
+
21
+ ### 1. Session/rollout file location
22
+ - Where does this CLI write its per-session log? (root dir + path layout)
23
+ - How does the framework already learn each agent's path? (it is captured into
24
+ runtime state per agent as `rollout_path`; confirm yours lands there.)
25
+ - Record it under the registry entry `file_location`.
26
+
27
+ ### 2. Turn-lifecycle event types (do the empirical capture FIRST)
28
+ Capture REAL records from a live session for each state and record the exact
29
+ record `type`/field. These become the contract fixtures (real-fixture-first):
30
+ - **turn-started / open turn** — the marker that a turn is in flight.
31
+ - **turn-complete** — the close that means idle.
32
+ - **interrupted** — user ESC / abort (idle_interrupted, idle + red note).
33
+ - **blocked / approval** — awaiting a human decision (blocked_on_human).
34
+ - **error / failed** — a structured terminal fault record.
35
+ Implement these as `extract_facts(records) -> (facts, diagnostics)` in your reader,
36
+ emitting `team_agent.provider_state.common` fact kinds: `TURN_OPEN`,
37
+ `TURN_COMPLETE`, `INTERRUPTED`, `FAILED`, `APPROVAL`, `ERROR`. Fault facts should
38
+ carry `signature`, `turn_id`, and `raw` (the original record). Filter out trailing
39
+ metadata/telemetry records so the verdict is the last LIFECYCLE fact, not the last
40
+ physical line.
41
+
42
+ Reference markers already implemented:
43
+ - Claude transcript: assistant `stop_reason==end_turn` (idle) / `==tool_use`
44
+ (working); user text `[Request interrupted by user]` (interrupted); user
45
+ `tool_result is_error==true` and system `subtype==api_error,level==error` (faults).
46
+ - Codex rollout: `event_msg payload.type==task_started|task_complete`;
47
+ `turn_aborted reason==interrupted`; app-server `turn.status==failed` and
48
+ `*/requestApproval`.
49
+
50
+ ### 3. Black/white list seed entries
51
+ - `error_lists.whitelist` — record/string patterns that are benign → skip.
52
+ - `error_lists.blacklist` — known error signatures → notify (`api error`,
53
+ `rate limit`, `overloaded`, traceback/panic, provider `failed`, ...).
54
+ - Precedence is whitelist > blacklist > default-notify (catch-bias for structured
55
+ faults only). Lists are DATA — adding a pattern is one edit + one fixture.
56
+
57
+ ### 4. Optional hook accelerator
58
+ - Does the CLI expose hooks that fire on turn boundaries (e.g. a `Stop`/`Notify`
59
+ program)? If so they can push a fact row to wake the watcher faster — but the
60
+ file fact remains the source of truth (the hook is validated against the file,
61
+ never the sole signal).
62
+
63
+ ### 5. Process/identity facts for the liveness guard
64
+ - How to read the provider process identity (start-time / cmdline) so an open
65
+ turn whose process was replaced (PID reuse) classifies as `crashed_mid_turn`,
66
+ never eternal `working` (C4). `provider_state.common.process_is_live` already
67
+ implements the comparison given `{"expected": {...}, "current": {...}}`.
68
+
69
+ ## Reused unchanged (do NOT modify per provider)
70
+ - `idle_predicate.evaluate_takeover_reminder` — all-idle + arm-after-delegation +
71
+ monotonic debounce + edge ack.
72
+ - `abnormal_track.process_abnormal_records` / `detect_whole_team_gone` — dedup,
73
+ catch-bias, coordinator-independent whole-team-gone.
74
+ - `wake` — file-change watch + mtime gate.
75
+ - `idle_takeover` — the public facade.
76
+
77
+ If you find yourself editing a neutral module to add a provider, stop — the fact
78
+ you need belongs in the reader or the registry entry instead.
@@ -0,0 +1,86 @@
1
+ """Provider turn-state readers behind one shared interface (Gap 32 §6).
2
+
3
+ ``read_turn_state`` is the single entry the rest of the runtime uses; provider
4
+ dispatch happens here (and in registry data), so the neutral predicate /
5
+ abnormal / wake modules never name a provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from typing import Any
12
+
13
+ from team_agent.provider_state.registry import get_provider_registry
14
+
15
+ _READER_CACHE: dict[str, Any] = {}
16
+
17
+
18
+ def read_turn_state(
19
+ provider: str,
20
+ session_log_text: str,
21
+ *,
22
+ process: Any = None,
23
+ file_silence_seconds: float = 0,
24
+ registry: Any = None,
25
+ ) -> dict[str, Any]:
26
+ """Classify a node's turn state from its provider session-log text.
27
+
28
+ Returns the stable dict shape: state / turn_id / reason / source /
29
+ annotations / diagnostics. A missing/unknown provider or an unreadable
30
+ file fails safe to ``unknown`` (never idle, Gap 32 C5).
31
+ """
32
+ _ = file_silence_seconds # open-turn beats silence (C14); silence never forces idle
33
+ reader = _reader_for(provider, registry)
34
+ if reader is None:
35
+ return {
36
+ "state": "unknown",
37
+ "turn_id": None,
38
+ "reason": "unknown_provider",
39
+ "source": "registry",
40
+ "annotations": [],
41
+ "diagnostics": [{"kind": "unknown_provider", "provider": provider}],
42
+ }
43
+ return reader.classify(session_log_text, process=process)
44
+
45
+
46
+ def read_fault_facts(provider: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
47
+ """Extract normalized fault/approval facts from already-parsed provider
48
+ records, using the provider reader. The abnormal track consumes these
49
+ without naming a provider.
50
+ """
51
+ reader = _reader_for(provider)
52
+ if reader is None or not hasattr(reader, "extract_facts"):
53
+ return []
54
+ facts, _diag = reader.extract_facts(records or [])
55
+ fault_kinds = {"error", "failed", "approval"}
56
+ out: list[dict[str, Any]] = []
57
+ for fact in facts:
58
+ if fact.get("kind") in fault_kinds:
59
+ enriched = dict(fact)
60
+ enriched.setdefault("provider", provider)
61
+ out.append(enriched)
62
+ return out
63
+
64
+
65
+ def _reader_for(provider: str, registry: Any = None) -> Any:
66
+ if provider in _READER_CACHE:
67
+ return _READER_CACHE[provider]
68
+ entry = None
69
+ if isinstance(registry, dict):
70
+ entry = registry.get(provider) if provider in registry else registry
71
+ if not isinstance(entry, dict) or "reader_module" not in entry:
72
+ entry = get_provider_registry(provider)
73
+ if not isinstance(entry, dict):
74
+ return None
75
+ module_name = entry.get("reader_module")
76
+ if not module_name:
77
+ return None
78
+ try:
79
+ module = importlib.import_module(module_name)
80
+ except ImportError:
81
+ return None
82
+ _READER_CACHE[provider] = module
83
+ return module
84
+
85
+
86
+ __all__ = ["read_turn_state", "read_fault_facts", "get_provider_registry"]