@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.
- package/package.json +1 -1
- package/schemas/team.schema.json +6 -0
- package/src/team_agent/abnormal_track.py +253 -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/compiler.py +1 -1
- package/src/team_agent/coordinator/lifecycle.py +23 -2
- package/src/team_agent/diagnose/orphan_cleanup.py +199 -28
- package/src/team_agent/display/__init__.py +31 -0
- package/src/team_agent/display/adaptive.py +425 -0
- package/src/team_agent/display/backend.py +46 -0
- package/src/team_agent/display/close.py +6 -0
- package/src/team_agent/display/rebuild.py +102 -0
- package/src/team_agent/display/tiling.py +156 -0
- package/src/team_agent/display/worker_window.py +4 -0
- package/src/team_agent/display/workspace.py +36 -127
- package/src/team_agent/idle_predicate.py +200 -0
- package/src/team_agent/idle_takeover.py +59 -0
- package/src/team_agent/idle_takeover_wiring.py +111 -0
- package/src/team_agent/launch/core.py +14 -4
- package/src/team_agent/leader/__init__.py +444 -61
- 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 +38 -11
- package/src/team_agent/message_store/leader_notification_log.py +47 -26
- package/src/team_agent/message_store/schema.py +8 -2
- package/src/team_agent/messaging/delivery.py +336 -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 +294 -0
- package/src/team_agent/messaging/scheduler.py +12 -0
- package/src/team_agent/messaging/send.py +54 -26
- package/src/team_agent/messaging/tmux_io.py +202 -33
- package/src/team_agent/messaging/tmux_prompt.py +87 -0
- package/src/team_agent/messaging/trust_auto_answer.py +52 -0
- package/src/team_agent/provider_state/README.md +78 -0
- package/src/team_agent/provider_state/__init__.py +86 -0
- package/src/team_agent/provider_state/claude.py +86 -0
- package/src/team_agent/provider_state/codex.py +84 -0
- package/src/team_agent/provider_state/common.py +207 -0
- package/src/team_agent/provider_state/registry.py +118 -0
- package/src/team_agent/restart/orchestration.py +215 -12
- package/src/team_agent/runtime.py +65 -15
- package/src/team_agent/sessions/capture.py +65 -15
- package/src/team_agent/spec.py +63 -3
- package/src/team_agent/status/queries.py +32 -1
- package/src/team_agent/wake.py +58 -0
- 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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
if
|
|
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":
|
|
340
|
+
"error": mode_result.get("error") or "tmux pane mode check failed",
|
|
286
341
|
}
|
|
287
|
-
|
|
288
|
-
|
|
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-
|
|
294
|
-
"verification": "
|
|
295
|
-
"error":
|
|
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 =
|
|
300
|
-
if check
|
|
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.
|
|
377
|
+
"error": check.get("error") or "tmux pane mode recheck failed",
|
|
306
378
|
}
|
|
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",
|
|
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"]
|