@team-agent/installer 0.2.3 → 0.2.5

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/team_agent/abnormal_track.py +253 -0
  3. package/src/team_agent/cli/commands.py +17 -1
  4. package/src/team_agent/cli/parser.py +2 -2
  5. package/src/team_agent/compiler.py +1 -1
  6. package/src/team_agent/coordinator/lifecycle.py +20 -2
  7. package/src/team_agent/display/__init__.py +31 -0
  8. package/src/team_agent/display/adaptive.py +425 -0
  9. package/src/team_agent/display/backend.py +46 -0
  10. package/src/team_agent/display/close.py +6 -0
  11. package/src/team_agent/display/rebuild.py +102 -0
  12. package/src/team_agent/display/tiling.py +156 -0
  13. package/src/team_agent/display/worker_window.py +4 -0
  14. package/src/team_agent/display/workspace.py +36 -127
  15. package/src/team_agent/idle_predicate.py +200 -0
  16. package/src/team_agent/idle_takeover.py +59 -0
  17. package/src/team_agent/idle_takeover_wiring.py +111 -0
  18. package/src/team_agent/launch/core.py +13 -4
  19. package/src/team_agent/leader/__init__.py +444 -61
  20. package/src/team_agent/message_store/agent_health.py +6 -2
  21. package/src/team_agent/message_store/core.py +51 -18
  22. package/src/team_agent/message_store/leader_notification_log.py +63 -38
  23. package/src/team_agent/message_store/result_watchers.py +17 -11
  24. package/src/team_agent/message_store/schema.py +19 -2
  25. package/src/team_agent/message_store/schema_migration.py +386 -0
  26. package/src/team_agent/messaging/delivery.py +45 -2
  27. package/src/team_agent/messaging/leader_panes.py +115 -21
  28. package/src/team_agent/messaging/send.py +33 -0
  29. package/src/team_agent/messaging/tmux_io.py +49 -10
  30. package/src/team_agent/messaging/trust_auto_answer.py +11 -3
  31. package/src/team_agent/provider_state/README.md +78 -0
  32. package/src/team_agent/provider_state/__init__.py +86 -0
  33. package/src/team_agent/provider_state/claude.py +86 -0
  34. package/src/team_agent/provider_state/codex.py +84 -0
  35. package/src/team_agent/provider_state/common.py +207 -0
  36. package/src/team_agent/provider_state/registry.py +118 -0
  37. package/src/team_agent/restart/orchestration.py +9 -9
  38. package/src/team_agent/runtime.py +62 -12
  39. package/src/team_agent/spec.py +4 -3
  40. package/src/team_agent/wake.py +58 -0
