forgexa-cli 1.14.11__tar.gz → 1.15.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.11
3
+ Version: 1.15.0
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: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.14.11"
2
+ __version__ = "1.15.0"
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
523
523
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
524
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
525
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.14.11"
526
+ DAEMON_VERSION = "1.15.0"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -944,18 +944,20 @@ def _daemon_extract_claude_output(raw: str) -> str:
944
944
  _OPENCODE_MIN_VERSION = (1, 4, 0)
945
945
 
946
946
 
947
- def _parse_semver(v: str) -> tuple[int, int, int]:
947
+ def _parse_semver(v: str) -> "tuple[int, int, int] | None":
948
948
  """Parse a semver string into a comparable (major, minor, patch) tuple.
949
949
 
950
950
  Strips a leading 'v' and ignores pre-release/build suffixes so that
951
951
  '1.4.0-beta.1' and 'v1.4.0' both return (1, 4, 0).
952
- Returns (0, 0, 0) when the string cannot be parsed.
952
+ Returns None when the string cannot be parsed (e.g. 'unknown', 'latest'),
953
+ so callers can skip the version check rather than erroneously rejecting
954
+ a valid agent whose version string is non-standard.
953
955
  """
954
956
  import re as _re
955
957
  m = _re.match(r"^v?(\d+)\.(\d+)\.(\d+)", v.strip())
956
958
  if m:
957
959
  return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
958
- return (0, 0, 0)
960
+ return None
959
961
 
960
962
 
961
963
  def _daemon_extract_opencode_output(raw: str) -> str:
@@ -1258,18 +1260,18 @@ def _get_design_output_for_type(req_type: str) -> str:
1258
1260
  Used by _validate_outputs and _required_deliverable_paths to avoid
1259
1261
  hardcoding 'design.md' for all node_type='design' nodes — spike produces
1260
1262
  'research.md' instead.
1263
+
1264
+ Reads the explicit `output_filename` field on the PhaseConfig dataclass
1265
+ (set in type_workflow_profiles.py) rather than using a fragile regex over
1266
+ the prompt template text.
1261
1267
  """
1262
1268
  try:
1263
1269
  from app.services.type_workflow_profiles import get_profile
1264
1270
  profile = get_profile(req_type)
1265
1271
  for phase in profile.execution_phases:
1266
1272
  if phase.node_type == "design":
1267
- from app.services.type_prompts import TYPE_PROMPT_TEMPLATES
1268
- tmpl = TYPE_PROMPT_TEMPLATES.get(phase.prompt_key, "")
1269
- import re as _re
1270
- m = _re.search(r"{doc_dir}/([\w.-]+)", tmpl)
1271
- if m:
1272
- return m.group(1)
1273
+ if phase.output_filename:
1274
+ return phase.output_filename
1273
1275
  break
1274
1276
  except Exception:
1275
1277
  pass
@@ -1754,18 +1756,30 @@ class WorkspaceManager:
1754
1756
  """
1755
1757
  import shutil
1756
1758
  import stat as _stat
1759
+ import sys as _sys
1757
1760
 
1758
1761
  if not path.exists():
1759
1762
  return
1760
1763
 
1761
1764
  def _handle_readonly(func, entry_path, excinfo):
1762
1765
  try:
1763
- os.chmod(entry_path, _stat.S_IWRITE | _stat.S_IREAD | _stat.S_IRWXU)
1766
+ os.chmod(entry_path, _stat.S_IWRITE | _stat.S_IREAD | _stat.S_IEXEC)
1764
1767
  func(entry_path)
1765
- except Exception:
1766
- pass # Best-effort: if even chmod fails, ignore
1768
+ except Exception as _chmod_err:
1769
+ logger.debug(
1770
+ "_safe_rmtree: failed to force-remove %s: %s",
1771
+ entry_path, _chmod_err,
1772
+ )
1767
1773
 
1768
- shutil.rmtree(path, onerror=_handle_readonly)
1774
+ # Python 3.12 deprecated onerror= in favour of onexc=.
1775
+ # The signatures differ: onerror gets (exc_type, exc_val, tb),
1776
+ # onexc gets (func, path, exc_instance).
1777
+ if _sys.version_info >= (3, 12):
1778
+ def _onexc(func, p, exc):
1779
+ _handle_readonly(func, p, (type(exc), exc, exc.__traceback__))
1780
+ shutil.rmtree(path, onexc=_onexc)
1781
+ else:
1782
+ shutil.rmtree(path, onerror=_handle_readonly)
1769
1783
 
1770
1784
  # Windows fallback: reserved device names survive shutil.rmtree.
