forgexa-cli 1.14.11__tar.gz → 1.15.1__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.1
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.1"
@@ -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.1"
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
@@ -1742,10 +1744,10 @@ class WorkspaceManager:
1742
1744
  The Win32 API intercepts these names before they reach the NTFS
1743
1745
  driver, so os.unlink / shutil.rmtree / PowerShell Remove-Item all
1744
1746
  fail with a permissions or access error. The solution is the
1745
- \\?\ extended-length path prefix, which instructs Windows to
1747
+ \\?\\ extended-length path prefix, which instructs Windows to
1746
1748
  skip Win32 name parsing and pass the path directly to the NT
1747
1749
  kernel file-system layer. 'cmd /c rmdir /s /q' reliably handles
1748
- this when given a \\?\ prefixed path.
1750
+ this when given a \\?\\ prefixed path.
1749
1751
 
1750
1752
  Deletion strategy (each stage only runs if the path still exists):
1751
1753
  1. shutil.rmtree with onerror chmod — handles read-only objects.
@@ -1754,36 +1756,51 @@ 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
- # cmd /c rmdir /s /q with the \\?\ extended-length prefix bypasses
1785
+ # cmd /c rmdir /s /q with the \\?\\ extended-length prefix bypasses
1772
1786
  # Win32 name interception and reaches the NTFS layer directly.
1773
1787
  if path.exists() and sys.platform == "win32":
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
 
@@ -4807,10 +4900,47 @@ class ProcessManager:
4807
4900
  ]
4808
4901
  if reasoning and not _retry_without_effort:
4809
4902
  cmd += ["--effort", reasoning]
4810
- cmd += ["-C", str(cwd), "-p", prompt]
4811
4903
  if model_override:
4812
4904
  cmd = [agent.command, "--model", model_override] + cmd[1:]
4813
4905
 
4906
+ # On Windows, batch/cmd wrappers (e.g. copilot.BAT from the VS Code
4907
+ # Copilot Chat extension) invoke cmd.exe when run via CreateProcess.
4908
+ # cmd.exe has an ~8191-char command-line limit; long multi-line prompts
4909
+ # are silently truncated so the agent runs but never sees the output
4910
+ # file instructions — producing no required deliverables.
4911
+ # Fix: write the full prompt to a workspace-local file and pass a short
4912
+ # single-line read-instruction via -p to stay under the limit.
4913
+ _task_file: Path | None = None
4914
+ _effective_prompt = prompt
4915
+ if sys.platform == "win32" and "\n" in prompt:
4916
+ _task_file = Path(cwd) / ".forgexa-task.md"
4917
+ try:
4918
+ _task_file.write_text(prompt, encoding="utf-8")
4919
+ # Prevent accidental git commit of this ephemeral file
4920
+ _git_exclude = Path(cwd) / ".git" / "info" / "exclude"
4921
+ if _git_exclude.exists():
4922
+ _excl = _git_exclude.read_text(encoding="utf-8", errors="replace")
4923
+ if ".forgexa-task.md" not in _excl:
4924
+ _git_exclude.write_text(
4925
+ _excl.rstrip("\n") + "\n.forgexa-task.md\n", encoding="utf-8",
4926
+ )
4927
+ _effective_prompt = (
4928
+ "Read the file .forgexa-task.md for your complete task "
4929
+ "specification. Follow all instructions in it exactly."
4930
+ )
4931
+ logger.debug(
4932
+ "Copilot task %s: wrote %d-char prompt to .forgexa-task.md",
4933
+ task_id, len(prompt),
4934
+ )
4935
+ except Exception as _tf_err:
4936
+ logger.warning(
4937
+ "Copilot task %s: could not write prompt file (%s); using -p directly",
4938
+ task_id, _tf_err,
4939
+ )
4940
+ _task_file = None
4941
+
4942
+ cmd += ["-C", str(cwd), "-p", _effective_prompt]
4943
+
4814
4944
  # Log the exact invocation (redact prompt body) so operators can
4815
4945
  # reproduce failures manually and diagnose CLI version incompatibilities.
4816
4946
  _cmd_display = " ".join(
@@ -4819,6 +4949,19 @@ class ProcessManager:
4819
4949
  ) + ' -p "<prompt>"'
4820
4950
  logger.debug("Copilot invocation for task %s: %s", task_id, _cmd_display)
4821
4951
 
