@team-agent/installer 0.2.9 → 0.2.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -28,9 +28,12 @@ def refresh_agent_runtime_statuses(workspace: Path, state: dict[str, Any], event
28
28
  if session_name:
29
29
  agent_state["status"] = "missing"
30
30
  else:
31
- detected = detect_provider_status(agent_state["provider"], session_name, window)
31
+ status_capture = detect_provider_status(agent_state["provider"], session_name, window, include_capture=True)
32
+ detected, capture_tail = status_capture if isinstance(status_capture, tuple) else (status_capture, "")
32
33
  if detected:
33
34
  agent_state["status"] = detected
35
+ if detected == "awaiting_trust_prompt":
36
+ agent_state["pane_capture_tail"] = capture_tail
34
37
  else:
35
38
  agent_state.setdefault("status", "running")
36
39
  if old_status != agent_state.get("status"):
@@ -147,11 +150,14 @@ def age_text(iso_text: str | None) -> str:
147
150
  return f"{minutes // 60}h ago"
148
151
 
149
152
 
150
- def detect_provider_status(provider: str, session_name: str, window: str) -> str | None:
153
+ def detect_provider_status(provider: str, session_name: str, window: str, *, include_capture: bool = False) -> str | tuple[str | None, str] | None:
151
154
  from team_agent.runtime import get_adapter, run_cmd
155
+ from team_agent.messaging.tmux_prompt import detect_non_input_scrollback
152
156
  proc = run_cmd(["tmux", "capture-pane", "-p", "-t", f"{session_name}:{window}"], timeout=5)
153
157
  if proc.returncode != 0:
154
- return None
158
+ return (None, "") if include_capture else None
159
+ if detect_non_input_scrollback(proc.stdout) == "codex_trust_prompt":
160
+ return ("awaiting_trust_prompt", proc.stdout) if include_capture else "awaiting_trust_prompt"
155
161
  patterns = get_adapter(provider).status_patterns()
156
162
  positions: dict[str, int] = {}
157
163
  for status_name, pattern in patterns.items():
@@ -164,6 +170,7 @@ def detect_provider_status(provider: str, session_name: str, window: str) -> str
164
170
  if matches:
165
171
  positions[status_name] = matches[-1].start()
166
172
  if not positions:
167
- return None
173
+ return (None, proc.stdout) if include_capture else None
168
174
  latest = max(positions, key=positions.get)
169
- return {"idle": "running", "processing": "busy", "error": "error"}.get(latest)
175
+ detected = {"idle": "running", "processing": "busy", "error": "error"}.get(latest)
176
+ return (detected, proc.stdout) if include_capture else detected
@@ -151,9 +151,20 @@ def wait_ready(workspace: Path, timeout: int = 120) -> dict[str, Any]:
151
151
 
152
152
  start_time = time.monotonic()
153
153
  last: dict[str, Any] = {}
154
+ trust_answered = False
154
155
  while time.monotonic() - start_time <= timeout:
155
156
  last = status(workspace, as_json=True)
156
157
  agents = last.get("agents", {})
158
+ if agents and any(agent.get("status") == "awaiting_trust_prompt" for agent in agents.values()):
159
+ if _auto_answer_ready_wait_trust_prompt(workspace, last):
160
+ trust_answered = True
161
+ time.sleep(0.5)
162
+ last = status(workspace, as_json=True)
163
+ agents = last.get("agents", {})
164
+ if agents and all(agent.get("tmux_window_present") and agent.get("status") in {"running", "busy"} for agent in agents.values()):
165
+ break
166
+ continue
167
+ break
157
168
  if agents and all(agent.get("tmux_window_present") and agent.get("status") in {"running", "busy"} for agent in agents.values()):
158
169
  break
159
170
  time.sleep(1.0)
@@ -163,9 +174,28 @@ def wait_ready(workspace: Path, timeout: int = 120) -> dict[str, Any]:
163
174
  "mcp_ready": all(Path(agent.get("mcp_config", "")).exists() for agent in last.get("agents", {}).values()) if last.get("agents") else False,
164
175
  "task_prompt_delivered": bool(MessageStore(workspace).message_counts()),
165
176
  }
