forgexa-cli 1.14.10__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.10
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.10"
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.10"
526
+ DAEMON_VERSION = "1.15.0"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -939,6 +939,27 @@ def _daemon_extract_claude_output(raw: str) -> str:
939
939
  return raw
940
940
 
941
941
 
942
+ # OpenCode requires v1.4.0+ for --dangerously-skip-permissions (added 2026-04-08).
943
+ # Without it, headless opencode run blocks on permission prompts and exits with code 1.
944
+ _OPENCODE_MIN_VERSION = (1, 4, 0)
945
+
946
+
947
+ def _parse_semver(v: str) -> "tuple[int, int, int] | None":
948
+ """Parse a semver string into a comparable (major, minor, patch) tuple.
949
+
950
+ Strips a leading 'v' and ignores pre-release/build suffixes so that
951
+ '1.4.0-beta.1' and 'v1.4.0' both return (1, 4, 0).
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.
955
+ """
956
+ import re as _re
957
+ m = _re.match(r"^v?(\d+)\.(\d+)\.(\d+)", v.strip())
958
+ if m:
959
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
960
+ return None
961
+
962
+
942
963
  def _daemon_extract_opencode_output(raw: str) -> str:
943
964
  """Extract text content from OpenCode JSONL output."""
944
965
  parts: list[str] = []
@@ -1239,18 +1260,18 @@ def _get_design_output_for_type(req_type: str) -> str:
1239
1260
  Used by _validate_outputs and _required_deliverable_paths to avoid
1240
1261
  hardcoding 'design.md' for all node_type='design' nodes — spike produces
1241
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.
1242
1267
  """
1243
1268
  try:
1244
1269
  from app.services.type_workflow_profiles import get_profile
1245
1270
  profile = get_profile(req_type)
1246
1271
  for phase in profile.execution_phases:
1247
1272
  if phase.node_type == "design":
1248
- from app.services.type_prompts import TYPE_PROMPT_TEMPLATES
1249
- tmpl = TYPE_PROMPT_TEMPLATES.get(phase.prompt_key, "")
1250
- import re as _re
1251
- m = _re.search(r"{doc_dir}/([\w.-]+)", tmpl)
1252
- if m:
1253
- return m.group(1)
1273
+ if phase.output_filename:
1274
+ return phase.output_filename
1254
1275
  break
1255
1276
  except Exception:
1256
1277
  pass
@@ -1735,18 +1756,30 @@ class WorkspaceManager:
1735
1756
  """
1736
1757
  import shutil
1737
1758
  import stat as _stat
1759
+ import sys as _sys
1738
1760
 
1739
1761
  if not path.exists():
1740
1762
  return
1741
1763
 
1742
1764
  def _handle_readonly(func, entry_path, excinfo):
1743
1765
  try:
1744
- 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)
1745
1767
  func(entry_path)
1746
- except Exception:
1747
- 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
+ )
1748
1773
 
1749
- 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)
1750
1783
 
1751
1784
  # Windows fallback: reserved device names survive shutil.rmtree.
1752
1785
  # cmd /c rmdir /s /q with the \\?\ extended-length prefix bypasses
@@ -1755,16 +1788,19 @@ class WorkspaceManager:
1755
1788
  try:
1756
1789
  import subprocess
1757
1790
  resolved = str(path.resolve())
1758
- # Prepend \\?\ extended-length prefix if not already present.
1759
- # In Python source "\\\\?\\" represents the 4-char string \\?\
1760
- # which is the Win32 flag to bypass reserved-name interception.
1761
- 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("\\\\?\\"):
1762
1797
  unc = "\\\\?\\" + resolved
1763
1798
  else:
1764
1799
  unc = resolved
1765
1800
  result = subprocess.run(
1766
1801
  ["cmd", "/c", "rmdir", "/s", "/q", unc],
1767
1802
  capture_output=True,
1803
+ stdin=subprocess.DEVNULL,
1768
1804
  timeout=60,
1769
1805
  )
1770
1806
  if result.returncode != 0 and path.exists():
@@ -2279,6 +2315,14 @@ class WorkspaceManager:
2279
2315
  "revert(analysis",
2280
2316
  "initial commit",
2281
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
+ )
2282
2326
  try:
2283
2327
  raw = await self._git(
2284
2328
  "log", "--format=%s",
@@ -2295,6 +2339,11 @@ class WorkspaceManager:
2295
2339
 
2296
2340
  for subject in subjects:
2297
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
2298
2347
  if not any(lower.startswith(p) for p in _ANALYSIS_ONLY_PREFIXES):
2299
2348
  return True # Found at least one implementation commit
2300
2349
 
@@ -2543,6 +2592,22 @@ class WorkspaceManager:
2543
2592
 
2544
2593
  if fresh_start:
2545
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
+ )
2546
2611
  # Use checkout -B to create or reset the branch to origin/default_branch.
