pdd-cli 0.0.45__py3-none-any.whl → 0.0.118__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 (195) hide show
  1. pdd/__init__.py +40 -8
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +497 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +526 -0
  6. pdd/agentic_common.py +598 -0
  7. pdd/agentic_crash.py +534 -0
  8. pdd/agentic_e2e_fix.py +319 -0
  9. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  10. pdd/agentic_fix.py +1294 -0
  11. pdd/agentic_langtest.py +162 -0
  12. pdd/agentic_update.py +387 -0
  13. pdd/agentic_verify.py +183 -0
  14. pdd/architecture_sync.py +565 -0
  15. pdd/auth_service.py +210 -0
  16. pdd/auto_deps_main.py +71 -51
  17. pdd/auto_include.py +245 -5
  18. pdd/auto_update.py +125 -47
  19. pdd/bug_main.py +196 -23
  20. pdd/bug_to_unit_test.py +2 -0
  21. pdd/change_main.py +11 -4
  22. pdd/cli.py +22 -1181
  23. pdd/cmd_test_main.py +350 -150
  24. pdd/code_generator.py +60 -18
  25. pdd/code_generator_main.py +790 -57
  26. pdd/commands/__init__.py +48 -0
  27. pdd/commands/analysis.py +306 -0
  28. pdd/commands/auth.py +309 -0
  29. pdd/commands/connect.py +290 -0
  30. pdd/commands/fix.py +163 -0
  31. pdd/commands/generate.py +257 -0
  32. pdd/commands/maintenance.py +175 -0
  33. pdd/commands/misc.py +87 -0
  34. pdd/commands/modify.py +256 -0
  35. pdd/commands/report.py +144 -0
  36. pdd/commands/sessions.py +284 -0
  37. pdd/commands/templates.py +215 -0
  38. pdd/commands/utility.py +110 -0
  39. pdd/config_resolution.py +58 -0
  40. pdd/conflicts_main.py +8 -3
  41. pdd/construct_paths.py +589 -111
  42. pdd/context_generator.py +10 -2
  43. pdd/context_generator_main.py +175 -76
  44. pdd/continue_generation.py +53 -10
  45. pdd/core/__init__.py +33 -0
  46. pdd/core/cli.py +527 -0
  47. pdd/core/cloud.py +237 -0
  48. pdd/core/dump.py +554 -0
  49. pdd/core/errors.py +67 -0
  50. pdd/core/remote_session.py +61 -0
  51. pdd/core/utils.py +90 -0
  52. pdd/crash_main.py +262 -33
  53. pdd/data/language_format.csv +71 -63
  54. pdd/data/llm_model.csv +20 -18
  55. pdd/detect_change_main.py +5 -4
  56. pdd/docs/prompting_guide.md +864 -0
  57. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  58. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  59. pdd/fix_code_loop.py +523 -95
  60. pdd/fix_code_module_errors.py +6 -2
  61. pdd/fix_error_loop.py +491 -92
  62. pdd/fix_errors_from_unit_tests.py +4 -3
  63. pdd/fix_main.py +278 -21
  64. pdd/fix_verification_errors.py +12 -100
  65. pdd/fix_verification_errors_loop.py +529 -286
  66. pdd/fix_verification_main.py +294 -89
  67. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  68. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  69. pdd/frontend/dist/index.html +376 -0
  70. pdd/frontend/dist/logo.svg +33 -0
  71. pdd/generate_output_paths.py +139 -15
  72. pdd/generate_test.py +218 -146
  73. pdd/get_comment.py +19 -44
  74. pdd/get_extension.py +8 -9
  75. pdd/get_jwt_token.py +318 -22
  76. pdd/get_language.py +8 -7
  77. pdd/get_run_command.py +75 -0
  78. pdd/get_test_command.py +68 -0
  79. pdd/git_update.py +70 -19
  80. pdd/incremental_code_generator.py +2 -2
  81. pdd/insert_includes.py +13 -4
  82. pdd/llm_invoke.py +1711 -181
  83. pdd/load_prompt_template.py +19 -12
  84. pdd/path_resolution.py +140 -0
  85. pdd/pdd_completion.fish +25 -2
  86. pdd/pdd_completion.sh +30 -4
  87. pdd/pdd_completion.zsh +79 -4
  88. pdd/postprocess.py +14 -4
  89. pdd/preprocess.py +293 -24
  90. pdd/preprocess_main.py +41 -6
  91. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  92. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  93. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  94. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  95. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  96. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  97. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  98. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  99. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  100. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  101. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  102. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  103. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  104. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  105. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  106. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  107. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  108. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  109. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  110. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  111. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  112. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  113. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  114. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  115. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  116. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  117. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  118. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  119. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  120. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  121. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  122. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  123. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  124. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  125. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  126. pdd/prompts/agentic_update_LLM.prompt +925 -0
  127. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  128. pdd/prompts/auto_include_LLM.prompt +122 -905
  129. pdd/prompts/change_LLM.prompt +3093 -1
  130. pdd/prompts/detect_change_LLM.prompt +686 -27
  131. pdd/prompts/example_generator_LLM.prompt +22 -1
  132. pdd/prompts/extract_code_LLM.prompt +5 -1
  133. pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
  134. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  135. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  136. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  137. pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
  138. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
  139. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  140. pdd/prompts/generate_test_LLM.prompt +41 -7
  141. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  142. pdd/prompts/increase_tests_LLM.prompt +1 -5
  143. pdd/prompts/insert_includes_LLM.prompt +316 -186
  144. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  145. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  146. pdd/prompts/trace_LLM.prompt +25 -22
  147. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  148. pdd/prompts/update_prompt_LLM.prompt +22 -1
  149. pdd/pytest_output.py +127 -12
  150. pdd/remote_session.py +876 -0
  151. pdd/render_mermaid.py +236 -0
  152. pdd/server/__init__.py +52 -0
  153. pdd/server/app.py +335 -0
  154. pdd/server/click_executor.py +587 -0
  155. pdd/server/executor.py +338 -0
  156. pdd/server/jobs.py +661 -0
  157. pdd/server/models.py +241 -0
  158. pdd/server/routes/__init__.py +31 -0
  159. pdd/server/routes/architecture.py +451 -0
  160. pdd/server/routes/auth.py +364 -0
  161. pdd/server/routes/commands.py +929 -0
  162. pdd/server/routes/config.py +42 -0
  163. pdd/server/routes/files.py +603 -0
  164. pdd/server/routes/prompts.py +1322 -0
  165. pdd/server/routes/websocket.py +473 -0
  166. pdd/server/security.py +243 -0
  167. pdd/server/terminal_spawner.py +209 -0
  168. pdd/server/token_counter.py +222 -0
  169. pdd/setup_tool.py +648 -0
  170. pdd/simple_math.py +2 -0
  171. pdd/split_main.py +3 -2
  172. pdd/summarize_directory.py +237 -195
  173. pdd/sync_animation.py +8 -4
  174. pdd/sync_determine_operation.py +839 -112
  175. pdd/sync_main.py +351 -57
  176. pdd/sync_orchestration.py +1400 -756
  177. pdd/sync_tui.py +848 -0
  178. pdd/template_expander.py +161 -0
  179. pdd/template_registry.py +264 -0
  180. pdd/templates/architecture/architecture_json.prompt +237 -0
  181. pdd/templates/generic/generate_prompt.prompt +174 -0
  182. pdd/trace.py +168 -12
  183. pdd/trace_main.py +4 -3
  184. pdd/track_cost.py +140 -63
  185. pdd/unfinished_prompt.py +51 -4
  186. pdd/update_main.py +567 -67
  187. pdd/update_model_costs.py +2 -2
  188. pdd/update_prompt.py +19 -4
  189. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
  190. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  191. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
  192. pdd_cli-0.0.45.dist-info/RECORD +0 -116
  193. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  194. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  195. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/remote_session.py ADDED