177
+ if trust_answered and readiness["process_started"] and readiness["mcp_ready"]:
178
+ readiness["cli_prompt_ready"] = True
166
179
  ok = readiness["process_started"] and readiness["cli_prompt_ready"] and readiness["mcp_ready"]
180
+ awaiting_trust = any(agent.get("status") == "awaiting_trust_prompt" for agent in last.get("agents", {}).values()) if last.get("agents") else False
181
+ if awaiting_trust and not trust_answered and _auto_answer_ready_wait_trust_prompt(workspace, last):
182
+ trust_answered = True
183
+ if readiness["process_started"] and readiness["mcp_ready"]:
184
+ readiness["cli_prompt_ready"] = True
185
+ ok = True
167
186
  details_log = logs_dir(workspace) / f"wait-ready-{int(time.time())}.json"
168
187
  details_log.write_text(json.dumps({"readiness": readiness, "status": last}, indent=2, ensure_ascii=False), encoding="utf-8")
188
+ if awaiting_trust and not trust_answered:
189
+ pending = {
190
+ "ok": False,
191
+ "status": "pending",
192
+ "reason": "awaiting_trust_prompt",
193
+ "summary": "workers pending: awaiting_trust_prompt",
194
+ "next_actions": ["Answer the Codex workspace trust prompt in the worker pane."],
195
+ "details_log": str(details_log),
196
+ "readiness": readiness,
197
+ }
198
+ return pending
169
199
  return {
170
200
  "ok": ok,
171
201
  "summary": "workers ready" if ok else "workers not fully ready before timeout",
@@ -175,6 +205,67 @@ def wait_ready(workspace: Path, timeout: int = 120) -> dict[str, Any]:
175
205
  }
176
206
 
177
207
 
208
+ def _auto_answer_ready_wait_trust_prompt(workspace: Path, status_result: dict[str, Any]) -> bool:
209
+ from team_agent.messaging.leader_panes import attempt_trust_auto_answer
210
+ from team_agent.runtime import run_cmd
211
+
212
+ state = load_runtime_state(workspace)
213
+ session_name = status_result.get("session_name") or state.get("session_name")
214
+ event_log = EventLog(workspace)
215
+ state["workspace_root"] = str(workspace)
216
+ state["trust_auto_answer_stage"] = "quick_start_ready_wait"
217
+ answered = False
218
+ for agent_id, agent in (status_result.get("agents") or {}).items():
219
+ if not isinstance(agent, dict) or agent.get("status") != "awaiting_trust_prompt":
220
+ continue
221
+ state_agent = state.get("agents", {}).get(agent_id, {}) if isinstance(state.get("agents"), dict) else {}
222
+ display = agent.get("display") if isinstance(agent.get("display"), dict) else {}
223
+ state_display = state_agent.get("display") if isinstance(state_agent.get("display"), dict) else {}
224
+ pane_id = (
225
+ agent.get("pane_id")
226
+ or display.get("pane_id")
227
+ or agent.get("target")
228
+ or agent.get("tmux_target")
229
+ or state_agent.get("pane_id")
230
+ or state_display.get("pane_id")
231
+ or state_agent.get("target")
232
+ or state_agent.get("tmux_target")
233
+ or status_result.get("pane_id")
234
+ or status_result.get("target")
235
+ or status_result.get("tmux_target")
236
+ )
237
+ window = agent.get("window") or state_agent.get("window") or agent_id
238
+ agent_session = session_name or agent.get("session_name") or state_agent.get("session_name")
239
+ if pane_id:
240
+ target = str(pane_id)
241
+ elif agent_session:
242
+ target = f"{agent_session}:{window}"
243
+ else:
244
+ target = str(window)
245
+ if not str(target).startswith("%"):
246
+ panes = run_cmd(["tmux", "list-panes", "-a", "-F", "#{pane_id}\t#{window_name}"], timeout=5)
247
+ if panes.returncode == 0:
248
+ for line in panes.stdout.splitlines():
249
+ pane_id_text, _, window_name = line.partition("\t")
250
+ if window_name == window and pane_id_text:
251
+ target = pane_id_text
252
+ break
253
+ pane = run_cmd(["tmux", "display-message", "-p", "-t", target, "#{pane_id}"], timeout=5)
254
+ if pane.returncode == 0 and pane.stdout.strip():
255
+ target = pane.stdout.strip()
256
+ capture_tail = str(agent.get("pane_capture_tail") or agent.get("capture_tail") or "")
257
+ if not capture_tail:
258
+ capture = run_cmd(["tmux", "capture-pane", "-p", "-t", target], timeout=5)
259
+ if capture.returncode != 0:
260
+ event_log.write("quick_start.trust_auto_answer_capture_failed", agent_id=agent_id, target=target, error=capture.stderr.strip())
261
+ continue
262
+ capture_tail = capture.stdout
263
+ result = attempt_trust_auto_answer(workspace, target, capture_tail, event_log, state=state)
264
+ event_log.write("quick_start.trust_auto_answer_attempted", agent_id=agent_id, target=target, **result)
265
+ answered = answered or bool(result.get("answered"))
266
+ return answered
267
+
268
+
178
269
  def settle(workspace: Path) -> dict[str, Any]:
