@team-agent/installer 0.2.2 → 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.
- package/package.json +1 -1
- package/schemas/team.schema.json +6 -0
- package/src/team_agent/approvals/runtime_prompts.py +1 -1
- package/src/team_agent/cli/commands.py +104 -3
- package/src/team_agent/cli/parser.py +10 -1
- package/src/team_agent/coordinator/lifecycle.py +3 -0
- package/src/team_agent/diagnose/orphan_cleanup.py +199 -28
- package/src/team_agent/launch/core.py +2 -1
- package/src/team_agent/lifecycle/operations.py +1 -0
- package/src/team_agent/lifecycle/start.py +1 -1
- package/src/team_agent/message_store/core.py +8 -7
- package/src/team_agent/message_store/schema.py +8 -2
- package/src/team_agent/messaging/delivery.py +293 -1
- package/src/team_agent/messaging/leader.py +13 -4
- package/src/team_agent/messaging/leader_api_errors.py +216 -0
- package/src/team_agent/messaging/leader_panes.py +200 -0
- package/src/team_agent/messaging/scheduler.py +12 -0
- package/src/team_agent/messaging/send.py +21 -26
- package/src/team_agent/messaging/tmux_io.py +153 -23
- package/src/team_agent/messaging/tmux_prompt.py +87 -0
- package/src/team_agent/messaging/trust_auto_answer.py +44 -0
- package/src/team_agent/restart/orchestration.py +207 -4
- package/src/team_agent/runtime.py +3 -3
- package/src/team_agent/sessions/capture.py +65 -15
- package/src/team_agent/spec.py +59 -0
- package/src/team_agent/status/queries.py +32 -1
- package/src/team_agent/watch/__init__.py +145 -0
|
@@ -503,6 +503,206 @@ def _leader_command_looks_usable(command: str, provider: str) -> bool:
|
|
|
503
503
|
return command_name in {"codex", "node", "nodejs", "claude", "claude.exe"}
|
|
504
504
|
|
|
505
505
|
|
|
506
|
+
def attempt_trust_auto_answer(
|
|
507
|
+
workspace: Path,
|
|
508
|
+
pane_id: str | None,
|
|
509
|
+
pane_capture_tail: str,
|
|
510
|
+
event_log: EventLog,
|
|
511
|
+
*,
|
|
512
|
+
spec: dict[str, Any] | None = None,
|
|
513
|
+
state: dict[str, Any] | None = None,
|
|
514
|
+
) -> dict[str, Any]:
|
|
515
|
+
"""Gap 29 (Slice 2 Stage 2) — opt-in auto-answer of the codex first-run trust prompt.
|
|
516
|
+
|
|
517
|
+
Called by the inject path when developer's structured envelope reports
|
|
518
|
+
detected=='codex_trust_prompt'. Auto-answers ONLY when both:
|
|
519
|
+
(1) runtime is opted in. The PREFERRED opt-in is the per-session env var
|
|
520
|
+
TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE in {1,true,yes,on}. The legacy
|
|
521
|
+
spec.runtime.auto_trust_own_workspace=True path is still honoured for
|
|
522
|
+
backwards compatibility but is DEPRECATED (constitution-reviewer F3:
|
|
523
|
+
a YAML field permanently erases the trust prompt's cognitive moment
|
|
524
|
+
across all sessions, defeating its purpose). The spec path will be
|
|
525
|
+
removed in 0.3.0.
|
|
526
|
+
(2) the trust-prompt pane capture references this workspace's absolute path
|
|
527
|
+
(so a worker can only trust its own dir, never some arbitrary path).
|
|
528
|
+
|
|
529
|
+
On match, sends '1' + Enter to the pane and emits
|
|
530
|
+
leader_panes.trust_auto_answered. Default is opt-out — every refusal returns
|
|
531
|
+
answered=False with a structured reason and the existing failure envelope
|
|
532
|
+
bubbles up unchanged.
|
|
533
|
+
|
|
534
|
+
Return: {"ok": bool, "answered": bool, "reason": str, ...}
|
|
535
|
+
"""
|
|
536
|
+
if spec is None and state is not None:
|
|
537
|
+
spec_path_str = state.get("spec_path")
|
|
538
|
+
if spec_path_str:
|
|
539
|
+
try:
|
|
540
|
+
from team_agent.spec import load_spec as _load_spec
|
|
541
|
+
spec = _load_spec(Path(spec_path_str))
|
|
542
|
+
except Exception:
|
|
543
|
+
spec = None
|
|
544
|
+
if not _auto_trust_opt_in(spec, event_log=event_log):
|
|
545
|
+
# Spark LOW #6: emit a structured event so the not-opted-in branch is
|
|
546
|
+
# as observable as the workspace_dir_mismatch / tmux_send_keys_failed
|
|
547
|
+
# branches. Keeps the decision matrix uniformly auditable.
|
|
548
|
+
event_log.write(
|
|
549
|
+
"leader_panes.trust_auto_answer_skipped",
|
|
550
|
+
pane_id=pane_id,
|
|
551
|
+
workspace=str(workspace),
|
|
552
|
+
reason="not_opted_in",
|
|
553
|
+
)
|
|
554
|
+
return {"ok": False, "answered": False, "reason": "not_opted_in"}
|
|
555
|
+
if not pane_id:
|
|
556
|
+
event_log.write(
|
|
557
|
+
"leader_panes.trust_auto_answer_skipped",
|
|
558
|
+
pane_id=None,
|
|
559
|
+
workspace=str(workspace),
|
|
560
|
+
reason="pane_id_missing",
|
|
561
|
+
)
|
|
562
|
+
return {"ok": False, "answered": False, "reason": "pane_id_missing"}
|
|
563
|
+
if not _capture_tail_references_workspace(pane_capture_tail, workspace):
|
|
564
|
+
event_log.write(
|
|
565
|
+
"leader_panes.trust_auto_answer_refused",
|
|
566
|
+
pane_id=pane_id,
|
|
567
|
+
workspace=str(workspace),
|
|
568
|
+
reason="workspace_dir_mismatch",
|
|
569
|
+
)
|
|
570
|
+
return {"ok": False, "answered": False, "reason": "workspace_dir_mismatch"}
|
|
571
|
+
answer = _tmux_inject_text(
|
|
572
|
+
str(pane_id),
|
|
573
|
+
"1",
|
|
574
|
+
"Enter",
|
|
575
|
+
f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
|
|
576
|
+
attempts=1,
|
|
577
|
+
provider="fake",
|
|
578
|
+
bypass_non_input_gate=True,
|
|
579
|
+
)
|
|
580
|
+
if not answer.get("ok"):
|
|
581
|
+
error = answer.get("error") or "tmux send-keys failed"
|
|
582
|
+
event_log.write(
|
|
583
|
+
"leader_panes.trust_auto_answer_failed",
|
|
584
|
+
pane_id=pane_id,
|
|
585
|
+
workspace=str(workspace),
|
|
586
|
+
error=error,
|
|
587
|
+
)
|
|
588
|
+
return {"ok": False, "answered": False, "reason": "tmux_send_keys_failed", "error": error}
|
|
589
|
+
event_log.write(
|
|
590
|
+
"leader_panes.trust_auto_answered",
|
|
591
|
+
pane_id=pane_id,
|
|
592
|
+
workspace=str(workspace),
|
|
593
|
+
opted_in=True,
|
|
594
|
+
)
|
|
595
|
+
return {"ok": True, "answered": True, "reason": "trust_auto_answered"}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
_SPEC_OPT_IN_DEPRECATION_MESSAGE = (
|
|
599
|
+
"WARNING: spec.runtime.auto_trust_own_workspace is deprecated. "
|
|
600
|
+
"Use env TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE=1 per session instead. "
|
|
601
|
+
"Spec-field will be removed in 0.3.0."
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _auto_trust_opt_in(spec: dict[str, Any] | None, *, event_log: EventLog | None = None) -> bool:
|
|
606
|
+
"""Constitution-reviewer F3 (2026-05-26): env-var per-session opt-in is the
|
|
607
|
+
preferred path. spec.runtime.auto_trust_own_workspace remains honoured for
|
|
608
|
+
backwards compatibility but emits a one-shot stderr deprecation warning AND
|
|
609
|
+
a structured trust_auto_answer_spec_opt_in_deprecated event so a normalized
|
|
610
|
+
YAML field is auditable from a fresh log."""
|
|
611
|
+
spec_opted_in = (
|
|
612
|
+
isinstance(spec, dict)
|
|
613
|
+
and bool((spec.get("runtime") or {}).get("auto_trust_own_workspace"))
|
|
614
|
+
)
|
|
615
|
+
if spec_opted_in:
|
|
616
|
+
_emit_spec_opt_in_deprecation(event_log)
|
|
617
|
+
env = os.environ.get("TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE", "").strip().lower()
|
|
618
|
+
env_opted_in = env in {"1", "true", "yes", "on"}
|
|
619
|
+
return env_opted_in or spec_opted_in
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _emit_spec_opt_in_deprecation(event_log: EventLog | None) -> None:
|
|
623
|
+
"""Emit the deprecation warning once per process. The structured event still
|
|
624
|
+
fires per call so an audit log captures every yaml-driven decision."""
|
|
625
|
+
import sys
|
|
626
|
+
global _SPEC_OPT_IN_DEPRECATION_WARNED
|
|
627
|
+
if not _SPEC_OPT_IN_DEPRECATION_WARNED:
|
|
628
|
+
try:
|
|
629
|
+
print(_SPEC_OPT_IN_DEPRECATION_MESSAGE, file=sys.stderr, flush=True)
|
|
630
|
+
except Exception:
|
|
631
|
+
pass
|
|
632
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = True
|
|
633
|
+
if event_log is not None:
|
|
634
|
+
try:
|
|
635
|
+
event_log.write(
|
|
636
|
+
"trust_auto_answer_spec_opt_in_deprecated",
|
|
637
|
+
preferred_opt_in="env:TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE",
|
|
638
|
+
deprecated_field="spec.runtime.auto_trust_own_workspace",
|
|
639
|
+
removal_target_version="0.3.0",
|
|
640
|
+
)
|
|
641
|
+
except Exception:
|
|
642
|
+
pass
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = False
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _reset_spec_opt_in_deprecation_state() -> None:
|
|
649
|
+
"""Test-only helper: reset the per-process one-shot guard so multiple cases
|
|
650
|
+
in the same interpreter can each observe the warning. Not part of the
|
|
651
|
+
public API."""
|
|
652
|
+
global _SPEC_OPT_IN_DEPRECATION_WARNED
|
|
653
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = False
|
|
654
|
+
|
|
655
|
+
|
|
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."""
|
|
663
|
+
if not tail:
|
|
664
|
+
return False
|
|
665
|
+
workspace_canonical = _canonicalize_path(workspace)
|
|
666
|
+
if not workspace_canonical:
|
|
667
|
+
return False
|
|
668
|
+
for candidate in _candidate_paths_from_prompt(tail):
|
|
669
|
+
if _canonicalize_path(Path(candidate)) == workspace_canonical:
|
|
670
|
+
return True
|
|
671
|
+
return False
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
_PATH_LINE_RE = re.compile(r"(/[\w\-./~+@]+)")
|
|
675
|
+
|
|
676
|
+
|
|
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] = []
|
|
682
|
+
for raw_line in tail.splitlines():
|
|
683
|
+
line = raw_line.strip()
|
|
684
|
+
# Codex draws box-glyph prefixes (▌ ▎ │) that need to be stripped.
|
|
685
|
+
for glyph in ("▌", "▎", "│"):
|
|
686
|
+
line = line.lstrip(glyph).strip()
|
|
687
|
+
if not line:
|
|
688
|
+
continue
|
|
689
|
+
for match in _PATH_LINE_RE.finditer(line):
|
|
690
|
+
token = match.group(1).rstrip("/")
|
|
691
|
+
if token and token not in paths:
|
|
692
|
+
paths.append(token)
|
|
693
|
+
return paths
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _canonicalize_path(p: Path | str) -> str:
|
|
697
|
+
try:
|
|
698
|
+
resolved = Path(p).expanduser().resolve(strict=False)
|
|
699
|
+
except OSError:
|
|
700
|
+
return ""
|
|
701
|
+
text = resolved.as_posix()
|
|
702
|
+
# Strip a trailing slash so boundary-safe equality holds.
|
|
703
|
+
return text.rstrip("/") if text != "/" else "/"
|
|
704
|
+
|
|
705
|
+
|
|
506
706
|
def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]:
|
|
507
707
|
if provider != "codex":
|
|
508
708
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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))
|
|
@@ -336,6 +319,8 @@ def _send_single_message_unlocked(
|
|
|
336
319
|
"submit_verification": delivered_result.get("submit_verification"),
|
|
337
320
|
"turn_verification": delivered_result.get("turn_verification"),
|
|
338
321
|
}
|
|
322
|
+
result.update({key: delivered_result[key] for key in ("reason", "stage") if delivered_result.get(key)})
|
|
323
|
+
result.update(_structured_delivery_refusal(delivered_result))
|
|
339
324
|
if delivered_result.get("queued"):
|
|
340
325
|
result["queued"] = True
|
|
341
326
|
result["reason"] = delivered_result.get("reason")
|
|
@@ -490,7 +475,7 @@ def _broadcast_targets(state: dict[str, Any], spec: dict[str, Any], sender: str)
|
|
|
490
475
|
|
|
491
476
|
|
|
492
477
|
def _compact_broadcast_delivery(result: dict[str, Any]) -> dict[str, Any]:
|
|
493
|
-
keys = ["ok", "status", "message_id", "to", "reason", "channel"]
|
|
478
|
+
keys = ["ok", "status", "message_id", "to", "reason", "channel", "detected", "pane_id", "pane_mode", "pane_capture_tail", "stage", "verification"]
|
|
494
479
|
return {key: result[key] for key in keys if key in result}
|
|
495
480
|
|
|
496
481
|
|
|
@@ -498,3 +483,13 @@ def _compact_fanout_delivery(result: dict[str, Any]) -> dict[str, Any]:
|
|
|
498
483
|
compact = _compact_broadcast_delivery(result)
|
|
499
484
|
compact["delivered"] = bool(result.get("submitted") or result.get("visible") or result.get("status") in {"submitted", "visible", "delivered", "acknowledged"})
|
|
500
485
|
return compact
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _structured_delivery_refusal(delivered_result: dict[str, Any]) -> dict[str, Any]:
|
|
489
|
+
attempts = delivered_result.get("paste_attempts")
|
|
490
|
+
if not isinstance(attempts, list):
|
|
491
|
+
return {}
|
|
492
|
+
for attempt in attempts:
|
|
493
|
+
if isinstance(attempt, dict) and attempt.get("reason") == "recipient_pane_in_non_input_mode":
|
|
494
|
+
return {key: attempt[key] for key in ("detected", "pane_id", "pane_mode", "pane_capture_tail") if key in attempt}
|
|
495
|
+
return {}
|
|
@@ -20,6 +20,7 @@ from team_agent.messaging.deps import (
|
|
|
20
20
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Any
|
|
23
|
+
from team_agent.messaging.tmux_prompt import detect_non_input_scrollback, non_input_scrollback_window
|
|
23
24
|
|
|
24
25
|
def _tmux_inject_text(
|
|
25
26
|
target: str,
|
|
@@ -28,6 +29,8 @@ def _tmux_inject_text(
|
|
|
28
29
|
buffer_name: str,
|
|
29
30
|
attempts: int = 3,
|
|
30
31
|
provider: str = "fake",
|
|
32
|
+
*,
|
|
33
|
+
bypass_non_input_gate: bool = False,
|
|
31
34
|
) -> dict[str, Any]:
|
|
32
35
|
token_match = re.search(r"\[team-agent-token:([^\]]+)\]", text)
|
|
33
36
|
token = token_match.group(1) if token_match else ""
|
|
@@ -37,15 +40,25 @@ def _tmux_inject_text(
|
|
|
37
40
|
submit_settle_timeout = _tmux_submit_settle_timeout(text)
|
|
38
41
|
text_bytes = _tmux_text_size(text)
|
|
39
42
|
for attempt in range(1, max(attempts, 1) + 1):
|
|
40
|
-
prepared =
|
|
43
|
+
prepared = (
|
|
44
|
+
{"ok": True, "verification": "non_input_gate_bypassed"}
|
|
45
|
+
if bypass_non_input_gate
|
|
46
|
+
else _prepare_tmux_pane_for_input(target)
|
|
47
|
+
)
|
|
41
48
|
if not prepared["ok"]:
|
|
42
|
-
attempt_log.append(
|
|
49
|
+
attempt_log.append(_prepare_failure_attempt(attempt, prepared))
|
|
43
50
|
return {
|
|
44
51
|
"ok": False,
|
|
52
|
+
"status": "failed",
|
|
45
53
|
"stage": prepared["stage"],
|
|
54
|
+
"reason": prepared.get("reason"),
|
|
46
55
|
"error": prepared.get("error"),
|
|
47
56
|
"attempts": attempt_log,
|
|
48
57
|
"verification": prepared["verification"],
|
|
58
|
+
"detected": prepared.get("detected"),
|
|
59
|
+
"pane_id": prepared.get("pane_id"),
|
|
60
|
+
"pane_mode": prepared.get("pane_mode"),
|
|
61
|
+
"pane_capture_tail": prepared.get("pane_capture_tail"),
|
|
49
62
|
}
|
|
50
63
|
baseline = _capture_tmux_pane_text(target)
|
|
51
64
|
if not baseline["ok"]:
|
|
@@ -97,6 +110,9 @@ def _tmux_inject_text(
|
|
|
97
110
|
attempt_entry["buffer_delete_error"] = deleted.get("error")
|
|
98
111
|
if prepared.get("recovered_from_mode"):
|
|
99
112
|
attempt_entry["recovered_from_mode"] = True
|
|
113
|
+
attempt_entry["recovered_from_pane_mode"] = prepared.get("pane_mode")
|
|
114
|
+
if prepared.get("warning_event"):
|
|
115
|
+
attempt_entry["warning_event"] = prepared["warning_event"]
|
|
100
116
|
attempt_log.append(attempt_entry)
|
|
101
117
|
if not visible:
|
|
102
118
|
time.sleep(0.2)
|
|
@@ -276,50 +292,164 @@ def _tmux_load_buffer_stdin(buffer_name: str, text: str) -> subprocess.Completed
|
|
|
276
292
|
|
|
277
293
|
|
|
278
294
|
def _prepare_tmux_pane_for_input(target: str) -> dict[str, Any]:
|
|
279
|
-
|
|
280
|
-
if
|
|
295
|
+
mode_result = _pane_mode(target)
|
|
296
|
+
if not mode_result["ok"]:
|
|
281
297
|
return {
|
|
282
298
|
"ok": False,
|
|
283
299
|
"stage": "pane-mode-check",
|
|
284
300
|
"verification": "pane_mode_check_failed",
|
|
285
|
-
"error":
|
|
301
|
+
"error": mode_result.get("error") or "tmux pane mode check failed",
|
|
286
302
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
cancel = run_cmd(["tmux", "send-keys", "-t", target, "-X", "cancel"], timeout=10)
|
|
290
|
-
if cancel.returncode != 0:
|
|
303
|
+
capture_result = _pane_capture_tail(target, lines=30)
|
|
304
|
+
if not capture_result["ok"]:
|
|
291
305
|
return {
|
|
292
306
|
"ok": False,
|
|
293
|
-
"stage": "pane-
|
|
294
|
-
"verification": "
|
|
295
|
-
"error":
|
|
307
|
+
"stage": "pane-tail-capture",
|
|
308
|
+
"verification": "pane_tail_capture_failed",
|
|
309
|
+
"error": capture_result.get("error") or "tmux capture-pane failed",
|
|
296
310
|
}
|
|
311
|
+
pane_mode = _normalize_pane_mode(mode_result.get("pane_mode"))
|
|
312
|
+
capture_tail = str(capture_result.get("capture") or "")
|
|
313
|
+
detected = detect_non_input_scrollback(capture_tail)
|
|
314
|
+
if detected:
|
|
315
|
+
return _non_input_refusal(target, pane_mode, capture_tail, detected)
|
|
316
|
+
if not pane_mode:
|
|
317
|
+
return {"ok": True, "verification": "pane_input_ready"}
|
|
318
|
+
cancel = _pane_mode_cancel(target, pane_mode)
|
|
319
|
+
if not cancel["ok"]:
|
|
320
|
+
return _non_input_refusal(
|
|
321
|
+
target,
|
|
322
|
+
pane_mode,
|
|
323
|
+
capture_tail,
|
|
324
|
+
f"tmux_{pane_mode}",
|
|
325
|
+
error=cancel.get("error") or "tmux pane mode cancel failed",
|
|
326
|
+
verification="pane_mode_cancel_failed",
|
|
327
|
+
warning_event=cancel.get("warning_event"),
|
|
328
|
+
)
|
|
329
|
+
warning_event = cancel.get("warning_event")
|
|
297
330
|
deadline = time.monotonic() + 1.5
|
|
298
331
|
while True:
|
|
299
|
-
check =
|
|
300
|
-
if check
|
|
332
|
+
check = _pane_mode(target)
|
|
333
|
+
if not check["ok"]:
|
|
301
334
|
return {
|
|
302
335
|
"ok": False,
|
|
303
336
|
"stage": "pane-mode-check",
|
|
304
337
|
"verification": "pane_mode_recheck_failed",
|
|
305
|
-
"error": check.
|
|
338
|
+
"error": check.get("error") or "tmux pane mode recheck failed",
|
|
306
339
|
}
|
|
307
|
-
if check.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
"
|
|
312
|
-
"
|
|
313
|
-
"verification": "pane_mode_still_active_after_cancel",
|
|
314
|
-
"error": "tmux pane stayed in copy-mode after cancel",
|
|
340
|
+
if not _normalize_pane_mode(check.get("pane_mode")):
|
|
341
|
+
result = {
|
|
342
|
+
"ok": True,
|
|
343
|
+
"verification": "pane_input_ready_after_mode_cancel",
|
|
344
|
+
"recovered_from_mode": True,
|
|
345
|
+
"pane_mode": pane_mode,
|
|
315
346
|
}
|
|
347
|
+
if warning_event:
|
|
348
|
+
result["warning_event"] = warning_event
|
|
349
|
+
return result
|
|
350
|
+
if time.monotonic() >= deadline:
|
|
351
|
+
return _non_input_refusal(
|
|
352
|
+
target,
|
|
353
|
+
pane_mode,
|
|
354
|
+
capture_tail,
|
|
355
|
+
f"tmux_{pane_mode}",
|
|
356
|
+
error=f"tmux pane stayed in {pane_mode} after cancel",
|
|
357
|
+
verification="pane_mode_still_active_after_cancel",
|
|
358
|
+
warning_event=warning_event,
|
|
359
|
+
)
|
|
316
360
|
time.sleep(0.1)
|
|
317
361
|
|
|
318
362
|
|
|
363
|
+
def _pane_mode(target: str) -> dict[str, Any]:
|
|
364
|
+
proc = run_cmd(["tmux", "display-message", "-p", "-t", target, "#{pane_mode}"], timeout=5)
|
|
365
|
+
if proc.returncode != 0:
|
|
366
|
+
return {"ok": False, "error": proc.stderr.strip() or "tmux pane mode check failed"}
|
|
367
|
+
return {"ok": True, "pane_mode": proc.stdout.strip()}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _pane_capture_tail(target: str, lines: int = 30) -> dict[str, Any]:
|
|
371
|
+
capture = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{lines}", "-t", target], timeout=5)
|
|
372
|
+
if capture.returncode != 0:
|
|
373
|
+
return {"ok": False, "capture": "", "error": capture.stderr.strip() or "tmux capture-pane failed"}
|
|
374
|
+
return {"ok": True, "capture": capture.stdout}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _pane_mode_cancel(target: str, pane_mode: str) -> dict[str, Any]:
|
|
378
|
+
mode = _normalize_pane_mode(pane_mode)
|
|
379
|
+
warning_event = None
|
|
380
|
+
if mode == "copy-mode":
|
|
381
|
+
args = ["tmux", "send-keys", "-t", target, "-X", "cancel"]
|
|
382
|
+
elif mode in {"tree-mode", "view-mode"}:
|
|
383
|
+
args = ["tmux", "send-keys", "-t", target, "q"]
|
|
384
|
+
elif mode == "client-mode":
|
|
385
|
+
args = ["tmux", "send-keys", "-t", target, "d"]
|
|
386
|
+
else:
|
|
387
|
+
args = ["tmux", "send-keys", "-t", target, "-X", "cancel"]
|
|
388
|
+
warning_event = "pane_mode_unknown_cancel_attempted"
|
|
389
|
+
cancel = run_cmd(args, timeout=10)
|
|
390
|
+
if cancel.returncode != 0:
|
|
391
|
+
return {
|
|
392
|
+
"ok": False,
|
|
393
|
+
"error": cancel.stderr.strip() or f"tmux {mode or 'unknown'} cancel failed",
|
|
394
|
+
"warning_event": warning_event,
|
|
395
|
+
}
|
|
396
|
+
result = {"ok": True, "mode": mode, "args": args}
|
|
397
|
+
if warning_event:
|
|
398
|
+
result["warning_event"] = warning_event
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _normalize_pane_mode(mode: Any) -> str:
|
|
403
|
+
value = str(mode or "").strip()
|
|
404
|
+
if value == "0":
|
|
405
|
+
return ""
|
|
406
|
+
if value == "1":
|
|
407
|
+
return "copy-mode"
|
|
408
|
+
return value
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _non_input_refusal(
|
|
412
|
+
target: str,
|
|
413
|
+
pane_mode: str,
|
|
414
|
+
capture_tail: str,
|
|
415
|
+
detected: str,
|
|
416
|
+
*,
|
|
417
|
+
error: str | None = None,
|
|
418
|
+
verification: str = "recipient_pane_in_non_input_mode",
|
|
419
|
+
warning_event: str | None = None,
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
result = {
|
|
422
|
+
"ok": False,
|
|
423
|
+
"status": "failed",
|
|
424
|
+
"stage": "pre-paste-pane-state",
|
|
425
|
+
"reason": "recipient_pane_in_non_input_mode",
|
|
426
|
+
"error": error or "recipient_pane_in_non_input_mode",
|
|
427
|
+
"verification": verification,
|
|
428
|
+
"detected": detected,
|
|
429
|
+
"pane_id": target,
|
|
430
|
+
"pane_mode": pane_mode,
|
|
431
|
+
"pane_capture_tail": non_input_scrollback_window(capture_tail) or _last_lines(capture_tail, 10),
|
|
432
|
+
}
|
|
433
|
+
if warning_event:
|
|
434
|
+
result["warning_event"] = warning_event
|
|
435
|
+
return result
|
|
319
436
|
|
|
320
437
|
|
|
438
|
+
def _prepare_failure_attempt(attempt: int, prepared: dict[str, Any]) -> dict[str, Any]:
|
|
439
|
+
entry = {
|
|
440
|
+
"attempt": attempt,
|
|
441
|
+
"visible": False,
|
|
442
|
+
"verification": prepared["verification"],
|
|
443
|
+
}
|
|
444
|
+
for key in ("reason", "detected", "pane_id", "pane_mode", "pane_capture_tail", "warning_event"):
|
|
445
|
+
if key in prepared:
|
|
446
|
+
entry[key] = prepared[key]
|
|
447
|
+
return entry
|
|
321
448
|
|
|
322
449
|
|
|
450
|
+
def _last_lines(text: str, count: int) -> str:
|
|
451
|
+
lines = text.splitlines()
|
|
452
|
+
return "\n".join(lines[-count:])
|
|
323
453
|
|
|
324
454
|
|
|
325
455
|
|
|
@@ -12,6 +12,93 @@ from team_agent.messaging.deps import (
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
|
+
|
|
16
|
+
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_non_input_scrollback(capture_tail: str) -> str | None:
|
|
20
|
+
nonempty = _non_input_scrollback_lines(capture_tail)
|
|
21
|
+
tail_text = "\n".join(nonempty)
|
|
22
|
+
lower = tail_text.lower()
|
|
23
|
+
stale_before_input = _stale_non_input_before_ready_prompt(nonempty)
|
|
24
|
+
if re.search(r"do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory", lower):
|
|
25
|
+
if stale_before_input:
|
|
26
|
+
return None
|
|
27
|
+
return "codex_trust_prompt"
|
|
28
|
+
if "press enter to log in" in lower or "press enter to login" in lower:
|
|
29
|
+
if stale_before_input:
|
|
30
|
+
return None
|
|
31
|
+
return "codex_first_run_auth"
|
|
32
|
+
if "capability may degrade" in lower:
|
|
33
|
+
if stale_before_input:
|
|
34
|
+
return None
|
|
35
|
+
return "codex_compaction_warning"
|
|
36
|
+
if re.search(r"press\s+(enter|return)\s+to\s+continue", lower):
|
|
37
|
+
if stale_before_input:
|
|
38
|
+
return None
|
|
39
|
+
return "generic_press_enter"
|
|
40
|
+
if re.search(r"press\s+any\s+key", lower):
|
|
41
|
+
if stale_before_input:
|
|
42
|
+
return None
|
|
43
|
+
return "generic_press_enter"
|
|
44
|
+
if re.search(r"(\(y/n\)|\([yY]/n\)|\[y/N\]|\[Y/n\]|\[y/n\])", tail_text):
|
|
45
|
+
if stale_before_input:
|
|
46
|
+
return None
|
|
47
|
+
return "y_n_confirm"
|
|
48
|
+
for first, second in zip(nonempty, nonempty[1:]):
|
|
49
|
+
if _starts_numbered_choice(first, "1") and _starts_numbered_choice(second, "2"):
|
|
50
|
+
if stale_before_input:
|
|
51
|
+
return None
|
|
52
|
+
return "numbered_menu"
|
|
53
|
+
if nonempty:
|
|
54
|
+
last = nonempty[-1]
|
|
55
|
+
if re.search(r"(^|[\s~/.\w-])[$%]\s*$", last):
|
|
56
|
+
return "shell_prompt_cli_dead"
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def non_input_scrollback_window(capture_tail: str, limit: int = 15) -> str:
|
|
61
|
+
return "\n".join(_non_input_scrollback_lines(capture_tail, limit=limit))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _non_input_scrollback_lines(capture_tail: str, limit: int = 15) -> list[str]:
|
|
65
|
+
lines = [_ANSI_ESCAPE_RE.sub("", line).rstrip() for line in capture_tail.splitlines()]
|
|
66
|
+
while lines and not lines[-1].strip():
|
|
67
|
+
lines.pop()
|
|
68
|
+
return [line for line in lines if line.strip()][-limit:]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _starts_numbered_choice(line: str, number: str) -> bool:
|
|
72
|
+
return bool(re.match(rf"^\s*(?:[›❯>]\s*)?{number}\.\s+", line))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _stale_non_input_before_ready_prompt(lines: list[str]) -> bool:
|
|
76
|
+
latest_non_input = -1
|
|
77
|
+
latest_ready = -1
|
|
78
|
+
for index, line in enumerate(lines):
|
|
79
|
+
lower = line.lower()
|
|
80
|
+
if (
|
|
81
|
+
"do you trust the contents of this directory" in lower
|
|
82
|
+
or re.search(r"press\s+(enter|return)\s+to\s+continue", lower)
|
|
83
|
+
or re.search(r"press\s+any\s+key", lower)
|
|
84
|
+
or _starts_numbered_choice(line, "1")
|
|
85
|
+
or _starts_numbered_choice(line, "2")
|
|
86
|
+
):
|
|
87
|
+
latest_non_input = index
|
|
88
|
+
if _is_input_ready_prompt(line):
|
|
89
|
+
latest_ready = index
|
|
90
|
+
return latest_non_input >= 0 and latest_ready > latest_non_input
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_input_ready_prompt(line: str) -> bool:
|
|
94
|
+
if _starts_numbered_choice(line, "1") or _starts_numbered_choice(line, "2"):
|
|
95
|
+
return False
|
|
96
|
+
value = line.strip()
|
|
97
|
+
if re.match(r"^[›❯>]\s+\S", value):
|
|
98
|
+
return True
|
|
99
|
+
return bool(re.search(r"\b(codex|claude)\s*[>›❯]\s*$", value, re.IGNORECASE))
|
|
100
|
+
|
|
101
|
+
|
|
15
102
|
def _enable_codex_fast_mode(session_name: str, window_name: str) -> dict[str, Any]:
|
|
16
103
|
target = f"{session_name}:{window_name}"
|
|
17
104
|
proc = run_cmd(["tmux", "send-keys", "-t", target, "/fast", "Enter"], timeout=10)
|