2547
2612
  # Plain `checkout branch_name` fails when the branch doesn't exist locally.
2548
2613
  try:
@@ -3109,7 +3174,17 @@ class WorkspaceManager:
3109
3174
  except Exception:
3110
3175
  pass
3111
3176
  else:
3112
- _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")
3113
3188
  env = {
3114
3189
  **os.environ,
3115
3190
  "GIT_SSH_COMMAND": (
@@ -3169,6 +3244,7 @@ class WorkspaceManager:
3169
3244
  # we use taskkill /T there instead of killpg.
3170
3245
  proc = await asyncio.create_subprocess_exec(
3171
3246
  "git", *longpath_args, *git_prefix_args, *args,
3247
+ stdin=asyncio.subprocess.DEVNULL,
3172
3248
  stdout=asyncio.subprocess.PIPE,
3173
3249
  stderr=asyncio.subprocess.PIPE,
3174
3250
  cwd=str(cwd) if cwd else None,
@@ -4155,7 +4231,12 @@ class ProcessManager:
4155
4231
  model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
4156
4232
  if model_override:
4157
4233
  env["ANTHROPIC_MODEL"] = model_override
4158
- 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)))
4159
4240
 
4160
4241
  try:
4161
4242
  proc = await asyncio.create_subprocess_exec(
@@ -4243,6 +4324,8 @@ class ProcessManager:
4243
4324
  )
4244
4325
  finally:
4245
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))
4246
4329
 
4247
4330
  async def _run_codex(
4248
4331
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
@@ -4318,6 +4401,35 @@ class ProcessManager:
4318
4401
  'Failed to run the query PRAGMA wal_checkpoint(PASSIVE)' errors and
4319
4402
  exit code 1 when multiple tasks run simultaneously.
4320
4403
  """
4404
+ # --dangerously-skip-permissions was introduced in opencode v1.4.0 (2026-04-08).
4405
+ # Older versions silently ignore the flag but then block on permission prompts
4406
+ # in headless mode (no TTY), eventually exiting with code 1 after a long wait.
4407
+ # Fail fast here so the error is immediately actionable.
4408
+ _oc_ver = _parse_semver(agent.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:
4417
+ _min_str = ".".join(str(x) for x in _OPENCODE_MIN_VERSION)
4418
+ return TaskResult(
4419
+ status="failed",
4420
+ exit_code=-1,
4421
+ stdout="",
4422
+ stderr="",
4423
+ error=(
4424
+ f"OpenCode {agent.version} is too old for headless execution. "
4425
+ f"Version {_min_str}+ is required: --dangerously-skip-permissions "
4426
+ f"(which auto-approves permission prompts in non-interactive mode) "
4427
+ f"was added in v{_min_str} on 2026-04-08. "
4428
+ f"Upgrade with: opencode upgrade"
4429
+ ),
4430
+ failure_code="agent_version_incompatible",
4431
+ )
4432
+
4321
4433
  cmd = [
4322
4434
  agent.command, "run",
4323
4435
  "--format", "json",
@@ -4519,8 +4631,13 @@ class ProcessManager:
4519
4631
  return result
4520
4632
 
4521
4633
  @staticmethod
4522
- def _prepare_claude_environment() -> dict[str, str]:
4523
- """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.
4524
4641
 
4525
4642
  The daemon service runs with ProtectSystem=strict and only allows
4526
4643
  writes under ~/.forgexa plus the workspace root. Claude Code writes
@@ -4532,7 +4649,8 @@ class ProcessManager:
4532
4649
  the same writable sandbox, and mirror only the small set of user
4533
4650
  config files it needs for headless execution.
4534
4651
  """
4535
- sandbox_root = Path.home() / ".forgexa" / "claude-home"
4652
+ if sandbox_root is None:
4653
+ sandbox_root = Path.home() / ".forgexa" / "claude-home"
4536
4654
  config_root = sandbox_root / "config"
4537
4655
  data_root = sandbox_root / "data"
4538
4656
  cache_root = sandbox_root / "cache"
@@ -4614,6 +4732,22 @@ class ProcessManager:
4614
4732
  if not source_root.exists():
4615
4733
  return str(target_root)
4616
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
+
4617
4751
  # Collect names present in source for stale-file detection.
4618
4752
  source_names: set[str] = {child.name for child in source_root.iterdir()}
4619
4753
 
@@ -4778,6 +4912,19 @@ class ProcessManager:
4778
4912
  ) + ' -p "<prompt>"'
4779
4913
  logger.debug("Copilot invocation for task %s: %s", task_id, _cmd_display)
4780
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
+
4781
4928
  try:
4782
4929
  proc = await asyncio.create_subprocess_exec(
4783
4930
  *cmd,
@@ -4799,6 +4946,7 @@ class ProcessManager:
4799
4946
  env=env,
4800
4947
  limit=100 * 1024 * 1024,
4801
4948
  start_new_session=True,
4949
+ **_win_flags,
4802
4950
  )
4803
4951
  self.active_processes[task_id] = proc
4804
4952
  stdout, stderr, returncode = await self._stream_process(
@@ -4947,8 +5095,8 @@ class ProcessManager:
4947
5095
  )
4948
5096
  finally:
4949
5097
  self.active_processes.pop(task_id, None)
4950
- if _is_outer_call:
4951
- 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))
4952
5100
 
4953
5101
  async def _run_generic(
4954
5102
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
@@ -4970,7 +5118,7 @@ class ProcessManager:
4970
5118
  *cmd,
4971
5119
  stdout=asyncio.subprocess.PIPE,
4972
5120
  stderr=asyncio.subprocess.PIPE,
4973
- stdin=asyncio.subprocess.PIPE if stdin_input else None,
5121
+ stdin=asyncio.subprocess.PIPE if stdin_input else asyncio.subprocess.DEVNULL,
4974
5122
  cwd=str(cwd),
4975
5123
  env=env,
4976
5124
  limit=100 * 1024 * 1024, # 100MB line buffer for large agent output
@@ -6648,6 +6796,10 @@ class RuntimeDaemon:
6648
6796
  logger.info("Daemon ready. Connected to %d server(s). Polling for tasks...",
6649
6797
  len(self.connections))
6650
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
+
6651
6803
  # 3. Main loop
6652
6804
  try:
6653
6805
  while not self._shutdown:
@@ -6992,7 +7144,6 @@ class RuntimeDaemon:
6992
7144
  Using 1-second ticks keeps the UI responsive without flooding
6993
7145
  the backend. Empty ticks are skipped to reduce HTTP traffic.
6994
7146
  """
6995
- import math as _math
6996
7147
  tick = 0
6997
7148
  while not progress_stop.is_set():
6998
7149
  await asyncio.sleep(1)
@@ -7426,9 +7577,22 @@ class RuntimeDaemon:
7426
7577
  if commit_result:
7427
7578
  # Propagate push/commit errors in metrics so they're visible
7428
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
+ )
7429
7593
  # Push failure is a real problem for downstream nodes — mark
