forgexa-cli 1.47.3__tar.gz → 1.47.4__tar.gz

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 (28) hide show
  1. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/PKG-INFO +9 -3
  2. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/README.md +8 -2
  3. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/__init__.py +1 -1
  4. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/daemon.py +281 -51
  5. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/main.py +27 -4
  6. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/PKG-INFO +9 -3
  7. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/pyproject.toml +1 -1
  8. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_auth_and_runtime_commands.py +21 -2
  9. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/_build_config.py +0 -0
  10. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/_local_bind.py +0 -0
  11. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/agent_core.py +0 -0
  12. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/autoupgrade.py +0 -0
  13. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/runtime_evidence.py +0 -0
  15. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/setup.cfg +0 -0
  21. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.47.3
3
+ Version: 1.47.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -132,10 +132,16 @@ forgexa upgrade --target-version 1.12.3
132
132
 
133
133
  The command detects how `forgexa-cli` was installed:
134
134
 
135
- - `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
136
- - `pip` install: uses `python -m pip install --upgrade forgexa-cli`
135
+ - `pipx` install: uses `pipx upgrade --index-url https://pypi.org/simple forgexa-cli`, or `pipx install --force --index-url https://pypi.org/simple forgexa-cli==<version>` for a pinned version
136
+ - `pip` install: uses `python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli`
137
137
  - non-editable local path install (`pip install /path/to/cli`): follows the same `pip` upgrade path and switches to the published PyPI release
138
138
 
139
+ Self-upgrades pass `--index-url https://pypi.org/simple` to pip or pipx, matching the official PyPI release check and avoiding a configured mirror that has not yet synchronized a new release. A direct `pip install --upgrade forgexa-cli` command always follows that Python environment's pip configuration. When its mirror is behind, upgrade manually with:
140
+
141
+ ```bash
142
+ python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli==1.12.3
143
+ ```
144
+
139
145
  For safety, `forgexa upgrade` stops any running local daemon before upgrading. If the daemon was previously started in background mode, the CLI starts it again automatically after a successful upgrade; otherwise it prints a manual restart hint.
140
146
 
141
147
  `forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
@@ -100,10 +100,16 @@ forgexa upgrade --target-version 1.12.3
100
100
 
101
101
  The command detects how `forgexa-cli` was installed:
102
102
 
103
- - `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
104
- - `pip` install: uses `python -m pip install --upgrade forgexa-cli`
103
+ - `pipx` install: uses `pipx upgrade --index-url https://pypi.org/simple forgexa-cli`, or `pipx install --force --index-url https://pypi.org/simple forgexa-cli==<version>` for a pinned version
104
+ - `pip` install: uses `python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli`
105
105
  - non-editable local path install (`pip install /path/to/cli`): follows the same `pip` upgrade path and switches to the published PyPI release
106
106
 
107
+ Self-upgrades pass `--index-url https://pypi.org/simple` to pip or pipx, matching the official PyPI release check and avoiding a configured mirror that has not yet synchronized a new release. A direct `pip install --upgrade forgexa-cli` command always follows that Python environment's pip configuration. When its mirror is behind, upgrade manually with:
108
+
109
+ ```bash
110
+ python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli==1.12.3
111
+ ```
112
+
107
113
  For safety, `forgexa upgrade` stops any running local daemon before upgrading. If the daemon was previously started in background mode, the CLI starts it again automatically after a successful upgrade; otherwise it prints a manual restart hint.
108
114
 