@@ -0,0 +1,118 @@
1
+ """Per-CLI idle/turn-state registry — PURE INFRA DATA (Gap 32 C7).
2
+
3
+ This module is data only: session-file locations, turn-lifecycle marker
4
+ descriptions, and per-CLI error white/black lists. It carries no predicate,
5
+ abnormal, or wake logic. Adding a new provider is one entry here plus one
6
+ reader module under ``provider_state/``; the neutral layers never change.
7
+
8
+ The registry is shipped with the runtime as infra data — it is NOT
9
+ user-mandatory configuration and is never loaded from a workspace.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ # Each entry is consumed by the matching provider reader. The neutral
17
+ # idle_predicate / abnormal_track / wake modules never read provider names.
18
+ _PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
19
+ "claude": {
20
+ "kind": "claude",
21
+ "reader_module": "team_agent.provider_state.claude",
22
+ "source": "infra",
23
+ "file_location": {
24
+ "root": "~/.claude/projects",
25
+ "layout": "<cwd-slug>/<session_id>.jsonl",
26
+ "format": "transcript-jsonl",
27
+ },
28
+ "event_types": {
29
+ "turn_open": "assistant message.stop_reason == tool_use",
30
+ "turn_complete": "assistant message.stop_reason == end_turn",
31
+ "interrupted": "user text == [Request interrupted by user]",
32
+ "tool_error": "user tool_result is_error == true",
33
+ "api_error": "system subtype == api_error and level == error",
34
+ },
35
+ "metadata_ignore": [
36
+ "stop_hook_summary",
37
+ "turn_duration",
38
+ "last-prompt",
39
+ "ai-title",
40
+ "permission-mode",
41
+ "file-history-snapshot",
42
+ "queue-operation",
43
+ ],
44
+ "error_whitelist": [],
45
+ "error_blacklist": [
46
+ "api_error",
47
+ "rate limit",
48
+ "overloaded",
49
+ "traceback",
50
+ "panic",
51
+ ],
52
+ "error_lists": {
53
+ "whitelist": [],
54
+ "blacklist": ["api_error", "rate limit", "overloaded", "traceback", "panic"],
55
+ },
56
+ },
57
+ "codex": {
58
+ "kind": "codex",
59
+ "reader_module": "team_agent.provider_state.codex",
60
+ "source": "infra",
61
+ "file_location": {
62
+ "root": "~/.codex/sessions",
63
+ "layout": "<YYYY>/<MM>/<DD>/rollout-<stamp>-<session_id>.jsonl",
64
+ "format": "rollout-jsonl",
65
+ },
66
+ "event_types": {
67
+ "turn_open": "event_msg payload.type == task_started",
68
+ "turn_complete": "event_msg payload.type == task_complete",
69
+ "interrupted": "event_msg payload.type == turn_aborted and reason == interrupted",
70
+ "failed": "app-server turn.status == failed",
71
+ "approval": "app-server method endswith requestApproval",
72
+ },
73
+ "metadata_ignore": [
74
+ "token_count",
75
+ "agent_message",
76
+ "context_compacted",
77
+ "mcp_tool_call_end",
78
+ "patch_apply_end",
79
+ "web_search_end",
80
+ "thread_goal_updated",
81
+ ],
82
+ "error_whitelist": [],
83
+ "error_blacklist": [
84
+ "failed",
85
+ "api error",
86
+ "rate limit",
87
+ "overloaded",
88
+ "traceback",
89
+ "panic",
90
+ ],
91
+ "error_lists": {
92
+ "whitelist": [],
93
+ "blacklist": ["failed", "api error", "rate limit", "overloaded", "traceback", "panic"],
94
+ },
95
+ },
96
+ }
97
+
98
+
99
+ def get_provider_registry(provider: str | None = None) -> Any:
100
+ """Return the infra registry.
101
+
102
+ With no argument, returns a copy of the whole per-CLI registry mapping.
103
+ With a provider name, returns that provider's entry (or ``None``).
104
+ """
105
+ if provider is None:
106
+ return {name: _copy_entry(entry) for name, entry in _PROVIDER_REGISTRY.items()}
107
+ entry = _PROVIDER_REGISTRY.get(provider)
108
+ return _copy_entry(entry) if entry is not None else None
109
+
110
+
111
+ def supported_providers() -> list[str]:
112
+ return sorted(_PROVIDER_REGISTRY)
113
+
114
+
115
+ def _copy_entry(entry: dict[str, Any]) -> dict[str, Any]:
116
+ import copy
117
+
118
+ return copy.deepcopy(entry)
@@ -8,18 +8,19 @@ from typing import Any
8
8
  from team_agent.events import EventLog
9
9
  from team_agent.message_store import MessageStore
10
10
  from team_agent.permissions import resolve_permissions
11
+ from team_agent.display.backend import display_backend_has_worker_views, display_backend_opens_before_leader_rebind, resolve_restart_display_backend
12
+ from team_agent.display.close import close_team_display_backends
13
+ from team_agent.display.rebuild import rebuild_restart_display_after_rebind
11
14
  from team_agent.restart.selection import select_restart_state
12
15
  from team_agent.restart.snapshot import save_team_runtime_snapshot
13
16
  from team_agent.spec import load_spec
14
17
  from team_agent.state import (
15
18
  check_team_owner,
16
- load_runtime_state,
17
19
  populate_team_owner_from_env,
18
20
  save_runtime_state,
19
21
  write_team_state,
20
22
  )
21
23
 
22
-
23
24
  def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None) -> dict[str, Any]:
24
25
  # Lazy-import everything from team_agent.runtime so existing tests that
25
26
  # patch runtime.shell_resume_command_for_agent / runtime.run_cmd /
@@ -27,7 +28,6 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
27
28
  # at call time. Runtime re-exports the provider helpers, so this also
28
29
  # routes through the providers module without binding it directly.
29
30
  from team_agent.runtime import (
30
- GHOSTTY_DISPLAY_BACKENDS,
31
31
  ResumeUnavailable,
32
32
  RuntimeError,
33
33
  _attach_profile_resume_root,
@@ -35,7 +35,6 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
35
35
  _capture_agent_session,
36
36
  _clear_session_capture_fields,
37
37
  _close_ghostty_display,
38
- _close_ghostty_workspace,
39
38
  _compile_team_dir_spec,
40
39
  _effective_runtime_config,
41
40
  _ensure_agent_start_requirements,
@@ -83,7 +82,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
83
82
  )
84
83
  raise RuntimeError(_tmux_session_conflict_error(session_name))
85
84
  runtime_cfg = _effective_runtime_config(spec.get("runtime", {}))
86
- display_backend = spec.get("runtime", {}).get("display_backend", state.get("display_backend", "none"))
85
+ display_backend = resolve_restart_display_backend(spec, state, event_log)
87
86
  # Stage 7 S5 — Slice 6 lifecycle atomicity contract: compute restart_agents
88
87
  # early so we can pre-validate resumability BEFORE any destructive teardown
89
88
  # (ghostty close, tmux session creation). Without --allow-fresh, every
@@ -146,7 +145,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
146
145
  "allow_fresh": bool(allow_fresh),
147
146
  "error": _format_atomic_refusal_error(refused),
148
147
  }
149
- _close_ghostty_workspace(state, event_log)
148
+ close_team_display_backends(state, event_log)
150
149
  for agent_id, agent_state in state.get("agents", {}).items():
151
150
  _close_ghostty_display(agent_id, agent_state, event_log)
152
151
  state["display_backend"] = display_backend
@@ -330,7 +329,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
330
329
  exclude_session_ids=known_session_ids,
331
330
  raise_on_missed=False,
332
331
  )
333
- if display_backend in GHOSTTY_DISPLAY_BACKENDS:
332
+ if display_backend_has_worker_views(display_backend):
334
333
  display_jobs.append((agent["id"], agent))
335
334
  new_agents[agent["id"]] = agent_state
336
335
  restarted.append(
@@ -341,7 +340,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
341
340
  "display_target": None,
342
341
  }
343
342
  )
344
- display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log, display_backend)
343
+ display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log, display_backend) if display_backend_opens_before_leader_rebind(display_backend) else {}
345
344
  for agent_id, display in display_results.items():
346
345
  if agent_id in new_agents:
347
346
  new_agents[agent_id]["display"] = display
@@ -367,7 +366,8 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
367
366
  write_team_state(workspace, spec, state)
368
367
  from team_agent.leader import autobind_leader_receiver_from_env
369
368
  leader_provider = str(spec.get("leader", {}).get("provider") or "codex")
370
- autobind_leader_receiver_from_env(workspace, leader_provider, source="restart")
369
+ rebound_receiver = autobind_leader_receiver_from_env(workspace, leader_provider, source="restart")
370
+ rebuild_restart_display_after_rebind(display_backend, workspace, session_name, spec, event_log, restarted, receiver=rebound_receiver)
371
371
  coordinator = start_coordinator(workspace)
372
372
  event_log.write("restart.complete", session=session_name, agents=restarted, coordinator=coordinator)
373
373
  return {"ok": True, "session_name": session_name, "agents": restarted, "coordinator": coordinator}
@@ -39,10 +39,12 @@ from team_agent.providers import (
39
39
  shell_resume_command_for_agent,
40
40
  )
41
41
  from team_agent.display import (
42
+ GHOSTTY_DISPLAY_BACKENDS,
42
43
  GHOSTTY_WORKSPACE_PANES_PER_WINDOW,
43
44
  close_ghostty_display as _close_ghostty_display,
44
45
  close_ghostty_workspace as _close_ghostty_workspace,
45
46
  close_ghostty_workspace_slot as _close_ghostty_workspace_slot,
47
+ close_team_display_backends as _close_team_display_backends,
46
48
  ghostty_app_exists as _ghostty_app_exists,
47
49
  ghostty_attach_args as _ghostty_attach_args,
48
50
  ghostty_command as _ghostty_command,
@@ -65,6 +67,7 @@ from team_agent.display import (
65
67
  set_ghostty_workspace_pane_title as _set_ghostty_workspace_pane_title,
66
68
  )
67
69
  from team_agent.leader import (
70
+ LEADER_OWNERSHIP_LOCK,
68
71
  attach_leader,
69
72
  attach_leader_to_state as _attach_leader_to_state,
70
73
  claim_leader,
@@ -456,7 +459,6 @@ TMUX_PANE_FORMAT = (
456
459
  "#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}"
457
460
  )
458
461
  HEALTH_STATUSES = {"RUNNING", "IDLE", "AWAITING_APPROVAL", "BLOCKED", "ERROR", "DONE"}
459
- GHOSTTY_DISPLAY_BACKENDS = {"ghostty", "ghostty_window", "ghostty_workspace"}
460
462
  DELIVERY_CAPTURE_LINES = 40
461
463
  SUBMITTED_DELIVERY_STATUSES = {"injected", "visible", "submitted", "submitted_unverified", "delivered", "acknowledged"}
462
464
  TMUX_STDIN_BUFFER_THRESHOLD = 16 * 1024
@@ -480,7 +482,6 @@ def ensure_workspace_dirs(workspace: Path) -> None:
480
482
  path.mkdir(parents=True, exist_ok=True)
481
483
 
482
484
 
483
-
484
485
  def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -> dict[str, Any]:
485
486
  from team_agent.state import resolve_team_scoped_state
486
487
  state, refusal = resolve_team_scoped_state(workspace, team)
@@ -521,7 +522,7 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
521
522
  if proc.returncode == 0:
522
523
  log_path.write_text(proc.stdout, encoding="utf-8")
523
524
  captured.append(str(log_path))
524
- _close_ghostty_workspace(state, event_log)
525
+ _close_team_display_backends(state, event_log)
525
526
  for agent_id, agent_state in state.get("agents", {}).items():
526
527
  _close_ghostty_display(agent_id, agent_state, event_log)
527
528
  closed_displays.add(agent_id)
@@ -535,7 +536,7 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
535
536
  event_log.write("shutdown.kill_session", session=session_name, keep_logs=keep_logs, captured=captured)
536
537
  else:
537
538
  event_log.write("shutdown.idempotent", session=session_name, reason="session missing")
538
- _close_ghostty_workspace(state, event_log)
539
+ _close_team_display_backends(state, event_log)
539
540
  for agent_id, agent_state in state.get("agents", {}).items():
540
541
  if agent_id not in closed_displays:
541
542
  _close_ghostty_display(agent_id, agent_state, event_log)
@@ -617,7 +618,7 @@ def takeover(workspace: Path, team: str | None = None, confirm: bool = False) ->
617
618
  "reason": "no_caller_identity",
618
619
  "action": "set TEAM_AGENT_LEADER_PANE_ID/PROVIDER/MACHINE_FINGERPRINT or run from a tmux pane",
619
620
  }
620
- with _runtime_lock(workspace, "send"):
621
+ with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
621
622
  try:
622
623
  team_state = select_runtime_state(workspace, team)
623
624
  except RuntimeError as exc:
@@ -628,23 +629,72 @@ def takeover(workspace: Path, team: str | None = None, confirm: bool = False) ->
628
629
  "team": team,
629
630
  "error": str(exc),
630
631
  }
631
- previous_owner = team_state.get("team_owner")
632
+ previous_owner = team_state.get("team_owner") if isinstance(team_state.get("team_owner"), dict) else {}
633
+ previous_receiver = team_state.get("leader_receiver") if isinstance(team_state.get("leader_receiver"), dict) else {}
634
+ from team_agent.leader import _lease_epoch, _receiver_from_claim_target
635
+ next_epoch = _lease_epoch(previous_owner, previous_receiver) + 1
636
+ leader_uuid = str(previous_owner.get("leader_session_uuid") or "")
632
637
  new_owner = {
633
638
  "pane_id": pane_id,
634
639
  "provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER", ""),
635
640
  "machine_fingerprint": os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT", ""),
641
+ "owner_epoch": next_epoch,
636
642
  "claimed_at": datetime.now(timezone.utc).isoformat(),
637
643
  "claimed_via": "takeover",
638
644
  }
645
+ if leader_uuid:
646
+ new_owner["leader_session_uuid"] = leader_uuid
639
647
  team_state["team_owner"] = new_owner
640
- save_team_scoped_state(workspace, team_state)
641
- EventLog(workspace).write(
642
- "team_owner.takeover",
643
- team=team,
644
- previous_owner=previous_owner,
648
+ # C11/C17: takeover converges on the same lease mutation as claim-leader.
649
+ # Rebind the leader receiver to the caller pane and write owner + receiver
650
+ # to both state locations together, so takeover never leaves the receiver
651
+ # pointing at the old (often dead) pane.
652
+ targets_result = core_list_targets()
653
+ targets = targets_result.get("targets", []) if isinstance(targets_result, dict) and targets_result.get("ok") else []
654
+ caller_target = next((item for item in targets if isinstance(item, dict) and str(item.get("pane_id")) == str(pane_id)), None)
655
+ new_receiver = None
656
+ if caller_target:
657
+ new_receiver = _receiver_from_claim_target(
658
+ caller_target,
659
+ previous_receiver,
660
+ leader_uuid or None,
661
+ next_epoch,
662
+ )
663
+ new_receiver["discovery"] = "takeover"
664
+ team_state["leader_receiver"] = new_receiver
665
+ from team_agent.leader import _write_lease_dual_state
666
+ _write_lease_dual_state(workspace, team_state)
667
+ # C11: takeover converges on the same lease audit events as claim-leader
668
+ # instead of a divergent legacy team_owner.takeover record.
669
+ event_log = EventLog(workspace)
670
+ uuid_prefix = leader_uuid[:8]
671
+ old_pane_id = previous_receiver.get("pane_id") or (previous_owner or {}).get("pane_id")
672
+ if new_receiver is not None:
673
+ event_log.write(
674
+ "leader_receiver.rebind_applied",
675
+ reason="takeover_confirmed",
676
+ old_pane_id=old_pane_id,
677
+ new_pane_id=pane_id,
678
+ owner_epoch=next_epoch,
679
+ uuid_prefix=uuid_prefix,
680
+ team_id=team,
681
+ )
682
+ event_log.write(
683
+ "owner_epoch_advanced",
684
+ reason="takeover_confirmed",
685
+ old_pane_id=old_pane_id,
686
+ new_pane_id=pane_id,
687
+ owner_epoch=next_epoch,
688
+ uuid_prefix=uuid_prefix,
689
+ team_id=team,
690
+ previous_owner=previous_owner or None,
645
691
  new_owner=new_owner,
692
+ receiver_rebound=bool(new_receiver),
646
693
  )
647
- return {"ok": True, "status": "claimed", "team": team, "team_owner": new_owner, "previous_owner": previous_owner}
694
+ response = {"ok": True, "status": "claimed", "team": team, "team_owner": new_owner, "previous_owner": previous_owner or None, "owner_epoch": next_epoch}
695
+ if new_receiver is not None:
696
+ response["leader_receiver"] = new_receiver
697
+ return response
648
698
 
649
699
 
650
700
  def _running_agent_state(workspace: Path, agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from typing import Any
5
5
 
6
6
  from team_agent.errors import ValidationError
7
+ from team_agent.display.backend import VALID_DISPLAY_BACKENDS
7
8
  from team_agent.permissions import CANONICAL_TOOLS, expand_tools
8
9
  from team_agent.profiles import AUTH_MODES
9
10
  from team_agent.simple_yaml import loads
@@ -233,8 +234,8 @@ def _check_communication(comm: Any, errors: list[str]) -> None:
233
234
 
234
235
 
235
236
  def _check_runtime(runtime: Any, errors: list[str]) -> None:
236
- required = {"backend", "display_backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
237
- allowed = required | {
237
+ required = {"backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
238
+ allowed = required | {"display_backend"} | {
238
239
  "dangerous_auto_approve",
239
240
  "auto_attach_leader",
240
241
  "fast",
@@ -253,7 +254,7 @@ def _check_runtime(runtime: Any, errors: list[str]) -> None:
253
254
  return
254
255
  if runtime.get("backend") not in {"tmux", "pty"}:
255
256
  errors.append("/runtime/backend: invalid backend")
256
- if runtime.get("display_backend") not in {"none", "tmux_attach", "iterm", "ghostty", "ghostty_window", "ghostty_workspace"}:
257
+ if "display_backend" in runtime and runtime.get("display_backend") not in VALID_DISPLAY_BACKENDS:
257
258
  errors.append("/runtime/display_backend: invalid display backend")
258
259
  if "dangerous_auto_approve" in runtime and not isinstance(runtime["dangerous_auto_approve"], bool):
259
260
  errors.append("/runtime/dangerous_auto_approve: must be a boolean")
@@ -0,0 +1,58 @@
1
+ """Provider-neutral wake layer (Gap 32 §2): decide WHEN to re-read a session
2
+ file, never HOW to parse it. No polling loop, no screen, no provider names.
3
+
4
+ Two cheap, passive sources:
5
+ - a file-change watch (the caller wires FSEvents/inotify/kqueue and calls
6
+ ``on_file_changed``) — naturally per-session (one file per node);
7
+ - an mtime gate — only re-read the tail when the file changed since the last
8
+ classification, or when it has been quiet past the debounce window.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+
16
+ def should_reread(
17
+ *,
18
+ last_mtime: float | None,
19
+ current_mtime: float | None,
20
+ last_classified_mtime: float | None,
21
+ now: float,
22
+ debounce_seconds: float,
23
+ ) -> dict[str, Any]:
24
+ """Return whether a fresh tail-read is warranted and why.
25
+
26
+ A read is warranted when the file changed since we last classified it, or
27
+ when it has been silent longer than the debounce window and we have not yet
28
+ classified at this mtime (so an idle-close that we already saw is not
29
+ re-read forever).
30
+ """
31
+ if current_mtime is None:
32
+ return {"reread": False, "reason": "no_file"}
33
+ if last_classified_mtime is None:
34
+ return {"reread": True, "reason": "never_classified"}
35
+ if current_mtime != last_classified_mtime:
36
+ return {"reread": True, "reason": "file_changed"}
37
+ silent_for = max(0.0, now - current_mtime)
38
+ if silent_for >= debounce_seconds:
39
+ return {"reread": False, "reason": "quiescent_already_classified"}
40
+ return {"reread": False, "reason": "unchanged"}
41
+
42
+
43
+ def on_file_changed(watch_state: dict[str, Any] | None, *, node_id: str, mtime: float) -> dict[str, Any]:
44
+ """Record a file-change wake for a node (the push path)."""
45
+ state = dict(watch_state or {})
46
+ pending = set(state.get("pending") or [])
47
+ pending.add(node_id)
48
+ state["pending"] = sorted(pending)
49
+ state.setdefault("mtimes", {})[node_id] = mtime
50
+ return state
51
+
52
+
53
+ def take_pending(watch_state: dict[str, Any] | None) -> tuple[list[str], dict[str, Any]]:
54
+ """Drain the set of nodes whose files changed since the last drain."""
55
+ state = dict(watch_state or {})
56
+ pending = sorted(state.get("pending") or [])
57
+ state["pending"] = []
58
+ return pending, state