@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
@@ -0,0 +1,216 @@
1
+ """Gap 28 (Slice 2 Stage 2): observe-only detection of leader-pane API errors.
2
+
3
+ The coordinator tick captures the leader pane scrollback once per cycle, scans it for
4
+ known upstream-API error patterns (Claude/Codex CLI errors that occur mid-turn), and
5
+ emits a structured `leader.api_error` audit event. The intent is observability — auto-
6
+ retry belongs to the upstream CLI; this module never touches the pane.
7
+
8
+ Event schema (logged via EventLog.write):
9
+
10
+ event: 'leader.api_error'
11
+ ts: ISO-8601 UTC (added by EventLog)
12
+ leader_session_uuid: str | None
13
+ error_class: 'Overloaded' | 'RateLimit' | 'Timeout' |
14
+ 'NetworkError' | 'Unknown'
15
+ provider: 'claude' | 'codex' | 'claude_code' | str | None
16
+ partial_response_streamed: bool (heuristic: assistant text before the error)
17
+ worker_dispatch_just_before: list[str] (leader→worker msg_ids in the prior 60s)
18
+ retry_count: int (always 0 — the framework does not retry today)
19
+ matched_pattern_snippet: str (the captured error line, ≤160 chars)
20
+
21
+ Detection dedupes within the coordinator state via a (error_class, snippet-tail)
22
+ fingerprint stored under `state['coordinator']['last_api_error_fingerprint']`. A
23
+ clean tick (no error pattern present) clears the fingerprint so the next genuine
24
+ error re-emits. This keeps event volume bounded while still catching distinct
25
+ errors as they occur.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from datetime import datetime, timedelta, timezone
31
+ from pathlib import Path
32
+ from typing import Any, Callable
33
+
34
+ from team_agent.events import EventLog
35
+ from team_agent.message_store import MessageStore
36
+
37
+
38
+ # Spark MEDIUM sweeps (2026-05-26):
39
+ # (#3) Require an API/provider context marker near the error keyword. Bare '503' /
40
+ # 'fetch failed' / 'timed out' in user text used to false-fire.
41
+ # (#7) Match across short sliding windows of 1-3 adjacent lines so wrapped tmux
42
+ # output ("claude:\n request timed out") still resolves to a single
43
+ # detection. Window joined with a single space; capped at _WINDOW_MAX_CHARS
44
+ # so the scan stays bounded.
45
+ _API_CONTEXT = (
46
+ r"(?:API\s+Error|HTTP\s*Error|HTTPError|request\s+failed|"
47
+ r"codex|claude|Anthropic|OpenAI|TypeError)"
48
+ )
49
+
50
+ # Patterns operate against a sliding window of up to 3 joined lines. The window
51
+ # never contains '\n' (lines are joined with a single space), so `[^\n]` and `.`
52
+ # behave the same; we use `[^\n]` for self-documentation.
53
+ _ERROR_PATTERNS: list[tuple[re.Pattern[str], str]] = [
54
+ # Overloaded — keyword itself already includes the "API Error:" prefix.
55
+ (re.compile(r"API\s+Error:\s*Overloaded", re.IGNORECASE), "Overloaded"),
56
+ # RateLimit — 429 with "Too Many Requests" is sufficiently specific; require it
57
+ # appear AFTER an API context marker OR before "Too Many Requests" tightly.
58
+ (re.compile(rf"(?:{_API_CONTEXT}[^\n]*\b429\b|\b429\s+Too\s+Many\s+Requests)", re.IGNORECASE), "RateLimit"),
59
+ # 5xx — must share a window with an API-context marker on either side.
60
+ (re.compile(rf"{_API_CONTEXT}[^\n]{{0,200}}\b5(?:00|02|03|04)\b", re.IGNORECASE), "NetworkError"),
61
+ (re.compile(rf"\b5(?:00|02|03|04)\b[^\n]{{0,200}}{_API_CONTEXT}", re.IGNORECASE), "NetworkError"),
62
+ # fetch failed — needs an API-context marker in the same window. The TypeError
63
+ # marker on its own counts (Node fetch frames the error this way).
64
+ (re.compile(rf"{_API_CONTEXT}[^\n]{{0,200}}fetch\s+failed", re.IGNORECASE), "NetworkError"),
65
+ (re.compile(rf"fetch\s+failed[^\n]{{0,200}}{_API_CONTEXT}", re.IGNORECASE), "NetworkError"),
66
+ # Timeout — likewise requires an API-context marker in the window, except for
67
+ # the unambiguous syscall token ETIMEDOUT.
68
+ (re.compile(rf"{_API_CONTEXT}[^\n]{{0,200}}(?:request|connection)\s+(?:timed\s+out|timeout)", re.IGNORECASE), "Timeout"),
69
+ (re.compile(rf"(?:request|connection)\s+(?:timed\s+out|timeout)[^\n]{{0,200}}{_API_CONTEXT}", re.IGNORECASE), "Timeout"),
70
+ (re.compile(r"\bETIMEDOUT\b", re.IGNORECASE), "Timeout"),
71
+ ]
72
+
73
+ _RECENT_LINE_WINDOW = 100 # scan only the most recent N lines
74
+ _SLIDING_WINDOW_LINES = 3 # join up to 3 adjacent lines per scan window
75
+ _WINDOW_MAX_CHARS = 400 # discard windows beyond this length to bound work
76
+ _DISPATCH_WINDOW_SECONDS = 60 # leader→worker sends counted within this lookback
77
+ _PARTIAL_RESPONSE_HEAD_BYTES = 4000
78
+
79
+ _PARTIAL_RESPONSE_HINT = re.compile(
80
+ r"(?:^|\n)\s*(?:Assistant|⏺|●|> |I'll |I will |I'm |I am |Let me )",
81
+ re.IGNORECASE,
82
+ )
83
+
84
+
85
+ def detect_leader_api_errors(
86
+ workspace: Path,
87
+ state: dict[str, Any],
88
+ store: MessageStore,
89
+ event_log: EventLog,
90
+ *,
91
+ capture_fn: Callable[[str], dict[str, Any]] | None = None,
92
+ now_fn: Callable[[], datetime] | None = None,
93
+ ) -> list[dict[str, Any]]:
94
+ """Coordinator-tick entry point. Returns a list of emitted events (0 or 1)."""
95
+ receiver = state.get("leader_receiver") or {}
96
+ pane = receiver.get("pane_id") if receiver.get("mode") == "direct_tmux" else None
97
+ if not pane:
98
+ return []
99
+ capture_fn = capture_fn or _default_capture_fn()
100
+ capture = capture_fn(str(pane))
101
+ if not capture.get("ok"):
102
+ return []
103
+ scrollback = str(capture.get("capture") or "")
104
+ coordinator_state = state.setdefault("coordinator", {})
105
+ found = _match_first_error(scrollback)
106
+ if not found:
107
+ if coordinator_state.get("last_api_error_fingerprint"):
108
+ coordinator_state["last_api_error_fingerprint"] = None
109
+ return []
110
+ error_class, snippet = found
111
+ fingerprint = f"{error_class}::{snippet[-120:]}"
112
+ if coordinator_state.get("last_api_error_fingerprint") == fingerprint:
113
+ return []
114
+ coordinator_state["last_api_error_fingerprint"] = fingerprint
115
+ now = (now_fn() if now_fn else datetime.now(timezone.utc))
116
+ cutoff_iso = (now - timedelta(seconds=_DISPATCH_WINDOW_SECONDS)).isoformat()
117
+ leader_uuid = (
118
+ str((state.get("team_owner") or {}).get("leader_session_uuid") or "")
119
+ or str(receiver.get("leader_session_uuid") or "")
120
+ or None
121
+ )
122
+ provider = str(receiver.get("provider") or "") or None
123
+ event = event_log.write(
124
+ "leader.api_error",
125
+ leader_session_uuid=leader_uuid,
126
+ error_class=error_class,
127
+ provider=provider,
128
+ partial_response_streamed=_scrollback_has_partial_response(scrollback, snippet),
129
+ worker_dispatch_just_before=_recent_leader_dispatches(store, cutoff_iso),
130
+ retry_count=0,
131
+ matched_pattern_snippet=snippet[:160],
132
+ )
133
+ return [event]
134
+
135
+
136
+ def _default_capture_fn() -> Callable[[str], dict[str, Any]]:
137
+ from team_agent.messaging.deps import _capture_tmux_pane_text
138
+ return _capture_tmux_pane_text
139
+
140
+
141
+ def _match_first_error(scrollback: str) -> tuple[str, str] | None:
142
+ """Spark MEDIUM #7: sliding window of 1..N adjacent lines. Lines inside a
143
+ window are joined with a single space so a wrapped pair such as
144
+ claude:
145
+ request timed out
146
+ is detected as one event without permitting unbounded cross-line matches.
147
+ Latest window wins so the freshest error is reported."""
148
+ if not scrollback:
149
+ return None
150
+ lines = [line.strip() for line in scrollback.splitlines()[-_RECENT_LINE_WINDOW:]]
151
+ if not lines:
152
+ return None
153
+ best: tuple[int, str, str] | None = None
154
+ for start in range(len(lines)):
155
+ for size in range(1, _SLIDING_WINDOW_LINES + 1):
156
+ end = start + size
157
+ if end > len(lines):
158
+ break
159
+ window = " ".join(line for line in lines[start:end] if line)
160
+ if not window:
161
+ continue
162
+ # Spark MEDIUM sweep #3 (2026-05-26): tail-preserve instead of
163
+ # dropping the window wholesale. Errors land at the END of verbose
164
+ # diagnostics (stack traces, retry chatter, etc.). If we discarded
165
+ # any window over the cap we silently lost recall on long wrapped
166
+ # output. Scanning the LAST _WINDOW_MAX_CHARS still bounds regex
167
+ # cost while keeping the freshest context — the bit most likely to
168
+ # contain the actual provider error keyword.
169
+ if len(window) > _WINDOW_MAX_CHARS:
170
+ window = window[-_WINDOW_MAX_CHARS:]
171
+ for pattern, error_class in _ERROR_PATTERNS:
172
+ match = pattern.search(window)
173
+ if not match:
174
+ continue
175
+ snippet = window[:240]
176
+ if best is None or start > best[0]:
177
+ best = (start, error_class, snippet)
178
+ # First match per window is enough; later windows may override.
179
+ break
180
+ if best is None:
181
+ return None
182
+ return best[1], best[2]
183
+
184
+
185
+ def _scrollback_has_partial_response(scrollback: str, error_snippet: str) -> bool:
186
+ idx = scrollback.rfind(error_snippet)
187
+ if idx == -1:
188
+ return False
189
+ head = scrollback[max(0, idx - _PARTIAL_RESPONSE_HEAD_BYTES): idx]
190
+ return bool(_PARTIAL_RESPONSE_HINT.search(head))
191
+
192
+
193
+ def _recent_leader_dispatches(store: MessageStore, cutoff_iso: str) -> list[str]:
194
+ out: list[str] = []
195
+ try:
196
+ rows = store.messages()
197
+ except Exception:
198
+ return out
199
+ for row in rows:
200
+ sender = str(row.get("sender") or "")
201
+ if sender not in {"leader", "Leader"} and not _looks_like_leader_sender(sender):
202
+ continue
203
+ created = str(row.get("created_at") or "")
204
+ if not created or created < cutoff_iso:
205
+ continue
206
+ msg_id = str(row.get("message_id") or "")
207
+ if msg_id:
208
+ out.append(msg_id)
209
+ return out
210
+
211
+
212
+ def _looks_like_leader_sender(sender: str) -> bool:
213
+ return sender.startswith("leader") or sender.lower() == "leader"
214
+
215
+
216
+ __all__ = ["detect_leader_api_errors"]
@@ -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 "")
@@ -503,6 +506,297 @@ def _leader_command_looks_usable(command: str, provider: str) -> bool:
503
506
  return command_name in {"codex", "node", "nodejs", "claude", "claude.exe"}
504
507
 
505
508
 
509
+ def attempt_trust_auto_answer(
510
+ workspace: Path,
511
+ pane_id: str | None,
512
+ pane_capture_tail: str,
513
+ event_log: EventLog,
514
+ *,
515
+ spec: dict[str, Any] | None = None,
516
+ state: dict[str, Any] | None = None,
517
+ ) -> dict[str, Any]:
518
+ """Gap 29 (Slice 2 Stage 2) — opt-in auto-answer of the codex first-run trust prompt.
519
+
520
+ Called by the inject path when developer's structured envelope reports
521
+ detected=='codex_trust_prompt'. Auto-answers ONLY when both:
522
+ (1) runtime is opted in. The PREFERRED opt-in is the per-session env var
523
+ TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE in {1,true,yes,on}. The legacy
524
+ spec.runtime.auto_trust_own_workspace=True path is still honoured for
525
+ backwards compatibility but is DEPRECATED (constitution-reviewer F3:
526
+ a YAML field permanently erases the trust prompt's cognitive moment
527
+ across all sessions, defeating its purpose). The spec path will be
528
+ removed in 0.3.0.
529
+ (2) the trust-prompt pane capture references this workspace's absolute path
530
+ (so a worker can only trust its own dir, never some arbitrary path).
531
+
532
+ On match, sends '1' + Enter to the pane and emits
533
+ leader_panes.trust_auto_answered. Default is opt-out — every refusal returns
534
+ answered=False with a structured reason and the existing failure envelope
535
+ bubbles up unchanged.
536
+
537
+ Return: {"ok": bool, "answered": bool, "reason": str, ...}
538
+ """
539
+ if spec is None and state is not None:
540
+ spec_path_str = state.get("spec_path")
541
+ if spec_path_str:
542
+ try:
543
+ from team_agent.spec import load_spec as _load_spec
544
+ spec = _load_spec(Path(spec_path_str))
545
+ except Exception:
546
+ spec = None
547
+ if not _auto_trust_opt_in(spec, event_log=event_log):
548
+ # Spark LOW #6: emit a structured event so the not-opted-in branch is
549
+ # as observable as the workspace_dir_mismatch / tmux_send_keys_failed
550
+ # branches. Keeps the decision matrix uniformly auditable.
551
+ event_log.write(
552
+ "leader_panes.trust_auto_answer_skipped",
553
+ pane_id=pane_id,
554
+ workspace=str(workspace),
555
+ reason="not_opted_in",
556
+ )
557
+ return {"ok": False, "answered": False, "reason": "not_opted_in"}
558
+ if not pane_id:
559
+ event_log.write(
560
+ "leader_panes.trust_auto_answer_skipped",
561
+ pane_id=None,
562
+ workspace=str(workspace),
563
+ reason="pane_id_missing",
564
+ )
565
+ return {"ok": False, "answered": False, "reason": "pane_id_missing"}
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):
568
+ event_log.write(
569
+ "leader_panes.trust_auto_answer_refused",
570
+ pane_id=pane_id,
571
+ workspace=str(workspace),
572
+ reason="workspace_dir_mismatch",
573
+ )
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.
581
+ answer = _tmux_inject_text(
582
+ str(pane_id),
583
+ "",
584
+ "Enter",
585
+ f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
586
+ attempts=1,
587
+ provider="fake",
588
+ bypass_non_input_gate=True,
589
+ )
590
+ if not answer.get("ok"):
591
+ error = answer.get("error") or "tmux send-keys failed"
592
+ event_log.write(
593
+ "leader_panes.trust_auto_answer_failed",
594
+ pane_id=pane_id,
595
+ workspace=str(workspace),
596
+ error=error,
597
+ )
598
+ return {"ok": False, "answered": False, "reason": "tmux_send_keys_failed", "error": error}
599
+ event_log.write(
600
+ "leader_panes.trust_auto_answered",
601
+ pane_id=pane_id,
602
+ workspace=str(workspace),
603
+ opted_in=True,
604
+ )
605
+ return {"ok": True, "answered": True, "reason": "trust_auto_answered"}
606
+
607
+
608
+ _SPEC_OPT_IN_DEPRECATION_MESSAGE = (
609
+ "WARNING: spec.runtime.auto_trust_own_workspace is deprecated. "
610
+ "Use env TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE=1 per session instead. "
611
+ "Spec-field will be removed in 0.3.0."
612
+ )
613
+
614
+
615
+ def _auto_trust_opt_in(spec: dict[str, Any] | None, *, event_log: EventLog | None = None) -> bool:
616
+ """Constitution-reviewer F3 (2026-05-26): env-var per-session opt-in is the
617
+ preferred path. spec.runtime.auto_trust_own_workspace remains honoured for
618
+ backwards compatibility but emits a one-shot stderr deprecation warning AND
619
+ a structured trust_auto_answer_spec_opt_in_deprecated event so a normalized
620
+ YAML field is auditable from a fresh log."""
621
+ spec_opted_in = (
622
+ isinstance(spec, dict)
623
+ and bool((spec.get("runtime") or {}).get("auto_trust_own_workspace"))
624
+ )
625
+ if spec_opted_in:
626
+ _emit_spec_opt_in_deprecation(event_log)
627
+ env = os.environ.get("TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE", "").strip().lower()
628
+ env_opted_in = env in {"1", "true", "yes", "on"}
629
+ return env_opted_in or spec_opted_in
630
+
631
+
632
+ def _emit_spec_opt_in_deprecation(event_log: EventLog | None) -> None:
633
+ """Emit the deprecation warning once per process. The structured event still
634
+ fires per call so an audit log captures every yaml-driven decision."""
635
+ import sys
636
+ global _SPEC_OPT_IN_DEPRECATION_WARNED
637
+ if not _SPEC_OPT_IN_DEPRECATION_WARNED:
638
+ try:
639
+ print(_SPEC_OPT_IN_DEPRECATION_MESSAGE, file=sys.stderr, flush=True)
640
+ except Exception:
641
+ pass
642
+ _SPEC_OPT_IN_DEPRECATION_WARNED = True
643
+ if event_log is not None:
644
+ try:
645
+ event_log.write(
646
+ "trust_auto_answer_spec_opt_in_deprecated",
647
+ preferred_opt_in="env:TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE",
648
+ deprecated_field="spec.runtime.auto_trust_own_workspace",
649
+ removal_target_version="0.3.0",
650
+ )
651
+ except Exception:
652
+ pass
653
+
654
+
655
+ _SPEC_OPT_IN_DEPRECATION_WARNED = False
656
+
657
+
658
+ def _reset_spec_opt_in_deprecation_state() -> None:
659
+ """Test-only helper: reset the per-process one-shot guard so multiple cases
660
+ in the same interpreter can each observe the warning. Not part of the
661
+ public API."""
662
+ global _SPEC_OPT_IN_DEPRECATION_WARNED
663
+ _SPEC_OPT_IN_DEPRECATION_WARNED = False
664
+
665
+
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
+ """
682
+ if not tail:
683
+ return False
684
+ workspace_canonical = _canonicalize_path(workspace)
685
+ if not workspace_canonical:
686
+ return False
687
+ for token, source_line in _candidate_path_lines_from_prompt(tail):
688
+ if _workspace_matches_token(workspace_canonical, token, source_line, pane_width):
689
+ return True
690
+ return False
691
+
692
+
693
+ _PATH_LINE_RE = re.compile(r"(/[\w\-./~+@…]+)")
694
+ _ELLIPSIS_TOKENS = ("…", "...")
695
+
696
+
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()
703
+ for raw_line in tail.splitlines():
704
+ line = raw_line.strip()
705
+ for glyph in ("▌", "▎", "│"):
706
+ line = line.lstrip(glyph).strip()
707
+ if not line:
708
+ continue
709
+ for match in _PATH_LINE_RE.finditer(line):
710
+ token = match.group(1).rstrip("/")
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)
788
+
789
+
790
+ def _canonicalize_path(p: Path | str) -> str:
791
+ try:
792
+ resolved = Path(p).expanduser().resolve(strict=False)
793
+ except OSError:
794
+ return ""
795
+ text = resolved.as_posix()
796
+ # Strip a trailing slash so boundary-safe equality holds.
797
+ return text.rstrip("/") if text != "/" else "/"
798
+
799
+
506
800
  def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]:
507
801
  if provider != "codex":
508
802
  return "Enter", "non_codex_provider"
@@ -84,6 +84,18 @@ def _fire_due_scheduled_events(workspace: Path, store: MessageStore, event_log:
84
84
  elif row["kind"] == "health_ping":
85
85
  result = {"ok": True, "status": "logged"}
86
86
  event_log.write("coordinator.health_ping", target=row["target"], payload=payload)
87
+ elif row["kind"] == "trust_retry":
88
+ # Spark MEDIUM sweep #3 (2026-05-26) — bounded-backoff consumer
89
+ # for delivery.py:_handle_trust_retry_needed. payload carries the
90
+ # message_id and current attempt; _execute_trust_retry resets the
91
+ # row to 'accepted', re-runs _deliver_pending_message with the
92
+ # attempt threaded through, and either delivers, reschedules, or
93
+ # hits the terminal trust_auto_answer_exhausted branch.
94
+ from team_agent.messaging.delivery import _execute_trust_retry
95
+ result = _execute_trust_retry(
96
+ workspace, store, event_log, payload,
97
+ owner_team_id=row.get("owner_team_id"),
98
+ )
87
99
  else:
88
100
  result = {"ok": False, "error": f"unknown scheduled event kind: {row['kind']}"}
89
101
  if not result.get("ok") and row["kind"] == "send":
@@ -34,19 +34,10 @@ from pathlib import Path
34
34
  from typing import Any
35
35
 
36
36
  def send_message(
37
- workspace: Path,
38
- target: str | list[str] | None,
39
- content: str,
40
- task_id: str | None = None,
41
- sender: str = "leader",
42
- requires_ack: bool = True,
43
- confirm_human: bool = False,
44
- wait_visible: bool = True,
45
- timeout: float = 30.0,
46
- lock_timeout: float = 5.0,
47
- watch_result: bool = False,
48
- block_until_delivered: bool = True,
49
- team: str | None = None,
37
+ workspace: Path, target: str | list[str] | None, content: str, task_id: str | None = None,
38
+ sender: str = "leader", requires_ack: bool = True, confirm_human: bool = False,
39
+ wait_visible: bool = True, timeout: float = 30.0, lock_timeout: float = 5.0,
40
+ watch_result: bool = False, block_until_delivered: bool = True, team: str | None = None,
50
41
  ) -> dict[str, Any]:
51
42
  with _runtime_lock(workspace, "send", timeout=lock_timeout):
52
43
  return _send_message_unlocked(
@@ -66,18 +57,10 @@ def send_message(
66
57
 
67
58
 
68
59
  def _send_message_unlocked(
69
- workspace: Path,
70
- target: str | list[str] | None,
71
- content: str,
72
- task_id: str | None = None,
73
- sender: str = "leader",
74
- requires_ack: bool = True,
75
- confirm_human: bool = False,
76
- wait_visible: bool = True,
77
- timeout: float = 30.0,
78
- watch_result: bool = False,
79
- block_until_delivered: bool = True,
80
- team: str | None = None,
60
+ workspace: Path, target: str | list[str] | None, content: str, task_id: str | None = None,
61
+ sender: str = "leader", requires_ack: bool = True, confirm_human: bool = False,
62
+ wait_visible: bool = True, timeout: float = 30.0, watch_result: bool = False,
63
+ block_until_delivered: bool = True, team: str | None = None,
81
64
  ) -> dict[str, Any]:
82
65
  if team is None:
83
66
  ambiguous = ambiguous_team_target_result(load_runtime_state(workspace))
@@ -94,6 +77,7 @@ def _send_message_unlocked(
94
77
  return gate
95
78
  owner_team_id = team_state_key(state)
96
79
  leader_id = _leader_id(state, spec)
80
+ _flag_rebind_required_when_unbound_plain_shell_leader(workspace, state, spec, sender, leader_id, event_log)
97
81
 
98
82
  if isinstance(target, list):
99
83
  if watch_result:
@@ -151,6 +135,38 @@ def _send_message_unlocked(
151
135
  )
152
136
 
153
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
+
154
170
  def _send_single_message_unlocked(
155
171
  workspace: Path,
156
172
  state: dict[str, Any],
@@ -336,6 +352,8 @@ def _send_single_message_unlocked(
336
352
  "submit_verification": delivered_result.get("submit_verification"),
337
353
  "turn_verification": delivered_result.get("turn_verification"),
338
354
  }
355
+ result.update({key: delivered_result[key] for key in ("reason", "stage") if delivered_result.get(key)})
356
+ result.update(_structured_delivery_refusal(delivered_result))
339
357
  if delivered_result.get("queued"):
340
358
  result["queued"] = True
341
359
  result["reason"] = delivered_result.get("reason")
@@ -490,7 +508,7 @@ def _broadcast_targets(state: dict[str, Any], spec: dict[str, Any], sender: str)
490
508
 
491
509
 
492
510
  def _compact_broadcast_delivery(result: dict[str, Any]) -> dict[str, Any]:
493
- keys = ["ok", "status", "message_id", "to", "reason", "channel"]
511
+ keys = ["ok", "status", "message_id", "to", "reason", "channel", "detected", "pane_id", "pane_mode", "pane_capture_tail", "stage", "verification"]
494
512
  return {key: result[key] for key in keys if key in result}
495
513
 
496
514
 
@@ -498,3 +516,13 @@ def _compact_fanout_delivery(result: dict[str, Any]) -> dict[str, Any]:
498
516
  compact = _compact_broadcast_delivery(result)
499
517
  compact["delivered"] = bool(result.get("submitted") or result.get("visible") or result.get("status") in {"submitted", "visible", "delivered", "acknowledged"})
500
518
  return compact
519
+
520
+
521
+ def _structured_delivery_refusal(delivered_result: dict[str, Any]) -> dict[str, Any]:
522
+ attempts = delivered_result.get("paste_attempts")
523
+ if not isinstance(attempts, list):
524
+ return {}
525
+ for attempt in attempts:
526
+ if isinstance(attempt, dict) and attempt.get("reason") == "recipient_pane_in_non_input_mode":
527
+ return {key: attempt[key] for key in ("detected", "pane_id", "pane_mode", "pane_capture_tail") if key in attempt}
528
+ return {}