@team-agent/installer 0.2.8 → 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.8",
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
 
@@ -245,34 +245,51 @@ def adaptive_blocked(
245
245
  return displays
246
246
 
247
247
 
248
- def close_adaptive_display(state: dict[str, Any], event_log: EventLog) -> None:
248
+ def close_adaptive_display(state: dict[str, Any], event_log: EventLog) -> dict[str, Any]:
249
249
  displays = [
250
250
  (agent_id, agent_state.get("display") or {})
251
251
  for agent_id, agent_state in state.get("agents", {}).items()
252
252
  if (agent_state.get("display") or {}).get("backend") == "adaptive"
253
253
  ]
254
254
  if not displays:
255
- return
255
+ return {"windows": [], "linked_sessions": [], "orphans_detected": {}}
256
256
  killed_windows: list[str] = []
257
257
  linked_sessions: list[str] = []
258
+ session_name = str(state.get("session_name") or "")
259
+ leader_session = _adaptive_leader_session(state, displays)
260
+ needs_named_fallback = False
258
261
  for _agent_id, display in displays:
259
262
  linked = display.get("linked_session")
260
263
  if linked:
261
264
  linked_sessions.append(str(linked))
265
+ if not linked or not display.get("leader_session") or not (display.get("workspace_window") or display.get("window")):
266
+ needs_named_fallback = True
262
267
  seen_targets: set[str] = set()
263
268
  for _agent_id, display in displays:
264
- leader_session = str(display.get("leader_session") or "")
269
+ display_leader_session = str(display.get("leader_session") or "")
265
270
  window_name = str(display.get("workspace_window") or display.get("window") or "")
266
- if not leader_session or not window_name:
271
+ if not display_leader_session or not window_name:
267
272
  continue
268
- target = f"{leader_session}:{window_name}"
273
+ target = f"{display_leader_session}:{window_name}"
269
274
  if target in seen_targets:
270
275
  continue
271
276
  seen_targets.add(target)
272
277
  if kill_adaptive_window(target):
273
278
  killed_windows.append(target)
274
- linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
275
- event_log.write("display.adaptive_closed", windows=killed_windows, linked_sessions=linked_closed)
279
+ removed_orphans: dict[str, list[str]] = {}
280
+ if needs_named_fallback and leader_session and session_name:
281
+ named_windows = close_adaptive_windows(leader_session, session_name, event_log)
282
+ killed_windows.extend(named_windows)
283
+ linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
284
+ named_closed, named_failed = _kill_adaptive_named_display_sessions(session_name, [agent_id for agent_id, _display in displays])
285
+ linked_closed.extend(named_closed)
286
+ removed_orphans = _adaptive_orphan_summary(named_closed, named_windows)
287
+ else:
288
+ named_failed = []
289
+ linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
290
+ orphans = _adaptive_orphans(session_name, leader_session, [agent_id for agent_id, _display in displays], named_failed) if needs_named_fallback else {}
291
+ event_log.write("display.adaptive_closed", windows=killed_windows, linked_sessions=linked_closed, orphans_detected=orphans, orphans_removed=removed_orphans)
292
+ return {"windows": killed_windows, "linked_sessions": linked_closed, "orphans_detected": orphans, "orphans_removed": removed_orphans}
276
293
 
277
294
 
278
295
  def close_adaptive_windows(leader_session: str, session_name: str, event_log: EventLog | None = None) -> list[str]:
@@ -293,6 +310,75 @@ def close_adaptive_windows(leader_session: str, session_name: str, event_log: Ev
293
310
  return killed
294
311
 
295
312
 
313
+ def _adaptive_leader_session(state: dict[str, Any], displays: list[tuple[str, dict[str, Any]]]) -> str:
314
+ for _agent_id, display in displays:
315
+ if display.get("leader_session"):
316
+ return str(display["leader_session"])
317
+ receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
318
+ return str(receiver.get("session_name") or "")
319
+
320
+
321
+ def _adaptive_named_display_sessions(session_name: str, agent_ids: list[str], fallback_exact: bool = True) -> list[str]:
322
+ from team_agent.runtime import run_cmd
323
+ if not session_name or not agent_ids:
324
+ return []
325
+ exact = [ghostty_display_session_name(session_name, agent_id) for agent_id in agent_ids]
326
+ proc = run_cmd(["tmux", "list-sessions", "-F", "#{session_name}"], timeout=10)
327
+ if proc.returncode != 0:
328
+ return exact if fallback_exact else []
329
+ prefixes = [ghostty_display_session_name(session_name, agent_id).rsplit("__", 1)[0] + "__" for agent_id in agent_ids]
330
+ matched = [name for name in proc.stdout.splitlines() if any(name.startswith(prefix) for prefix in prefixes)]
331
+ return matched or (exact if fallback_exact else [])
332
+
333
+
334
+ def _kill_adaptive_named_display_sessions(session_name: str, agent_ids: list[str]) -> tuple[list[str], list[str]]:
335
+ from team_agent.runtime import run_cmd
336
+ killed: list[str] = []
337
+ failed: list[str] = []
338
+ for display_session in _adaptive_named_display_sessions(session_name, agent_ids):
339
+ proc = run_cmd(["tmux", "kill-session", "-t", display_session], timeout=10)
340
+ if proc.returncode == 0:
341
+ killed.append(display_session)
342
+ else:
343
+ failed.append(display_session)
344
+ return killed, failed
345
+
346
+
347
+ def _adaptive_orphans(session_name: str, leader_session: str, agent_ids: list[str], failed_sessions: list[str]) -> dict[str, list[str]]:
348
+ display_sessions = sorted(set([*_adaptive_named_display_sessions(session_name, agent_ids, fallback_exact=False), *failed_sessions]))
349
+ windows: list[str] = []
350
+ if leader_session and session_name:
351
+ windows = _adaptive_window_orphans(leader_session, session_name)
352
+ if not display_sessions and not windows:
353
+ return {}
354
+ return {
355
+ "adaptive_display_sessions": sorted(set(display_sessions)),
356
+ "adaptive_overview_windows": sorted(set(windows)),
357
+ }
358
+
359
+
360
+ def _adaptive_orphan_summary(display_sessions: list[str], windows: list[str]) -> dict[str, list[str]]:
361
+ if not display_sessions and not windows:
362
+ return {}
363
+ return {
364
+ "adaptive_display_sessions": sorted(set(display_sessions)),
365
+ "adaptive_overview_windows": sorted(set(windows)),
366
+ }
367
+
368
+
369
+ def _adaptive_window_orphans(leader_session: str, session_name: str) -> list[str]:
370
+ from team_agent.runtime import run_cmd
371
+ prefix = f"team-agent:{session_name}:overview"
372
+ proc = run_cmd(["tmux", "list-windows", "-t", leader_session, "-F", "#{window_name}"], timeout=10)
373
+ if proc.returncode != 0:
374
+ return []
375
+ return [
376
+ f"{leader_session}:{window_name}"
377
+ for window_name in proc.stdout.splitlines()
378
+ if window_name == prefix or window_name.startswith(f"{prefix}-")
379
+ ]
380
+
381
+
296
382
  def kill_adaptive_window(target: str) -> bool:
297
383
  from team_agent.runtime import run_cmd
298
384
  proc = run_cmd(["tmux", "kill-window", "-t", target], timeout=10)
@@ -8,9 +8,10 @@ from team_agent.display.ghostty import ghostty_pids_by_title
8
8
  from team_agent.display.workspace import kill_ghostty_workspace_linked_sessions
9
9
 
10
10
 
11
- def close_team_display_backends(state: dict[str, Any], event_log: EventLog) -> None:
12
- close_adaptive_display(state, event_log)
11
+ def close_team_display_backends(state: dict[str, Any], event_log: EventLog) -> dict[str, Any]:
12
+ result = close_adaptive_display(state, event_log)
13
13
  close_ghostty_workspace(state, event_log)
14
+ return result
14
15
 
15
16
 
16
17
  def close_ghostty_display(
@@ -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
@@ -527,7 +527,7 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
527
527
  if proc.returncode == 0:
528
528
  log_path.write_text(proc.stdout, encoding="utf-8")
529
529
  captured.append(str(log_path))
530
- _close_team_display_backends(state, event_log)
530
+ display_cleanup = _close_team_display_backends(state, event_log)
531
531
  for agent_id, agent_state in state.get("agents", {}).items():
532
532
  _close_ghostty_display(agent_id, agent_state, event_log)
533
533
  closed_displays.add(agent_id)
@@ -541,7 +541,7 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
541
541
  event_log.write("shutdown.kill_session", session=session_name, keep_logs=keep_logs, captured=captured)
542
542
  else:
543
543
  event_log.write("shutdown.idempotent", session=session_name, reason="session missing")
544
- _close_team_display_backends(state, event_log)
544
+ display_cleanup = _close_team_display_backends(state, event_log)
545
545
  for agent_id, agent_state in state.get("agents", {}).items():
546
546
  if agent_id not in closed_displays:
547
547
  _close_ghostty_display(agent_id, agent_state, event_log)
@@ -573,7 +573,7 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
573
573
  archive_path, teams_remaining, new_active = _commit_shutdown_cleanup(
574
574
  workspace, str(resolved_team_id or ""), session_name, event_log
575
575
  )
576
- return {
576
+ result = {
577
577
  "ok": True,
578
578
  "session_name": session_name,
579
579
  "team": resolved_team_id,
@@ -584,6 +584,24 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
584
584
  "new_active_team_key": new_active,
585
585
  "cleanup_mode": "synchronous_committed",
586
586
  }
587
+ removed_orphans = (display_cleanup or {}).get("orphans_removed") or {}
588
+ remaining_orphans = (display_cleanup or {}).get("orphans_detected") or {}
589
+ if removed_orphans:
590
+ result["orphans_detected"] = removed_orphans
591
+ result["warnings"] = ["Adaptive display tmux objects were found and removed during shutdown cleanup."]
592
+ if remaining_orphans:
593
+ result["cleanup_mode"] = "synchronous_with_orphans"
594
+ result["orphans_detected"] = remaining_orphans
595
+ result["warning"] = "Adaptive display tmux objects remain after shutdown cleanup."
596
+ event_log.write(
597
+ "shutdown.orphans_detected",
598
+ warning=result["warning"],
599
+ message=result["warning"],
600
+ orphans_detected=remaining_orphans,
601
+ adaptive_display_sessions=remaining_orphans.get("adaptive_display_sessions", []),
602
+ adaptive_overview_windows=remaining_orphans.get("adaptive_overview_windows", []),
603
+ )
604
+ return result
587
605
 
588
606
 
589
607
  def _commit_shutdown_cleanup(