7430
7594
  # as failed so the orchestrator can retry (transient network).
7431
- if commit_result.get("push_error"):
7595
+ elif commit_result.get("push_error"):
7432
7596
  push_err = commit_result["push_error"]
7433
7597
  result.status = "failed"
7434
7598
  if commit_result.get("push_error_code") == "forbidden_branch_merge":
@@ -9511,15 +9675,21 @@ class RuntimeDaemon:
9511
9675
  current_branch: str,
9512
9676
  default_branch: str,
9513
9677
  ) -> list[str]:
9514
- """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).
9515
9684
 
9516
- Requirement branches must stay linear and requirement-scoped. Any new
9517
- merge commit pending push indicates an explicit merge/rebase workflow that
9518
- can import unrelated changes (for example, merging origin/develop into a
9519
- 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.
9520
9688
  """
9521
9689
  git = self.workspace_manager._git
9522
9690
 
9691
+ # Collect all unpushed merge commits first
9692
+ all_merge_shas: list[str] = []
9523
9693
  try:
9524
9694
  await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
9525
9695
  out = await git(
@@ -9534,8 +9704,42 @@ class RuntimeDaemon:
9534
9704
  )
9535
9705
  except RuntimeError:
9536
9706
  return []
9707
+ all_merge_shas = [line.strip() for line in out.splitlines() if line.strip()]
9708
+ if not all_merge_shas:
9709
+ return []
9537
9710
 
9538
- return [line.strip() for line in out.splitlines() if line.strip()]
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)
9741
+
9742
+ return forbidden
9539
9743
 
9540
9744
  async def _ai_resolve_conflicts(
9541
9745
  self, workspace_path: Path, default_branch: str, task: TaskInfo,
@@ -10010,6 +10214,37 @@ class RuntimeDaemon:
10010
10214
  for a in self.agents
10011
10215
  ]
10012
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
+
10013
10248
  async def _shutdown_gracefully(self):
10014
10249
  """Graceful shutdown."""
10015
10250
  logger.info("Shutting down daemon...")
@@ -73,6 +73,75 @@ except ImportError:
73
73
  _SERVER_URL_OVERRIDE: str | None = None
74
74
 
75
75
 
