@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 +1 -1
- package/src/team_agent/approvals/status.py +12 -5
- package/src/team_agent/diagnose/quick_start.py +91 -0
- package/src/team_agent/display/adaptive.py +93 -7
- package/src/team_agent/display/close.py +3 -2
- package/src/team_agent/display/worker_window.py +1 -1
- package/src/team_agent/lifecycle/operations.py +13 -1
- package/src/team_agent/messaging/leader_panes.py +27 -35
- package/src/team_agent/messaging/tmux_prompt.py +22 -0
- package/src/team_agent/runtime.py +21 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
|
271
|
+
if not display_leader_session or not window_name:
|
|
267
272
|
continue
|
|
268
|
-
target = f"{
|
|
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
|
-
|
|
275
|
-
|
|
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) ->
|
|
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 = "
|
|
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 {
|
|
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
|
-
"""
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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 {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|