@@ -0,0 +1,876 @@
1
+ """
2
+ Remote Session Management for PDD Connect.
3
+
4
+ This module handles remote session management for PDD Connect. It enables users to run
5
+ `pdd connect` on any machine and access it remotely via PDD Cloud. The cloud acts as a
6
+ message bus - it relays commands from the browser to the CLI via Firestore.
7
+ No external tunnel (ngrok) is required - the cloud hosts everything.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import asyncio
14
+ import datetime
15
+ import platform
16
+ import socket
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Dict, List, Optional, Tuple
21
+
22
+ import httpx
23
+ from rich.console import Console
24
+
25
+ from .core.cloud import CloudConfig
26
+
27
+ console = Console()
28
+
29
+ # Global state for the active session manager
30
+ _active_session_manager: Optional[RemoteSessionManager] = None
31
+
32
+
33
+ def get_active_session_manager() -> Optional[RemoteSessionManager]:
34
+ """Get the currently active remote session manager."""
35
+ return _active_session_manager
36
+
37
+
38
+ def set_active_session_manager(manager: Optional[RemoteSessionManager]) -> None:
39
+ """Set the currently active remote session manager."""
40
+ global _active_session_manager
41
+ _active_session_manager = manager
42
+
43
+
44
+ class RemoteSessionError(Exception):
45
+ """Custom exception for remote session operations."""
46
+
47
+ def __init__(self, message: str, status_code: Optional[int] = None):
48
+ self.message = message
49
+ self.status_code = status_code
50
+ super().__init__(f"{message} (Status: {status_code})" if status_code else message)
51
+
52
+
53
+ @dataclass
54
+ class SessionInfo:
55
+ """
56
+ Represents a remote PDD session discovered from the cloud.
57
+
58
+ The cloud_url is the URL users can access in their browser to interact
59
+ with this session (e.g., https://pdd.dev/connect/{session_id}).
60
+ """
61
+ session_id: str
62
+ cloud_url: str
63
+ project_name: str
64
+ project_path: str
65
+ created_at: datetime.datetime
66
+ last_heartbeat: datetime.datetime
67
+ status: str
68
+ metadata: Dict[str, Any]
69
+
70
+ @classmethod
71
+ def from_dict(cls, data: Dict[str, Any]) -> SessionInfo:
72
+ """Factory method to create SessionInfo from cloud API response."""
73
+ def parse_dt(dt_str: Optional[str]) -> datetime.datetime:
74
+ if not dt_str:
75
+ return datetime.datetime.now(datetime.timezone.utc)
76
+ # Handle 'Z' for UTC which fromisoformat didn't handle before 3.11
77
+ if dt_str.endswith('Z'):
78
+ dt_str = dt_str[:-1] + '+00:00'
79
+ return datetime.datetime.fromisoformat(dt_str)
80
+
81
+ return cls(
82
+ session_id=data.get("sessionId", ""),
83
+ cloud_url=data.get("cloudUrl", ""),
84
+ project_name=data.get("projectName", "Unknown Project"),
85
+ project_path=data.get("projectPath", ""),
86
+ created_at=parse_dt(data.get("createdAt")),
87
+ last_heartbeat=parse_dt(data.get("lastHeartbeat")),
88
+ status=data.get("status", "unknown"),
89
+ metadata=data.get("metadata", {})
90
+ )
91
+
92
+
93
+ @dataclass
94
+ class CommandInfo:
95
+ """
96
+ Represents a command from the Firestore message bus.
97
+
98
+ Commands are created by the browser and picked up by the CLI for execution.
99
+ """
100
+ command_id: str
101
+ type: str # "generate" | "fix" | "sync" | "custom"
102
+ payload: Dict[str, Any]
103
+ status: str # "pending" | "processing" | "completed" | "failed"
104
+ created_at: datetime.datetime
105
+ response: Optional[Dict[str, Any]] = None
106
+
107
+ @classmethod
108
+ def from_dict(cls, data: Dict[str, Any]) -> CommandInfo:
109
+ """Factory method to create CommandInfo from cloud API response."""
110
+ def parse_dt(dt_str: Optional[str]) -> datetime.datetime:
111
+ if not dt_str:
112
+ return datetime.datetime.now(datetime.timezone.utc)
113
+ if dt_str.endswith('Z'):
114
+ dt_str = dt_str[:-1] + '+00:00'
115
+ return datetime.datetime.fromisoformat(dt_str)
116
+
117
+ return cls(
118
+ command_id=data.get("commandId", ""),
119
+ type=data.get("type", "custom"),
120
+ payload=data.get("payload", {}),
121
+ status=data.get("status", "pending"),
122
+ created_at=parse_dt(data.get("createdAt")),
123
+ response=data.get("response"),
124
+ )
125
+
126
+
127
+ class RemoteSessionManager:
128
+ """
129
+ Manages the lifecycle of a remote session: registration, heartbeats, and deregistration.
130
+
131
+ The cloud acts as a message bus - commands from the browser are relayed via Firestore.
132
+ No external tunnel is required; the cloud generates the access URL.
133
+ """
134
+
135
+ def __init__(self, jwt_token: str, project_path: Path):
136
+ self.jwt_token = jwt_token
137
+ self.project_path = project_path
138
+ self.session_id: Optional[str] = None
139
+ self.cloud_url: Optional[str] = None
140
+ self._heartbeat_task: Optional[asyncio.Task] = None
141
+ self._command_polling_task: Optional[asyncio.Task] = None
142
+ self._stop_event: Optional[asyncio.Event] = None
143
+ self._client_timeout = 30.0
144
+
145
+ def _get_headers(self) -> Dict[str, str]:
146
+ return {
147
+ "Authorization": f"Bearer {self.jwt_token}",
148
+ "Content-Type": "application/json",
149
+ }
150
+
151
+ def _get_metadata(self) -> Dict[str, Any]:
152
+ return {
153
+ "hostname": socket.gethostname(),
154
+ "platform": platform.system(),
155
+ "platformRelease": platform.release(),
156
+ "pythonVersion": sys.version.split()[0],
157
+ }
158
+
159
+ async def register(self, session_name: Optional[str] = None) -> str:
160
+ """
161
+ Register the session with the cloud.
162
+
163
+ No public URL is required - the cloud generates the access URL.
164
+
165
+ Args:
166
+ session_name: Optional custom name for the session.
167
+
168
+ Returns:
169
+ str: The cloud access URL (e.g., https://pdd.dev/connect/{session_id}).
170
+
171
+ Raises:
172
+ RemoteSessionError: If registration fails.
173
+ """
174
+ endpoint = CloudConfig.get_endpoint_url("registerSession")
175
+
176
+ payload = {
177
+ "projectPath": str(self.project_path),
178
+ "metadata": self._get_metadata()
179
+ }
180
+ if session_name:
181
+ payload["sessionName"] = session_name
182
+
183
+ async with httpx.AsyncClient(timeout=self._client_timeout) as client:
184
+ try:
185
+ response = await client.post(
186
+ endpoint,
187
+ json=payload,
188
+ headers=self._get_headers()
189
+ )
190
+
191
+ if response.status_code >= 400:
192
+ raise RemoteSessionError(
193
+ f"Failed to register session: {response.text}",
194
+ status_code=response.status_code
195
+ )
196
+
197
+ data = response.json()
198
+ self.session_id = data.get("sessionId")
199
+ self.cloud_url = data.get("cloudUrl")
200
+
201
+ if not self.session_id:
202
+ raise RemoteSessionError("Cloud response missing sessionId")
203
+ if not self.cloud_url:
204
+ raise RemoteSessionError("Cloud response missing cloudUrl")
205
+
206
+ return self.cloud_url
207
+
208
+ except httpx.RequestError as e:
209
+ raise RemoteSessionError(f"Network error during registration: {str(e)}")
210
+
211
+ async def _heartbeat_loop(self) -> None:
212
+ """Internal loop to send heartbeats every 60 seconds."""
213
+ endpoint = CloudConfig.get_endpoint_url("heartbeatSession")
214
+
215
+ # Ensure stop event is initialized
216
+ if self._stop_event is None:
217
+ self._stop_event = asyncio.Event()
218
+
219
+ while not self._stop_event.is_set():
220
+ try:
221
+ # Wait for 60 seconds or until stop event is set
222
+ try:
223
+ await asyncio.wait_for(self._stop_event.wait(), timeout=60.0)
224
+ break # Stop event was set
225
+ except asyncio.TimeoutError:
226
+ pass # Timeout reached, send heartbeat
227
+
228
+ if not self.session_id:
229
+ continue
230
+
231
+ async with httpx.AsyncClient(timeout=10.0) as client:
232
+ response = await client.post(
233
+ endpoint,
234
+ json={"sessionId": self.session_id},
235
+ headers=self._get_headers()
236
+ )
237
+
238
+ if response.status_code >= 400:
239
+ console.print(f"[yellow]Warning: Heartbeat failed (Status: {response.status_code})[/yellow]")
240
+
241
+ except Exception as e:
242
+ # We don't want to crash the server loop due to a heartbeat failure
243
+ console.print(f"[yellow]Warning: Heartbeat error: {str(e)}[/yellow]")
244
+
245
+ def start_heartbeat(self) -> None:
246
+ """Start the background heartbeat task."""
247
+ if self._heartbeat_task is not None:
248
+ return
249
+
250
+ # Initialize stop event if needed (must have event loop running)
251
+ if self._stop_event is None:
252
+ self._stop_event = asyncio.Event()
253
+ else:
254
+ self._stop_event.clear()
255
+
256
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
257
+
258
+ async def stop_heartbeat(self) -> None:
259
+ """Stop the heartbeat task gracefully."""
260
+ if self._heartbeat_task:
261
+ if self._stop_event:
262
+ self._stop_event.set()
263
+ try:
264
+ await self._heartbeat_task
265
+ except asyncio.CancelledError:
266
+ pass
267
+ self._heartbeat_task = None
268
+
269
+ async def deregister(self) -> None:
270
+ """
271
+ Deregister the session from the cloud.
272
+ Should be called on application shutdown.
273
+ """
274
+ if not self.session_id:
275
+ return
276
+
277
+ endpoint = CloudConfig.get_endpoint_url("deregisterSession")
278
+
279
+ # Stop heartbeat and command polling first to prevent race conditions
280
+ await self.stop_heartbeat()
281
+ await self.stop_command_polling()
282
+
283
+ async with httpx.AsyncClient(timeout=5.0) as client:
284
+ try:
285
+ # Server expects POST method for deregisterSession
286
+ response = await client.post(
287
+ endpoint,
288
+ json={"sessionId": self.session_id},
289
+ headers=self._get_headers()
290
+ )
291
+
292
+ if response.status_code < 400:
293
+ console.print("[dim]Session deregistered from cloud.[/dim]")
294
+ else:
295
+ console.print(f"[yellow]Warning: Failed to deregister session (Status: {response.status_code})[/yellow]")
296
+
297
+ except Exception as e:
298
+ # Idempotent: don't raise on failure during shutdown
299
+ console.print(f"[yellow]Warning: Error deregistering session: {str(e)}[/yellow]")
300
+ finally:
301
+ self.session_id = None
302
+
303
+ async def get_pending_commands(self) -> List[CommandInfo]:
304
+ """
305
+ Retrieve pending commands from the cloud for this session.
306
+
307
+ Returns:
308
+ List[CommandInfo]: List of pending commands to execute.
309
+
310
+ Raises:
311
+ RemoteSessionError: If fetching commands fails.
312
+ """
313
+ if not self.session_id:
314
+ return []
315
+
316
+ endpoint = CloudConfig.get_endpoint_url("getCommands")
317
+
318
+ async with httpx.AsyncClient(timeout=10.0) as client:
319
+ try:
320
+ response = await client.get(
321
+ endpoint,
322
+ params={"sessionId": self.session_id},
323
+ headers=self._get_headers()
324
+ )
325
+
326
+ if response.status_code >= 400:
327
+ console.print(f"[yellow]Warning: Failed to get commands (Status: {response.status_code})[/yellow]")
328
+ return []
329
+
330
+ data = response.json()
331
+ commands_data = data.get("commands", [])
332
+
333
+ return [CommandInfo.from_dict(c) for c in commands_data]
334
+
335
+ except httpx.RequestError as e:
336
+ console.print(f"[yellow]Warning: Network error getting commands: {str(e)}[/yellow]")
337
+ return []
338
+ except Exception as e:
339
+ console.print(f"[yellow]Warning: Error parsing commands: {str(e)}[/yellow]")
340
+ return []
341
+
342
+ async def update_command(
343
+ self,
344
+ command_id: str,
345
+ status: str,
346
+ response: Optional[Dict[str, Any]] = None
347
+ ) -> None:
348
+ """
349
+ Update the status of a command in the cloud with retry logic.
350
+
351
+ Args:
352
+ command_id: The command ID to update.
353
+ status: New status ("processing", "completed", "failed").
354
+ response: Optional response data (for completed/failed status).
355
+
356
+ Raises:
357
+ RemoteSessionError: If update fails after all retries.
358
+ """
359
+ if not self.session_id:
360
+ return
361
+
362
+ endpoint = CloudConfig.get_endpoint_url("updateCommand")
363
+
364
+ payload = {
365
+ "sessionId": self.session_id,
366
+ "commandId": command_id,
367
+ "status": status
368
+ }
369
+ if response is not None:
370
+ payload["response"] = response
371
+
372
+ # Retry logic with exponential backoff
373
+ max_retries = 3
374
+ retry_delay = 1 # Start with 1 second
375
+
376
+ for attempt in range(max_retries):
377
+ try:
378
+ async with httpx.AsyncClient(timeout=10.0) as client:
379
+ result = await client.post(
380
+ endpoint,
381
+ json=payload,
382
+ headers=self._get_headers()
383
+ )
384
+
385
+ if result.status_code >= 400:
386
+ error_msg = f"Failed to update command status: {result.text}"
387
+ console.print(f"[red]{error_msg}[/red]")
388
+ raise RuntimeError(error_msg)
389
+
390
+ # Success - return immediately
391
+ return
392
+
393
+ except Exception as e:
394
+ if attempt == max_retries - 1:
395
+ # Final attempt failed - raise error
396
+ console.print(f"[red]Failed to update command after {max_retries} attempts: {e}[/red]")
397
+ raise
398
+ # Retry with exponential backoff
399
+ console.print(f"[yellow]Cloud update failed (attempt {attempt + 1}/{max_retries}), retrying in {retry_delay}s...[/yellow]")
400
+ await asyncio.sleep(retry_delay)
401
+ retry_delay *= 2 # Exponential backoff
402
+
403
+ async def _get_command_status(self, command_id: str) -> str:
404
+ """
405
+ Get current status of a command from cloud.
406
+
407
+ Uses the getCommandStatus endpoint which returns any command regardless
408
+ of status (unlike getCommands which only returns pending commands).
409
+
410
+ Args:
411
+ command_id: The command ID to check.
412
+
413
+ Returns:
414
+ str: Current status ('pending', 'processing', 'completed', 'failed', 'cancelled', or 'unknown').
415
+ """
416
+ if not self.session_id:
417
+ return "unknown"
418
+
419
+ endpoint = CloudConfig.get_endpoint_url("getCommandStatus")
420
+
421
+ try:
422
+ async with httpx.AsyncClient(timeout=10.0) as client:
423
+ result = await client.get(
424
+ endpoint,
425
+ params={
426
+ "sessionId": self.session_id,
427
+ "commandId": command_id
428
+ },
429
+ headers=self._get_headers()
430
+ )
431
+
432
+ if result.status_code == 200:
433
+ data = result.json()
434
+ command = data.get("command", {})
435
+ return command.get("status", "unknown")
436
+ elif result.status_code == 404:
437
+ # Command not found
438
+ return "unknown"
439
+ return "unknown"
440
+
441
+ except Exception as e:
442
+ console.print(f"[yellow]Failed to check command status: {e}[/yellow]")
443
+ return "unknown"
444
+
445
+ async def _is_cancelled(self, command_id: str) -> bool:
446
+ """
447
+ Check if command was cancelled.
448
+
449
+ Args:
450
+ command_id: The command ID to check.
451
+
452
+ Returns:
453
+ bool: True if command status is 'cancelled', False otherwise.
454
+ """
455
+ status = await self._get_command_status(command_id)
456
+ return status == "cancelled"
457
+
458
+ async def _do_execute(self, cmd: CommandInfo) -> Tuple[str, dict]:
459
+ """
460
+ Actually execute the command via local FastAPI endpoint with log streaming.
461
+
462
+ Args:
463
+ cmd: The command to execute.
464
+
465
+ Returns:
466
+ Tuple[str, dict]: (job_id, response) from the local server.
467
+
468
+ Raises:
469
+ Exception: If execution fails.
470
+ asyncio.CancelledError: If the command was cancelled.
471
+ """
472
+ local_url = "http://127.0.0.1:9876"
473
+ execute_endpoint = "/api/v1/commands/execute"
474
+
475
+ # Build request payload in CommandRequest format
476
+ cmd_args = cmd.payload.get("args", {})
477
+ cmd_options = cmd.payload.get("options", {})
478
+
479
+ # Defensive parsing: handle cases where arrays might arrive as stringified JSON
480
+ # This can happen if the cloud/Firestore serializes arrays incorrectly
481
+ def parse_if_stringified_list(value):
482
+ """Parse value if it looks like a stringified Python list."""
483
+ if isinstance(value, str):
484
+ stripped = value.strip()
485
+ if stripped.startswith('[') and stripped.endswith(']'):
486
+ try:
487
+ # Try to parse as Python literal (e.g., "['a', 'b']")
488
+ parsed = ast.literal_eval(stripped)
489
+ if isinstance(parsed, list):
490
+ return parsed
491
+ except (ValueError, SyntaxError):
492
+ pass
493
+ return value
494
+
495
+ # Apply defensive parsing to args and options
496
+ for key in list(cmd_args.keys()):
497
+ cmd_args[key] = parse_if_stringified_list(cmd_args[key])
498
+ for key in list(cmd_options.keys()):
499
+ cmd_options[key] = parse_if_stringified_list(cmd_options[key])
500
+
501
+ request_payload = {
502
+ "command": cmd.type,
503
+ "args": cmd_args,
504
+ "options": cmd_options
505
+ }
506
+
507
+ # Build CLI command string for display
508
+ cli_parts = ["pdd", cmd.type]
509
+ # Handle positional args first (special 'args' key contains positional arguments)
510
+ if "args" in cmd_args:
511
+ args_value = cmd_args["args"]
512
+ if isinstance(args_value, (list, tuple)):
513
+ cli_parts.extend(str(v) for v in args_value)
514
+ elif args_value is not None:
515
+ cli_parts.append(str(args_value))
516
+ # Then handle other args as named options
517
+ for key, value in cmd_args.items():
518
+ if key == "args":
519
+ continue # Already handled above
520
+ if isinstance(value, bool):
521
+ if value:
522
+ cli_parts.append(f"--{key}")
523
+ elif isinstance(value, (list, tuple)):
524
+ # Handle list values (e.g., multiple --env flags)
525
+ for v in value:
526
+ cli_parts.append(f"--{key} {v}")
527
+ elif isinstance(value, str) and " " in value:
528
+ cli_parts.append(f'--{key} "{value}"')
529
+ else:
530
+ cli_parts.append(f"--{key} {value}")
531
+ for key, value in cmd_options.items():
532
+ if isinstance(value, bool):
533
+ if value:
534
+ cli_parts.append(f"--{key}")
535
+ elif isinstance(value, (list, tuple)):
536
+ # Handle list values (e.g., multiple --env flags)
537
+ for v in value:
538
+ cli_parts.append(f"--{key} {v}")
539
+ else:
540
+ cli_parts.append(f"--{key} {value}")
541
+ cli_command = " ".join(cli_parts)
542
+
543
+ # Log command details
544
+ console.print(f"\n[bold cyan]{'═' * 60}[/bold cyan]")
545
+ console.print(f"[bold cyan]REMOTE COMMAND RECEIVED[/bold cyan]")
546
+ console.print(f"[bold cyan]{'═' * 60}[/bold cyan]")
547
+ console.print(f"[bold]Command:[/bold] [green]{cli_command}[/green]")
548
+ console.print(f"[dim]{'─' * 60}[/dim]")
549
+ console.print(f"[bold]Output:[/bold]")
550
+
551
+ async with httpx.AsyncClient(timeout=300.0) as client:
552
+ # Submit the job
553
+ submit_result = await client.post(
554
+ f"{local_url}{execute_endpoint}",
555
+ json=request_payload
556
+ )
557
+
558
+ if submit_result.status_code >= 400:
559
+ raise Exception(submit_result.text)
560
+
561
+ submit_data = submit_result.json()
562
+ job_id = submit_data.get("job_id")
563
+
564
+ if not job_id:
565
+ raise Exception("No job_id in response")
566
+
567
+ # Poll for job completion with log streaming and cancellation checks
568
+ status_endpoint = f"/api/v1/commands/jobs/{job_id}"
569
+ last_update_time = asyncio.get_event_loop().time()
570
+ update_interval = 2.0 # Send updates every 2 seconds (was 3)
571
+ last_stdout_len = 0
572
+ last_stderr_len = 0
573
+
574
+ while True:
575
+ # Check for cancellation BEFORE polling - this is critical for responsiveness
576
+ if await self._is_cancelled(cmd.command_id):
577
+ console.print(f"[yellow]Cancellation detected during job execution[/yellow]")
578
+ # Cancel the local job
579
+ await self._cancel_local_job(job_id)
580
+ # Return cancelled status
581
+ return job_id, {"status": "cancelled", "result": {}}
582
+
583
+ status_result = await client.get(f"{local_url}{status_endpoint}")
584
+ if status_result.status_code >= 400:
585
+ raise Exception(status_result.text)
586
+
587
+ status_data = status_result.json()
588
+ job_status = status_data.get("status")
589
+
590
+ # Get current output and display new content
591
+ result = status_data.get("result", {})
592
+ if isinstance(result, dict):
593
+ stdout = result.get("stdout", "")
594
+ stderr = result.get("stderr", "")
595
+
596
+ # Print incremental stdout (raw, preserving formatting)
597
+ if stdout and len(stdout) > last_stdout_len:
598
+ new_stdout = stdout[last_stdout_len:]
599
+ # Print raw output directly to preserve formatting
600
+ print(new_stdout, end="", flush=True)
601
+ last_stdout_len = len(stdout)
602
+
603
+ # Send periodic output updates to cloud
604
+ current_time = asyncio.get_event_loop().time()
605
+ if current_time - last_update_time >= update_interval:
606
+ if stdout or stderr:
607
+ try:
608
+ await self._update_command_output(
609
+ cmd.command_id,
610
+ stdout=stdout,
611
+ stderr=stderr
612
+ )
613
+ last_update_time = current_time
614
+ except Exception:
615
+ pass # Don't clutter output with cloud update errors
616
+
617
+ if job_status in ("completed", "failed", "cancelled"):
618
+ # Get final output
619
+ final_result = status_data.get("result", {})
620
+ final_stdout = final_result.get("stdout", "") if isinstance(final_result, dict) else ""
621
+ final_stderr = final_result.get("stderr", "") if isinstance(final_result, dict) else ""
622
+ exit_code = final_result.get("exit_code", 0) if isinstance(final_result, dict) else 0
623
+
624
+ # Print final summary
625
+ console.print(f"\n[dim]{'─' * 60}[/dim]")
626
+ console.print(f"[bold]Exit code:[/bold] {exit_code}")
627
+ if final_stdout:
628
+ console.print(f"[bold]Stdout ({len(final_stdout)} chars)[/bold]")
629
+ if final_stderr:
630
+ console.print(f"[bold yellow]Stderr ({len(final_stderr)} chars):[/bold yellow]")
631
+ # Print stderr since we didn't stream it
632
+ for line in final_stderr.splitlines():
633
+ console.print(f"[yellow]{line}[/yellow]")
634
+ console.print(f"[dim]{'─' * 60}[/dim]")
635
+ if job_status == "completed":
636
+ console.print(f"[bold green]✓ COMMAND COMPLETED[/bold green]")
637
+ elif job_status == "failed":
638
+ console.print(f"[bold red]✗ COMMAND FAILED[/bold red]")
639
+ elif job_status == "cancelled":
640
+ console.print(f"[bold yellow]⊘ COMMAND CANCELLED[/bold yellow]")
641
+ console.print(f"[bold cyan]{'═' * 60}[/bold cyan]\n")
642
+ return job_id, status_data
643
+
644
+ # Short sleep to be responsive to cancellation
645
+ await asyncio.sleep(0.5)
646
+
647
+ async def _update_command_output(
648
+ self,
649
+ command_id: str,
650
+ stdout: str = "",
651
+ stderr: str = ""
652
+ ) -> None:
653
+ """
654
+ Update cloud with intermediate command output for log streaming.
655
+
656
+ Args:
657
+ command_id: The command ID to update.
658
+ stdout: Current stdout output.
659
+ stderr: Current stderr output.
660
+ """
661
+ if not self.session_id:
662
+ return
663
+
664
+ endpoint = CloudConfig.get_endpoint_url("updateCommand")
665
+
666
+ payload = {
667
+ "sessionId": self.session_id,
668
+ "commandId": command_id,
669
+ "status": "processing", # Keep status as processing
670
+ "response": {
671
+ "stdout": stdout,
672
+ "stderr": stderr,
673
+ "streaming": True, # Indicate this is a streaming update
674
+ }
675
+ }
676
+
677
+ try:
678
+ async with httpx.AsyncClient(timeout=5.0) as client:
679
+ await client.post(
680
+ endpoint,
681
+ json=payload,
682
+ headers=self._get_headers()
683
+ )
684
+ except Exception:
685
+ pass # Don't fail on streaming updates
686
+
687
+ async def _cancel_local_job(self, job_id: str) -> bool:
688
+ """
689
+ Cancel a job running on the local server.
690
+
691
+ Args:
692
+ job_id: The local job ID to cancel.
693
+
694
+ Returns:
695
+ bool: True if cancellation was successful.
696
+ """
697
+ local_url = "http://127.0.0.1:9876"
698
+ cancel_endpoint = f"/api/v1/commands/jobs/{job_id}/cancel"
699
+
700
+ try:
701
+ async with httpx.AsyncClient(timeout=10.0) as client:
702
+ result = await client.post(f"{local_url}{cancel_endpoint}")
703
+ if result.status_code < 400:
704
+ data = result.json()
705
+ return data.get("cancelled", False)
706
+ return False
707
+ except Exception as e:
708
+ console.print(f"[yellow]Failed to cancel local job: {e}[/yellow]")
709
+ return False
710
+
711
+ async def _execute_command(self, cmd: CommandInfo) -> None:
712
+ """
713
+ Execute a command locally and report results back to the cloud.
714
+
715
+ Supports cancellation during execution - cancellation is now checked
716
+ inside _do_execute() for faster response times.
717
+
718
+ Args:
719
+ cmd: The command to execute.
720
+ """
721
+ try:
722
+ # 1. Update status to "processing"
723
+ await self.update_command(cmd.command_id, status="processing")
724
+
725
+ # 2. Check if command was cancelled before starting execution
726
+ if await self._is_cancelled(cmd.command_id):
727
+ console.print(f"[yellow]Command cancelled before execution[/yellow]")
728
+ await self.update_command(cmd.command_id, status="cancelled")
729
+ return
730
+
731
+ # 3. Execute the command - cancellation is checked inside _do_execute()
732
+ local_job_id, response_data = await self._do_execute(cmd)
733
+
734
+ # 4. Check if the local job was cancelled
735
+ job_status = response_data.get("status", "")
736
+ if job_status == "cancelled":
737
+ await self.update_command(cmd.command_id, status="cancelled")
738
+ console.print(f"[yellow]Command was cancelled[/yellow]")
739
+ return
740
+
741
+ # 5. Map to expected structure for frontend
742
+ result = response_data.get("result", {})
743
+ error_msg = ""
744
+ if job_status == "failed":
745
+ # Capture error from response or from stderr
746
+ error_msg = response_data.get("error", "")
747
+ if not error_msg and isinstance(result, dict):
748
+ error_msg = result.get("stderr", "") or result.get("stdout", "")
749
+
750
+ formatted_response = {
751
+ "success": job_status == "completed",
752
+ "message": error_msg,
753
+ "exit_code": result.get("exit_code", 0) if isinstance(result, dict) else 0,
754
+ "stdout": result.get("stdout", "") if isinstance(result, dict) else "",
755
+ "stderr": result.get("stderr", "") if isinstance(result, dict) else "",
756
+ "files_created": result.get("files_created", []) if isinstance(result, dict) else [],
757
+ "cost": response_data.get("cost", 0.0),
758
+ }
759
+
760
+ final_status = "completed" if job_status == "completed" else "failed"
761
+ await self.update_command(
762
+ cmd.command_id,
763
+ status=final_status,
764
+ response=formatted_response
765
+ )
766
+
767
+ except asyncio.CancelledError:
768
+ # Task was cancelled externally
769
+ console.print(f"[yellow]Command execution cancelled[/yellow]")
770
+ await self.update_command(cmd.command_id, status="cancelled")
771
+
772
+ except Exception as e:
773
+ # Execution error
774
+ console.print(f"[red]Error executing command: {str(e)}[/red]")
775
+ await self.update_command(
776
+ cmd.command_id,
777
+ status="failed",
778
+ response={"error": str(e)}
779
+ )
780
+
781
+ async def _command_polling_loop(self) -> None:
782
+ """
783
+ Background task that polls for pending commands and executes them.
784
+ Runs every 5 seconds until stopped.
785
+ """
786
+ if self._stop_event is None:
787
+ self._stop_event = asyncio.Event()
788
+
789
+ console.print("[dim]Command polling started[/dim]")
790
+
791
+ while not self._stop_event.is_set():
792
+ try:
793
+ # Wait for 5 seconds or until stop event is set
794
+ try:
795
+ await asyncio.wait_for(self._stop_event.wait(), timeout=5.0)
796
+ break # Stop event was set
797
+ except asyncio.TimeoutError:
798
+ pass # Timeout reached, poll for commands
799
+
800
+ # Get pending commands
801
+ commands = await self.get_pending_commands()
802
+
803
+ # Execute each command sequentially
804
+ for cmd in commands:
805
+ if self._stop_event.is_set():
806
+ break
807
+ await self._execute_command(cmd)
808
+
809
+ except Exception as e:
810
+ console.print(f"[yellow]Warning: Command polling error: {str(e)}[/yellow]")
811
+
812
+ console.print("[dim]Command polling stopped[/dim]")
813
+
814
+ def start_command_polling(self) -> None:
815
+ """Start the background command polling task."""
816
+ if self._command_polling_task is not None:
817
+ return
818
+
819
+ # Initialize stop event if needed
820
+ if self._stop_event is None:
821
+ self._stop_event = asyncio.Event()
822
+ else:
823
+ self._stop_event.clear()
824
+
825
+ self._command_polling_task = asyncio.create_task(self._command_polling_loop())
826
+
827
+ async def stop_command_polling(self) -> None:
828
+ """Stop the command polling task gracefully."""
829
+ if self._command_polling_task:
830
+ if self._stop_event:
831
+ self._stop_event.set()
832
+ try:
833
+ await self._command_polling_task
834
+ except asyncio.CancelledError:
835
+ pass
836
+ self._command_polling_task = None
837
+
838
+ @staticmethod
839
+ async def list_sessions(jwt_token: str) -> List[SessionInfo]:
840
+ """
841
+ List all active sessions available to the user.
842
+
843
+ Args:
844
+ jwt_token: The user's JWT authentication token.
845
+
846
+ Returns:
847
+ List[SessionInfo]: A list of active sessions.
848
+
849
+ Raises:
850
+ RemoteSessionError: If the listing fails.
851
+ """
852
+ endpoint = CloudConfig.get_endpoint_url("listSessions")
853
+ headers = {
854
+ "Authorization": f"Bearer {jwt_token}",
855
+ "Content-Type": "application/json",
856
+ }
857
+
858
+ async with httpx.AsyncClient(timeout=30.0) as client:
859
+ try:
860
+ response = await client.get(endpoint, headers=headers)
861
+
862
+ if response.status_code >= 400:
863
+ raise RemoteSessionError(
864
+ f"Failed to list sessions: {response.text}",
865
+ status_code=response.status_code
866
+ )
867
+
868
+ data = response.json()
869
+ sessions_data = data.get("sessions", [])
870
+
871
+ return [SessionInfo.from_dict(s) for s in sessions_data]
872
+
873
+ except httpx.RequestError as e:
874
+ raise RemoteSessionError(f"Network error listing sessions: {str(e)}")
875
+ except ValueError as e:
876
+ raise RemoteSessionError(f"Invalid response format: {str(e)}")