pulse-orchestrator 0.3.0__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 (207) hide show
  1. pulse/__init__.py +3 -0
  2. pulse/__main__.py +3 -0
  3. pulse/acp/__init__.py +3 -0
  4. pulse/acp/capabilities.py +27 -0
  5. pulse/acp/client.py +234 -0
  6. pulse/acp/reply_clock.py +44 -0
  7. pulse/acp/session_runner.py +543 -0
  8. pulse/acp/step_turns.py +206 -0
  9. pulse/acp/watched_prompts.py +62 -0
  10. pulse/api/__init__.py +3 -0
  11. pulse/api/app.py +39 -0
  12. pulse/api/browser_guard.py +44 -0
  13. pulse/api/config_routes.py +97 -0
  14. pulse/api/deps.py +28 -0
  15. pulse/api/history_routes.py +104 -0
  16. pulse/api/run_routes.py +143 -0
  17. pulse/api/settings_routes.py +239 -0
  18. pulse/api/system_routes.py +67 -0
  19. pulse/api/token_guard.py +52 -0
  20. pulse/cli/__init__.py +0 -0
  21. pulse/cli/daemon_command.py +71 -0
  22. pulse/cli/main.py +205 -0
  23. pulse/client/__init__.py +3 -0
  24. pulse/client/active_runs.py +39 -0
  25. pulse/client/daemon_process.py +74 -0
  26. pulse/client/engine_client.py +229 -0
  27. pulse/client/protocol.py +42 -0
  28. pulse/client/run_session.py +344 -0
  29. pulse/client/runtime_login.py +25 -0
  30. pulse/config/__init__.py +56 -0
  31. pulse/config/agent_profiles.py +34 -0
  32. pulse/config/agents.py +106 -0
  33. pulse/config/commands.py +50 -0
  34. pulse/config/global_config.py +264 -0
  35. pulse/config/model_catalog.py +68 -0
  36. pulse/config/onboarding.py +63 -0
  37. pulse/config/project_config.py +358 -0
  38. pulse/config/project_settings.py +202 -0
  39. pulse/config/ready.py +14 -0
  40. pulse/config/resolved.py +309 -0
  41. pulse/daemon/__init__.py +1 -0
  42. pulse/daemon/__main__.py +4 -0
  43. pulse/daemon/adapters.py +217 -0
  44. pulse/daemon/events.py +150 -0
  45. pulse/daemon/logs.py +32 -0
  46. pulse/daemon/runs.py +448 -0
  47. pulse/daemon/server.py +220 -0
  48. pulse/doctor/__init__.py +13 -0
  49. pulse/doctor/checks.py +126 -0
  50. pulse/doctor/report.py +124 -0
  51. pulse/domain/__init__.py +0 -0
  52. pulse/domain/branch_name.py +29 -0
  53. pulse/domain/control.py +56 -0
  54. pulse/domain/git_options.py +67 -0
  55. pulse/domain/outcome.py +201 -0
  56. pulse/domain/ports.py +574 -0
  57. pulse/domain/run.py +519 -0
  58. pulse/domain/task_brief.py +34 -0
  59. pulse/domain/workflow.py +154 -0
  60. pulse/history/__init__.py +10 -0
  61. pulse/history/catalog.py +130 -0
  62. pulse/history/events.py +191 -0
  63. pulse/history/log_wording.py +19 -0
  64. pulse/history/models.py +130 -0
  65. pulse/history/prompt_detail.py +224 -0
  66. pulse/history/run_lines.py +59 -0
  67. pulse/history/run_log.py +161 -0
  68. pulse/history/session_log.py +267 -0
  69. pulse/history/status_labels.py +78 -0
  70. pulse/history/tool_classify.py +48 -0
  71. pulse/history/tool_detail.py +537 -0
  72. pulse/mcp/__init__.py +1 -0
  73. pulse/mcp/ask_bridge.py +109 -0
  74. pulse/mcp/ask_protocol.py +126 -0
  75. pulse/mcp/ask_stdio.py +128 -0
  76. pulse/mcp/channel.py +30 -0
  77. pulse/mcp/config.py +14 -0
  78. pulse/mcp/http_bridge.py +166 -0
  79. pulse/orchestrator/__init__.py +4 -0
  80. pulse/orchestrator/agent_text.py +197 -0
  81. pulse/orchestrator/delivery.py +115 -0
  82. pulse/orchestrator/engine.py +620 -0
  83. pulse/orchestrator/fanout.py +84 -0
  84. pulse/orchestrator/loops.py +47 -0
  85. pulse/orchestrator/menus.py +119 -0
  86. pulse/orchestrator/render.py +46 -0
  87. pulse/orchestrator/turn.py +67 -0
  88. pulse/orchestrator/waits.py +33 -0
  89. pulse/orchestrator/wiring.py +67 -0
  90. pulse/orchestrator/wording.py +136 -0
  91. pulse/orchestrator/worktree.py +76 -0
  92. pulse/replies/__init__.py +63 -0
  93. pulse/replies/acp_payloads.py +208 -0
  94. pulse/replies/approval.py +106 -0
  95. pulse/replies/ask_question.py +173 -0
  96. pulse/replies/permission.py +127 -0
  97. pulse/replies/session_models.py +140 -0
  98. pulse/replies/text_questionnaire.py +265 -0
  99. pulse/replies/wording.py +25 -0
  100. pulse/runtimes/__init__.py +83 -0
  101. pulse/runtimes/base.py +228 -0
  102. pulse/runtimes/claude.py +93 -0
  103. pulse/runtimes/codex.py +81 -0
  104. pulse/runtimes/cursor.py +51 -0
  105. pulse/store/__init__.py +3 -0
  106. pulse/store/agent_settings.py +109 -0
  107. pulse/store/codec.py +16 -0
  108. pulse/store/db.py +418 -0
  109. pulse/store/factory/__init__.py +522 -0
  110. pulse/store/factory/agents/codebase-docs/AGENT.md +980 -0
  111. pulse/store/factory/agents/codebase-docs/PHASE.md +27 -0
  112. pulse/store/factory/agents/codebase-explore/AGENT.md +980 -0
  113. pulse/store/factory/agents/codebase-explore/PHASE.md +56 -0
  114. pulse/store/factory/agents/implement/AGENT.md +447 -0
  115. pulse/store/factory/agents/implement/FIX.md +76 -0
  116. pulse/store/factory/agents/implement/PHASE.md +37 -0
  117. pulse/store/factory/agents/prd/AGENT.md +763 -0
  118. pulse/store/factory/agents/prd/PHASE.md +31 -0
  119. pulse/store/factory/agents/quick-implement/AGENT.md +654 -0
  120. pulse/store/factory/agents/quick-implement/PHASE.md +32 -0
  121. pulse/store/factory/agents/quick-plan/AGENT.md +594 -0
  122. pulse/store/factory/agents/quick-plan/PHASE.md +78 -0
  123. pulse/store/factory/agents/review/AGENT.md +1088 -0
  124. pulse/store/factory/agents/review/PHASE.md +93 -0
  125. pulse/store/factory/agents/review/STANDALONE.md +117 -0
  126. pulse/store/factory/agents/review-fix/AGENT.md +625 -0
  127. pulse/store/factory/agents/review-fix/PHASE.md +106 -0
  128. pulse/store/factory/agents/review-fix/STANDALONE.md +124 -0
  129. pulse/store/factory/agents/setup-artifacts/AGENT.md +525 -0
  130. pulse/store/factory/agents/setup-artifacts/PHASE.md +19 -0
  131. pulse/store/factory/agents/setup-interview/AGENT.md +525 -0
  132. pulse/store/factory/agents/setup-interview/PHASE.md +56 -0
  133. pulse/store/factory/agents/tasks/AGENT.md +846 -0
  134. pulse/store/factory/agents/tasks/PHASE.md +47 -0
  135. pulse/store/factory/agents/techspec/AGENT.md +577 -0
  136. pulse/store/factory/agents/techspec/PHASE.md +28 -0
  137. pulse/store/factory/agents/verify/AGENT.md +368 -0
  138. pulse/store/factory/agents/verify/PHASE.md +118 -0
  139. pulse/store/factory/pull_request.md +20 -0
  140. pulse/store/factory/shared/approval-skip.md +27 -0
  141. pulse/store/factory/shared/approval.md +18 -0
  142. pulse/store/factory/shared/clarify.md +74 -0
  143. pulse/store/factory/shared/handoff.md +16 -0
  144. pulse/store/factory/shared/isolation-reuse.md +4 -0
  145. pulse/store/factory/shared/isolation.md +7 -0
  146. pulse/store/factory/shared/output-language.md +20 -0
  147. pulse/store/factory/shared/review-close.md +25 -0
  148. pulse/store/paths.py +119 -0
  149. pulse/store/runs.py +558 -0
  150. pulse/store/sessions.py +98 -0
  151. pulse/store/workflow_defs.py +198 -0
  152. pulse/tui/__init__.py +11 -0
  153. pulse/tui/adapters/__init__.py +6 -0
  154. pulse/tui/adapters/cockpit_sink.py +505 -0
  155. pulse/tui/adapters/prompt_bridge.py +431 -0
  156. pulse/tui/app.py +79 -0
  157. pulse/tui/artifact_tab.py +159 -0
  158. pulse/tui/artifacts.py +106 -0
  159. pulse/tui/brand.py +10 -0
  160. pulse/tui/cursor_store.py +199 -0
  161. pulse/tui/engine.py +454 -0
  162. pulse/tui/history_rows.py +89 -0
  163. pulse/tui/history_theme.py +81 -0
  164. pulse/tui/home_theme.py +340 -0
  165. pulse/tui/hydrate.py +236 -0
  166. pulse/tui/key_hints.py +16 -0
  167. pulse/tui/menu_rows.py +65 -0
  168. pulse/tui/palette.py +12 -0
  169. pulse/tui/screens/__init__.py +8 -0
  170. pulse/tui/screens/branch_name.py +80 -0
  171. pulse/tui/screens/cockpit.py +483 -0
  172. pulse/tui/screens/confirm.py +62 -0
  173. pulse/tui/screens/history.py +311 -0
  174. pulse/tui/screens/history_detail.py +273 -0
  175. pulse/tui/screens/home.py +479 -0
  176. pulse/tui/screens/navigation.py +17 -0
  177. pulse/tui/screens/onboarding.py +311 -0
  178. pulse/tui/screens/resume_choice.py +60 -0
  179. pulse/tui/screens/settings.py +488 -0
  180. pulse/tui/settings_rows.py +65 -0
  181. pulse/tui/settings_theme.py +72 -0
  182. pulse/tui/state.py +635 -0
  183. pulse/tui/strings.py +345 -0
  184. pulse/tui/theme.py +501 -0
  185. pulse/tui/widgets/__init__.py +9 -0
  186. pulse/tui/widgets/app_footer.py +58 -0
  187. pulse/tui/widgets/app_header.py +48 -0
  188. pulse/tui/widgets/artifact_pane.py +241 -0
  189. pulse/tui/widgets/file_mention.py +100 -0
  190. pulse/tui/widgets/log_stream.py +347 -0
  191. pulse/tui/widgets/menu_list.py +73 -0
  192. pulse/tui/widgets/options.py +13 -0
  193. pulse/tui/widgets/options_toggle.py +63 -0
  194. pulse/tui/widgets/phase_sidebar.py +211 -0
  195. pulse/tui/widgets/prompt_dock.py +179 -0
  196. pulse/tui/widgets/task_composer.py +131 -0
  197. pulse/tui/widgets/task_form.py +238 -0
  198. pulse/vcs/__init__.py +258 -0
  199. pulse/vcs/changes.py +110 -0
  200. pulse/vcs/publish.py +109 -0
  201. pulse/vcs/workspace.py +102 -0
  202. pulse_orchestrator-0.3.0.dist-info/METADATA +444 -0
  203. pulse_orchestrator-0.3.0.dist-info/RECORD +207 -0
  204. pulse_orchestrator-0.3.0.dist-info/WHEEL +5 -0
  205. pulse_orchestrator-0.3.0.dist-info/entry_points.txt +4 -0
  206. pulse_orchestrator-0.3.0.dist-info/licenses/LICENSE +21 -0
  207. pulse_orchestrator-0.3.0.dist-info/top_level.txt +1 -0