1771
1785
  # cmd /c rmdir /s /q with the \\?\ extended-length prefix bypasses
@@ -1774,16 +1788,19 @@ class WorkspaceManager:
1774
1788
  try:
1775
1789
  import subprocess
1776
1790
  resolved = str(path.resolve())
1777
- # Prepend \\?\ extended-length prefix if not already present.
1778
- # In Python source "\\\\?\\" represents the 4-char string \\?\
1779
- # which is the Win32 flag to bypass reserved-name interception.
1780
- if not resolved.startswith("\\\\?\\"):
1791
+ # Build the correct extended-length prefix:
1792
+ # UNC paths (\\server\share) \\?\UNC\server\share
1793
+ # Regular paths → \\?\C:\path
1794
+ if resolved.startswith("\\\\"):
1795
+ unc = "\\\\?\\UNC\\" + resolved[2:]
1796
+ elif not resolved.startswith("\\\\?\\"):
1781
1797
  unc = "\\\\?\\" + resolved
1782
1798
  else:
1783
1799
  unc = resolved
1784
1800
  result = subprocess.run(
1785
1801
  ["cmd", "/c", "rmdir", "/s", "/q", unc],
1786
1802
  capture_output=True,
1803
+ stdin=subprocess.DEVNULL,
1787
1804
  timeout=60,
1788
1805
  )
1789
1806
  if result.returncode != 0 and path.exists():
@@ -2298,6 +2315,14 @@ class WorkspaceManager:
2298
2315
  "revert(analysis",
2299
2316
  "initial commit",
2300
2317
  )
2318
+ # These look like docs/* but contain actual design/architecture work —
2319
+ # treat them as implementation commits so fresh-start never discards them.
2320
+ _IMPL_DOCS_PREFIXES = (
2321
+ "docs(design",
2322
+ "docs(rfc",
2323
+ "docs(architecture",
2324
+ "docs(spec",
2325
+ )
2301
2326
  try:
