@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.
@@ -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
- 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))
@@ -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 = _prepare_tmux_pane_for_input(target)
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({"attempt": attempt, "visible": False, "verification": prepared["verification"]})
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
- mode = run_cmd(["tmux", "display-message", "-p", "-t", target, "#{pane_in_mode}"], timeout=5)
280
- if mode.returncode != 0:
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": mode.stderr.strip() or "tmux pane mode check failed",
301
+ "error": mode_result.get("error") or "tmux pane mode check failed",
286
302
  }
287
- if mode.stdout.strip() != "1":
288
- return {"ok": True, "verification": "pane_input_ready"}
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-mode-cancel",
294
- "verification": "pane_mode_cancel_failed",
295
- "error": cancel.stderr.strip() or "tmux copy-mode cancel failed",
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 = run_cmd(["tmux", "display-message", "-p", "-t", target, "#{pane_in_mode}"], timeout=5)
300
- if check.returncode != 0:
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.stderr.strip() or "tmux pane mode recheck failed",
338
+ "error": check.get("error") or "tmux pane mode recheck failed",
306
339
  }
307
- if check.stdout.strip() != "1":
308
- return {"ok": True, "verification": "pane_input_ready_after_mode_cancel", "recovered_from_mode": True}
309
- if time.monotonic() >= deadline:
310
- return {
311
- "ok": False,
312
- "stage": "pane-mode-cancel",
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)