4952
+ # On Windows, CREATE_NO_WINDOW ensures GetConsoleWindow() returns NULL for
4953
+ # the child process, even when the parent (daemon or Tauri app) has a console
4954
+ # window attached. Copilot CLI 1.0.68+ added a secondary interactive-mode
4955
+ # check via GetConsoleWindow(): if a console window is visible, it uses only
4956
+ # the first line of -p as a brief context note and reports "No task was
4957
+ # provided" for the rest of the prompt — silently discarding the full task.
4958
+ # stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
4959
+ # CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
4960
+ # headless environment and processes -p as the complete task specification.
4961
+ _win_flags: dict = {}
4962
+ if sys.platform == "win32":
4963
+ _win_flags["creationflags"] = subprocess.CREATE_NO_WINDOW
4964
+
4822
4965
  try:
4823
4966
  proc = await asyncio.create_subprocess_exec(
4824
4967
  *cmd,
@@ -4840,6 +4983,7 @@ class ProcessManager:
4840
4983
  env=env,
4841
4984
  limit=100 * 1024 * 1024,
4842
4985
  start_new_session=True,
4986
+ **_win_flags,
4843
4987
  )
4844
4988
  self.active_processes[task_id] = proc
4845
4989
  stdout, stderr, returncode = await self._stream_process(
@@ -4988,8 +5132,13 @@ class ProcessManager:
4988
5132
  )
4989
5133
  finally:
4990
5134
  self.active_processes.pop(task_id, None)
4991
- if _is_outer_call:
4992
- shutil.rmtree(_isolated_copilot_home, ignore_errors=True)
5135
+ if _is_outer_call and _isolated_copilot_home:
5136
+ WorkspaceManager._safe_rmtree(Path(_isolated_copilot_home))
5137
+ if _task_file is not None:
5138
+ try:
5139
+ _task_file.unlink(missing_ok=True)
5140
+ except Exception:
5141
+ pass
4993
5142
 