2302
2327
  raw = await self._git(
2303
2328
  "log", "--format=%s",
@@ -2314,6 +2339,11 @@ class WorkspaceManager:
2314
2339
 
2315
2340
  for subject in subjects:
2316
2341
  lower = subject.lower()
2342
+ # First check: is it an explicit design/rfc/architecture doc commit?
2343
+ # These look like "docs(design)" but carry real design artifacts and
2344
+ # must never be discarded on a fresh-start reset.
2345
+ if any(lower.startswith(p) for p in _IMPL_DOCS_PREFIXES):
2346
+ return True # Design/architecture commit — treat as implementation
2317
2347
  if not any(lower.startswith(p) for p in _ANALYSIS_ONLY_PREFIXES):
2318
2348
  return True # Found at least one implementation commit
2319
2349
 
@@ -2562,6 +2592,22 @@ class WorkspaceManager:
2562
2592
 
2563
2593
  if fresh_start:
2564
2594
  logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
2595
+ # Safety: create a backup tag before resetting, so commits can
2596
+ # be recovered even if the remote branch was deleted and the
2597
+ # _branch_has_impl_commits heuristic misfired.
2598
+ import time as _time
2599
+ _backup_tag = f"backup/pre-fresh-start-{int(_time.time())}"
2600
+ try:
2601
+ await self._git("tag", _backup_tag, "HEAD", cwd=ws_path)
2602
+ logger.info(
2603
+ "Created safety tag %s before fresh re-analysis reset on %s",
2604
+ _backup_tag, branch_name,
2605
+ )
2606
+ except RuntimeError as _tag_err:
2607
+ logger.warning(
2608
+ "Could not create backup tag %s before fresh-start reset: %s",
2609
+ _backup_tag, _tag_err,
2610
+ )
2565
2611
  # Use checkout -B to create or reset the branch to origin/default_branch.
2566
2612
  # Plain `checkout branch_name` fails when the branch doesn't exist locally.
2567
2613
  try:
@@ -3128,7 +3174,17 @@ class WorkspaceManager:
3128
3174
  except Exception:
3129
3175
  pass
3130
3176
  else:
3131
- _known_hosts_null = "/dev/null"
3177
+ # Use a persistent per-daemon known_hosts file under ~/.forgexa.
3178
+ # This provides TOFU (Trust On First Use) protection: the first
3179
+ # connection stores the host key; subsequent connections verify it.
3180
+ # Using /dev/null discards all host keys on every git operation,
3181
+ # leaving every connection open to MITM attacks.
3182
+ _kh_dir = Path.home() / ".forgexa"
3183
+ try:
3184
+ _kh_dir.mkdir(parents=True, exist_ok=True)
3185
+ except OSError:
3186
+ pass
3187
+ _known_hosts_null = str(_kh_dir / "known_hosts")
3132
3188
  env = {
3133
3189
  **os.environ,
3134
3190
  "GIT_SSH_COMMAND": (
@@ -3188,6 +3244,7 @@ class WorkspaceManager:
3188
3244
  # we use taskkill /T there instead of killpg.
3189
3245
  proc = await asyncio.create_subprocess_exec(
3190
3246
  "git", *longpath_args, *git_prefix_args, *args,
3247
+ stdin=asyncio.subprocess.DEVNULL,
3191
3248
  stdout=asyncio.subprocess.PIPE,
3192
3249
  stderr=asyncio.subprocess.PIPE,
3193
3250
  cwd=str(cwd) if cwd else None,
@@ -4174,7 +4231,12 @@ class ProcessManager:
4174
4231
  model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
4175
4232
  if model_override:
4176
4233
  env["ANTHROPIC_MODEL"] = model_override
4177
- env.update(self._prepare_claude_environment())
4234
+
4235
+ # Per-task isolated Claude home prevents cross-task state leakage
4236
+ # (cache/XDG data, session state) between concurrent task executions.
4237
+ import tempfile
4238
+ _claude_home = tempfile.mkdtemp(prefix=f"claude-{task_id[:8]}-")
4239
+ env.update(self._prepare_claude_environment(sandbox_root=Path(_claude_home)))
4178
4240
 
4179
4241
  try:
4180
4242
  proc = await asyncio.create_subprocess_exec(
@@ -4262,6 +4324,8 @@ class ProcessManager:
4262
4324
  )
4263
4325
  finally:
4264
4326
  self.active_processes.pop(task_id, None)
4327
+ # Clean up per-task isolated Claude home (suppress errors — best-effort)
4328
+ WorkspaceManager._safe_rmtree(Path(_claude_home))
4265
4329
 
4266
4330
  async def _run_codex(
4267
4331
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
@@ -4342,7 +4406,14 @@ class ProcessManager:
4342
4406
  # in headless mode (no TTY), eventually exiting with code 1 after a long wait.
4343
4407
  # Fail fast here so the error is immediately actionable.
4344
4408
  _oc_ver = _parse_semver(agent.version)
4345
- if _oc_ver < _OPENCODE_MIN_VERSION:
4409
+ if _oc_ver is None:
4410
+ # Non-standard version string (e.g. 'unknown', 'latest') — skip version
4411
+ # check rather than incorrectly rejecting a potentially valid agent.
4412
+ logger.warning(
4413
+ "Could not parse opencode version %r; skipping version check",
4414
+ agent.version,
4415
+ )
4416
+ elif _oc_ver < _OPENCODE_MIN_VERSION:
4346
4417
  _min_str = ".".join(str(x) for x in _OPENCODE_MIN_VERSION)
4347
4418
  return TaskResult(
4348
4419
  status="failed",
@@ -4560,8 +4631,13 @@ class ProcessManager:
4560
4631
  return result
4561
4632
 
4562
4633
  @staticmethod
4563
- def _prepare_claude_environment() -> dict[str, str]:
4564
- """Prepare writable Claude config/data/cache dirs under ~/.forgexa.
4634
+ def _prepare_claude_environment(
4635
+ sandbox_root: "Path | None" = None,
4636
+ ) -> dict[str, str]:
4637
+ """Prepare writable Claude config/data/cache dirs for isolated execution.
4638
+
4639
+ When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
4640
+ directory. When None, falls back to the shared legacy path under ~/.forgexa.
4565
4641
 
4566
4642
  The daemon service runs with ProtectSystem=strict and only allows
4567
4643
  writes under ~/.forgexa plus the workspace root. Claude Code writes
@@ -4573,7 +4649,8 @@ class ProcessManager:
4573
4649
  the same writable sandbox, and mirror only the small set of user
4574
4650
  config files it needs for headless execution.
4575
4651
  """
4576
- sandbox_root = Path.home() / ".forgexa" / "claude-home"
4652
+ if sandbox_root is None:
4653
+ sandbox_root = Path.home() / ".forgexa" / "claude-home"
4577
4654
  config_root = sandbox_root / "config"
4578
4655
  data_root = sandbox_root / "data"
4579
4656
  cache_root = sandbox_root / "cache"
@@ -4655,6 +4732,22 @@ class ProcessManager:
4655
4732
  if not source_root.exists():
4656
4733
  return str(target_root)
4657
4734
 
4735
+ # P3-3.5: Prune old session-state WAL/SHM files and log files > 7 days.
4736
+ # These accumulate indefinitely without cleanup.
4737
+ import time as _time
4738
+ _prune_cutoff = _time.time() - 7 * 86400 # 7 days
4739
+ for _prune_dir_name in ("session-state", "logs"):
4740
+ _prune_dir = target_root / _prune_dir_name
4741
+ if not _prune_dir.is_dir():
4742
+ continue
4743
+ for _old_file in _prune_dir.iterdir():
4744
+ try:
4745
+ if _old_file.is_file() and _old_file.stat().st_mtime < _prune_cutoff:
4746
+ _old_file.unlink(missing_ok=True)
4747
+ logger.debug("Pruned old Copilot home file: %s", _old_file)
4748
+ except Exception as _prune_err:
4749
+ logger.debug("Could not prune %s: %s", _old_file, _prune_err)
4750
+
4658
4751
  # Collect names present in source for stale-file detection.
4659
4752
  source_names: set[str] = {child.name for child in source_root.iterdir()}
4660
4753
 
@@ -4819,6 +4912,19 @@ class ProcessManager:
4819
4912
  ) + ' -p "<prompt>"'
4820
4913
  logger.debug("Copilot invocation for task %s: %s", task_id, _cmd_display)
4821
4914
 
4915
+ # On Windows, CREATE_NO_WINDOW ensures GetConsoleWindow() returns NULL for
4916
+ # the child process, even when the parent (daemon or Tauri app) has a console
4917
+ # window attached. Copilot CLI 1.0.68+ added a secondary interactive-mode
4918
+ # check via GetConsoleWindow(): if a console window is visible, it uses only
4919
+ # the first line of -p as a brief context note and reports "No task was
4920
+ # provided" for the rest of the prompt — silently discarding the full task.
4921
+ # stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
4922
+ # CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
4923
+ # headless environment and processes -p as the complete task specification.
4924
+ _win_flags: dict = {}
4925
+ if sys.platform == "win32":
4926
+ _win_flags["creationflags"] = subprocess.CREATE_NO_WINDOW
4927
+
4822
4928
  try:
4823
4929
  proc = await asyncio.create_subprocess_exec(
4824
4930
  *cmd,
@@ -4840,6 +4946,7 @@ class ProcessManager:
4840
4946
  env=env,
4841
4947
  limit=100 * 1024 * 1024,
4842
4948
  start_new_session=True,
4949
+ **_win_flags,
4843
4950
  )
4844
4951
  self.active_processes[task_id] = proc
4845
4952
  stdout, stderr, returncode = await self._stream_process(
@@ -4988,8 +5095,8 @@ class ProcessManager:
4988
5095
  )
4989
5096
  finally:
4990
5097
  self.active_processes.pop(task_id, None)
4991
- if _is_outer_call:
4992
- shutil.rmtree(_isolated_copilot_home, ignore_errors=True)
5098
+ if _is_outer_call and _isolated_copilot_home:
5099
+ WorkspaceManager._safe_rmtree(Path(_isolated_copilot_home))
4993
5100
 
4994
5101
  async def _run_generic(
4995
5102
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
@@ -5011,7 +5118,7 @@ class ProcessManager:
5011
5118
  *cmd,
5012
5119
  stdout=asyncio.subprocess.PIPE,
5013
5120
  stderr=asyncio.subprocess.PIPE,
5014
- stdin=asyncio.subprocess.PIPE if stdin_input else None,
5121
+ stdin=asyncio.subprocess.PIPE if stdin_input else asyncio.subprocess.DEVNULL,
5015
5122
  cwd=str(cwd),
5016
5123
  env=env,
5017
5124
  limit=100 * 1024 * 1024, # 100MB line buffer for large agent output
@@ -6689,6 +6796,10 @@ class RuntimeDaemon:
6689
6796
  logger.info("Daemon ready. Connected to %d server(s). Polling for tasks...",
6690
6797
  len(self.connections))
6691
6798
 
6799
+ # Clean up stale Copilot/Claude temp directories left by prior OOM-killed
6800
+ # daemon processes. This runs once at startup so /tmp doesn't fill up.
6801
+ await self._cleanup_stale_tmp_dirs()
6802
+
6692
6803
  # 3. Main loop
6693
6804
  try:
6694
6805
  while not self._shutdown:
@@ -7033,7 +7144,6 @@ class RuntimeDaemon:
7033
7144
  Using 1-second ticks keeps the UI responsive without flooding
7034
7145
  the backend. Empty ticks are skipped to reduce HTTP traffic.
7035
7146
  """
7036
- import math as _math
7037
7147
  tick = 0
7038
7148
  while not progress_stop.is_set():
7039
7149
  await asyncio.sleep(1)
@@ -7467,9 +7577,22 @@ class RuntimeDaemon:
7467
7577
  if commit_result:
7468
7578
  # Propagate push/commit errors in metrics so they're visible
7469
7579
  result.metrics.update(commit_result)
7580
+ # Commit failure means changes were never committed — the task
7581
+ # produced no persisted output. Mark as failed so the
7582
+ # orchestrator does not treat this as a successful node and
7583
+ # propagate stale state to downstream nodes.
7584
+ if commit_result.get("commit_error"):
7585
+ commit_err = commit_result["commit_error"]
7586
+ result.status = "failed"
7587
+ result.failure_code = result.failure_code or "auto_commit_failed"
7588
+ result.error = f"Auto-commit failed: {commit_err}"
7589
+ logger.error(
7590
+ "Task %s: auto-commit failed — changes not persisted: %s",
7591
+ task.task_id, commit_err,
7592
+ )
7470
7593
  # Push failure is a real problem for downstream nodes — mark
7471
7594
  # as failed so the orchestrator can retry (transient network).
7472
- if commit_result.get("push_error"):
7595
+ elif commit_result.get("push_error"):
7473
7596
  push_err = commit_result["push_error"]
7474
7597
  result.status = "failed"
7475
7598
  if commit_result.get("push_error_code") == "forbidden_branch_merge":
@@ -9552,15 +9675,21 @@ class RuntimeDaemon:
9552
9675
  current_branch: str,
9553
9676
  default_branch: str,
9554
9677
  ) -> list[str]:
9555
- """Return merge commits introduced locally and not yet on the remote branch.
9678
+ """Return merge commits that pull default-branch changes into the requirement branch.
9679
+
9680
+ Requirement branches must stay linear and requirement-scoped. Only
9681
+ merges whose parent chain includes a commit from ``origin/{default_branch}``
9682
+ are rejected — these indicate an explicit merge/rebase that imported
9683
+ default-branch changes (and potentially other requirements' changes).
9556
9684
 
9557
- Requirement branches must stay linear and requirement-scoped. Any new
9558
- merge commit pending push indicates an explicit merge/rebase workflow that
9559
- can import unrelated changes (for example, merging origin/develop into a
9560
- fix branch). Those pushes are rejected by the caller.
9685
+ Legitimate merges (e.g. AI-assisted conflict resolution that merges
9686
+ two sub-feature branches) are allowed through because neither parent
9687
+ is an ancestor of the default branch.
9561
9688
  """
9562
9689
  git = self.workspace_manager._git
9563
9690
 
9691
+ # Collect all unpushed merge commits first
9692
+ all_merge_shas: list[str] = []
9564
9693
  try:
9565
9694
  await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
9566
9695
  out = await git(
@@ -9575,8 +9704,42 @@ class RuntimeDaemon:
9575
9704
  )
9576
9705
  except RuntimeError:
9577
9706
  return []
9707
+ all_merge_shas = [line.strip() for line in out.splitlines() if line.strip()]
9708
+ if not all_merge_shas:
9709
+ return []
9710
+
9711
+ # Filter: only flag merges where at least one parent is an ancestor of
9712
+ # origin/{default_branch}. This distinguishes "merged default branch in"
9713
+ # (forbidden) from "merged two local sub-branches" (legitimate).
9714
+ forbidden: list[str] = []
9715
+ for sha in all_merge_shas:
9716
+ try:
9717
+ parents_out = await git(
9718
+ "show", "-s", "--format=%P", sha, cwd=workspace_path,
9719
+ )
9720
+ parents = parents_out.strip().split()
9721
+ except RuntimeError:
9722
+ # Cannot determine parents — treat as forbidden (fail-safe)
9723
+ forbidden.append(sha)
9724
+ continue
9725
+
9726
+ is_default_merge = False
9727
+ for parent in parents:
9728
+ try:
9729
+ await git(
9730
+ "merge-base", "--is-ancestor", parent,
9731
+ f"origin/{default_branch}", cwd=workspace_path,
9732
+ )
9733
+ # This parent is an ancestor of the default branch — the
9734
+ # merge imported default-branch changes → forbidden
9735
+ is_default_merge = True
9736
+ break
9737
+ except RuntimeError:
9738
+ continue # Parent is not on the default branch — OK
9739
+ if is_default_merge:
9740
+ forbidden.append(sha)
9578
9741
 
9579
- return [line.strip() for line in out.splitlines() if line.strip()]
9742
+ return forbidden
9580
9743
 
9581
9744
  async def _ai_resolve_conflicts(
9582
9745
  self, workspace_path: Path, default_branch: str, task: TaskInfo,
@@ -10051,6 +10214,37 @@ class RuntimeDaemon:
10051
10214
  for a in self.agents
10052
10215
  ]
10053
10216
 
10217
+ async def _cleanup_stale_tmp_dirs(self) -> None:
10218
+ """Remove stale Copilot/Claude temp dirs from prior OOM-killed daemon runs.
10219
+
10220
+ Both GitHub Copilot and Claude agents use mkdtemp(prefix="copilot-…") /
10221
+ mkdtemp(prefix="claude-…") directories. When the daemon is killed before
10222
+ cleanup these directories accumulate in /tmp. We clean anything older
10223
+ than 1 hour at startup.
10224
+ """
10225
+ import glob
10226
+ import tempfile
10227
+ import time as _time
10228
+
10229
+ tmp_dir = tempfile.gettempdir()
10230
+ patterns = [
10231
+ "copilot-*",
10232
+ "claude-*",
10233
+ ]
10234
+ cutoff = _time.time() - 3600 # 1 hour
10235
+
10236
+ for pattern in patterns:
10237
+ for d in glob.glob(os.path.join(tmp_dir, pattern)):
10238
+ try:
10239
+ if not os.path.isdir(d):
10240
+ continue
10241
+ mtime = os.path.getmtime(d)
10242
+ if mtime < cutoff:
10243
+ self.workspace_manager._safe_rmtree(Path(d))
10244
+ logger.info("Cleaned up stale agent temp dir: %s", d)
10245
+ except Exception as e:
10246
+ logger.debug("Could not clean temp dir %s: %s", d, e)
10247
+
10054
10248
  async def _shutdown_gracefully(self):
10055
10249
  """Graceful shutdown."""
10056
10250
  logger.info("Shutting down daemon...")
@@ -92,6 +92,18 @@ def _supports_color() -> bool:
92
92
  return False
93
93
  if os.environ.get("TERM") in ("dumb", ""):
94
94
  return False
95
+ # Windows: older cmd.exe and PowerShell don't support ANSI escape sequences.
96
+ # Only enable color when running in a known ANSI-capable environment.
97
+ if sys.platform == "win32":
98
+ # Windows Terminal sets WT_SESSION; ConEmu/ANSICON set ANSICON;
99
+ # VS Code terminal sets TERM_PROGRAM=vscode.
100
+ if not (
101
+ os.environ.get("WT_SESSION")
102
+ or os.environ.get("ANSICON")
103
+ or os.environ.get("TERM_PROGRAM") == "vscode"
104
+ or os.environ.get("COLORTERM") in ("truecolor", "24bit", "256color")
105
+ ):
106
+ return False
95
107
  return True
96
108
 
97
109
 
@@ -117,17 +129,17 @@ def _print_banner() -> None:
117
129
  from forgexa_cli import __version__
118
130
 
119
131
  if _supports_color():
120
- purple = _brand_color()
132
+ blue = _brand_color()
121
133
  reset = "\033[0m"
122
- logo = f"{purple}{_LOGO_ART}{reset}"
123
- tagline = f" {purple}AI-Driven Software Factory · v{__version__}{reset}"
134
+ logo = f"{blue}{_LOGO_ART}{reset}"
135
+ tagline = f" {blue}AI-Driven Software Factory · v{__version__}{reset}"
124
136
  else:
125
137
  logo = _LOGO_ART
126
138
  tagline = f" AI-Driven Software Factory · v{__version__}"
127
139
 
128
- print(logo)
129
- print(tagline)
130
- print()
140
+ print(logo, file=sys.stderr)
141
+ print(tagline, file=sys.stderr)
142
+ print(file=sys.stderr)
131
143
 
132
144
 
133
145
  # ── Config file helpers (~/.forgexa/config) ──
@@ -225,6 +237,7 @@ def _refresh_access_token() -> bool:
225
237
  try:
226
238
  with httpx.Client(timeout=15) as client:
227
239
  resp = client.post(url, json={"refresh_token": refresh_token})
240
+ resp.raise_for_status()
228
241
  payload = resp.json()
229
242
  except Exception:
230
243
  return False
@@ -502,9 +515,19 @@ def _write_update_cache(latest_version: str) -> None:
502
515
 
503
516
 
504
517
  def _version_is_newer(candidate: str, reference: str) -> bool:
505
- """Return True if candidate is strictly newer than reference (numeric tuple comparison)."""
518
+ """Return True if candidate is strictly newer than reference.
519
+
520
+ Strips pre-release/build suffixes (e.g. '1.14.3a1', '1.14.3.post1')
521
+ before comparison so that release version 1.14.3 is treated as newer
522
+ than pre-release 1.14.3a1, not equal.
523
+ """
524
+ import re as _re
525
+
506
526
  def _parts(v: str) -> tuple[int, ...]:
507
- return tuple(int(x) for x in v.split(".")[:4] if x.isdigit())
527
+ # Strip pre-release/build suffixes: take only the leading numeric segments
528
+ clean = _re.split(r"[^0-9.]", v)[0].rstrip(".")
529
+ return tuple(int(x) for x in clean.split(".")[:4] if x.isdigit())
530
+
508
531
  try:
509
532
  return _parts(candidate) > _parts(reference)
510
533
  except Exception:
@@ -683,12 +706,24 @@ def _request_json(
683
706
  timeout=timeout,
684
707
  allow_refresh=False,
685
708
  )
686
- body_text = e.response.text
709
+ body_text = e.response.text[:500]
710
+ # Mask secrets that may leak from server 5xx responses
711
+ import re as _re
712
+ body_text = _re.sub(
713
+ r'(password|token|secret|key|auth)["\']?\s*[:=]\s*["\']?[^\s&,"\']+',
714
+ r'\1=***',
715
+ body_text,
716
+ flags=_re.IGNORECASE,
717
+ )
687
718
  print(f"Error {e.response.status_code}: {body_text}", file=sys.stderr)
688
- sys.exit(1)
719
+ # Use distinct exit codes so callers / CI scripts can distinguish error types:
720
+ # 1 → generic / server error (4xx except auth, 5xx)
721
+ # 2 → authentication failure (401 / 403)
722
+ status = e.response.status_code
723
+ sys.exit(2 if status in (401, 403) else 1)
689
724
  except httpx.RequestError as e:
690
725
  print(f"Connection error: {e}\nServer: {_api_url()}", file=sys.stderr)
691
- sys.exit(1)
726
+ sys.exit(3) # 3 → network / connection error
692
727
 
693
728
 
694
729
  def _get(path: str) -> dict | list:
@@ -822,7 +857,12 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
822
857
 
823
858
 
824
859
  def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
825
- """Like _get_runtimes() but returns [] instead of sys.exit on any error."""
860
+ """Like _get_runtimes() but returns [] instead of sys.exit on any error.
861
+
862
+ On 401, tries once to refresh the token (same as _request_json) rather
863
+ than silently returning [] — the original bug was that 401 caused a 12s
864
+ polling timeout because the stale token was never refreshed.
865
+ """
826
866
  import httpx
827
867
 
828
868
  path = "/runtimes" if include_all else "/runtimes/me"
@@ -830,6 +870,11 @@ def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
830
870
  try:
831
871
  with httpx.Client(headers=_headers(), timeout=10) as client:
832
872
  resp = client.get(url)
873
+ if resp.status_code == 401 and _refresh_access_token():
874
+ # Retry once with the new token
875
+ with httpx.Client(headers=_headers(), timeout=10) as retry_client:
876
+ resp = retry_client.get(url)
877
+ resp.raise_for_status()
833
878
  result = resp.json() if resp.content else []
834
879
  return result if isinstance(result, list) else []
835
880
  except Exception:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.11
3
+ Version: 1.15.0
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: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.14.11"
3
+ version = "1.15.0"
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 = { text = "MIT" }
@@ -1,11 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- import io
4
3
  import contextlib
4
+ import io
5
5
  import json
6
6
  from pathlib import Path
7
7
  from types import SimpleNamespace
8
- import urllib.error
8
+ from unittest.mock import MagicMock
9
9
 
10
10
  import httpx
11
11
  import pytest
@@ -13,27 +13,15 @@ import pytest
13
13
  from forgexa_cli import daemon, main
14
14
 
15
15
 
16
- class UrlopenResponse:
17
- def __init__(self, payload):
18
- self._payload = payload
19
-
20
- def read(self) -> bytes:
21
- return json.dumps(self._payload).encode("utf-8")
22
-
23
- def __enter__(self):
24
- return self
25
-
26
- def __exit__(self, exc_type, exc, tb):
27
- return False
28
-
29
-
30
16
  class HttpxResponse:
31
- def __init__(self, status_code: int, payload: dict):
17
+ def __init__(self, status_code: int, payload: dict | list):
32
18
  self.status_code = status_code
33
19
  self._payload = payload
34
- self.request = httpx.Request("POST", "https://api.example.com/api/v1/runtimes/register")
20
+ self.request = httpx.Request("GET", "https://api.example.com/api/v1/runtimes/me")
21
+ self.content = json.dumps(payload).encode("utf-8")
22
+ self.text = json.dumps(payload)
35
23
 
36
- def json(self) -> dict:
24
+ def json(self) -> dict | list:
37
25
  return self._payload
38
26
 
39
27
  def raise_for_status(self) -> None:
@@ -45,16 +33,6 @@ class HttpxResponse:
45
33
  )
46
34
 
47
35
 
48
- def _http_error(url: str, status_code: int, payload: dict) -> urllib.error.HTTPError:
49
- return urllib.error.HTTPError(
50
- url,
51
- status_code,
52
- payload.get("detail", "error"),
53
- hdrs=None,
54
- fp=io.BytesIO(json.dumps(payload).encode("utf-8")),
55
- )
56
-
57
-
58
36
  def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
59
37
  monkeypatch.setenv("HOME", str(tmp_path))
60
38
  monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
@@ -100,40 +78,62 @@ def test_get_retries_once_after_refresh_on_401(
100
78
  )
101
79
  (config_dir / "token").write_text("old-access")
102
80
 
103
- import urllib.request
104
-
105
- runtime_called = {"count": 0}
81
+ # httpx.Client is created once per request call. Track creation order:
82
+ # call #1 — initial GET (with old-access token) → 401
83
+ # call #2 POST to /auth/refresh (no auth header) → returns new tokens
84
+ # call #3 — retry GET (with new-access token) → returns runtimes list
85
+ client_call_count = {"n": 0}
86
+ runtime_call_count = {"n": 0}
87
+
88
+ class FakeClient:
89
+ def __init__(self, **kwargs):
90
+ client_call_count["n"] += 1
91
+ self._call_num = client_call_count["n"]
92
+ self._headers = kwargs.get("headers", {})
93
+
94
+ def __enter__(self):
95
+ return self
96
+
97
+ def __exit__(self, *args):
98
+ return False
99
+
100
+ def request(self, method, url, **kw):
101
+ # GET /runtimes/me — first time 401, second time success
102
+ assert "/runtimes/me" in url
103
+ runtime_call_count["n"] += 1
104
+ if runtime_call_count["n"] == 1:
105
+ # Verify old token was used
106
+ assert "old-access" in self._headers.get("Authorization", "")
107
+ resp = HttpxResponse(401, {"detail": "Invalid token"})
108
+ resp.request = httpx.Request(method, url)
109
+ raise httpx.HTTPStatusError(
110
+ "HTTP 401",
111
+ request=resp.request,
112
+ response=resp,
113
+ )
114
+ # Second call: verify new token was used
115
+ assert "new-access" in self._headers.get("Authorization", "")
116
+ return HttpxResponse(
117
+ 200,
118
+ [{"id": "12345678", "daemon_id": "demo-daemon", "status": "online"}],
119
+ )
106
120
 
107
- def fake_urlopen(req, timeout=0):
108
- url = req.full_url
109
- if url.endswith("/api/v1/runtimes/me"):
110
- runtime_called["count"] += 1
111
- if runtime_called["count"] == 1:
112
- raise _http_error(url, 401, {"detail": "Invalid token"})
113
- assert req.headers.get("Authorization") == "Bearer new-access"
114
- return UrlopenResponse([
115
- {
116
- "id": "12345678",
117
- "daemon_id": "demo-daemon",
118
- "status": "online",
119
- }
120
- ])
121
- if url.endswith("/api/v1/auth/refresh"):
122
- assert json.loads(req.data.decode("utf-8")) == {"refresh_token": "refresh-1"}
123
- return UrlopenResponse(
124
- {
125
- "access_token": "new-access",
126
- "refresh_token": "refresh-2",
127
- }
121
+ def post(self, url, **kw):
122
+ # POST /auth/refresh
123
+ assert "/auth/refresh" in url
124
+ body = kw.get("json", {})
125
+ assert body.get("refresh_token") == "refresh-1"
126
+ return HttpxResponse(
127
+ 200,
128
+ {"access_token": "new-access", "refresh_token": "refresh-2"},
128
129
  )
129
- raise AssertionError(f"Unexpected URL: {url}")
130
130
 
131
- monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
131
+ monkeypatch.setattr(httpx, "Client", FakeClient)
132
132
 
133
133
  result = main._get("/runtimes/me")
134
134
 
135
135
  assert isinstance(result, list)
136
- assert runtime_called["count"] == 2
136
+ assert runtime_call_count["n"] == 2
137
137
  cfg = json.loads((config_dir / "config").read_text())
138
138
  assert cfg["token"] == "new-access"
139
139
  assert cfg["refresh_token"] == "refresh-2"
File without changes
File without changes