pulse/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("pulse-orchestrator")
pulse/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pulse.cli.main import main
2
+
3
+ raise SystemExit(main())
pulse/acp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pulse.acp.client import AcpProbeResult, probe_acp_connection
2
+
3
+ __all__ = ["AcpProbeResult", "probe_acp_connection"]
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from pulse import __version__
4
+
5
+ # Pulse does not run terminals for the agent; permission requests whose kind
6
+ # names a terminal or shell are rejected unless this changes. `execute`, the
7
+ # agent running its own shell, follows the step's policy instead.
8
+ CLIENT_RUNS_TERMINAL = False
9
+
10
+ PULSE_CLIENT_CAPABILITIES: dict[str, object] = {
11
+ "fs": {
12
+ "readTextFile": False,
13
+ "writeTextFile": False,
14
+ },
15
+ "terminal": CLIENT_RUNS_TERMINAL,
16
+ "_meta": {
17
+ "cursor": {
18
+ "interactiveExtensions": ["cursor/ask_question", "cursor/create_plan"],
19
+ }
20
+ },
21
+ }
22
+
23
+ PULSE_CLIENT_INFO: dict[str, str] = {
24
+ "name": "pulse",
25
+ "title": "Pulse",
26
+ "version": __version__,
27
+ }
pulse/acp/client.py ADDED
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import threading
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from queue import Empty, Queue
9
+ from typing import Any
10
+
11
+ from pulse.acp.capabilities import PULSE_CLIENT_CAPABILITIES, PULSE_CLIENT_INFO
12
+ from pulse.replies.session_models import extract_model_labels
13
+
14
+
15
+ @dataclass
16
+ class AcpProbeResult:
17
+ success: bool
18
+ message: str
19
+ response_text: str = ""
20
+ available_models: list[str] = field(default_factory=list)
21
+ error: str | None = None
22
+
23
+
24
+ class AcpClient:
25
+ def __init__(
26
+ self,
27
+ command: list[str],
28
+ *,
29
+ cwd: str,
30
+ timeout_seconds: float = 60.0,
31
+ ) -> None:
32
+ self._command = command
33
+ self._cwd = cwd
34
+ self._timeout_seconds = timeout_seconds
35
+ self._process: subprocess.Popen[str] | None = None
36
+ self._responses: dict[int, dict[str, Any]] = {}
37
+ self._response_queue: Queue[int] = Queue()
38
+ self._reader: threading.Thread | None = None
39
+ self._next_id = 1
40
+ self._session_updates: list[dict[str, Any]] = []
41
+
42
+ def start(self) -> None:
43
+ # argv list from the user's effective config, never from agent output.
44
+ self._process = subprocess.Popen( # noqa: S603
45
+ self._command,
46
+ cwd=self._cwd,
47
+ stdin=subprocess.PIPE,
48
+ stdout=subprocess.PIPE,
49
+ stderr=subprocess.DEVNULL,
50
+ text=True,
51
+ bufsize=1,
52
+ )
53
+ self._reader = threading.Thread(target=self._read_stdout, daemon=True)
54
+ self._reader.start()
55
+
56
+ def close(self) -> None:
57
+ if self._process is None:
58
+ return
59
+ if self._process.stdin:
60
+ try:
61
+ self._process.stdin.close()
62
+ except OSError:
63
+ pass
64
+ try:
65
+ self._process.wait(timeout=5)
66
+ except subprocess.TimeoutExpired:
67
+ self._process.terminate()
68
+ self._process = None
69
+
70
+ def _read_stdout(self) -> None:
71
+ if self._process is None or self._process.stdout is None:
72
+ return
73
+ for line in self._process.stdout:
74
+ line = line.strip()
75
+ if not line:
76
+ continue
77
+ try:
78
+ message = json.loads(line)
79
+ except json.JSONDecodeError:
80
+ continue
81
+ if "method" in message and "id" not in message:
82
+ if message.get("method") == "session/update":
83
+ params = message.get("params", {})
84
+ self._session_updates.append(params)
85
+ continue
86
+ message_id = message.get("id")
87
+ if isinstance(message_id, int):
88
+ self._responses[message_id] = message
89
+ self._response_queue.put(message_id)
90
+
91
+ def _send(self, payload: dict[str, Any]) -> None:
92
+ if self._process is None or self._process.stdin is None:
93
+ raise RuntimeError("ACP process not started")
94
+ self._process.stdin.write(json.dumps(payload) + "\n")
95
+ self._process.stdin.flush()
96
+
97
+ def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
98
+ request_id = self._next_id
99
+ self._next_id += 1
100
+ self._send(
101
+ {
102
+ "jsonrpc": "2.0",
103
+ "id": request_id,
104
+ "method": method,
105
+ "params": params,
106
+ }
107
+ )
108
+ try:
109
+ response_id = self._response_queue.get(timeout=self._timeout_seconds)
110
+ except Empty as error:
111
+ raise TimeoutError(f"timed out waiting for a response to `{method}`") from error
112
+
113
+ response = self._responses.pop(response_id, {})
114
+ if "error" in response:
115
+ failure = response["error"]
116
+ raise RuntimeError(failure.get("message", "unknown ACP error"))
117
+ return response.get("result", {})
118
+
119
+ def initialize(self) -> dict[str, Any]:
120
+ result = self._request(
121
+ "initialize",
122
+ {
123
+ "protocolVersion": 1,
124
+ "clientCapabilities": PULSE_CLIENT_CAPABILITIES,
125
+ "clientInfo": PULSE_CLIENT_INFO,
126
+ },
127
+ )
128
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
129
+ return result
130
+
131
+ def new_session(self, cwd: str) -> dict[str, Any]:
132
+ return self._request("session/new", {"cwd": cwd, "mcpServers": []})
133
+
134
+ def prompt(self, session_id: str, text: str) -> dict[str, Any]:
135
+ return self._request(
136
+ "session/prompt",
137
+ {
138
+ "sessionId": session_id,
139
+ "prompt": [{"type": "text", "text": text}],
140
+ },
141
+ )
142
+
143
+ def collect_message_text(self) -> str:
144
+ chunks: list[str] = []
145
+ for params in self._session_updates:
146
+ update = params.get("update", {})
147
+ if update.get("sessionUpdate") != "agent_message_chunk":
148
+ continue
149
+ content = update.get("content", {})
150
+ if content.get("type") == "text":
151
+ chunks.append(str(content.get("text", "")))
152
+ return "".join(chunks)
153
+
154
+
155
+ def fetch_acp_available_models(
156
+ *,
157
+ command: list[str] | None = None,
158
+ cwd: str,
159
+ timeout_seconds: float = 30.0,
160
+ ) -> list[str]:
161
+ acp_command = command or ["agent", "acp"]
162
+ client = AcpClient(acp_command, cwd=cwd, timeout_seconds=timeout_seconds)
163
+ try:
164
+ client.start()
165
+ client.initialize()
166
+ session = client.new_session(cwd)
167
+ return extract_model_labels(session)
168
+ except (RuntimeError, TimeoutError, OSError):
169
+ return []
170
+ finally:
171
+ client.close()
172
+
173
+
174
+ def _extract_models(session_result: dict[str, Any]) -> list[str]:
175
+ return extract_model_labels(session_result)
176
+
177
+
178
+ def probe_acp_connection(
179
+ *,
180
+ command: list[str] | None = None,
181
+ cwd: str,
182
+ prompt_text: str = "Say hello",
183
+ timeout_seconds: float = 60.0,
184
+ on_status: Callable[[str], None] | None = None,
185
+ ) -> AcpProbeResult:
186
+ acp_command = command or ["agent", "acp"]
187
+ client = AcpClient(acp_command, cwd=cwd, timeout_seconds=timeout_seconds)
188
+
189
+ def status(message: str) -> None:
190
+ if on_status:
191
+ on_status(message)
192
+
193
+ try:
194
+ status("Starting the ACP agent…")
195
+ client.start()
196
+ status("Handshake initialize…")
197
+ client.initialize()
198
+ status("Creating a session…")
199
+ session = client.new_session(cwd)
200
+ session_id = session.get("sessionId")
201
+ if not session_id:
202
+ return AcpProbeResult(
203
+ success=False,
204
+ message="ACP session returned no sessionId",
205
+ error="missing_session_id",
206
+ )
207
+
208
+ models = _extract_models(session)
209
+ status(f'Sending the test prompt: "{prompt_text}"…')
210
+ client.prompt(str(session_id), prompt_text)
211
+ response_text = client.collect_message_text().strip()
212
+
213
+ if not response_text:
214
+ return AcpProbeResult(
215
+ success=False,
216
+ message="ACP replied with no message content",
217
+ available_models=models,
218
+ error="empty_response",
219
+ )
220
+
221
+ return AcpProbeResult(
222
+ success=True,
223
+ message="ACP connection verified",
224
+ response_text=response_text,
225
+ available_models=models,
226
+ )
227
+ except (RuntimeError, TimeoutError, OSError) as error:
228
+ return AcpProbeResult(
229
+ success=False,
230
+ message="ACP test failed",
231
+ error=str(error),
232
+ )
233
+ finally:
234
+ client.close()
@@ -0,0 +1,44 @@
1
+ """How long the runner waits for the agent's reply to one JSON-RPC request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ CANCEL_GRACE_SECONDS = 10.0
9
+
10
+ # Requests whose deadline measures silence: a working agent keeps talking.
11
+ IDLE_CLOCK_METHODS = frozenset({"session/prompt"})
12
+
13
+
14
+ @dataclass
15
+ class ReplyClock:
16
+ """When a request stops waiting for its reply.
17
+
18
+ `session/prompt` measures agent silence (`idle`): the deadline follows the last
19
+ sign of activity. Other requests keep a fixed deadline. A cancelled turn gets
20
+ a short grace period instead, so the agent can close it on its own.
21
+ """
22
+
23
+ limit: float
24
+ idle: bool
25
+ deadline: float
26
+ cancelling: bool = False
27
+
28
+ def follow(self, activity: float) -> None:
29
+ if self.idle and not self.cancelling:
30
+ self.deadline = activity + self.limit
31
+
32
+ def expired(self) -> bool:
33
+ return time.monotonic() >= self.deadline
34
+
35
+ def remaining(self) -> float:
36
+ return max(0.0, self.deadline - time.monotonic())
37
+
38
+ def start_cancel(self) -> None:
39
+ self.cancelling = True
40
+ self.deadline = time.monotonic() + CANCEL_GRACE_SECONDS
41
+
42
+ @classmethod
43
+ def for_request(cls, method: str, limit: float, started: float) -> ReplyClock:
44
+ return cls(limit=limit, idle=method in IDLE_CLOCK_METHODS, deadline=started + limit)