4994
5143
  async def _run_generic(
4995
5144
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
@@ -5011,7 +5160,7 @@ class ProcessManager:
5011
5160
  *cmd,
5012
5161
  stdout=asyncio.subprocess.PIPE,
5013
5162
  stderr=asyncio.subprocess.PIPE,
5014
- stdin=asyncio.subprocess.PIPE if stdin_input else None,
5163
+ stdin=asyncio.subprocess.PIPE if stdin_input else asyncio.subprocess.DEVNULL,
5015
5164
  cwd=str(cwd),
5016
5165
  env=env,
5017
5166
  limit=100 * 1024 * 1024, # 100MB line buffer for large agent output
@@ -6689,6 +6838,10 @@ class RuntimeDaemon:
6689
6838
  logger.info("Daemon ready. Connected to %d server(s). Polling for tasks...",
6690
6839
  len(self.connections))
6691
6840
 
6841
+ # Clean up stale Copilot/Claude temp directories left by prior OOM-killed
6842
+ # daemon processes. This runs once at startup so /tmp doesn't fill up.
6843
+ await self._cleanup_stale_tmp_dirs()
6844
+
6692
6845
  # 3. Main loop
6693
6846
  try:
6694
6847
  while not self._shutdown:
@@ -7033,7 +7186,6 @@ class RuntimeDaemon:
7033
7186
  Using 1-second ticks keeps the UI responsive without flooding
7034
7187
  the backend. Empty ticks are skipped to reduce HTTP traffic.
7035
7188
  """
7036
- import math as _math
7037
7189
  tick = 0
7038
7190
  while not progress_stop.is_set():
7039
7191
  await asyncio.sleep(1)
@@ -7467,9 +7619,22 @@ class RuntimeDaemon:
7467
7619
  if commit_result:
7468
7620
  # Propagate push/commit errors in metrics so they're visible
7469
7621
  result.metrics.update(commit_result)
7622
+ # Commit failure means changes were never committed — the task
7623
+ # produced no persisted output. Mark as failed so the
7624
+ # orchestrator does not treat this as a successful node and
7625
+ # propagate stale state to downstream nodes.
7626
+ if commit_result.get("commit_error"):
7627
+ commit_err = commit_result["commit_error"]
7628
+ result.status = "failed"
7629
+ result.failure_code = result.failure_code or "auto_commit_failed"
7630
+ result.error = f"Auto-commit failed: {commit_err}"
7631
+ logger.error(
7632
+ "Task %s: auto-commit failed — changes not persisted: %s",
7633
+ task.task_id, commit_err,
7634
+ )
7470
7635
  # Push failure is a real problem for downstream nodes — mark
7471
7636
  # as failed so the orchestrator can retry (transient network).
7472
- if commit_result.get("push_error"):
7637
+ elif commit_result.get("push_error"):
7473
7638
  push_err = commit_result["push_error"]
7474
7639
  result.status = "failed"
7475
7640
  if commit_result.get("push_error_code") == "forbidden_branch_merge":
@@ -9552,15 +9717,21 @@ class RuntimeDaemon:
9552
9717
  current_branch: str,
9553
9718
  default_branch: str,
9554
9719
  ) -> list[str]:
9555
- """Return merge commits introduced locally and not yet on the remote branch.
9720
+ """Return merge commits that pull default-branch changes into the requirement branch.
9556
9721
 
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.
9722
+ Requirement branches must stay linear and requirement-scoped. Only
9723
+ merges whose parent chain includes a commit from ``origin/{default_branch}``
9724
+ are rejected these indicate an explicit merge/rebase that imported
9725
+ default-branch changes (and potentially other requirements' changes).
9726
+
9727
+ Legitimate merges (e.g. AI-assisted conflict resolution that merges
9728
+ two sub-feature branches) are allowed through because neither parent
9729
+ is an ancestor of the default branch.
9561
9730
  """
9562
9731
  git = self.workspace_manager._git
9563
9732
 
9733
+ # Collect all unpushed merge commits first
9734
+ all_merge_shas: list[str] = []
9564
9735
  try:
9565
9736
  await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
9566
9737
  out = await git(
@@ -9575,8 +9746,42 @@ class RuntimeDaemon:
9575
9746
  )
9576
9747
  except RuntimeError:
9577
9748
  return []
9749
+ all_merge_shas = [line.strip() for line in out.splitlines() if line.strip()]
9750
+ if not all_merge_shas:
9751
+ return []
9752
+
9753
+ # Filter: only flag merges where at least one parent is an ancestor of
9754
+ # origin/{default_branch}. This distinguishes "merged default branch in"
9755
+ # (forbidden) from "merged two local sub-branches" (legitimate).
9756
+ forbidden: list[str] = []
9757
+ for sha in all_merge_shas:
9758
+ try:
9759
+ parents_out = await git(
9760
+ "show", "-s", "--format=%P", sha, cwd=workspace_path,
9761
+ )
9762
+ parents = parents_out.strip().split()
9763
+ except RuntimeError:
9764
+ # Cannot determine parents — treat as forbidden (fail-safe)
9765
+ forbidden.append(sha)
9766
+ continue
9767
+
9768
+ is_default_merge = False
9769
+ for parent in parents:
9770
+ try:
9771
+ await git(
9772
+ "merge-base", "--is-ancestor", parent,
9773
+ f"origin/{default_branch}", cwd=workspace_path,
9774
+ )
9775
+ # This parent is an ancestor of the default branch — the
9776
+ # merge imported default-branch changes → forbidden
9777
+ is_default_merge = True
9778
+ break
9779
+ except RuntimeError:
9780
+ continue # Parent is not on the default branch — OK
9781
+ if is_default_merge:
9782
+ forbidden.append(sha)
9578
9783
 
9579
- return [line.strip() for line in out.splitlines() if line.strip()]
9784
+ return forbidden
9580
9785
 
9581
9786
  async def _ai_resolve_conflicts(
9582
9787
  self, workspace_path: Path, default_branch: str, task: TaskInfo,
@@ -10051,6 +10256,37 @@ class RuntimeDaemon:
10051
10256
  for a in self.agents
10052
10257
  ]
10053
10258
 
10259
+ async def _cleanup_stale_tmp_dirs(self) -> None:
10260
+ """Remove stale Copilot/Claude temp dirs from prior OOM-killed daemon runs.
10261
+
10262
+ Both GitHub Copilot and Claude agents use mkdtemp(prefix="copilot-…") /
10263
+ mkdtemp(prefix="claude-…") directories. When the daemon is killed before
10264
+ cleanup these directories accumulate in /tmp. We clean anything older
10265
+ than 1 hour at startup.
10266
+ """
10267
+ import glob
10268
+ import tempfile
10269
+ import time as _time
10270
+
10271
+ tmp_dir = tempfile.gettempdir()
10272
+ patterns = [
10273
+ "copilot-*",
10274
+ "claude-*",
10275
+ ]
10276
+ cutoff = _time.time() - 3600 # 1 hour
10277
+
10278
+ for pattern in patterns:
10279
+ for d in glob.glob(os.path.join(tmp_dir, pattern)):
10280
+ try:
10281
+ if not os.path.isdir(d):
10282
+ continue
10283
+ mtime = os.path.getmtime(d)
10284
+ if mtime < cutoff:
10285
+ self.workspace_manager._safe_rmtree(Path(d))
10286
+ logger.info("Cleaned up stale agent temp dir: %s", d)
10287
+ except Exception as e:
10288
+ logger.debug("Could not clean temp dir %s: %s", d, e)
10289
+
10054
10290
  async def _shutdown_gracefully(self):
10055
10291
  """Graceful shutdown."""
10056
10292
  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.1
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.1"
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