@team-agent/installer 0.1.10 → 0.2.0

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.
Files changed (111) hide show
  1. package/crates/team-agent-core/src/lib.rs +50 -5
  2. package/package.json +1 -1
  3. package/schemas/team.schema.json +1 -0
  4. package/skills/team-agent/SKILL.md +1 -1
  5. package/src/team_agent/approvals/__init__.py +65 -0
  6. package/src/team_agent/approvals/constants.py +6 -0
  7. package/src/team_agent/approvals/parsing.py +176 -0
  8. package/src/team_agent/approvals/runtime_prompts.py +171 -0
  9. package/src/team_agent/approvals/status.py +165 -0
  10. package/src/team_agent/cli/__init__.py +135 -0
  11. package/src/team_agent/cli/commands.py +335 -0
  12. package/src/team_agent/cli/e2e.py +202 -0
  13. package/src/team_agent/cli/helpers.py +137 -0
  14. package/src/team_agent/cli/parser.py +470 -0
  15. package/src/team_agent/compiler.py +98 -33
  16. package/src/team_agent/coordinator/__init__.py +53 -0
  17. package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
  18. package/src/team_agent/coordinator/lifecycle.py +319 -0
  19. package/src/team_agent/coordinator/metadata.py +61 -0
  20. package/src/team_agent/coordinator/paths.py +17 -0
  21. package/src/team_agent/diagnose/__init__.py +48 -0
  22. package/src/team_agent/diagnose/checks.py +101 -0
  23. package/src/team_agent/diagnose/health.py +241 -0
  24. package/src/team_agent/diagnose/preflight.py +194 -0
  25. package/src/team_agent/diagnose/quick_start.py +233 -0
  26. package/src/team_agent/display/__init__.py +61 -0
  27. package/src/team_agent/display/close.py +147 -0
  28. package/src/team_agent/display/ghostty.py +77 -0
  29. package/src/team_agent/display/worker_window.py +110 -0
  30. package/src/team_agent/display/workspace.py +473 -0
  31. package/src/team_agent/launch/__init__.py +41 -0
  32. package/src/team_agent/launch/bootstrap.py +85 -0
  33. package/src/team_agent/launch/config.py +106 -0
  34. package/src/team_agent/launch/core.py +291 -0
  35. package/src/team_agent/launch/requirements.py +57 -0
  36. package/src/team_agent/leader/__init__.py +320 -0
  37. package/src/team_agent/lifecycle/__init__.py +5 -0
  38. package/src/team_agent/lifecycle/agents.py +226 -0
  39. package/src/team_agent/lifecycle/operations.py +321 -0
  40. package/src/team_agent/lifecycle/start.py +360 -0
  41. package/src/team_agent/mcp_server/__init__.py +42 -0
  42. package/src/team_agent/mcp_server/__main__.py +7 -0
  43. package/src/team_agent/mcp_server/contracts.py +148 -0
  44. package/src/team_agent/mcp_server/normalize.py +257 -0
  45. package/src/team_agent/mcp_server/server.py +150 -0
  46. package/src/team_agent/mcp_server/tools.py +205 -0
  47. package/src/team_agent/message_store/__init__.py +23 -0
  48. package/src/team_agent/message_store/agent_health.py +109 -0
  49. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  50. package/src/team_agent/message_store/result_watchers.py +102 -0
  51. package/src/team_agent/message_store/schema.py +266 -0
  52. package/src/team_agent/messaging/__init__.py +1 -0
  53. package/src/team_agent/messaging/activity_detector.py +190 -0
  54. package/src/team_agent/messaging/delivery.py +128 -0
  55. package/src/team_agent/messaging/deps.py +263 -0
  56. package/src/team_agent/messaging/idle_alerts.py +217 -0
  57. package/src/team_agent/messaging/internal_delivery.py +46 -0
  58. package/src/team_agent/messaging/leader.py +317 -0
  59. package/src/team_agent/messaging/leader_panes.py +343 -0
  60. package/src/team_agent/messaging/result_delivery.py +300 -0
  61. package/src/team_agent/messaging/results.py +456 -0
  62. package/src/team_agent/messaging/scheduler.py +418 -0
  63. package/src/team_agent/messaging/send.py +493 -0
  64. package/src/team_agent/messaging/tmux_io.py +337 -0
  65. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  66. package/src/team_agent/orchestrator/__init__.py +376 -0
  67. package/src/team_agent/orchestrator/plan.py +122 -0
  68. package/src/team_agent/orchestrator/state.py +128 -0
  69. package/src/team_agent/profiles/__init__.py +82 -0
  70. package/src/team_agent/profiles/constants.py +19 -0
  71. package/src/team_agent/profiles/core.py +407 -0
  72. package/src/team_agent/profiles/helpers.py +69 -0
  73. package/src/team_agent/profiles/provider_env.py +188 -0
  74. package/src/team_agent/profiles/smoke.py +201 -0
  75. package/src/team_agent/provider_cli/__init__.py +43 -0
  76. package/src/team_agent/provider_cli/adapter.py +167 -0
  77. package/src/team_agent/provider_cli/base.py +48 -0
  78. package/src/team_agent/provider_cli/claude.py +457 -0
  79. package/src/team_agent/provider_cli/codex.py +319 -0
  80. package/src/team_agent/provider_cli/copilot.py +8 -0
  81. package/src/team_agent/provider_cli/fake.py +39 -0
  82. package/src/team_agent/provider_cli/gemini.py +95 -0
  83. package/src/team_agent/provider_cli/opencode.py +8 -0
  84. package/src/team_agent/provider_cli/prompt.py +62 -0
  85. package/src/team_agent/provider_cli/registry.py +18 -0
  86. package/src/team_agent/provider_cli/unsupported.py +32 -0
  87. package/src/team_agent/providers.py +67 -949
  88. package/src/team_agent/quality_gates.py +104 -0
  89. package/src/team_agent/restart/__init__.py +34 -0
  90. package/src/team_agent/restart/orchestration.py +328 -0
  91. package/src/team_agent/restart/selection.py +89 -0
  92. package/src/team_agent/restart/snapshot.py +70 -0
  93. package/src/team_agent/runtime.py +802 -5740
  94. package/src/team_agent/rust_core.py +22 -5
  95. package/src/team_agent/sessions/__init__.py +25 -0
  96. package/src/team_agent/sessions/capture.py +93 -0
  97. package/src/team_agent/sessions/inventory.py +44 -0
  98. package/src/team_agent/sessions/resume.py +135 -0
  99. package/src/team_agent/spec.py +3 -1
  100. package/src/team_agent/state.py +204 -4
  101. package/src/team_agent/status/__init__.py +63 -0
  102. package/src/team_agent/status/approvals.py +52 -0
  103. package/src/team_agent/status/compact.py +158 -0
  104. package/src/team_agent/status/constants.py +18 -0
  105. package/src/team_agent/status/inbox.py +28 -0
  106. package/src/team_agent/status/peek.py +117 -0
  107. package/src/team_agent/status/queries.py +168 -0
  108. package/src/team_agent/terminal.py +57 -0
  109. package/src/team_agent/cli.py +0 -857
  110. package/src/team_agent/mcp_server.py +0 -579
  111. package/src/team_agent/profiles.py +0 -882
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ from team_agent.messaging.deps import (
4
+ EventLog,
5
+ MessageStore,
6
+ _choose_leader_submit_key,
7
+ _leader_id,
8
+ _rediscover_leader_receiver,
9
+ _tmux_inject_text,
10
+ _validate_leader_receiver,
11
+ core_render_message,
12
+ json,
13
+ runtime_dir,
14
+ save_runtime_state,
15
+ time,
16
+ )
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ def allow_peer_talk(workspace: Path, agent_a: str, agent_b: str) -> dict[str, Any]:
22
+ MessageStore(workspace).allow_peer(agent_a, agent_b)
23
+ EventLog(workspace).write("communication.peer_allowed", a=agent_a, b=agent_b)
24
+ return {"ok": True, "a": agent_a, "b": agent_b, "status": "compat_noop", "reason": "team_scoped_peer_messages_enabled"}
25
+
26
+
27
+ def _mirror_peer_message_to_leader(
28
+ workspace: Path,
29
+ state: dict[str, Any],
30
+ sender: str,
31
+ target: str,
32
+ content: str,
33
+ task_id: str | None,
34
+ event_log: EventLog,
35
+ ) -> None:
36
+ leader_id = _leader_id(state, {})
37
+ mirror = f"Team Agent peer message from {sender} to {target}"
38
+ if task_id:
39
+ mirror += f" for {task_id}"
40
+ mirror += f":\n\n{content}"
41
+ try:
42
+ result = _send_to_leader_receiver(workspace, state, leader_id, mirror, task_id, sender, False, event_log)
43
+ event_log.write("communication.peer_mirrored", sender=sender, target=target, ok=result.get("ok"))
44
+ except Exception as exc:
45
+ event_log.write("communication.peer_mirror_failed", sender=sender, target=target, error=str(exc))
46
+
47
+
48
+ def _leader_inbox_path(workspace: Path) -> Path:
49
+ return runtime_dir(workspace) / "leader-inbox.log"
50
+
51
+
52
+ def _send_to_leader_receiver(
53
+ workspace: Path,
54
+ state: dict[str, Any],
55
+ leader_id: str,
56
+ content: str,
57
+ task_id: str | None,
58
+ sender: str,
59
+ requires_ack: bool,
60
+ event_log: EventLog,
61
+ ) -> dict[str, Any]:
62
+ store = MessageStore(workspace)
63
+ message_id = store.create_message(task_id, sender, leader_id, content, requires_ack=False)
64
+ if requires_ack:
65
+ event_log.write("leader_receiver.no_ack_forced", message_id=message_id, requested_requires_ack=True)
66
+ row = _message_by_id(store, message_id)
67
+ if not row:
68
+ return {"ok": False, "message_id": message_id, "status": "failed", "to": leader_id, "reason": "message_missing"}
69
+ if not store.claim_for_delivery(message_id):
70
+ current = _message_by_id(store, message_id)
71
+ status = current["status"] if current else "missing"
72
+ event_log.write("leader_receiver.delivery_claim_skipped", message_id=message_id, status=status)
73
+ return {
74
+ "ok": status in {"submitted", "visible", "delivered", "acknowledged"},
75
+ "message_id": message_id,
76
+ "status": status,
77
+ "to": leader_id,
78
+ "channel": "direct_tmux",
79
+ "reason": "message_already_claimed",
80
+ }
81
+ payload = _message_payload(row)
82
+ rendered = core_render_message(payload)
83
+ text = rendered["text"]
84
+ receiver = state.get("leader_receiver", {})
85
+ if not _leader_receiver_is_direct(receiver):
86
+ return _fail_leader_delivery(
87
+ workspace,
88
+ state,
89
+ store,
90
+ message_id,
91
+ payload,
92
+ event_log,
93
+ reason="leader_not_attached",
94
+ error="No direct leader tmux pane is attached. Run team-agent attach-leader.",
95
+ )
96
+
97
+ validation = _validate_leader_receiver(receiver)
98
+ if not validation["ok"]:
99
+ owner_identity = state.get("team_owner") or None
100
+ rediscovery = _rediscover_leader_receiver(receiver, event_log, owner_identity)
101
+ if rediscovery.get("status") == "updated":
102
+ state["leader_receiver"].update(rediscovery["receiver"])
103
+ receiver = state["leader_receiver"]
104
+ validation = _validate_leader_receiver(receiver)
105
+ elif rediscovery.get("status") == "ambiguous":
106
+ return _fail_leader_delivery(
107
+ workspace,
108
+ state,
109
+ store,
110
+ message_id,
111
+ payload,
112
+ event_log,
113
+ reason="ambiguous",
114
+ error="multiple possible leader panes found; rerun team-agent attach-leader --pane <pane_id>",
115
+ message_status="ambiguous",
116
+ )
117
+ if not validation["ok"]:
118
+ return _fail_leader_delivery(
119
+ workspace,
120
+ state,
121
+ store,
122
+ message_id,
123
+ payload,
124
+ event_log,
125
+ reason=validation["reason"],
126
+ error=validation.get("error"),
127
+ )
128
+ state["leader_receiver"].update(validation["pane"])
129
+ submit_key, submit_reason = _choose_leader_submit_key(receiver.get("provider", "codex"), validation.get("capture", ""))
130
+ target = receiver["pane_id"]
131
+ event_log.write(
132
+ "leader_receiver.deliver_attempt",
133
+ message_id=message_id,
134
+ target=target,
135
+ provider=receiver.get("provider"),
136
+ submit_key=submit_key,
137
+ submit_reason=submit_reason,
138
+ render_engine=rendered.get("engine"),
139
+ visible_token=rendered.get("token"),
140
+ payload=payload,
141
+ warning=validation.get("warning"),
142
+ )
143
+ injection = _tmux_inject_text(
144
+ target,
145
+ text,
146
+ submit_key,
147
+ f"team-agent-leader-receiver-{message_id}",
148
+ provider=receiver.get("provider", "codex"),
149
+ )
150
+ if injection["ok"]:
151
+ store.mark(message_id, "submitted")
152
+ event_log.write(
153
+ "leader_receiver.submitted",
154
+ message_id=message_id,
155
+ sender=sender,
156
+ task_id=task_id,
157
+ target=target,
158
+ provider=receiver.get("provider"),
159
+ submit_key=submit_key,
160
+ submit_reason=submit_reason,
161
+ visible=True,
162
+ submitted=True,
163
+ visible_token=rendered.get("token"),
164
+ verification=injection.get("verification"),
165
+ submit_verification=injection.get("submit_verification"),
166
+ turn_verification=injection.get("turn_verification"),
167
+ attempts=injection.get("attempts"),
168
+ submit_attempts=injection.get("submit_attempts"),
169
+ )
170
+ save_runtime_state(workspace, state)
171
+ return {
172
+ "ok": True,
173
+ "message_id": message_id,
174
+ "status": "submitted",
175
+ "to": leader_id,
176
+ "channel": "direct_tmux",
177
+ "leader_receiver": state["leader_receiver"],
178
+ "submit_key": submit_key,
179
+ "visible": True,
180
+ "submitted": True,
181
+ "visible_token": rendered.get("token"),
182
+ "verification": injection.get("verification"),
183
+ "submit_verification": injection.get("submit_verification"),
184
+ "turn_verification": injection.get("turn_verification"),
185
+ "attempts": injection.get("attempts"),
186
+ "submit_attempts": injection.get("submit_attempts"),
187
+ "warning": "leader messages are no-ack; requires_ack was forced false" if requires_ack else None,
188
+ }
189
+ return _fail_leader_delivery(
190
+ workspace,
191
+ state,
192
+ store,
193
+ message_id,
194
+ payload,
195
+ event_log,
196
+ reason="tmux_injection_failed",
197
+ error=injection.get("error"),
198
+ stage=injection.get("stage"),
199
+ attempts=injection.get("attempts"),
200
+ submit_attempts=injection.get("submit_attempts"),
201
+ )
202
+
203
+
204
+ def _fail_leader_delivery(
205
+ workspace: Path,
206
+ state: dict[str, Any],
207
+ store: MessageStore,
208
+ message_id: str,
209
+ payload: dict[str, Any],
210
+ event_log: EventLog,
211
+ reason: str,
212
+ error: str | None = None,
213
+ stage: str | None = None,
214
+ message_status: str = "failed",
215
+ attempts: list[dict[str, Any]] | None = None,
216
+ submit_attempts: list[dict[str, Any]] | None = None,
217
+ ) -> dict[str, Any]:
218
+ store.mark(message_id, message_status, error or reason)
219
+ fallback_path = _write_leader_fallback_audit(workspace, payload, reason, error)
220
+ event_log.write(
221
+ "leader_receiver.delivery_failed",
222
+ message_id=message_id,
223
+ target=state.get("leader_receiver", {}).get("pane_id"),
224
+ reason=reason,
225
+ error=error,
226
+ stage=stage,
227
+ attempts=attempts,
228
+ submit_attempts=submit_attempts,
229
+ fallback_path=str(fallback_path),
230
+ suggestion="Run team-agent attach-leader --workspace . --provider codex, or pass --pane <pane_id>.",
231
+ )
232
+ save_runtime_state(workspace, state)
233
+ return {
234
+ "ok": False,
235
+ "message_id": message_id,
236
+ "status": "fallback",
237
+ "message_status": message_status,
238
+ "to": payload["to"],
239
+ "channel": "fallback_inbox",
240
+ "reason": reason,
241
+ "error": error,
242
+ "attempts": attempts,
243
+ "submit_attempts": submit_attempts,
244
+ "fallback_path": str(fallback_path),
245
+ "suggestion": "Run team-agent attach-leader --workspace . --provider codex, or pass --pane <pane_id>.",
246
+ }
247
+
248
+
249
+ def _write_leader_fallback_audit(workspace: Path, payload: dict[str, Any], reason: str, error: str | None) -> Path:
250
+ inbox_path = _leader_inbox_path(workspace)
251
+ inbox_path.parent.mkdir(parents=True, exist_ok=True)
252
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
253
+ text = core_render_message(payload)["text"]
254
+ with inbox_path.open("a", encoding="utf-8") as inbox:
255
+ inbox.write(f"\n[{stamp}] fallback reason={reason} error={error or '-'}\n{text}\n")
256
+ return inbox_path
257
+
258
+
259
+ def _leader_receiver_is_direct(receiver: dict[str, Any] | None) -> bool:
260
+ return bool(receiver and receiver.get("mode") == "direct_tmux" and receiver.get("pane_id"))
261
+
262
+
263
+ def _message_by_id(store: MessageStore, message_id: str) -> dict[str, Any] | None:
264
+ return next((m for m in store.messages() if m["message_id"] == message_id), None)
265
+
266
+
267
+ def _message_payload(row: dict[str, Any]) -> dict[str, Any]:
268
+ return {
269
+ "message_id": row["message_id"],
270
+ "task_id": row["task_id"],
271
+ "from": row["sender"],
272
+ "to": row["recipient"],
273
+ "reply_to": row["reply_to"],
274
+ "requires_ack": bool(row["requires_ack"]),
275
+ "artifact_refs": json.loads(row["artifact_refs"] or "[]"),
276
+ "content": row["content"],
277
+ }
278
+
279
+
280
+ def _format_team_agent_message(payload: dict[str, Any]) -> str:
281
+ return core_render_message(payload)["text"]
282
+
283
+
284
+
285
+
286
+
287
+
288
+
289
+
290
+
291
+
292
+
293
+
294
+
295
+
296
+
297
+
298
+
299
+
300
+
301
+
302
+
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+
311
+
312
+
313
+
314
+
315
+
316
+
317
+
@@ -0,0 +1,343 @@
1
+ from __future__ import annotations
2
+
3
+ from team_agent.messaging.deps import (
4
+ EventLog,
5
+ RuntimeError,
6
+ TMUX_PANE_FORMAT,
7
+ _infer_active_tmux_pane as _runtime_infer_active_tmux_pane,
8
+ _infer_workspace_tmux_pane as _runtime_infer_workspace_tmux_pane,
9
+ _tmux_current_client_pane_info as _runtime_tmux_current_client_pane_info,
10
+ _tmux_list_panes as _runtime_tmux_list_panes,
11
+ _tmux_pane_info as _runtime_tmux_pane_info,
12
+ core_list_targets,
13
+ datetime,
14
+ os,
15
+ re,
16
+ run_cmd,
17
+ timezone,
18
+ )
19
+
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ def _resolve_leader_pane(
24
+ pane: str | None,
25
+ provider: str,
26
+ workspace: Path | None = None,
27
+ require_current: bool = False,
28
+ ) -> tuple[dict[str, str], str]:
29
+ if pane:
30
+ pane_info = _tmux_pane_info(pane)
31
+ if not pane_info:
32
+ raise RuntimeError(f"tmux pane not found: {pane}")
33
+ return pane_info, "explicit_pane"
34
+ pane_info = _runtime_tmux_current_client_pane_info()
35
+ if pane_info and _pane_is_usable_leader(pane_info, provider, workspace):
36
+ return pane_info, "current_client"
37
+ if workspace is not None:
38
+ workspace_match = _runtime_infer_workspace_tmux_pane(provider, workspace)
39
+ if workspace_match["status"] == "ok":
40
+ return workspace_match["pane"], "workspace_pane_scan"
41
+ if workspace_match["status"] == "ambiguous":
42
+ raise RuntimeError(
43
+ "multiple tmux leader panes match this workspace; pass --pane explicitly. "
44
+ + _format_leader_pane_candidates(workspace_match["candidates"])
45
+ )
46
+ if require_current:
47
+ details = ""
48
+ if pane_info:
49
+ details = (
50
+ f" Current tmux client points at pane {pane_info.get('pane_id')} "
51
+ f"command={pane_info.get('pane_current_command')!r} "
52
+ f"cwd={pane_info.get('pane_current_path')!r}, not a usable pane for this workspace."
53
+ )
54
+ raise RuntimeError(
55
+ "Team Agent could not locate a tmux-managed leader pane for this workspace. "
56
+ "Run quick-start from the visible tmux-managed leader pane, pass --pane explicitly, "
57
+ "or use `team-agent codex`/`team-agent claude` as a convenience fallback."
58
+ + details
59
+ )
60
+ if pane_info and workspace is None:
61
+ return pane_info, "current_client"
62
+ pane_info = _runtime_infer_active_tmux_pane(provider)
63
+ if pane_info:
64
+ return pane_info, "active_pane_scan"
65
+ raise RuntimeError("could not infer a tmux leader pane; pass --pane <pane_id>")
66
+
67
+
68
+ def _tmux_current_client_pane_info() -> dict[str, str] | None:
69
+ proc = run_cmd(["tmux", "display-message", "-p", "-F", TMUX_PANE_FORMAT], timeout=5)
70
+ if proc.returncode != 0:
71
+ return None
72
+ return _parse_tmux_pane_info(proc.stdout.strip())
73
+
74
+
75
+ def _tmux_list_panes() -> list[dict[str, str]]:
76
+ proc = run_cmd(["tmux", "list-panes", "-a", "-F", TMUX_PANE_FORMAT], timeout=5)
77
+ if proc.returncode != 0:
78
+ return []
79
+ return [pane for line in proc.stdout.splitlines() if (pane := _parse_tmux_pane_info(line))]
80
+
81
+
82
+ def _infer_active_tmux_pane(provider: str) -> dict[str, str] | None:
83
+ panes = _runtime_tmux_list_panes()
84
+ active = [pane for pane in panes if pane.get("pane_active") == "1"]
85
+ preferred = [pane for pane in active if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)]
86
+ if len(preferred) == 1:
87
+ return preferred[0]
88
+ if len(active) == 1:
89
+ return active[0]
90
+ if preferred:
91
+ return preferred[0]
92
+ return active[0] if active else None
93
+
94
+
95
+ def _tmux_pane_info(target: str | None) -> dict[str, str] | None:
96
+ if not target:
97
+ return None
98
+ proc = run_cmd(["tmux", "display-message", "-p", "-t", target, "-F", TMUX_PANE_FORMAT], timeout=5)
99
+ if proc.returncode != 0:
100
+ return None
101
+ return _parse_tmux_pane_info(proc.stdout.strip())
102
+
103
+
104
+ def _parse_tmux_pane_info(line: str) -> dict[str, str] | None:
105
+ parts = line.split("\t")
106
+ if len(parts) not in {8, 10, 11}:
107
+ return None
108
+ keys = [
109
+ "pane_id",
110
+ "session_name",
111
+ "window_index",
112
+ "window_name",
113
+ "pane_index",
114
+ "pane_tty",
115
+ "pane_current_command",
116
+ "pane_active",
117
+ ]
118
+ if len(parts) >= 10:
119
+ keys.extend(["pane_current_path", "session_attached"])
120
+ if len(parts) == 11:
121
+ keys.append("pane_in_mode")
122
+ return dict(zip(keys, parts))
123
+
124
+
125
+ def _infer_workspace_tmux_pane(provider: str, workspace: Path) -> dict[str, Any]:
126
+ panes = _runtime_tmux_list_panes()
127
+ workspace_panes = [pane for pane in panes if _pane_path_matches_workspace(pane, workspace)]
128
+ candidates = [
129
+ pane
130
+ for pane in workspace_panes
131
+ if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)
132
+ or _leader_command_provider(pane.get("pane_current_command", "")) is not None
133
+ ]
134
+ if not candidates:
135
+ return {"status": "missing", "workspace_panes": workspace_panes}
136
+ ranked = sorted(candidates, key=lambda item: _leader_pane_rank(item, provider), reverse=True)
137
+ best_rank = _leader_pane_rank(ranked[0], provider)
138
+ best = [pane for pane in ranked if _leader_pane_rank(pane, provider) == best_rank]
139
+ if len(best) == 1:
140
+ return {"status": "ok", "pane": best[0], "candidates": candidates}
141
+ return {"status": "ambiguous", "candidates": best}
142
+
143
+
144
+ def _pane_is_usable_leader(pane: dict[str, str], provider: str, workspace: Path | None) -> bool:
145
+ command = pane.get("pane_current_command", "")
146
+ if not _leader_command_looks_usable(command, provider) and _leader_command_provider(command) is None:
147
+ return False
148
+ if workspace is not None and not _pane_path_matches_workspace(pane, workspace):
149
+ return False
150
+ return True
151
+
152
+
153
+ def _pane_path_matches_workspace(pane: dict[str, str], workspace: Path) -> bool:
154
+ current_path = pane.get("pane_current_path")
155
+ if not current_path:
156
+ return False
157
+ return os.path.realpath(current_path) == os.path.realpath(str(workspace.resolve()))
158
+
159
+
160
+ def _leader_pane_rank(pane: dict[str, str], provider: str) -> tuple[int, int, int]:
161
+ return (
162
+ _tmux_truthy(pane.get("session_attached", "")),
163
+ 1 if pane.get("pane_active") == "1" else 0,
164
+ 1 if _leader_command_is_exact(pane.get("pane_current_command", ""), provider) else 0,
165
+ )
166
+
167
+
168
+ def _tmux_truthy(value: str) -> int:
169
+ try:
170
+ return 1 if int(value) > 0 else 0
171
+ except (TypeError, ValueError):
172
+ return 1 if value and value != "0" else 0
173
+
174
+
175
+ def _leader_command_is_exact(command: str, provider: str) -> bool:
176
+ command_name = Path(command).name
177
+ if provider == "codex":
178
+ return command_name == "codex"
179
+ if provider in {"claude", "claude_code"}:
180
+ return command_name in {"claude", "claude.exe"}
181
+ return provider == "fake"
182
+
183
+
184
+ def _leader_command_provider(command: str) -> str | None:
185
+ command_name = Path(command).name
186
+ if command_name in {"codex", "node", "nodejs"}:
187
+ return "codex"
188
+ if command_name in {"claude", "claude.exe"}:
189
+ return "claude_code"
190
+ return None
191
+
192
+
193
+ def _format_leader_pane_candidates(candidates: list[dict[str, str]]) -> str:
194
+ compact = []
195
+ for pane in candidates[:5]:
196
+ compact.append(
197
+ "{pane_id} session={session_name} pane={window_index}.{pane_index} "
198
+ "cmd={pane_current_command} cwd={pane_current_path} active={pane_active}".format(**pane)
199
+ )
200
+ suffix = "" if len(candidates) <= 5 else f" ... +{len(candidates) - 5} more"
201
+ return "candidates: " + "; ".join(compact) + suffix
202
+
203
+
204
+ def _target_fingerprint(pane_info: dict[str, Any]) -> str:
205
+ return "|".join(
206
+ str(pane_info.get(key, ""))
207
+ for key in ["session_name", "window_index", "pane_index", "pane_tty"]
208
+ )
209
+
210
+
211
+ def _rediscover_leader_receiver(
212
+ receiver: dict[str, Any],
213
+ event_log: EventLog,
214
+ owner_identity: dict[str, Any] | None = None,
215
+ ) -> dict[str, Any]:
216
+ provider = str(receiver.get("provider") or "codex")
217
+ if provider != "codex":
218
+ return {"status": "missing", "reason": "rediscovery_only_for_codex"}
219
+ targets = core_list_targets()
220
+ if not targets.get("ok"):
221
+ event_log.write("leader_receiver.rediscover_failed", provider=provider, error=targets.get("error"))
222
+ return {"status": "failed", "error": targets.get("error")}
223
+ candidates = [
224
+ target
225
+ for target in targets.get("targets", [])
226
+ if _leader_command_looks_usable(str(target.get("pane_current_command", "")), provider)
227
+ ]
228
+ if owner_identity:
229
+ owner_candidates = [target for target in candidates if _target_matches_owner_identity(target, owner_identity)]
230
+ if len(owner_candidates) == 1:
231
+ return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity)
232
+ if len(owner_candidates) > 1:
233
+ event_log.write(
234
+ "leader_receiver.rediscover_ambiguous",
235
+ provider=provider,
236
+ old_target=receiver.get("pane_id"),
237
+ candidates=[target.get("pane_id") for target in owner_candidates],
238
+ owner_identity=owner_identity,
239
+ )
240
+ return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity}
241
+ event_log.write(
242
+ "leader_receiver.rediscover_missing",
243
+ provider=provider,
244
+ old_target=receiver.get("pane_id"),
245
+ owner_identity=owner_identity,
246
+ candidate_count=len(candidates),
247
+ )
248
+ return {"status": "missing", "owner_identity": owner_identity}
249
+ if len(candidates) == 1:
250
+ return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None)
251
+ if len(candidates) > 1:
252
+ event_log.write(
253
+ "leader_receiver.rediscover_ambiguous",
254
+ provider=provider,
255
+ old_target=receiver.get("pane_id"),
256
+ candidates=[target.get("pane_id") for target in candidates],
257
+ )
258
+ return {"status": "ambiguous", "candidates": candidates}
259
+ event_log.write("leader_receiver.rediscover_missing", provider=provider, old_target=receiver.get("pane_id"))
260
+ return {"status": "missing"}
261
+
262
+
263
+ def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[str, Any]) -> bool:
264
+ env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
265
+ return (
266
+ env.get("TEAM_AGENT_LEADER_PANE_ID") == (owner_identity.get("pane_id") or "")
267
+ and env.get("TEAM_AGENT_LEADER_PROVIDER") == (owner_identity.get("provider") or "")
268
+ and env.get("TEAM_AGENT_MACHINE_FINGERPRINT") == (owner_identity.get("machine_fingerprint") or "")
269
+ )
270
+
271
+
272
+ def _rediscovered_receiver(
273
+ receiver: dict[str, Any],
274
+ provider: str,
275
+ target: dict[str, Any],
276
+ event_log: EventLog,
277
+ owner_identity: dict[str, Any] | None,
278
+ ) -> dict[str, Any]:
279
+ updated = {
280
+ "mode": "direct_tmux",
281
+ "status": "attached",
282
+ "provider": provider,
283
+ "pane_id": target["pane_id"],
284
+ "session_name": target["session_name"],
285
+ "window_index": str(target["window_index"]),
286
+ "window_name": target["window_name"],
287
+ "pane_index": str(target["pane_index"]),
288
+ "pane_tty": target["pane_tty"],
289
+ "pane_current_command": target["pane_current_command"],
290
+ "fingerprint": target.get("fingerprint") or _target_fingerprint(target),
291
+ "attached_at": datetime.now(timezone.utc).isoformat(),
292
+ "discovery": "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate",
293
+ }
294
+ event_log.write(
295
+ "leader_receiver.rediscovered",
296
+ provider=provider,
297
+ old_target=receiver.get("pane_id"),
298
+ new_target=updated["pane_id"],
299
+ candidate_count=1,
300
+ owner_identity=owner_identity,
301
+ )
302
+ return {"status": "updated", "receiver": updated, "owner_identity": owner_identity}
303
+
304
+
305
+ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
306
+ pane_info = _runtime_tmux_pane_info(receiver.get("pane_id"))
307
+ if not pane_info:
308
+ return {"ok": False, "reason": "leader_pane_missing", "error": "tmux pane does not exist"}
309
+ capture = run_cmd(["tmux", "capture-pane", "-p", "-S", "-40", "-t", pane_info["pane_id"]], timeout=5)
310
+ if capture.returncode != 0:
311
+ return {
312
+ "ok": False,
313
+ "reason": "leader_capture_failed",
314
+ "error": capture.stderr.strip() or "tmux capture-pane failed",
315
+ "pane": pane_info,
316
+ }
317
+ warning = None
318
+ provider = str(receiver.get("provider") or "codex")
319
+ if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
320
+ warning = (
321
+ f"pane command {pane_info.get('pane_current_command')!r} is not a typical {provider} host; "
322
+ "continuing because tmux capture works"
323
+ )
324
+ return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": warning}
325
+
326
+
327
+ def _leader_command_looks_usable(command: str, provider: str) -> bool:
328
+ if provider == "fake":
329
+ return True
330
+ command_name = Path(command).name
331
+ if provider == "codex":
332
+ return command_name in {"codex", "node", "nodejs"}
333
+ return bool(command_name)
334
+
335
+
336
+ def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]:
337
+ if provider != "codex":
338
+ return "Enter", "non_codex_provider"
339
+ if re.search(r"esc to interrupt|working|running", capture_text, re.IGNORECASE):
340
+ return "Enter", "codex_busy_submit_followup"
341
+ if re.search(r"(›|❯|codex>)", capture_text):
342
+ return "Enter", "codex_idle_prompt"
343
+ return "Enter", "codex_state_unknown_submit"