109
115
  `forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.47.3"
2
+ __version__ = "1.47.4"
@@ -197,6 +197,7 @@ def _windows_daemon_process_query(exclude_pid: int) -> str:
197
197
 
198
198
 
199
199
  _LOCAL_RUNTIME_REGISTRATION_SCHEMA_VERSION = 1
200
+ _DESKTOP_DAEMON_RESTART_REQUEST_SCHEMA_VERSION = 1
200
201
 
201
202
 
202
203
  def _local_runtime_registration_path() -> Path:
@@ -210,6 +211,47 @@ def _local_runtime_registration_path() -> Path:
210
211
  return Path.home() / ".forgexa" / "daemon" / "runtime-registrations.json"
211
212
 
212
213
 
214
+ def _desktop_daemon_restart_request_path(instance_id: str) -> Path:
215
+ return (
216
+ Path.home()
217
+ / ".forgexa"
218
+ / "daemon"
219
+ / "restart-requests"
220
+ / f"{instance_id}.json"
221
+ )
222
+
223
+
224
+ def _desktop_daemon_restart_target_version(instance_id: str) -> str | None:
225
+ """Return a restart target only when the request belongs to this daemon."""
226
+ try:
227
+ request = json.loads(
228
+ _desktop_daemon_restart_request_path(instance_id).read_text(encoding="utf-8")
229
+ )
230
+ except (OSError, ValueError):
231
+ return None
232
+ if not isinstance(request, dict):
233
+ return None
234
+ if request.get("schema_version") != _DESKTOP_DAEMON_RESTART_REQUEST_SCHEMA_VERSION:
235
+ return None
236
+ if request.get("instance_id") != instance_id:
237
+ return None
238
+ target_version = request.get("target_version")
239
+ return target_version.strip() if isinstance(target_version, str) and target_version.strip() else None
240
+
241
+
242
+ def _clear_desktop_daemon_restart_request(instance_id: str) -> None:
243
+ path = _desktop_daemon_restart_request_path(instance_id)
244
+ try:
245
+ request = json.loads(path.read_text(encoding="utf-8"))
246
+ if not isinstance(request, dict) or request.get("instance_id") != instance_id:
247
+ return
248
+ path.unlink(missing_ok=True)
249
+ except FileNotFoundError:
250
+ return
251
+ except (OSError, ValueError):
252
+ logger.warning("Failed to clear desktop daemon restart request")
253
+
254
+
213
255
  def _write_local_runtime_registration_snapshot(
214
256
  instance_id: str,
215
257
  state: str,
@@ -221,13 +263,16 @@ def _write_local_runtime_registration_snapshot(
221
263
  "schema_version": _LOCAL_RUNTIME_REGISTRATION_SCHEMA_VERSION,
222
264
  "pid": os.getpid(),
223
265
  "instance_id": instance_id,
266
+ "daemon_version": DAEMON_VERSION,
267
+ "client_type": _CLIENT_TYPE,
268
+ "desktop_restart_protocol_version": _DESKTOP_DAEMON_RESTART_REQUEST_SCHEMA_VERSION,
224
269
  "updated_at": int(time.time()),
225
270
  "state": state,
226
271
  "registrations": registrations,
227
272
  }
228
273
  try:
229
274
  path.parent.mkdir(parents=True, exist_ok=True)
230
- tmp_path = path.with_name(path.name + ".tmp")
275
+ tmp_path = path.with_name(f"{path.name}.{os.getpid()}.tmp")
231
276
  tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
232
277
  if os.name != "nt":
233
278
  os.chmod(tmp_path, 0o600)
@@ -1075,7 +1120,7 @@ except (ImportError, ModuleNotFoundError):
1075
1120
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1076
1121
  # Kept in sync with pyproject.toml version via bump-version.sh.
1077
1122
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1078
- DAEMON_VERSION = "1.47.3"
1123
+ DAEMON_VERSION = "1.47.4"
1079
1124
 
1080
1125
 
1081
1126
  def _detect_client_type() -> str:
@@ -1508,14 +1553,19 @@ def _filter_local_bind_task_result(result: "TaskResult") -> None:
1508
1553
  agent observations are classified as data that must NEVER leave the
1509
1554
  user's machine by default for workspace_resolution="local_bind" tasks —
1510
1555
  only Git-tracked artifacts (already pushed) and status/metrics/Git
1511
- metadata sync. error/failure_code/metrics/git/artifacts/evidence_summary are left as-is
1512
- (status metadata, not raw output) only stdout/stderr tail and raw
1513
- agent observations are cleared. Mutates `result` in place; call this
1514
- right before ProgressReporter.report_complete().
1556
+ metadata sync. Failure codes, metrics, Git metadata, artifacts, and
1557
+ evidence summaries are preserved; free-text errors are replaced because
1558
+ they can contain local paths or Agent output. Mutates `result` in place;
1559
+ call this right before ProgressReporter.report_complete().
1515
1560
  """
1516
1561
  result.stdout = ""
1517
1562
  result.stderr = ""
1518
1563
  result.observations = []
1564
+ if result.error:
1565
+ result.error = (
1566
+ "Local task failed. Review this task in Forgexa Desktop on the "
1567
+ "running device."
1568
+ )
1519
1569
 
1520
1570
  def _prepare_ai_job_output(
1521
1571
  *,
@@ -2212,6 +2262,99 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
2212
2262
  logger.warning("Failed to append local-bind output log for task %s: %s", task_id, e)
2213
2263
 
2214
2264
 
2265
+ def _local_bind_tool_activity(tool_name: object, completed: bool = False) -> str:
2266
+ """Return a content-free activity label for a local Agent tool event."""
2267
+ normalized = tool_name.lower() if isinstance(tool_name, str) else ""
2268
+ if normalized in {
2269
+ "read", "read_file", "file_read", "file_search", "grep_search",
2270
+ "semantic_search", "search", "list_dir", "glob",
2271
+ }:
2272
+ return "agent.inspecting_files"
2273
+ if normalized in {
2274
+ "write", "create_file", "apply_patch", "str_replace", "str_replace_editor",
2275
+ "file_edit", "file_change", "edit",
2276
+ }:
2277
+ return "agent.updating_files"
2278
+ if normalized in {
2279
+ "bash", "run_in_terminal", "execute_command", "command_execution", "terminal",
2280
+ }:
2281
+ return "agent.completed_command" if completed else "agent.running_command"
2282
+ if normalized in {"get_errors", "lint", "test"}:
2283
+ return "agent.checking_diagnostics"
2284
+ return "agent.using_workspace_tool"
2285
+
2286
+
2287
+ def _summarize_local_bind_output(lines: list[str]) -> list[str]:
2288
+ """Derive Web-safe activity labels without returning Agent output content."""
2289
+ activity_lines: list[str] = []
2290
+ seen: set[str] = set()
2291
+
2292
+ for line in lines:
2293
+ stripped = line.strip()
2294
+ if not stripped:
2295
+ continue
2296
+ try:
2297
+ event = json.loads(stripped)
2298
+ except json.JSONDecodeError:
2299
+ activity = "agent.producing_output"
2300
+ else:
2301
+ if not isinstance(event, dict):
2302
+ continue
2303
+ event_type = event.get("type")
2304
+ data = event.get("data") if isinstance(event.get("data"), dict) else event
2305
+ activity = None
2306
+
2307
+ if event_type == "tool.execution_start":
2308
+ activity = _local_bind_tool_activity(data.get("toolName"))
2309
+ elif event_type == "assistant.message":
2310
+ activity = (
2311
+ "agent.planning_next_action"
2312
+ if data.get("toolRequests")
2313
+ else "agent.wrote_update"
2314
+ )
2315
+ elif event_type == "assistant":
2316
+ message = event.get("message")
2317
+ content = message.get("content") if isinstance(message, dict) else None
2318
+ if isinstance(content, list):
2319
+ for item in content:
2320
+ if not isinstance(item, dict):
2321
+ continue
2322
+ if item.get("type") == "tool_use":
2323
+ activity = _local_bind_tool_activity(item.get("name"))
2324
+ break
2325
+ if item.get("type") == "text":
2326
+ activity = "agent.wrote_update"
2327
+ break
2328
+ elif event_type in {"item.started", "item.completed"}:
2329
+ item = event.get("item")
2330
+ item_type = item.get("type") if isinstance(item, dict) else None
2331
+ if item_type == "agent_message":
2332
+ activity = "agent.wrote_update"
2333
+ elif item_type:
2334
+ activity = _local_bind_tool_activity(
2335
+ item_type,
2336
+ completed=event_type == "item.completed",
2337
+ )
2338
+ elif event_type == "tool_use":
2339
+ part = event.get("part")
2340
+ tool_name = part.get("tool") if isinstance(part, dict) else None
2341
+ activity = _local_bind_tool_activity(tool_name)
2342
+ elif event_type == "text":
2343
+ activity = "agent.wrote_update"
2344
+ elif event_type == "result":
2345
+ activity = (
2346
+ "agent.reported_error"
2347
+ if event.get("is_error")
2348
+ else "agent.completed_response"
2349
+ )
2350
+
2351
+ if activity and activity not in seen:
2352
+ activity_lines.append(activity)
2353
+ seen.add(activity)
2354
+
2355
+ return activity_lines
2356
+
2357
+
2215
2358
  _LOCAL_BIND_SSH_SCHEME_RE = re.compile(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
2216
2359
  _LOCAL_BIND_HTTP_SCHEME_RE = re.compile(r"^https?://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
2217
2360
  _LOCAL_BIND_SCP_LIKE_RE = re.compile(r"^[^@/]+@([^:/]+):(.+?)(?:\.git)?/?$")
@@ -5382,6 +5525,7 @@ class ProcessManager:
5382
5525
  timeout,
5383
5526
  task.task_id,
5384
5527
  on_chunk,
5528
+ stream_stderr=task.workspace_resolution == "local_bind",
5385
5529
  disable_builtin_mcps=task.input_data.get("task_type") in {
5386
5530
  "test_script_generate",
5387
5531
  "qa_scenario_preview",
@@ -6274,6 +6418,7 @@ class ProcessManager:
6274
6418
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
6275
6419
  on_chunk: Any = None,
6276
6420
  *,
6421
+ stream_stderr: bool = False,
6277
6422
  disable_builtin_mcps: bool = False,
6278
6423
  _retry_without_effort: bool = False,
6279
6424
  _retry_without_token_env: bool = False,
@@ -6454,6 +6599,7 @@ class ProcessManager:
6454
6599
  stdout, stderr, returncode = await self._stream_process(
6455
6600
  proc, None, timeout, task_id, on_chunk,
6456
6601
  workspace_path=cwd,
6602
+ stream_stderr=stream_stderr,
6457
6603
  )
6458
6604
 
6459
6605
  # Parse copilot JSONL output for metrics
@@ -6515,6 +6661,7 @@ class ProcessManager:
6515
6661
  timeout,
6516
6662
  task_id,
6517
6663
  on_chunk,
6664
+ stream_stderr=stream_stderr,
6518
6665
  disable_builtin_mcps=True,
6519
6666
  _retry_without_effort=_retry_without_effort,
6520
6667
  _retry_without_token_env=_retry_without_token_env,
@@ -6559,6 +6706,7 @@ class ProcessManager:
6559
6706
  timeout,
6560
6707
  task_id,
6561
6708
  on_chunk,
6709
+ stream_stderr=stream_stderr,
6562
6710
  disable_builtin_mcps=disable_builtin_mcps,
6563
6711
  _retry_without_effort=_retry_without_effort,
6564
6712
  _retry_without_token_env=True,
@@ -6622,6 +6770,7 @@ class ProcessManager:
6622
6770
  )
6623
6771
  return await self._run_copilot(
6624
6772
  agent, prompt, cwd, timeout, task_id, on_chunk,
6773
+ stream_stderr=stream_stderr,
6625
6774
  disable_builtin_mcps=disable_builtin_mcps,
6626
6775
  _retry_without_effort=True,
6627
6776
  _retry_without_token_env=_retry_without_token_env,
@@ -7056,6 +7205,7 @@ class ProgressReporter:
7056
7205
  self.server_url = server_url.rstrip("/")
7057
7206
  self.runtime_id = runtime_id
7058
7207
  self._last_progress_sent: dict[str, dict[str, Any]] = {}
7208
+ self._pending_output_lines: dict[str, list[str]] = {}
7059
7209
 
7060
7210
  def _should_skip_progress(
7061
7211
  self,
@@ -7063,11 +7213,12 @@ class ProgressReporter:
7063
7213
  progress_percent: int,
7064
7214
  current_step: str,
7065
7215
  output_lines: list[str],
7216
+ activity_lines: list[str],
7066
7217
  files_changed: list[str],
7067
7218
  lines_added: int,
7068
7219
  lines_removed: int,
7069
7220
  ) -> bool:
7070
- if output_lines or files_changed or lines_added or lines_removed:
7221
+ if output_lines or activity_lines or files_changed or lines_added or lines_removed:
7071
7222
  return False
7072
7223
 
7073
7224
  last = self._last_progress_sent.get(task_id)
@@ -7085,47 +7236,68 @@ class ProgressReporter:
7085
7236
  progress_percent: int,
7086
7237
  current_step: str,
7087
7238
  output_lines: list[str] | None = None,
7239
+ activity_lines: list[str] | None = None,
7088
7240
  files_changed: list[str] | None = None,
7089
7241
  lines_added: int = 0,
7090
7242
  lines_removed: int = 0,
7091
- ):
7092
- output_lines = output_lines or []
7243
+ ) -> bool:
7244
+ output_lines = [
7245
+ *self._pending_output_lines.get(task_id, []),
7246
+ *(output_lines or []),
7247
+ ]
7248
+ activity_lines = activity_lines or []
7093
7249
  files_changed = files_changed or []
7094
7250
  if self._should_skip_progress(
7095
7251
  task_id,
7096
7252
  progress_percent,
7097
7253
  current_step,
7098
7254
  output_lines,
7255
+ activity_lines,
7099
7256
  files_changed,
7100
7257
  lines_added,
7101
7258
  lines_removed,
7102
7259
  ):
7103
- return
7260
+ return True
7104
7261
 
7105
7262
  try:
7106
- await self.client.post(
7263
+ response = await self.client.post(
7107
7264
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
7108
7265
  json={
7109
7266
  "progress_percent": progress_percent,
7110
7267
  "current_step": current_step,
7111
7268
  "output_lines": output_lines,
7269
+ "activity_lines": activity_lines,
7112
7270
  "files_changed": files_changed,
7113
7271
  "lines_added": lines_added,
7114
7272
  "lines_removed": lines_removed,
7115
7273
  },
7116
7274
  timeout=10,
7117
7275
  )
7118
- self._last_progress_sent[task_id] = {
7119
- "progress_percent": progress_percent,
7120
- "current_step": current_step,
7121
- "sent_at": time.monotonic(),
7122
- }
7276
+ if 200 <= response.status_code < 300:
7277
+ self._pending_output_lines.pop(task_id, None)
7278
+ self._last_progress_sent[task_id] = {
7279
+ "progress_percent": progress_percent,
7280
+ "current_step": current_step,
7281
+ "sent_at": time.monotonic(),
7282
+ }
7283
+ return True
7284
+ logger.warning(
7285
+ "Failed to report progress for task %s: server returned %s %s",
7286
+ task_id,
7287
+ response.status_code,
7288
+ response.text[:500],
7289
+ )
7123
7290
  except Exception as e:
7124
- logger.warning("Failed to report progress for task %s: %s", task_id, e)
7291
+ logger.warning("Failed to report progress for task %s: %s", task_id, _format_exc(e))
7292
+
7293
+ if output_lines:
7294
+ self._pending_output_lines[task_id] = output_lines[-5000:]
7295
+ return False
7125
7296
 
7126
7297
  async def report_complete(self, task_id: str, result: TaskResult):
7127
7298
  """Report task completion with retry."""
7128
7299
  self._last_progress_sent.pop(task_id, None)
7300
+ self._pending_output_lines.pop(task_id, None)
7129
7301
  payload = {
7130
7302
  "status": result.status,
7131
7303
  "exit_code": result.exit_code,
@@ -7717,6 +7889,7 @@ class TaskPoller:
7717
7889
  self._on_auth_failure()
7718
7890
  return []
7719
7891
  else:
7892
+ logger.warning("Task poll failed: %s %s", resp.status_code, resp.text[:500])
7720
7893
  return []
7721
7894
 
7722
7895
  data = resp.json()
@@ -7775,10 +7948,12 @@ class TaskPoller:
7775
7948
  return data.get("ai_jobs", [])
7776
7949
  elif resp.status_code in (401, 403):
7777
7950
  self._on_auth_failure()
7951
+ else:
7952
+ logger.warning("AIJob poll failed: %s %s", resp.status_code, resp.text[:500])
7778
7953
  self.cancelled_ai_job_ids = []
7779
7954
  return []
7780
7955
  except Exception as e:
7781
- logger.debug("AIJob poll error: %s", _format_exc(e))
7956
+ logger.warning("AIJob poll error: %s", _format_exc(e))
7782
7957
  self.cancelled_ai_job_ids = []
7783
7958
  return []
7784
7959
 
@@ -8715,6 +8890,7 @@ class RuntimeDaemon:
8715
8890
  self._shutdown = False
8716
8891
  self._local_runtime_instance_id = secrets.token_urlsafe(18)
8717
8892
  self._local_runtime_registrations: dict[str, dict[str, int | str]] = {}
8893
+ self._desktop_restart_requested = False
8718
8894
 
8719
8895
  # Deployment merge worktree GC: runs at most every 6 hours.
8720
8896
  self._last_merge_gc: float = 0.0
@@ -8801,10 +8977,32 @@ class RuntimeDaemon:
8801
8977
  }
8802
8978
  _write_local_runtime_registration_snapshot(
8803
8979
  self._local_runtime_instance_id,
8804
- "ready",
8980
+ "draining" if getattr(self, "_desktop_restart_requested", False) else "ready",
8805
8981
  self._local_runtime_registrations,
8806
8982
  )
8807
8983
 
8984
+ def _begin_desktop_restart_drain_if_requested(self) -> bool:
8985
+ target_version = _desktop_daemon_restart_target_version(
8986
+ self._local_runtime_instance_id
8987
+ )
8988
+ if not target_version:
8989
+ return self._desktop_restart_requested
8990
+ if target_version == DAEMON_VERSION:
8991
+ _clear_desktop_daemon_restart_request(self._local_runtime_instance_id)
8992
+ return self._desktop_restart_requested
8993
+ if not self._desktop_restart_requested:
8994
+ self._desktop_restart_requested = True
8995
+ _write_local_runtime_registration_snapshot(
8996
+ self._local_runtime_instance_id,
8997
+ "draining",
8998
+ self._local_runtime_registrations,
8999
+ )
9000
+ logger.info(
9001
+ "Desktop requested daemon restart for version %s; draining active tasks",
9002
+ target_version,
9003
+ )
9004
+ return True
9005
+
8808
9006
  @staticmethod
8809
9007
  def _resolve_task_output_dir(task: TaskInfo) -> str:
8810
9008
  input_data = task.input_data or {}
@@ -9270,7 +9468,16 @@ class RuntimeDaemon:
9270
9468
  # 3. Main loop
9271
9469
  try:
9272
9470
  while not self._shutdown:
9273
- had_work = await self._poll_and_execute()
9471
+ draining_for_desktop_restart = self._begin_desktop_restart_drain_if_requested()
9472
+ had_work = await self._poll_and_execute(
9473
+ accept_new_tasks=not draining_for_desktop_restart,
9474
+ )
9475
+ if draining_for_desktop_restart and not self.active_tasks:
9476
+ logger.info("Desktop daemon drain complete; restarting through the updated client")
9477
+ self._shutdown = True
9478
+ continue
9479
+ if self._shutdown:
9480
+ break
9274
9481
  next_delay = self.poll_interval
9275
9482
  if self.connections:
9276
9483
  next_delay = min(
@@ -9289,7 +9496,7 @@ class RuntimeDaemon:
9289
9496
  finally:
9290
9497
  await self._shutdown_gracefully()
9291
9498
 
9292
- async def _poll_and_execute(self) -> bool:
9499
+ async def _poll_and_execute(self, *, accept_new_tasks: bool = True) -> bool:
9293
9500
  """Poll for tasks from all connected servers and execute them."""
9294
9501
  had_work = False
9295
9502
 
@@ -9327,11 +9534,11 @@ class RuntimeDaemon:
9327
9534
 
9328
9535
  # Poll from all connected servers
9329
9536
  for conn in self.connections:
9330
- has_capacity = total_active < self.max_concurrent
9537
+ has_capacity = accept_new_tasks and total_active < self.max_concurrent
9331
9538
  tasks = await conn.poller.poll() if has_capacity else []
9332
9539
  # Always poll AI jobs so server-requested cancellations can stop a
9333
9540
  # process that is itself consuming the last available slot.
9334
- ai_jobs = await conn.poller.poll_ai_jobs()
9541
+ ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks else []
9335
9542
  _cancel_server_requested_ai_jobs(conn)
9336
9543
  total_active = len(self.active_tasks)
9337
9544
  has_capacity = total_active < self.max_concurrent
@@ -9665,6 +9872,11 @@ class RuntimeDaemon:
9665
9872
  # skipped for a node bound to the user's own real directory. See
9666
9873
  # docs/designs/forgexa-ide-design.md §6.2.
9667
9874
  is_local_bind = task.workspace_resolution == "local_bind"
9875
+ async def report_task_complete(result: TaskResult) -> None:
9876
+ if is_local_bind:
9877
+ _filter_local_bind_task_result(result)
9878
+ await reporter.report_complete(task.task_id, result)
9879
+
9668
9880
  default_branch = str((task.project or {}).get("default_branch") or "").strip()
9669
9881
  node_before_sha = ""
9670
9882
  # Use workspace-level granularity (requirement_workflow_id or graph_id) so that
@@ -9697,7 +9909,7 @@ class RuntimeDaemon:
9697
9909
  )
9698
9910
  if local_bind_file_lock is None:
9699
9911
  project_lock.release()
9700
- await reporter.report_complete(task.task_id, TaskResult(
9912
+ await report_task_complete(TaskResult(
9701
9913
  status="failed", exit_code=-1, stdout="", stderr="",
9702
9914
  error=(
9703
9915
  "local_binding_unavailable: another process on this "
@@ -9724,7 +9936,7 @@ class RuntimeDaemon:
9724
9936
  }
9725
9937
  hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
9726
9938
  logger.error("No agent found for type '%s' on this runtime", task.agent_type)
9727
- await reporter.report_complete(task.task_id, TaskResult(
9939
+ await report_task_complete(TaskResult(
9728
9940
  status="failed", exit_code=-1, stdout="", stderr="",
9729
9941
  error=(
9730
9942
  f"Agent '{task.agent_type}' is not available on this runtime. "
@@ -9758,7 +9970,7 @@ class RuntimeDaemon:
9758
9970
  _pf_checks = project_preflight.get("checks", [])
9759
9971
  _pf_detail = _pf_checks[0].get("detail", "Project preflight failed") if _pf_checks else "Project preflight failed"
9760
9972
  logger.warning("Project preflight failed for task %s: %s", task.task_id, _pf_detail)
9761
- await reporter.report_complete(task.task_id, TaskResult(
9973
+ await report_task_complete(TaskResult(
9762
9974
  status="failed", exit_code=-1, stdout="", stderr="",
9763
9975
  error=_pf_detail,
9764
9976
  failure_code="project_preflight_failed",
@@ -9810,9 +10022,10 @@ class RuntimeDaemon:
9810
10022
  # 3. Run agent with real-time output streaming + periodic progress heartbeat
9811
10023
  await reporter.report_progress(task.task_id, 10, "running_agent")
9812
10024
 
9813
- # Buffer agent output lines between ticks; the ticker flushes them
9814
- # every 10 seconds so the server accumulates a real-time log.
10025
+ # Buffer cloud output or local-bind activity summaries between
10026
+ # ticks so the server accumulates a real-time, safe display log.
9815
10027
  _line_buffer: list[str] = []
10028
+ last_local_activity: str | None = None
9816
10029
  _task_start = time.monotonic()
9817
10030
  progress_stop = asyncio.Event()
9818
10031
 
@@ -9821,18 +10034,43 @@ class RuntimeDaemon:
9821
10034
 
9822
10035
  Forgexa IDE local-bind (design doc §6.6/§12.6.2): raw agent
9823
10036
  terminal output must never leave the user's machine by
9824
- default skip buffering into the server-bound progress
9825
- report entirely (every report_progress() flush below sends
9826
- an empty output_lines list, not live stdout content).
10037
+ default. A content-free activity summary is sent through the
10038
+ dedicated activity_lines field so Web users can follow task
10039
+ progress without receiving stdout, commands, paths, or text.
9827
10040
  Instead, append to a local-only, OS-user-private log file so
9828
10041
  the IDE can still tail it on THIS machine (never sent over
9829
10042
  HTTP by this module).
9830
10043
  """
10044
+ nonlocal last_local_activity
9831
10045
  if is_local_bind:
9832
10046
  _append_local_bind_output_log(task.task_id, lines)
10047
+ for activity in _summarize_local_bind_output(lines):
10048
+ if activity != last_local_activity:
10049
+ _line_buffer.append(activity)
10050
+ last_local_activity = activity
9833
10051
  return
9834
10052
  _line_buffer.extend(lines)
9835
10053
 
10054
+ async def report_stream_progress(
10055
+ progress_percent: int,
10056
+ current_step: str,
10057
+ output_lines: list[str],
10058
+ ) -> None:
10059
+ if is_local_bind:
10060
+ await reporter.report_progress(
10061
+ task.task_id,
10062
+ progress_percent,
10063
+ current_step,
10064
+ activity_lines=output_lines,
10065
+ )
10066
+ else:
10067
+ await reporter.report_progress(
10068
+ task.task_id,
10069
+ progress_percent,
10070
+ current_step,
10071
+ output_lines=output_lines,
10072
+ )
10073
+
9836
10074
  async def _progress_ticker():
9837
10075
  """Flush buffered output lines + update progress % every 1 s.
9838
10076
 
@@ -9856,9 +10094,7 @@ class RuntimeDaemon:
9856
10094
  # Grab and clear the buffer atomically
9857
10095
  lines_to_send = _line_buffer[:]
9858
10096
  _line_buffer.clear()
9859
- await reporter.report_progress(
9860
- task.task_id, pct, step, output_lines=lines_to_send
9861
- )
10097
+ await report_stream_progress(pct, step, lines_to_send)
9862
10098
 
9863
10099
  ticker_task = asyncio.create_task(_progress_ticker())
9864
10100
 
@@ -9877,9 +10113,7 @@ class RuntimeDaemon:
9877
10113
  if _line_buffer:
9878
10114
  pid = self.process_manager.active_processes.get(task.task_id)
9879
10115
  step = f"running_agent (pid={pid.pid})" if pid else "running_agent"
9880
- await reporter.report_progress(
9881
- task.task_id, 90, step, output_lines=_line_buffer[:]
9882
- )
10116
+ await report_stream_progress(90, step, _line_buffer[:])
9883
10117
  _line_buffer.clear()
9884
10118
 
9885
10119
  tried_agents.add(agent.agent_id)
@@ -9978,9 +10212,7 @@ class RuntimeDaemon:
9978
10212
  step += f" (pid={pid.pid})"
9979
10213
  lines_to_send = _line_buffer[:]
9980
10214
  _line_buffer.clear()
9981
- await reporter.report_progress(
9982
- task.task_id, pct, step, output_lines=lines_to_send
9983
- )
10215
+ await report_stream_progress(pct, step, lines_to_send)
9984
10216
 
9985
10217
  ticker2 = asyncio.create_task(_progress_ticker2())
9986
10218
  try:
@@ -9995,9 +10227,10 @@ class RuntimeDaemon:
9995
10227
  except asyncio.CancelledError:
9996
10228
  pass
9997
10229
  if _line_buffer:
9998
- await reporter.report_progress(
9999
- task.task_id, 90, f"running_agent:{agent.agent_id}",
10000
- output_lines=_line_buffer[:],
10230
+ await report_stream_progress(
10231
+ 90,
10232
+ f"running_agent:{agent.agent_id}",
10233
+ _line_buffer[:],
10001
10234
  )
10002
10235
  _line_buffer.clear()
10003
10236
 
@@ -10480,19 +10713,14 @@ class RuntimeDaemon:
10480
10713
  or "validation_retry_exhausted"
10481
10714
  )
10482
10715
  result.execution_attempt_id = task.execution_attempt_id
10483
- if is_local_bind:
10484
- # See docs/designs/forgexa-ide-design.md §6.6 — full terminal
10485
- # output and raw agent observations must never leave the
10486
- # user's machine by default.
10487
- _filter_local_bind_task_result(result)
10488
10716
  await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed")
10489
- await reporter.report_complete(task.task_id, result)
10717
+ await report_task_complete(result)
10490
10718
 
10491
10719
  logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
10492
10720
 
10493
10721
  except Exception as e:
10494
10722
  logger.exception("Task %s failed with exception", task.task_id)
10495
- await reporter.report_complete(task.task_id, TaskResult(
10723
+ await report_task_complete(TaskResult(
10496
10724
  status="failed", exit_code=-1, stdout="", stderr="",
10497
10725
  error=str(e),
10498
10726
  execution_attempt_id=task.execution_attempt_id,
@@ -15394,7 +15622,9 @@ class RuntimeDaemon:
15394
15622
  for conn in self.connections:
15395
15623
  await conn.stop()
15396
15624
 
15397
- _clear_local_runtime_registration_snapshot(self._local_runtime_instance_id)
15625
+ if not getattr(self, "_desktop_restart_requested", False):
15626
+ _clear_local_runtime_registration_snapshot(self._local_runtime_instance_id)
15627
+ _clear_desktop_daemon_restart_request(self._local_runtime_instance_id)
15398
15628
  logger.info("Daemon stopped.")
15399
15629
 
15400
15630
  def handle_signal(self, sig):
@@ -79,6 +79,8 @@ except ImportError:
79
79
 
80
80
  # Module-level override applied by main() when --server-url is passed.
81
81
  _SERVER_URL_OVERRIDE: str | None = None
82
+ _OFFICIAL_PYPI_BASE_URL = "https://pypi.org"
83
+ _OFFICIAL_PYPI_SIMPLE_INDEX_URL = f"{_OFFICIAL_PYPI_BASE_URL}/simple"
82
84
 
83
85
 
84
86
  # ── Banner ────────────────────────────────────────────────────────────────────
@@ -924,9 +926,22 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
924
926
  "Use `pipx upgrade forgexa-cli` manually after restoring your pipx installation."
925
927
  )
926
928
  if target_version:
927
- command = [pipx, "install", "--force", _package_spec_for_version(target_version)]
929
+ command = [
930
+ pipx,
931
+ "install",
932
+ "--force",
933
+ "--index-url",
934
+ _OFFICIAL_PYPI_SIMPLE_INDEX_URL,
935
+ _package_spec_for_version(target_version),
936
+ ]
928
937
  else:
929
- command = [pipx, "upgrade", "forgexa-cli"]
938
+ command = [
939
+ pipx,
940
+ "upgrade",
941
+ "--index-url",
942
+ _OFFICIAL_PYPI_SIMPLE_INDEX_URL,
943
+ "forgexa-cli",
944
+ ]
930
945
  return UpgradePlan(
931
946
  installer="pipx",
932
947
  command=command,
@@ -940,7 +955,15 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
940
955
  "Reinstall forgexa-cli with pipx or a Python environment that includes pip."
941
956
  )
942
957
 
943
- command = [python, "-m", "pip", "install", "--upgrade"]
958
+ command = [
959
+ python,
960
+ "-m",
961
+ "pip",
962
+ "install",
963
+ "--upgrade",
964
+ "--index-url",
965
+ _OFFICIAL_PYPI_SIMPLE_INDEX_URL,
966
+ ]
944
967
  if _is_user_site_install(dist_root) and not _is_virtualenv():
945
968
  command.append("--user")
946
969
  if _is_pep668_environment():
@@ -1023,7 +1046,7 @@ def _latest_published_cli_version() -> str:
1023
1046
  import httpx
1024
1047
 
1025
1048
  with httpx.Client(timeout=5) as client:
1026
- response = client.get("https://pypi.org/pypi/forgexa-cli/json")
1049
+ response = client.get(f"{_OFFICIAL_PYPI_BASE_URL}/pypi/forgexa-cli/json")
1027
1050
  response.raise_for_status()
1028
1051
  version = _normalize_target_version(str(response.json()["info"]["version"]))
1029
1052
  except Exception as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.47.3
3
+ Version: 1.47.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -132,10 +132,16 @@ forgexa upgrade --target-version 1.12.3
132
132
 
133
133
  The command detects how `forgexa-cli` was installed:
134
134
 
135
- - `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
136
- - `pip` install: uses `python -m pip install --upgrade forgexa-cli`
135
+ - `pipx` install: uses `pipx upgrade --index-url https://pypi.org/simple forgexa-cli`, or `pipx install --force --index-url https://pypi.org/simple forgexa-cli==<version>` for a pinned version
136
+ - `pip` install: uses `python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli`
137
137
  - non-editable local path install (`pip install /path/to/cli`): follows the same `pip` upgrade path and switches to the published PyPI release
