@team-agent/installer 0.2.5 → 0.2.7
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/README.md +22 -0
- package/npm/bincheck.mjs +70 -0
- package/package.json +2 -1
- package/skills/team-agent/references/bug-as-artifact-flow.md +82 -0
- package/src/team_agent/_legacy_pane_discovery.py +189 -0
- package/src/team_agent/cli/helpers.py +89 -0
- package/src/team_agent/cli/parser.py +5 -0
- package/src/team_agent/diagnose/quick_start.py +1 -1
- package/src/team_agent/leader_binding.py +183 -0
- package/src/team_agent/mcp_server/tools.py +211 -64
- package/src/team_agent/message_store/schema.py +2 -9
- package/src/team_agent/message_store/schema_migration.py +123 -63
- package/src/team_agent/messaging/deps.py +1 -17
- package/src/team_agent/messaging/leader.py +2 -3
- package/src/team_agent/messaging/leader_panes.py +43 -166
- package/src/team_agent/messaging/scheduler.py +1 -1
- package/src/team_agent/provider_cli/adapter.py +10 -5
- package/src/team_agent/provider_cli/codex.py +26 -9
- package/src/team_agent/restart/orchestration.py +12 -0
- package/src/team_agent/runtime.py +246 -79
- package/src/team_agent/state.py +146 -31
|
@@ -157,19 +157,18 @@ class CodexAdapter(ProviderAdapter):
|
|
|
157
157
|
check=False,
|
|
158
158
|
)
|
|
159
159
|
output = proc.stdout if proc.returncode == 0 else ""
|
|
160
|
+
update = maybe_skip_update_prompt(target, output)
|
|
161
|
+
if update:
|
|
162
|
+
handled.append(update)
|
|
163
|
+
if sleep_s > 0:
|
|
164
|
+
time.sleep(sleep_s)
|
|
165
|
+
continue
|
|
160
166
|
trust_pos = max(
|
|
161
167
|
output.rfind("Do you trust the contents of this directory?"),
|
|
162
168
|
output.rfind("Do you trust the files in this folder?"),
|
|
163
169
|
output.rfind("Do you trust this folder?"),
|
|
164
170
|
)
|
|
165
|
-
update_pos = max(output.rfind("Update available!"), output.rfind("Update now"))
|
|
166
171
|
ready_pos = max(output.rfind("OpenAI Codex"), output.rfind("›"), output.rfind("codex>"))
|
|
167
|
-
if update_pos >= 0 and update_pos > ready_pos:
|
|
168
|
-
subprocess.run(["tmux", "send-keys", "-t", target, "Down", "Enter"], check=False)
|
|
169
|
-
handled.append({"prompt": "codex_update_available", "action": "sent_skip"})
|
|
170
|
-
if sleep_s > 0:
|
|
171
|
-
time.sleep(sleep_s)
|
|
172
|
-
continue
|
|
173
172
|
if trust_pos >= 0 and trust_pos > ready_pos:
|
|
174
173
|
subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=False)
|
|
175
174
|
handled.append({"prompt": "codex_workspace_trust", "action": "sent_enter"})
|
|
@@ -183,8 +182,17 @@ class CodexAdapter(ProviderAdapter):
|
|
|
183
182
|
return handled
|
|
184
183
|
|
|
185
184
|
def handle_runtime_prompts(self, session_name: str, window_name: str) -> list[dict[str, Any]]:
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
target = f"{session_name}:{window_name}"
|
|
186
|
+
proc = subprocess.run(
|
|
187
|
+
["tmux", "capture-pane", "-p", "-S", "-", "-t", target],
|
|
188
|
+
text=True,
|
|
189
|
+
capture_output=True,
|
|
190
|
+
timeout=5,
|
|
191
|
+
check=False,
|
|
192
|
+
)
|
|
193
|
+
output = proc.stdout if proc.returncode == 0 else ""
|
|
194
|
+
handled = maybe_skip_update_prompt(target, output)
|
|
195
|
+
return [handled] if handled else []
|
|
188
196
|
|
|
189
197
|
def validate_model(self, model: str | None) -> dict[str, Any]:
|
|
190
198
|
if not model:
|
|
@@ -251,6 +259,15 @@ class CodexAdapter(ProviderAdapter):
|
|
|
251
259
|
return self._model_catalog_cache
|
|
252
260
|
|
|
253
261
|
|
|
262
|
+
def maybe_skip_update_prompt(target: str, output: str) -> dict[str, Any] | None:
|
|
263
|
+
update_pos = max(output.rfind("Update available!"), output.rfind("Update now"))
|
|
264
|
+
ready_pos = max(output.rfind("OpenAI Codex"), output.rfind("›"), output.rfind("codex>"))
|
|
265
|
+
if update_pos >= 0 and update_pos > ready_pos:
|
|
266
|
+
subprocess.run(["tmux", "send-keys", "-t", target, "Down", "Enter"], check=False)
|
|
267
|
+
return {"prompt": "codex_update_available", "action": "sent_skip"}
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
254
271
|
def find_codex_rollout(
|
|
255
272
|
root: Path,
|
|
256
273
|
cwd: Path,
|
|
@@ -367,6 +367,18 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
367
367
|
from team_agent.leader import autobind_leader_receiver_from_env
|
|
368
368
|
leader_provider = str(spec.get("leader", {}).get("provider") or "codex")
|
|
369
369
|
rebound_receiver = autobind_leader_receiver_from_env(workspace, leader_provider, source="restart")
|
|
370
|
+
if rebound_receiver is None and state.get("leader_receiver"):
|
|
371
|
+
stale = state.pop("leader_receiver", None)
|
|
372
|
+
event_log.write(
|
|
373
|
+
"leader_receiver.rebind_required",
|
|
374
|
+
reason="restart_autobind_unresolved",
|
|
375
|
+
old_pane_id=(stale or {}).get("pane_id") if isinstance(stale, dict) else None,
|
|
376
|
+
old_session_name=(stale or {}).get("session_name") if isinstance(stale, dict) else None,
|
|
377
|
+
source="restart",
|
|
378
|
+
)
|
|
379
|
+
save_runtime_state(workspace, state)
|
|
380
|
+
save_team_runtime_snapshot(workspace, state)
|
|
381
|
+
write_team_state(workspace, spec, state)
|
|
370
382
|
rebuild_restart_display_after_rebind(display_backend, workspace, session_name, spec, event_log, restarted, receiver=rebound_receiver)
|
|
371
383
|
coordinator = start_coordinator(workspace)
|
|
372
384
|
event_log.write("restart.complete", session=session_name, agents=restarted, coordinator=coordinator)
|
|
@@ -70,7 +70,7 @@ from team_agent.leader import (
|
|
|
70
70
|
LEADER_OWNERSHIP_LOCK,
|
|
71
71
|
attach_leader,
|
|
72
72
|
attach_leader_to_state as _attach_leader_to_state,
|
|
73
|
-
claim_leader,
|
|
73
|
+
claim_leader as _legacy_claim_leader,
|
|
74
74
|
leader_identity,
|
|
75
75
|
leader_session_name as _leader_session_name,
|
|
76
76
|
leader_start_plan,
|
|
@@ -137,7 +137,7 @@ from team_agent.diagnose import (
|
|
|
137
137
|
preflight_next_actions as _preflight_next_actions,
|
|
138
138
|
profile_checks_for_agents as _profile_checks_for_agents,
|
|
139
139
|
profile_smoke_checks_for_agents as _profile_smoke_checks_for_agents,
|
|
140
|
-
quick_start,
|
|
140
|
+
quick_start as _legacy_quick_start,
|
|
141
141
|
repair_state,
|
|
142
142
|
settle,
|
|
143
143
|
start,
|
|
@@ -491,6 +491,11 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
|
|
|
491
491
|
if gate:
|
|
492
492
|
return gate
|
|
493
493
|
session_name = state.get("session_name")
|
|
494
|
+
resolved_team_id = (
|
|
495
|
+
team
|
|
496
|
+
or state.get("active_team_key")
|
|
497
|
+
or (team_state_key(state) if state.get("session_name") else None)
|
|
498
|
+
)
|
|
494
499
|
event_log = EventLog(workspace)
|
|
495
500
|
captured: list[str] = []
|
|
496
501
|
closed_displays: set[str] = set()
|
|
@@ -561,7 +566,72 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
|
|
|
561
566
|
agent_state["status"] = "stopped"
|
|
562
567
|
save_team_scoped_state(workspace, state)
|
|
563
568
|
_save_team_runtime_snapshot(workspace, state)
|
|
564
|
-
|
|
569
|
+
# 0.2.6 Family B (C10/C11/C12): atomically unregister the team and
|
|
570
|
+
# archive its runtime snapshot directory. Both branches of --keep-logs
|
|
571
|
+
# still drop the team from state.teams; logs survive inside the
|
|
572
|
+
# archived directory.
|
|
573
|
+
archive_path, teams_remaining, new_active = _commit_shutdown_cleanup(
|
|
574
|
+
workspace, str(resolved_team_id or ""), session_name, event_log
|
|
575
|
+
)
|
|
576
|
+
return {
|
|
577
|
+
"ok": True,
|
|
578
|
+
"session_name": session_name,
|
|
579
|
+
"team": resolved_team_id,
|
|
580
|
+
"logs": captured,
|
|
581
|
+
"coordinator": coordinator,
|
|
582
|
+
"archive_path": archive_path,
|
|
583
|
+
"teams_remaining": teams_remaining,
|
|
584
|
+
"new_active_team_key": new_active,
|
|
585
|
+
"cleanup_mode": "synchronous_committed",
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _commit_shutdown_cleanup(
|
|
590
|
+
workspace: Path,
|
|
591
|
+
team_key: str,
|
|
592
|
+
session_name: str | None,
|
|
593
|
+
event_log: EventLog,
|
|
594
|
+
) -> tuple[str | None, list[str], str | None]:
|
|
595
|
+
import shutil as _shutil
|
|
596
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
597
|
+
workspace_state = load_runtime_state(workspace)
|
|
598
|
+
teams = workspace_state.get("teams") if isinstance(workspace_state.get("teams"), dict) else {}
|
|
599
|
+
if team_key and team_key in teams:
|
|
600
|
+
teams.pop(team_key, None)
|
|
601
|
+
if workspace_state.get("active_team_key") == team_key:
|
|
602
|
+
workspace_state["active_team_key"] = None
|
|
603
|
+
workspace_state["teams"] = teams
|
|
604
|
+
archive_dest: Path | None = None
|
|
605
|
+
if session_name:
|
|
606
|
+
runtime_teams_dir = runtime_dir(workspace) / "teams"
|
|
607
|
+
from team_agent.restart.snapshot import safe_snapshot_name as _safe
|
|
608
|
+
snapshot_name = _safe(str(session_name))
|
|
609
|
+
snapshot_dir = runtime_teams_dir / snapshot_name
|
|
610
|
+
if snapshot_dir.exists():
|
|
611
|
+
ts = _dt.now(_tz.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
612
|
+
archive_dest = runtime_teams_dir / f".archived-{snapshot_name}-{ts}"
|
|
613
|
+
try:
|
|
614
|
+
_shutil.move(str(snapshot_dir), str(archive_dest))
|
|
615
|
+
except OSError as exc:
|
|
616
|
+
event_log.write(
|
|
617
|
+
"team.shutdown_blocked",
|
|
618
|
+
reason="archive_move_failed",
|
|
619
|
+
team_key=team_key,
|
|
620
|
+
error=str(exc),
|
|
621
|
+
hint="check filesystem permissions on .team/runtime/teams and rerun shutdown",
|
|
622
|
+
)
|
|
623
|
+
return None, sorted(teams.keys()), workspace_state.get("active_team_key")
|
|
624
|
+
save_runtime_state(workspace, workspace_state)
|
|
625
|
+
archive_path_str = str(archive_dest) if archive_dest is not None else None
|
|
626
|
+
new_active = workspace_state.get("active_team_key")
|
|
627
|
+
event_log.write(
|
|
628
|
+
"team.shutdown_completed",
|
|
629
|
+
team_key=team_key,
|
|
630
|
+
archive_path=archive_path_str,
|
|
631
|
+
teams_remaining=sorted(teams.keys()),
|
|
632
|
+
new_active_team_key=new_active,
|
|
633
|
+
)
|
|
634
|
+
return archive_path_str, sorted(teams.keys()), new_active
|
|
565
635
|
|
|
566
636
|
|
|
567
637
|
|
|
@@ -602,7 +672,44 @@ def acknowledge_idle(workspace: Path, agent_id: str | None = None, *, team: str
|
|
|
602
672
|
EventLog(workspace).write("coordinator.idle_acknowledged", agent_id=agent_id, team=owner_team_id, acknowledged_at=now, expires_at=expires_at, ttl_seconds=ttl_seconds)
|
|
603
673
|
return {"ok": True, "team": owner_team_id, "agent_id": agent_id, "acknowledged_at": now, "expires_at": expires_at, "ttl_seconds": ttl_seconds}
|
|
604
674
|
|
|
675
|
+
_OWNER_IDENTITY_FIELDS = (
|
|
676
|
+
"pane_id",
|
|
677
|
+
"leader_session_uuid",
|
|
678
|
+
"machine_fingerprint",
|
|
679
|
+
"provider",
|
|
680
|
+
"os_user",
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _owner_identity_matches(existing: dict[str, Any], candidate: dict[str, Any]) -> bool:
|
|
685
|
+
for field in _OWNER_IDENTITY_FIELDS:
|
|
686
|
+
if str(existing.get(field) or "") != str(candidate.get(field) or ""):
|
|
687
|
+
return False
|
|
688
|
+
return True
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _resolve_owner_team_id(state: dict[str, Any], team: str | None) -> str | None:
|
|
692
|
+
if team:
|
|
693
|
+
return str(team)
|
|
694
|
+
active = state.get("active_team_key")
|
|
695
|
+
if active:
|
|
696
|
+
return str(active)
|
|
697
|
+
teams = state.get("teams") or {}
|
|
698
|
+
if isinstance(teams, dict) and len(teams) == 1:
|
|
699
|
+
return next(iter(teams))
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
|
|
605
703
|
def takeover(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
|
|
704
|
+
"""0.2.6 Family A: positive-source ownership rebind.
|
|
705
|
+
|
|
706
|
+
Identity is sourced exclusively from ``bind_owner_from_caller_pane``
|
|
707
|
+
(``$TMUX_PANE`` + one targeted ``tmux display-message``). The new
|
|
708
|
+
owner record force-writes every identity field into
|
|
709
|
+
``state.teams[<team_id>].team_owner``; old fields are not merged,
|
|
710
|
+
migrated, or setdefaulted. Idempotent: re-running with the same
|
|
711
|
+
caller identity returns success without mutating state.
|
|
712
|
+
"""
|
|
606
713
|
if not confirm:
|
|
607
714
|
return {
|
|
608
715
|
"ok": False,
|
|
@@ -610,91 +717,151 @@ def takeover(workspace: Path, team: str | None = None, confirm: bool = False) ->
|
|
|
610
717
|
"reason": "confirm_required",
|
|
611
718
|
"action": "rerun with --confirm to claim ownership of this team",
|
|
612
719
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
"status": "refused",
|
|
618
|
-
"reason": "no_caller_identity",
|
|
619
|
-
"action": "set TEAM_AGENT_LEADER_PANE_ID/PROVIDER/MACHINE_FINGERPRINT or run from a tmux pane",
|
|
620
|
-
}
|
|
720
|
+
from team_agent.leader_binding import (
|
|
721
|
+
bind_owner_from_caller_pane,
|
|
722
|
+
emit_owner_bound_event,
|
|
723
|
+
)
|
|
621
724
|
with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
725
|
+
state = load_runtime_state(workspace)
|
|
726
|
+
team_id = _resolve_owner_team_id(state, team)
|
|
727
|
+
if not team_id:
|
|
625
728
|
return {
|
|
626
729
|
"ok": False,
|
|
627
730
|
"status": "refused",
|
|
628
731
|
"reason": "team_target_unresolved",
|
|
629
732
|
"team": team,
|
|
630
|
-
"
|
|
733
|
+
"hint": "pass --team <name> or run quick-start first to register an active team.",
|
|
734
|
+
}
|
|
735
|
+
bind = bind_owner_from_caller_pane(workspace, team_id)
|
|
736
|
+
if not bind.get("ok"):
|
|
737
|
+
return {"ok": False, "status": "refused", **bind}
|
|
738
|
+
new_owner = bind["owner"]
|
|
739
|
+
teams = state.setdefault("teams", {})
|
|
740
|
+
team_entry = teams.get(team_id) or {}
|
|
741
|
+
existing_owner = team_entry.get("team_owner") if isinstance(team_entry.get("team_owner"), dict) else {}
|
|
742
|
+
if existing_owner and _owner_identity_matches(existing_owner, new_owner):
|
|
743
|
+
return {
|
|
744
|
+
"ok": True,
|
|
745
|
+
"status": "claimed",
|
|
746
|
+
"team": team_id,
|
|
747
|
+
"team_owner": existing_owner,
|
|
748
|
+
"idempotent": True,
|
|
631
749
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
"
|
|
639
|
-
"
|
|
640
|
-
|
|
641
|
-
"
|
|
642
|
-
|
|
643
|
-
|
|
750
|
+
team_entry["team_owner"] = new_owner
|
|
751
|
+
teams[team_id] = team_entry
|
|
752
|
+
save_runtime_state(workspace, state)
|
|
753
|
+
emit_owner_bound_event(
|
|
754
|
+
workspace,
|
|
755
|
+
caller_pane_id=bind.get("caller_pane_id", ""),
|
|
756
|
+
caller_current_command=bind.get("caller_current_command", ""),
|
|
757
|
+
derived_leader_session_uuid=new_owner["leader_session_uuid"],
|
|
758
|
+
team_id=team_id,
|
|
759
|
+
old_leader_session_uuid=str(existing_owner.get("leader_session_uuid") or ""),
|
|
760
|
+
)
|
|
761
|
+
return {
|
|
762
|
+
"ok": True,
|
|
763
|
+
"status": "claimed",
|
|
764
|
+
"team": team_id,
|
|
765
|
+
"team_owner": new_owner,
|
|
766
|
+
"previous_owner": existing_owner or None,
|
|
644
767
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def claim_leader(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
|
|
771
|
+
"""0.2.6 Family A: positive-source claim-leader.
|
|
772
|
+
|
|
773
|
+
Calls :func:`bind_owner_from_caller_pane` to confirm the caller is in
|
|
774
|
+
a leader-shaped tmux pane, then delegates to the legacy multi-
|
|
775
|
+
candidate lease arbiter for residual handling. The bind step is the
|
|
776
|
+
only source of caller identity; the legacy lease path no longer
|
|
777
|
+
re-derives it.
|
|
778
|
+
"""
|
|
779
|
+
from team_agent.leader_binding import bind_owner_from_caller_pane
|
|
780
|
+
state = load_runtime_state(workspace)
|
|
781
|
+
team_id = _resolve_owner_team_id(state, team) or team_state_key(state)
|
|
782
|
+
bind = bind_owner_from_caller_pane(workspace, team_id)
|
|
783
|
+
if not bind.get("ok"):
|
|
784
|
+
return {"ok": False, "status": "refused", **bind}
|
|
785
|
+
return _legacy_claim_leader(workspace, team=team, confirm=confirm)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def quick_start(
|
|
789
|
+
agents_dir: Path,
|
|
790
|
+
name: str | None = None,
|
|
791
|
+
yes: bool = False,
|
|
792
|
+
fresh: bool = False,
|
|
793
|
+
team_id: str | None = None,
|
|
794
|
+
) -> dict[str, Any]:
|
|
795
|
+
"""0.2.6 Family A: positive-source quick-start.
|
|
796
|
+
|
|
797
|
+
The caller-pane shape gate is owned by
|
|
798
|
+
:func:`bind_owner_from_caller_pane`. Quick-start binds the caller
|
|
799
|
+
pane BEFORE any team setup runs; ``$TMUX_PANE`` missing or the
|
|
800
|
+
caller pane not running a leader host short-circuits to a refusal
|
|
801
|
+
(no fallback to legacy reverse-scan). On success, the legacy
|
|
802
|
+
bootstrap brings up the workspace and the bind-derived
|
|
803
|
+
``team_owner`` is force-written into
|
|
804
|
+
``state.teams[team_id].team_owner`` so the runtime owner identity
|
|
805
|
+
matches the caller pane verbatim.
|
|
806
|
+
"""
|
|
807
|
+
from team_agent.leader_binding import (
|
|
808
|
+
bind_owner_from_caller_pane,
|
|
809
|
+
emit_owner_bound_event,
|
|
810
|
+
)
|
|
811
|
+
from team_agent.diagnose.quick_start import prepare_quick_start_team
|
|
812
|
+
|
|
813
|
+
# Pre-resolve team_dir + workspace so the caller-pane bind can write
|
|
814
|
+
# its audit event before any worker is spawned. ``prepare_quick_start_team``
|
|
815
|
+
# is idempotent (mkdir + shutil.copy2 of role docs) and used inside
|
|
816
|
+
# ``_legacy_quick_start`` as the very first step anyway.
|
|
817
|
+
team_dir = prepare_quick_start_team(
|
|
818
|
+
Path(agents_dir).resolve(), Path.cwd().resolve(), name, team_id=team_id
|
|
819
|
+
)
|
|
820
|
+
workspace = team_workspace(team_dir)
|
|
821
|
+
ensure_workspace_dirs(workspace)
|
|
822
|
+
# Spark MED 1 (b1b17b1 review): the on-disk team_dir already passed
|
|
823
|
+
# through ``_safe_snapshot_name``; reusing ``team_dir.name`` here
|
|
824
|
+
# keeps the on-disk path and the state.teams key aligned. Using the
|
|
825
|
+
# raw ``team_id`` would have split the two writes whenever the
|
|
826
|
+
# caller-supplied id contained spaces or shell-unsafe characters.
|
|
827
|
+
resolved_team_id = team_dir.name or "current"
|
|
828
|
+
bind = bind_owner_from_caller_pane(workspace, resolved_team_id)
|
|
829
|
+
if not bind.get("ok"):
|
|
830
|
+
return {"ok": False, "status": "refused", **bind}
|
|
831
|
+
new_owner = bind["owner"]
|
|
832
|
+
result = _legacy_quick_start(
|
|
833
|
+
Path(agents_dir).resolve(), name=name, yes=yes, fresh=fresh, team_id=team_id
|
|
834
|
+
)
|
|
835
|
+
# Spark MED 2 (b1b17b1 review): only commit the owner force-write
|
|
836
|
+
# and emit ``owner.bound_from_caller_pane`` when the legacy bootstrap
|
|
837
|
+
# actually succeeded. Otherwise pass the refusal envelope back
|
|
838
|
+
# verbatim — ``existing_runtime_state`` / ``preflight`` failures
|
|
839
|
+
# must not leave a "team owner claimed" side effect behind.
|
|
840
|
+
if not result.get("ok"):
|
|
841
|
+
return result
|
|
842
|
+
state = load_runtime_state(workspace)
|
|
843
|
+
teams = state.setdefault("teams", {})
|
|
844
|
+
team_entry = teams.get(resolved_team_id) or {}
|
|
845
|
+
existing_owner = (
|
|
846
|
+
team_entry.get("team_owner")
|
|
847
|
+
if isinstance(team_entry.get("team_owner"), dict)
|
|
848
|
+
else {}
|
|
849
|
+
)
|
|
850
|
+
if not (existing_owner and _owner_identity_matches(existing_owner, new_owner)):
|
|
851
|
+
team_entry["team_owner"] = new_owner
|
|
852
|
+
teams[resolved_team_id] = team_entry
|
|
853
|
+
if not state.get("active_team_key"):
|
|
854
|
+
state["active_team_key"] = resolved_team_id
|
|
855
|
+
save_runtime_state(workspace, state)
|
|
856
|
+
emit_owner_bound_event(
|
|
857
|
+
workspace,
|
|
858
|
+
caller_pane_id=bind.get("caller_pane_id", ""),
|
|
859
|
+
caller_current_command=bind.get("caller_current_command", ""),
|
|
860
|
+
derived_leader_session_uuid=new_owner["leader_session_uuid"],
|
|
861
|
+
team_id=resolved_team_id,
|
|
862
|
+
old_leader_session_uuid=str(existing_owner.get("leader_session_uuid") or ""),
|
|
693
863
|
)
|
|
694
|
-
|
|
695
|
-
if new_receiver is not None:
|
|
696
|
-
response["leader_receiver"] = new_receiver
|
|
697
|
-
return response
|
|
864
|
+
return result
|
|
698
865
|
|
|
699
866
|
|
|
700
867
|
def _running_agent_state(workspace: Path, agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|