76
+ # ── Banner ────────────────────────────────────────────────────────────────────
77
+
78
+ _LOGO_ART = """\
79
+ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗ █████╗
80
+ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝╚██╗██╔╝██╔══██╗
81
+ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ╚███╔╝ ███████║
82
+ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██╔██╗ ██╔══██║
83
+ ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗██╔╝ ██╗██║ ██║
84
+ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝"""
85
+
86
+
87
+ def _supports_color() -> bool:
88
+ """Return True when stdout can render ANSI colors."""
89
+ if not sys.stdout.isatty():
90
+ return False
91
+ if os.environ.get("NO_COLOR"):
92
+ return False
93
+ if os.environ.get("TERM") in ("dumb", ""):
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
107
+ return True
108
+
109
+
110
+ def _brand_color() -> str:
111
+ """Return the best available ANSI escape for Forgexa brand blue (#096dd9).
112
+
113
+ Priority:
114
+ 1. 24-bit truecolor — exact match (COLORTERM=truecolor|24bit)
115
+ 2. Bold blue — 16-color fallback
116
+ """
117
+ colorterm = os.environ.get("COLORTERM", "").lower()
118
+ if colorterm in ("truecolor", "24bit"):
119
+ return "\033[38;2;9;109;217m" # #096dd9 exact
120
+ return "\033[1;34m" # bold blue — 16-color fallback
121
+
122
+
123
+ def _print_banner() -> None:
124
+ """Print the Forgexa logo + tagline.
125
+
126
+ Shown only on bare invocation and top-level --help/-h.
127
+ Respects NO_COLOR, TERM=dumb, and non-TTY environments.
128
+ """
129
+ from forgexa_cli import __version__
130
+
131
+ if _supports_color():
132
+ blue = _brand_color()
133
+ reset = "\033[0m"
134
+ logo = f"{blue}{_LOGO_ART}{reset}"
135
+ tagline = f" {blue}AI-Driven Software Factory · v{__version__}{reset}"
136
+ else:
137
+ logo = _LOGO_ART
138
+ tagline = f" AI-Driven Software Factory · v{__version__}"
139
+
140
+ print(logo, file=sys.stderr)
141
+ print(tagline, file=sys.stderr)
142
+ print(file=sys.stderr)
143
+
144
+
76
145
  # ── Config file helpers (~/.forgexa/config) ──
77
146
 
78
147
  def _config_path() -> Path:
@@ -168,6 +237,7 @@ def _refresh_access_token() -> bool:
168
237
  try:
169
238
  with httpx.Client(timeout=15) as client:
170
239
  resp = client.post(url, json={"refresh_token": refresh_token})
240
+ resp.raise_for_status()
171
241
  payload = resp.json()
172
242
  except Exception:
173
243
  return False
@@ -445,9 +515,19 @@ def _write_update_cache(latest_version: str) -> None:
445
515
 
446
516
 
447
517
  def _version_is_newer(candidate: str, reference: str) -> bool:
448
- """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
+
449
526
  def _parts(v: str) -> tuple[int, ...]:
450
- 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
+
451
531
  try:
452
532
  return _parts(candidate) > _parts(reference)
453
533
  except Exception:
@@ -626,12 +706,24 @@ def _request_json(
626
706
  timeout=timeout,
627
707
  allow_refresh=False,
628
708
  )
629
- 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
+ )
630
718
  print(f"Error {e.response.status_code}: {body_text}", file=sys.stderr)
631
- 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)
632
724
  except httpx.RequestError as e:
633
725
  print(f"Connection error: {e}\nServer: {_api_url()}", file=sys.stderr)
634
- sys.exit(1)
726
+ sys.exit(3) # 3 → network / connection error
635
727
 
636
728
 
637
729
  def _get(path: str) -> dict | list:
@@ -765,7 +857,12 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
765
857
 
766
858
 
767
859
  def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
768
- """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
+ """
769
866
  import httpx
770
867
 
771
868
  path = "/runtimes" if include_all else "/runtimes/me"
@@ -773,6 +870,11 @@ def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
773
870
  try:
774
871
  with httpx.Client(headers=_headers(), timeout=10) as client:
775
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()
776
878
  result = resp.json() if resp.content else []
777
879
  return result if isinstance(result, list) else []
778
880
  except Exception:
@@ -1396,6 +1498,10 @@ def main() -> None:
1396
1498
 
1397
1499
  active_url = _api_url() # resolved before parsing (for help text)
1398
1500
 
1501
+ # Show logo on bare invocation or top-level --help / -h
1502
+ if len(sys.argv) == 1 or (len(sys.argv) >= 2 and sys.argv[1] in ("-h", "--help")):
1503
+ _print_banner()
1504
+
1399
1505
  parser = argparse.ArgumentParser(
1400
1506
  prog="forgexa",
1401
1507
  description="Forgexa CLI — communicates with the Forgexa server via REST API.",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.10
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.10"
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