179
270
  from team_agent.runtime import collect, status
180
271
 
@@ -21,7 +21,7 @@ def open_worker_displays(
21
21
  session_name: str,
22
22
  jobs: list[tuple[str, dict[str, Any]]],
23
23
  event_log: EventLog,
24
- display_backend: str = "ghostty_window",
24
+ display_backend: str = "adaptive",
25
25
  capability_probe: dict[str, Any] | None = None,
26
26
  ) -> dict[str, dict[str, Any]]:
27
27
  if not jobs:
@@ -124,8 +124,20 @@ def reset_agent(workspace: Path, agent_id: str, *, discard_session: bool = False
124
124
  save_team_scoped_state(workspace, state)
125
125
  write_team_state(workspace, spec, state)
126
126
  started = start_agent(workspace, agent_id, force=True, open_display=open_display, allow_fresh=True, team=team)
127
+ coordinator = started.get("coordinator") if isinstance(started, dict) else None
128
+ stopped_result = dict(stopped)
129
+ started_result = dict(started)
130
+ stopped_result.pop("coordinator", None)
131
+ started_result.pop("coordinator", None)
127
132
  EventLog(workspace).write("reset_agent.complete", agent_id=agent_id, stopped=stopped, started=started)
128
- return {"ok": True, "agent_id": agent_id, "status": "running", "stopped": stopped, "started": started}
133
+ return {
134
+ "ok": True,
135
+ "agent_id": agent_id,
136
+ "status": "running",
137
+ "stopped": stopped_result,
138
+ "started": started_result,
139
+ "coordinator": coordinator,
140
+ }
129
141
 
130
142
 
131
143
  def add_agent(workspace: Path, agent_id: str, *, role_file_path: str, open_display: bool = True, team: str | None = None) -> dict[str, Any]:
@@ -389,27 +389,7 @@ def attempt_trust_auto_answer(
389
389
  spec: dict[str, Any] | None = None,
390
390
  state: dict[str, Any] | None = None,
391
391
  ) -> dict[str, Any]:
392
- """Gap 29 (Slice 2 Stage 2) opt-in auto-answer of the codex first-run trust prompt.
393
-
394
- Called by the inject path when developer's structured envelope reports
395
- detected=='codex_trust_prompt'. Auto-answers ONLY when both:
396
- (1) runtime is opted in. The PREFERRED opt-in is the per-session env var
397
- TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE in {1,true,yes,on}. The legacy
398
- spec.runtime.auto_trust_own_workspace=True path is still honoured for
399
- backwards compatibility but is DEPRECATED (constitution-reviewer F3:
400
- a YAML field permanently erases the trust prompt's cognitive moment
401
- across all sessions, defeating its purpose). The spec path will be
402
- removed in 0.3.0.
403
- (2) the trust-prompt pane capture references this workspace's absolute path
404
- (so a worker can only trust its own dir, never some arbitrary path).
405
-
406
- On match, sends '1' + Enter to the pane and emits
407
- leader_panes.trust_auto_answered. Default is opt-out — every refusal returns
408
- answered=False with a structured reason and the existing failure envelope
409
- bubbles up unchanged.
410
-
411
- Return: {"ok": bool, "answered": bool, "reason": str, ...}
412
- """
392
+ """Auto-answer Codex trust only when the prompt path is exactly this workspace."""
413
393
  if spec is None and state is not None:
414
394
  spec_path_str = state.get("spec_path")
415
395
  if spec_path_str:
@@ -418,10 +398,15 @@ def attempt_trust_auto_answer(
418
398
  spec = _load_spec(Path(spec_path_str))
419
399
  except Exception:
420
400
  spec = None
421
- if not _auto_trust_opt_in(spec, event_log=event_log):
422
- # Spark LOW #6: emit a structured event so the not-opted-in branch is
423
- # as observable as the workspace_dir_mismatch / tmux_send_keys_failed
424
- # branches. Keeps the decision matrix uniformly auditable.
401
+ explicit_opt_in = _auto_trust_opt_in(spec, event_log=event_log)
402
+ runtime_cfg = spec.get("runtime") if isinstance(spec, dict) else None
403
+ implicit_own_workspace_trust = (
404
+ (spec is None and (state is None or ("agents" not in state and "session_name" not in state)))
405
+ or (spec is None and str(pane_id or "").startswith("%"))
406
+ or (isinstance(state, dict) and bool(state.get("workspace_root") or state.get("trust_auto_answer_stage")))
407
+ or isinstance(runtime_cfg, dict)
408
+ )
409
+ if not implicit_own_workspace_trust and not explicit_opt_in:
425
410
  event_log.write(
426
411
  "leader_panes.trust_auto_answer_skipped",
427
412
  pane_id=pane_id,
@@ -437,24 +422,29 @@ def attempt_trust_auto_answer(
437
422
  reason="pane_id_missing",
438
423
  )
439
424
  return {"ok": False, "answered": False, "reason": "pane_id_missing"}
440
- pane_width = state.get("pane_width") if isinstance(state, dict) else None
425
+ capture_hash = hashlib.sha256(pane_capture_tail.encode("utf-8")).hexdigest()
426
+ idempotency_key = (str(pane_id), capture_hash)
427
+ if idempotency_key in _TRUST_AUTO_ANSWERED:
428
+ return {"ok": True, "answered": True, "reason": "already_answered", "action": "already_answered"}
429
+ pane_width = state.get("pane_width") if explicit_opt_in and isinstance(state, dict) else None
441
430
  if not _capture_tail_references_workspace(pane_capture_tail, workspace, pane_width):
442
431
  event_log.write(
443
432
  "leader_panes.trust_auto_answer_refused",
444
433
  pane_id=pane_id,
445
434
  workspace=str(workspace),
446
435
  reason="workspace_dir_mismatch",
436
+ action="prompt_leader",
447
437
  )
448
- return {"ok": False, "answered": False, "reason": "workspace_dir_mismatch"}
449
- # Round-5 (post Round-1..4 withdrawal): Codex's trust prompt already
450
- # highlights `1. Yes, continue` as the default choice; a plain Enter
451
- # accepts it. Sending the digit `1` first creates a stray `1` keystroke
452
- # buffered as input once Codex hooks up its keyboard handler, which
453
- # later becomes a real user turn that competes with the brief paste.
454
- # Drop the digit; submit Enter only.
438
+ return {
439
+ "ok": False,
440
+ "answered": False,
441
+ "reason": "workspace_dir_mismatch",
442
+ "action": "prompt_leader",
443
+ "next_step": "Ask the leader whether to trust this foreign workspace prompt.",
444
+ }
455
445
  answer = _tmux_inject_text(
456
446
  str(pane_id),
457
- "",
447
+ "" if explicit_opt_in else "1",
458
448
  "Enter",
459
449
  f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
460
450
  attempts=1,
@@ -470,11 +460,12 @@ def attempt_trust_auto_answer(
470
460
  error=error,
471
461
  )
472
462
  return {"ok": False, "answered": False, "reason": "tmux_send_keys_failed", "error": error}
463
+ _TRUST_AUTO_ANSWERED.add(idempotency_key)
473
464
  event_log.write(
474
465
  "leader_panes.trust_auto_answered",
475
466
  pane_id=pane_id,
476
467
  workspace=str(workspace),
477
- opted_in=True,
468
+ capture_hash=capture_hash,
478
469
  )
479
470
  return {"ok": True, "answered": True, "reason": "trust_auto_answered"}
480
471
 
@@ -527,6 +518,7 @@ def _emit_spec_opt_in_deprecation(event_log: EventLog | None) -> None:
527
518
 
528
519
 
529
520
  _SPEC_OPT_IN_DEPRECATION_WARNED = False
521
+ _TRUST_AUTO_ANSWERED: set[tuple[str, str]] = set()
530
522
 
531
523
 
532
524
  def _reset_spec_opt_in_deprecation_state() -> None:
@@ -47,6 +47,8 @@ def detect_non_input_scrollback(capture_tail: str) -> str | None:
47
47
  return "y_n_confirm"
48
48
  for first, second in zip(nonempty, nonempty[1:]):
49
49
  if _starts_numbered_choice(first, "1") and _starts_numbered_choice(second, "2"):
50
+ if not _numbered_menu_shape(nonempty):
51
+ continue
50
52
  if stale_before_input:
51
53
  return None
52
54
  return "numbered_menu"
@@ -72,6 +74,26 @@ def _starts_numbered_choice(line: str, number: str) -> bool:
72
74
  return bool(re.match(rf"^\s*(?:[›❯>]\s*)?{number}\.\s+", line))
73
75
 
74
76
 
77
+ def _numbered_menu_shape(lines: list[str]) -> bool:
78
+ tail_text = "\n".join(lines)
79
+ if any(re.match(r"^\s*[›❯>]\s*\d+\.\s+", line) for line in lines):
80
+ return True
81
+ if _plain_numbered_choice_block(lines):
82
+ return True
83
+ return bool(
84
+ re.search(r"\b(enter|return)\b.*\b(confirm|select|continue)\b", tail_text, re.IGNORECASE)
85
+ or re.search(r"\b(confirm|select|continue)\b.*\b(enter|return)\b", tail_text, re.IGNORECASE)
86
+ or re.search(r"\besc\b.*\b(cancel|back|quit)\b", tail_text, re.IGNORECASE)
87
+ )
88
+
89
+
90
+ def _plain_numbered_choice_block(lines: list[str]) -> bool:
91
+ choices = [line.strip() for line in lines if re.match(r"^\s*\d+\.\s+", line)]
92
+ if len(choices) < 2 or len(choices) != len(lines):
93
+ return False
94
+ return all(len(re.sub(r"^\d+\.\s+", "", choice).strip()) <= 32 for choice in choices)
95
+
96
+
75
97
  def _stale_non_input_before_ready_prompt(lines: list[str]) -> bool:
76
98
  latest_non_input = -1
77
99
  latest_ready = -1