synapse-cli-agent 0.1.13__py3-none-any.whl

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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,146 @@
1
+ """Workspace path helpers for virtual-mode file tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ # Common filesystem tool argument keys used by deepagents / our tools.
11
+ _PATH_KEYS = (
12
+ "path",
13
+ "file_path",
14
+ "filename",
15
+ "file",
16
+ "target",
17
+ "target_file",
18
+ "source",
19
+ "src",
20
+ "dst",
21
+ "destination",
22
+ "directory",
23
+ "dir",
24
+ "glob",
25
+ "pattern_path",
26
+ )
27
+
28
+
29
+ _WIN_DRIVE = re.compile(r"^[A-Za-z]:[\\/]")
30
+ _WIN_EXTENDED_PREFIX = re.compile(r"^(?:\\\\\?\\|//\?/)", re.IGNORECASE)
31
+
32
+
33
+ def is_windows_absolute(path: str) -> bool:
34
+ s = _strip_windows_extended_prefix(str(path))
35
+ return bool(_WIN_DRIVE.match(s)) or s.startswith("\\\\")
36
+
37
+
38
+ def _strip_windows_extended_prefix(path: str) -> str:
39
+ r"""Convert Windows ``\\?\`` paths to their normal drive / UNC form."""
40
+ s = str(path).strip()
41
+ if not _WIN_EXTENDED_PREFIX.match(s):
42
+ return s
43
+ remainder = _WIN_EXTENDED_PREFIX.sub("", s, count=1)
44
+ if remainder.upper().startswith("UNC\\"):
45
+ return "\\\\" + remainder[4:]
46
+ return remainder
47
+
48
+
49
+ def is_virtual_path(path: str) -> bool:
50
+ """True for POSIX-style virtual paths like ``/src/a.py`` (not ``C:/...``)."""
51
+ s = str(path).strip()
52
+ if not s.startswith("/"):
53
+ return False
54
+ # Reject /C:/... style mistakes
55
+ if len(s) >= 3 and s[2] == ":" and s[1].isalpha():
56
+ return False
57
+ return True
58
+
59
+
60
+ def to_virtual_path(path: str | os.PathLike[str] | None, workspace: Path) -> str | None:
61
+ """Map host paths under workspace to virtual paths starting with ``/``.
62
+
63
+ Examples (workspace = F:/project/repo):
64
+ - ``F:/project/repo/src/a.py`` -> ``/src/a.py``
65
+ - ``src/a.py`` -> ``/src/a.py``
66
+ - ``/src/a.py`` -> ``/src/a.py`` (unchanged)
67
+ - ``/`` -> ``/``
68
+ """
69
+ if path is None:
70
+ return None
71
+ s = _strip_windows_extended_prefix(str(path))
72
+ if not s:
73
+ return s
74
+
75
+ if is_virtual_path(s):
76
+ return s if s.startswith("/") else f"/{s}"
77
+
78
+ root = Path(workspace).resolve()
79
+
80
+ # Windows absolute / UNC / host absolute under workspace root
81
+ if is_windows_absolute(s) or os.path.isabs(s):
82
+ try:
83
+ cand = Path(s)
84
+ # resolve() may fail for non-existing; still ok for relative_to with abs paths
85
+ try:
86
+ cand_res = cand.resolve()
87
+ except OSError:
88
+ cand_res = cand
89
+ try:
90
+ rel = cand_res.relative_to(root)
91
+ rel_s = rel.as_posix()
92
+ return "/" if rel_s in {"", "."} else f"/{rel_s}"
93
+ except ValueError:
94
+ # Outside workspace: leave as-is
95
+ return s
96
+ except Exception: # noqa: BLE001
97
+ return s
98
+
99
+ # Relative host path -> virtual
100
+ rel = s.replace("\\", "/").lstrip("./")
101
+ return "/" + rel if rel else "/"
102
+
103
+
104
+ def rewrite_tool_args_paths(args: dict[str, Any], workspace: Path) -> dict[str, Any]:
105
+ """Return a shallow-copied args dict with path-like fields virtualized."""
106
+ out = dict(args)
107
+ for key in _PATH_KEYS:
108
+ if key not in out or out[key] is None:
109
+ continue
110
+ val = out[key]
111
+ if isinstance(val, str):
112
+ out[key] = to_virtual_path(val, workspace)
113
+ elif isinstance(val, list):
114
+ out[key] = [
115
+ to_virtual_path(v, workspace) if isinstance(v, str) else v for v in val
116
+ ]
117
+ return out
118
+
119
+
120
+ def summarize_tool_result(content: Any, *, limit: int = 80) -> str:
121
+ """Compact tool-result status for CLI (no file body dump)."""
122
+ try:
123
+ if content is None:
124
+ text = ""
125
+ elif isinstance(content, str):
126
+ text = content
127
+ elif isinstance(content, (bytes, bytearray)):
128
+ text = bytes(content).decode("utf-8", errors="replace")
129
+ else:
130
+ text = str(content)
131
+ except Exception: # noqa: BLE001
132
+ text = ""
133
+ stripped = text.strip() if isinstance(text, str) else ""
134
+ if not stripped:
135
+ return "ok"
136
+ lower = stripped.lower()
137
+ if lower.startswith("error") or "not supported" in lower or "traceback" in lower:
138
+ # Keep short error reason only
139
+ one = stripped.replace("\n", " ")
140
+ if len(one) > limit:
141
+ one = one[: limit - 3] + "..."
142
+ return one
143
+ # Success: size only, never dump body.
144
+ n = len(stripped)
145
+ lines = stripped.count("\n") + 1
146
+ return f"ok ({n} chars, {lines} lines)"
@@ -0,0 +1,184 @@
1
+ """Command safety helpers and named safety profiles.
2
+
3
+ Default product behavior: **no approval gate**, auto-pass (dev-autopass).
4
+ Profiles make the product posture explicit:
5
+
6
+ - dev-autopass: full tools, no HITL (default)
7
+ - dev-approve: HITL on execute/write/edit
8
+ - readonly: exclude write/execute tools via harness
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Any, Literal
16
+
17
+ SafetyProfileName = Literal["dev-autopass", "dev-approve", "readonly"]
18
+
19
+ # Patterns considered dangerous on developer machines.
20
+ DANGEROUS_PATTERNS: list[re.Pattern[str]] = [
21
+ re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b", re.I),
22
+ re.compile(r"\brm\s+-rf\s+/", re.I),
23
+ re.compile(r"\b(del|rmdir)\s+/s\b", re.I),
24
+ re.compile(r"\bformat\s+[a-z]:", re.I),
25
+ re.compile(r"\bmkfs\b", re.I),
26
+ re.compile(r"\bdd\s+if=", re.I),
27
+ re.compile(r":\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;", re.I), # fork bomb
28
+ re.compile(r"\bshutdown\b", re.I),
29
+ re.compile(r"\breboot\b", re.I),
30
+ re.compile(r"\bgit\s+push\s+.*--force\b", re.I),
31
+ re.compile(r"\bgit\s+reset\s+--hard\b", re.I),
32
+ re.compile(r"\bgit\s+clean\s+-fdx\b", re.I),
33
+ re.compile(r">\s*/dev/sd[a-z]", re.I),
34
+ re.compile(r"\bRemove-Item\b.*-Recurse\b.*-Force\b", re.I),
35
+ ]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SafetyVerdict:
40
+ allowed: bool
41
+ reason: str | None = None
42
+ matched_pattern: str | None = None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class SafetyProfile:
47
+ """Named product posture for approval / readonly / blacklist."""
48
+
49
+ name: SafetyProfileName
50
+ require_approval: bool
51
+ readonly: bool
52
+ auto_approve: bool
53
+ enable_command_blacklist: bool
54
+ description: str
55
+
56
+
57
+ SAFETY_PROFILES: dict[str, SafetyProfile] = {
58
+ "dev-autopass": SafetyProfile(
59
+ name="dev-autopass",
60
+ require_approval=False,
61
+ readonly=False,
62
+ auto_approve=True,
63
+ enable_command_blacklist=True,
64
+ description="Local dev default: full tools, no HITL, blacklist advisory.",
65
+ ),
66
+ "dev-approve": SafetyProfile(
67
+ name="dev-approve",
68
+ require_approval=True,
69
+ readonly=False,
70
+ auto_approve=False,
71
+ enable_command_blacklist=True,
72
+ description="Local dev with HITL on execute/write/edit.",
73
+ ),
74
+ "readonly": SafetyProfile(
75
+ name="readonly",
76
+ require_approval=False,
77
+ readonly=True,
78
+ auto_approve=True,
79
+ enable_command_blacklist=True,
80
+ description="Read-only: exclude write_file/edit_file/execute via harness.",
81
+ ),
82
+ }
83
+
84
+
85
+ def get_safety_profile(name: str | None) -> SafetyProfile:
86
+ key = (name or "dev-autopass").strip().casefold()
87
+ if key in SAFETY_PROFILES:
88
+ return SAFETY_PROFILES[key]
89
+ # aliases
90
+ if key in {"approve", "hitl", "dev_approve"}:
91
+ return SAFETY_PROFILES["dev-approve"]
92
+ if key in {"ro", "read-only", "read_only"}:
93
+ return SAFETY_PROFILES["readonly"]
94
+ if key in {"auto", "autopass", "dev_autopass", "off"}:
95
+ return SAFETY_PROFILES["dev-autopass"]
96
+ return SAFETY_PROFILES["dev-autopass"]
97
+
98
+
99
+ def apply_safety_to_settings(settings: Any, profile: SafetyProfile | str) -> list[str]:
100
+ """Mutate settings fields from a named profile. Returns change notes."""
101
+ prof = get_safety_profile(profile if isinstance(profile, str) else profile.name)
102
+ notes: list[str] = []
103
+ mapping = {
104
+ "require_approval": prof.require_approval,
105
+ "auto_approve": prof.auto_approve,
106
+ "readonly": prof.readonly,
107
+ "enable_command_blacklist": prof.enable_command_blacklist,
108
+ "safety_profile": prof.name,
109
+ }
110
+ for key, val in mapping.items():
111
+ if not hasattr(settings, key):
112
+ continue
113
+ old = getattr(settings, key)
114
+ if old != val:
115
+ try:
116
+ setattr(settings, key, val)
117
+ except Exception: # noqa: BLE001
118
+ # pydantic Settings may be frozen-ish; try model_copy pattern
119
+ pass
120
+ else:
121
+ notes.append(f"{key}: {old!r} -> {val!r}")
122
+ # pydantic BaseSettings often allows setattr on instance
123
+ if hasattr(settings, "model_copy") and not notes:
124
+ # try replace via object.__setattr__ for model fields
125
+ for key, val in mapping.items():
126
+ if hasattr(settings, key) and getattr(settings, key) != val:
127
+ object.__setattr__(settings, key, val)
128
+ notes.append(f"{key} -> {val!r}")
129
+ if not notes:
130
+ notes.append(f"profile already active: {prof.name}")
131
+ else:
132
+ notes.insert(0, f"safety profile: {prof.name}")
133
+ notes.append(prof.description)
134
+ return notes
135
+
136
+
137
+ def check_command(command: str, *, enable_blacklist: bool = True) -> SafetyVerdict:
138
+ """Return whether a shell command is considered safe enough to run."""
139
+ if not enable_blacklist:
140
+ return SafetyVerdict(allowed=True)
141
+
142
+ text = command.strip()
143
+ if not text:
144
+ return SafetyVerdict(allowed=False, reason="empty command")
145
+
146
+ for pattern in DANGEROUS_PATTERNS:
147
+ if pattern.search(text):
148
+ return SafetyVerdict(
149
+ allowed=False,
150
+ reason="command matches dangerous pattern",
151
+ matched_pattern=pattern.pattern,
152
+ )
153
+ return SafetyVerdict(allowed=True)
154
+
155
+
156
+ def build_interrupt_on(*, require_approval: bool) -> dict[str, bool] | None:
157
+ """Map product settings to deepagents `interrupt_on`.
158
+
159
+ Default: require_approval=False => no interrupt middleware.
160
+ """
161
+ if not require_approval:
162
+ return None
163
+ return {
164
+ "execute": True,
165
+ "write_file": True,
166
+ "edit_file": True,
167
+ }
168
+
169
+
170
+ def format_safety_status(settings: Any) -> list[str]:
171
+ profile = getattr(settings, "safety_profile", None) or (
172
+ "dev-approve"
173
+ if getattr(settings, "require_approval", False)
174
+ else ("readonly" if getattr(settings, "readonly", False) else "dev-autopass")
175
+ )
176
+ return [
177
+ f"safety profile: {profile}",
178
+ f" require_approval: {getattr(settings, 'require_approval', False)}",
179
+ f" auto_approve: {getattr(settings, 'auto_approve', True)}",
180
+ f" readonly: {getattr(settings, 'readonly', False)}",
181
+ f" command_blacklist: {getattr(settings, 'enable_command_blacklist', True)}",
182
+ "profiles: dev-autopass | dev-approve | readonly",
183
+ "switch: /safety <profile> (rebuilds agent)",
184
+ ]
@@ -0,0 +1,240 @@
1
+ """Mid-run user guidance queue (type-B HITL / steer).
2
+
3
+ While the agent loop is busy, the user can enqueue short instructions.
4
+ They are drained at the next model step (after the current tool batch) and
5
+ injected as HumanMessages so the LLM can change direction without Esc-cancel.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from collections import deque
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ STEER_PREFIX = "[Mid-run user guidance]"
16
+ # UI-only label used for silent follow-up turns (should never paint in transcript).
17
+ STEER_FOLLOWUP_PREFIX = "[steer follow-up]"
18
+
19
+ SteerListener = Callable[[list[str]], None]
20
+
21
+
22
+ def is_steer_message(msg: Any = None, *, text: str | None = None) -> bool:
23
+ """True if a LangChain message / plain text is mid-run guidance (model-only).
24
+
25
+ Used to keep steer chrome out of the transcript and status bar.
26
+ """
27
+ if text is None and msg is not None:
28
+ # Prefer kwargs flag set by middleware.
29
+ ak = getattr(msg, "additional_kwargs", None)
30
+ if isinstance(ak, dict) and ak.get("coding_steer"):
31
+ return True
32
+ if isinstance(msg, dict):
33
+ ak2 = msg.get("additional_kwargs") or {}
34
+ if isinstance(ak2, dict) and ak2.get("coding_steer"):
35
+ return True
36
+ content = msg.get("content")
37
+ else:
38
+ content = getattr(msg, "content", None)
39
+ if isinstance(content, str):
40
+ text = content
41
+ elif isinstance(content, list):
42
+ parts: list[str] = []
43
+ for block in content:
44
+ if isinstance(block, str):
45
+ parts.append(block)
46
+ elif isinstance(block, dict) and block.get("text"):
47
+ parts.append(str(block.get("text")))
48
+ text = "\n".join(parts)
49
+ else:
50
+ text = str(content or "")
51
+ body = (text or "").strip()
52
+ if not body:
53
+ return False
54
+ if body.startswith(STEER_PREFIX) or STEER_PREFIX in body[:80]:
55
+ return True
56
+ if body.startswith(STEER_FOLLOWUP_PREFIX):
57
+ return True
58
+ return False
59
+
60
+
61
+ class SteerQueue:
62
+ """Thread-safe FIFO of mid-run guidance strings."""
63
+
64
+ def __init__(self) -> None:
65
+ self._items: deque[str] = deque()
66
+ self._lock = threading.Lock()
67
+ self._listeners: list[SteerListener] = []
68
+ self._pending_notifications: deque[
69
+ tuple[list[str], list[SteerListener]]
70
+ ] = deque()
71
+ self._notifying = False
72
+
73
+ def add_listener(self, callback: SteerListener) -> None:
74
+ """Register a callback invoked with a snapshot after each change."""
75
+ if callback is None:
76
+ return
77
+ with self._lock:
78
+ if callback not in self._listeners:
79
+ self._listeners.append(callback)
80
+
81
+ def remove_listener(self, callback: SteerListener) -> None:
82
+ with self._lock:
83
+ self._listeners = [cb for cb in self._listeners if cb is not callback]
84
+
85
+ def _snapshot(self) -> list[str]:
86
+ return list(self._items)
87
+
88
+ def _enqueue_notification_unlocked(self) -> bool:
89
+ self._pending_notifications.append(
90
+ (self._snapshot(), list(self._listeners))
91
+ )
92
+ if self._notifying:
93
+ return False
94
+ self._notifying = True
95
+ return True
96
+
97
+ def _dispatch_notifications(self) -> None:
98
+ # Preserve mutation order without invoking application code under _lock.
99
+ while True:
100
+ with self._lock:
101
+ if not self._pending_notifications:
102
+ self._notifying = False
103
+ return
104
+ snap, listeners = self._pending_notifications.popleft()
105
+ for callback in listeners:
106
+ try:
107
+ callback(snap)
108
+ except Exception: # noqa: BLE001
109
+ pass
110
+
111
+ def push(self, text: str) -> int:
112
+ """Enqueue guidance. Returns pending count after push (0 if empty text)."""
113
+ body = (text or "").strip()
114
+ if not body:
115
+ return 0
116
+ with self._lock:
117
+ self._items.append(body)
118
+ n = len(self._items)
119
+ should_dispatch = self._enqueue_notification_unlocked()
120
+ if should_dispatch:
121
+ self._dispatch_notifications()
122
+ return n
123
+
124
+ def drain(self) -> list[str]:
125
+ """Pop all pending guidance (order preserved)."""
126
+ with self._lock:
127
+ items = list(self._items)
128
+ self._items.clear()
129
+ should_dispatch = bool(items) and self._enqueue_notification_unlocked()
130
+ if should_dispatch:
131
+ self._dispatch_notifications()
132
+ return items
133
+
134
+ def peek_count(self) -> int:
135
+ with self._lock:
136
+ return len(self._items)
137
+
138
+ def peek_items(self) -> list[str]:
139
+ """Return a copy of pending items (order preserved)."""
140
+ with self._lock:
141
+ return self._snapshot()
142
+
143
+ def remove_at(self, index: int) -> str | None:
144
+ """Remove one item by index. Returns removed text or None."""
145
+ with self._lock:
146
+ if index < 0 or index >= len(self._items):
147
+ return None
148
+ # deque has no pop(index); rebuild
149
+ items = list(self._items)
150
+ removed = items.pop(index)
151
+ self._items = deque(items)
152
+ should_dispatch = self._enqueue_notification_unlocked()
153
+ if should_dispatch:
154
+ self._dispatch_notifications()
155
+ return removed
156
+
157
+ def clear(self) -> list[str]:
158
+ """Drop all pending items. Returns the previous list."""
159
+ with self._lock:
160
+ if not self._items:
161
+ return []
162
+ items = list(self._items)
163
+ self._items.clear()
164
+ should_dispatch = self._enqueue_notification_unlocked()
165
+ if should_dispatch:
166
+ self._dispatch_notifications()
167
+ return items
168
+
169
+
170
+ def format_steer_message(items: list[str]) -> str:
171
+ """Render queued guidance for the model."""
172
+ cleaned = [str(x).strip() for x in items if str(x).strip()]
173
+ if not cleaned:
174
+ return ""
175
+ header = (
176
+ f"{STEER_PREFIX}\n"
177
+ "The user is steering the current task. Incorporate the guidance below "
178
+ "into your next actions. Prefer adjusting the plan over restarting "
179
+ "unrelated work.\n"
180
+ )
181
+ if len(cleaned) == 1:
182
+ return f"{header}\n{cleaned[0]}"
183
+ body = "\n".join(f"{i}. {line}" for i, line in enumerate(cleaned, 1))
184
+ return f"{header}\nMultiple notes (in order):\n{body}"
185
+
186
+
187
+ def format_steer_panel(items: list[str], *, max_preview: int = 72) -> str:
188
+ """Render a multi-line panel for TUI (human-facing)."""
189
+ cleaned = [str(x).strip() for x in items if str(x).strip()]
190
+ if not cleaned:
191
+ return ""
192
+ n = len(cleaned)
193
+ lines = [f" ▸ steer queue · {n} pending · applies before next model step"]
194
+ for i, text in enumerate(cleaned, 1):
195
+ one = " ".join(text.split())
196
+ if len(one) > max_preview:
197
+ one = one[: max(0, max_preview - 1)] + "…"
198
+ lines.append(f" {i}. {one}")
199
+ return "\n".join(lines)
200
+
201
+
202
+ def build_steer_middleware(queue: SteerQueue):
203
+ """Inject drained queue messages before each model call (post-tool boundary)."""
204
+ from langchain.agents.middleware import AgentMiddleware, AgentState
205
+ from langchain_core.messages import HumanMessage
206
+
207
+ def _logic(state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ARG001
208
+ items = queue.drain()
209
+ if not items:
210
+ return None
211
+ content = format_steer_message(items)
212
+ if not content:
213
+ return None
214
+ msg = HumanMessage(
215
+ content=content,
216
+ additional_kwargs={"coding_steer": True, "steer_count": len(items)},
217
+ )
218
+ return {"messages": [msg]}
219
+
220
+ def before_model(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ANN001
221
+ return _logic(state, runtime)
222
+
223
+ async def abefore_model(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ANN001
224
+ return _logic(state, runtime)
225
+
226
+ return type(
227
+ "inject_steer_queue",
228
+ (AgentMiddleware,),
229
+ {
230
+ "state_schema": AgentState,
231
+ "tools": [],
232
+ "before_model": before_model,
233
+ "abefore_model": abefore_model,
234
+ },
235
+ )()
236
+
237
+
238
+ def get_agent_steer_queue(agent: Any) -> SteerQueue | None:
239
+ q = getattr(agent, "_coding_steer_queue", None)
240
+ return q if isinstance(q, SteerQueue) else None