138
138
 
139
+ Self-upgrades pass `--index-url https://pypi.org/simple` to pip or pipx, matching the official PyPI release check and avoiding a configured mirror that has not yet synchronized a new release. A direct `pip install --upgrade forgexa-cli` command always follows that Python environment's pip configuration. When its mirror is behind, upgrade manually with:
140
+
141
+ ```bash
142
+ python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli==1.12.3
143
+ ```
144
+
139
145
  For safety, `forgexa upgrade` stops any running local daemon before upgrading. If the daemon was previously started in background mode, the CLI starts it again automatically after a successful upgrade; otherwise it prints a manual restart hint.
140
146
 
141
147
  `forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.47.3"
3
+ version = "1.47.4"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
@@ -575,7 +575,13 @@ def test_build_upgrade_plan_for_pipx_latest(monkeypatch: pytest.MonkeyPatch) ->
575
575
  plan = main._build_upgrade_plan(None)
576
576
 
577
577
  assert plan.installer == "pipx"
578
- assert plan.command == ["/usr/local/bin/pipx", "upgrade", "forgexa-cli"]
578
+ assert plan.command == [
579
+ "/usr/local/bin/pipx",
580
+ "upgrade",
581
+ "--index-url",
582
+ "https://pypi.org/simple",
583
+ "forgexa-cli",
584
+ ]
579
585
 
580
586
 
581
587
  def test_build_upgrade_plan_for_pipx_pinned_version(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -589,7 +595,14 @@ def test_build_upgrade_plan_for_pipx_pinned_version(monkeypatch: pytest.MonkeyPa
589
595
  plan = main._build_upgrade_plan("1.12.3")
590
596
 
591
597
  assert plan.installer == "pipx"
592
- assert plan.command == ["/usr/local/bin/pipx", "install", "--force", "forgexa-cli==1.12.3"]
598
+ assert plan.command == [
599
+ "/usr/local/bin/pipx",
600
+ "install",
601
+ "--force",
602
+ "--index-url",
603
+ "https://pypi.org/simple",
604
+ "forgexa-cli==1.12.3",
605
+ ]
593
606
 
594
607
 
595
608
  def test_build_upgrade_plan_for_pip_user_site(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -613,6 +626,8 @@ def test_build_upgrade_plan_for_pip_user_site(monkeypatch: pytest.MonkeyPatch) -
613
626
  "pip",
614
627
  "install",
615
628
  "--upgrade",
629
+ "--index-url",
630
+ "https://pypi.org/simple",
616
631
  "--user",
617
632
  "forgexa-cli==1.12.3",
618
633
  ]
@@ -634,6 +649,8 @@ def test_build_upgrade_plan_adds_break_system_packages_on_pep668(monkeypatch: py
634
649
 
635
650
  assert "--break-system-packages" in plan.command
636
651
  assert "--user" in plan.command
652
+ assert "--index-url" in plan.command
653
+ assert plan.command[plan.command.index("--index-url") + 1] == "https://pypi.org/simple"
637
654
  assert plan.command[-1] == "forgexa-cli"
638
655
 
639
656
 
@@ -666,6 +683,8 @@ def test_build_upgrade_plan_allows_non_editable_local_path_installs(monkeypatch:
666
683
  "pip",
667
684
  "install",
668
685
  "--upgrade",
686
+ "--index-url",
687
+ "https://pypi.org/simple",
669
688
  "--user",
670
689
  "--break-system-packages",
671
690
  "forgexa-cli",
File without changes