@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
@@ -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,7 +29,52 @@ 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]:
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
+ }
32
78
  token_match = re.search(r"\[team-agent-token:([^\]]+)\]", text)
33
79
  token = token_match.group(1) if token_match else ""
34
80
  attempt_log: list[dict[str, Any]] = []
@@ -37,15 +83,25 @@ def _tmux_inject_text(
37
83
  submit_settle_timeout = _tmux_submit_settle_timeout(text)
38
84
  text_bytes = _tmux_text_size(text)
39
85
  for attempt in range(1, max(attempts, 1) + 1):
40
- prepared = _prepare_tmux_pane_for_input(target)
86
+ prepared = (
87
+ {"ok": True, "verification": "non_input_gate_bypassed"}
88
+ if bypass_non_input_gate
89
+ else _prepare_tmux_pane_for_input(target)
90
+ )
41
91
  if not prepared["ok"]:
42
- attempt_log.append({"attempt": attempt, "visible": False, "verification": prepared["verification"]})
92
+ attempt_log.append(_prepare_failure_attempt(attempt, prepared))
43
93
  return {
44
94
  "ok": False,
95
+ "status": "failed",
45
96
  "stage": prepared["stage"],
97
+ "reason": prepared.get("reason"),
46
98
  "error": prepared.get("error"),
47
99
  "attempts": attempt_log,
48
100
  "verification": prepared["verification"],
101
+ "detected": prepared.get("detected"),
102
+ "pane_id": prepared.get("pane_id"),
103
+ "pane_mode": prepared.get("pane_mode"),
104
+ "pane_capture_tail": prepared.get("pane_capture_tail"),
49
105
  }
50
106
  baseline = _capture_tmux_pane_text(target)
51
107
  if not baseline["ok"]:
@@ -97,6 +153,9 @@ def _tmux_inject_text(
97
153
  attempt_entry["buffer_delete_error"] = deleted.get("error")
98
154
  if prepared.get("recovered_from_mode"):
99
155
  attempt_entry["recovered_from_mode"] = True
156
+ attempt_entry["recovered_from_pane_mode"] = prepared.get("pane_mode")
157
+ if prepared.get("warning_event"):
158
+ attempt_entry["warning_event"] = prepared["warning_event"]
100
159
  attempt_log.append(attempt_entry)
101
160
  if not visible:
102
161
  time.sleep(0.2)
@@ -118,6 +177,11 @@ def _tmux_inject_text(
118
177
  "submit_attempts": submit.get("attempts"),
119
178
  }
120
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.
121
185
  turn_visible, turn_verification, turn_capture = _wait_for_leader_new_turn(
122
186
  target,
123
187
  text,
@@ -126,16 +190,7 @@ def _tmux_inject_text(
126
190
  timeout=2.0,
127
191
  )
128
192
  if not turn_visible:
129
- return {
130
- "ok": False,
131
- "stage": "turn-boundary-verification",
132
- "error": f"leader turn boundary not verified: {turn_verification}",
133
- "attempts": attempt_log,
134
- "verification": verification,
135
- "submit_verification": submit_verification,
136
- "turn_verification": turn_verification,
137
- "submit_attempts": submit.get("attempts"),
138
- }
193
+ turn_verification = "not_yet_observed"
139
194
  return {
140
195
  "ok": True,
141
196
  "stage": "submitted",
@@ -276,50 +331,164 @@ def _tmux_load_buffer_stdin(buffer_name: str, text: str) -> subprocess.Completed
276
331
 
277
332
 
278
333
  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:
334
+ mode_result = _pane_mode(target)
335
+ if not mode_result["ok"]:
281
336
  return {
282
337
  "ok": False,
283
338
  "stage": "pane-mode-check",
284
339
  "verification": "pane_mode_check_failed",
285
- "error": mode.stderr.strip() or "tmux pane mode check failed",
340
+ "error": mode_result.get("error") or "tmux pane mode check failed",
286
341
  }
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:
342
+ capture_result = _pane_capture_tail(target, lines=30)
343
+ if not capture_result["ok"]:
291
344
  return {
292
345
  "ok": False,
293
- "stage": "pane-mode-cancel",
294
- "verification": "pane_mode_cancel_failed",
295
- "error": cancel.stderr.strip() or "tmux copy-mode cancel failed",
346
+ "stage": "pane-tail-capture",
347
+ "verification": "pane_tail_capture_failed",
348
+ "error": capture_result.get("error") or "tmux capture-pane failed",
296
349
  }
350
+ pane_mode = _normalize_pane_mode(mode_result.get("pane_mode"))
351
+ capture_tail = str(capture_result.get("capture") or "")
352
+ detected = detect_non_input_scrollback(capture_tail)
353
+ if detected:
354
+ return _non_input_refusal(target, pane_mode, capture_tail, detected)
355
+ if not pane_mode:
356
+ return {"ok": True, "verification": "pane_input_ready"}
357
+ cancel = _pane_mode_cancel(target, pane_mode)
358
+ if not cancel["ok"]:
359
+ return _non_input_refusal(
360
+ target,
361
+ pane_mode,
362
+ capture_tail,
363
+ f"tmux_{pane_mode}",
364
+ error=cancel.get("error") or "tmux pane mode cancel failed",
365
+ verification="pane_mode_cancel_failed",
366
+ warning_event=cancel.get("warning_event"),
367
+ )
368
+ warning_event = cancel.get("warning_event")
297
369
  deadline = time.monotonic() + 1.5
298
370
  while True:
299
- check = run_cmd(["tmux", "display-message", "-p", "-t", target, "#{pane_in_mode}"], timeout=5)
300
- if check.returncode != 0:
371
+ check = _pane_mode(target)
372
+ if not check["ok"]:
301
373
  return {
302
374
  "ok": False,
303
375
  "stage": "pane-mode-check",
304
376
  "verification": "pane_mode_recheck_failed",
305
- "error": check.stderr.strip() or "tmux pane mode recheck failed",
377
+ "error": check.get("error") or "tmux pane mode recheck failed",
306
378
  }
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",
379
+ if not _normalize_pane_mode(check.get("pane_mode")):
380
+ result = {
381
+ "ok": True,
382
+ "verification": "pane_input_ready_after_mode_cancel",
383
+ "recovered_from_mode": True,
384
+ "pane_mode": pane_mode,
315
385
  }
386
+ if warning_event:
387
+ result["warning_event"] = warning_event
388
+ return result
389
+ if time.monotonic() >= deadline:
390
+ return _non_input_refusal(
391
+ target,
392
+ pane_mode,
393
+ capture_tail,
394
+ f"tmux_{pane_mode}",
395
+ error=f"tmux pane stayed in {pane_mode} after cancel",
396
+ verification="pane_mode_still_active_after_cancel",
397
+ warning_event=warning_event,
398
+ )
316
399
  time.sleep(0.1)
317
400
 
318
401
 
402
+ def _pane_mode(target: str) -> dict[str, Any]:
403
+ proc = run_cmd(["tmux", "display-message", "-p", "-t", target, "#{pane_mode}"], timeout=5)
404
+ if proc.returncode != 0:
405
+ return {"ok": False, "error": proc.stderr.strip() or "tmux pane mode check failed"}
406
+ return {"ok": True, "pane_mode": proc.stdout.strip()}
407
+
408
+
409
+ def _pane_capture_tail(target: str, lines: int = 30) -> dict[str, Any]:
410
+ capture = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{lines}", "-t", target], timeout=5)
411
+ if capture.returncode != 0:
412
+ return {"ok": False, "capture": "", "error": capture.stderr.strip() or "tmux capture-pane failed"}
413
+ return {"ok": True, "capture": capture.stdout}
414
+
415
+
416
+ def _pane_mode_cancel(target: str, pane_mode: str) -> dict[str, Any]:
417
+ mode = _normalize_pane_mode(pane_mode)
418
+ warning_event = None
419
+ if mode == "copy-mode":
420
+ args = ["tmux", "send-keys", "-t", target, "-X", "cancel"]
421
+ elif mode in {"tree-mode", "view-mode"}:
422
+ args = ["tmux", "send-keys", "-t", target, "q"]
423
+ elif mode == "client-mode":
424
+ args = ["tmux", "send-keys", "-t", target, "d"]
425
+ else:
426
+ args = ["tmux", "send-keys", "-t", target, "-X", "cancel"]
427
+ warning_event = "pane_mode_unknown_cancel_attempted"
428
+ cancel = run_cmd(args, timeout=10)
429
+ if cancel.returncode != 0:
430
+ return {
431
+ "ok": False,
432
+ "error": cancel.stderr.strip() or f"tmux {mode or 'unknown'} cancel failed",
433
+ "warning_event": warning_event,
434
+ }
435
+ result = {"ok": True, "mode": mode, "args": args}
436
+ if warning_event:
437
+ result["warning_event"] = warning_event
438
+ return result
439
+
440
+
441
+ def _normalize_pane_mode(mode: Any) -> str:
442
+ value = str(mode or "").strip()
443
+ if value == "0":
444
+ return ""
445
+ if value == "1":
446
+ return "copy-mode"
447
+ return value
448
+
449
+
450
+ def _non_input_refusal(
451
+ target: str,
452
+ pane_mode: str,
453
+ capture_tail: str,
454
+ detected: str,
455
+ *,
456
+ error: str | None = None,
457
+ verification: str = "recipient_pane_in_non_input_mode",
458
+ warning_event: str | None = None,
459
+ ) -> dict[str, Any]:
460
+ result = {
461
+ "ok": False,
462
+ "status": "failed",
463
+ "stage": "pre-paste-pane-state",
464
+ "reason": "recipient_pane_in_non_input_mode",
465
+ "error": error or "recipient_pane_in_non_input_mode",
466
+ "verification": verification,
467
+ "detected": detected,
468
+ "pane_id": target,
469
+ "pane_mode": pane_mode,
470
+ "pane_capture_tail": non_input_scrollback_window(capture_tail) or _last_lines(capture_tail, 10),
471
+ }
472
+ if warning_event:
473
+ result["warning_event"] = warning_event
474
+ return result
319
475
 
320
476
 
477
+ def _prepare_failure_attempt(attempt: int, prepared: dict[str, Any]) -> dict[str, Any]:
478
+ entry = {
479
+ "attempt": attempt,
480
+ "visible": False,
481
+ "verification": prepared["verification"],
482
+ }
483
+ for key in ("reason", "detected", "pane_id", "pane_mode", "pane_capture_tail", "warning_event"):
484
+ if key in prepared:
485
+ entry[key] = prepared[key]
486
+ return entry
321
487
 
322
488
 
489
+ def _last_lines(text: str, count: int) -> str:
490
+ lines = text.splitlines()
491
+ return "\n".join(lines[-count:])
323
492
 
324
493
 
325
494
 
@@ -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)
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from team_agent.events import EventLog
7
+ from team_agent.messaging.deps import _tmux_inject_text
8
+
9
+
10
+ def retry_injection_after_trust_auto_answer(
11
+ workspace: Path,
12
+ state: dict[str, Any],
13
+ event_log: EventLog,
14
+ injection: dict[str, Any],
15
+ target: str,
16
+ text: str,
17
+ submit_key: str,
18
+ buffer_name: str,
19
+ provider: str,
20
+ ) -> dict[str, Any]:
21
+ from team_agent.messaging.delivery import _tmux_pane_width, _wait_for_trust_prompt_dismissal
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"]
31
+ answer = attempt_trust_auto_answer(
32
+ workspace,
33
+ pane_target,
34
+ injection.get("pane_capture_tail") or "",
35
+ event_log,
36
+ state=trust_state,
37
+ )
38
+ if not answer.get("answered"):
39
+ return injection
40
+ if not _wait_for_trust_prompt_dismissal(injection.get("pane_id") or target, timeout=3.0):
41
+ retry_blocked = dict(injection)
42
+ retry_blocked["error"] = "trust_prompt_not_dismissed_after_answer"
43
+ retry_blocked["verification"] = "trust_prompt_not_dismissed_after_answer"
44
+ retry_blocked["stage"] = "trust_auto_answer_dismissal_wait"
45
+ return retry_blocked
46
+ return _tmux_inject_text(
47
+ target,
48
+ text,
49
+ submit_key,
50
+ buffer_name,
51
+ provider=provider,
52
+ )
@@ -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"]