forgexa-cli 1.7.8__tar.gz → 1.8.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.7.8
3
+ Version: 1.8.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.7.8"
2
+ __version__ = "1.8.4"
@@ -332,7 +332,7 @@ except (ImportError, ModuleNotFoundError):
332
332
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
333
333
  # Kept in sync with pyproject.toml version via bump-version.sh.
334
334
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
335
- DAEMON_VERSION = "1.7.8"
335
+ DAEMON_VERSION = "1.8.4"
336
336
 
337
337
 
338
338
  def _detect_client_type() -> str:
@@ -611,7 +611,7 @@ class AgentDiscovery:
611
611
  """Scans for locally installed Agent CLI tools."""
612
612
 
613
613
  AGENT_REGISTRY = {
614
- "claude-code": {
614
+ "claude": {
615
615
  "commands": ["claude"],
616
616
  "detect": "claude --version",
617
617
  "invoke_modes": ["print", "app-server"],
@@ -642,19 +642,19 @@ class AgentDiscovery:
642
642
  "env_path_override": "FACTORY_GEMINI_PATH",
643
643
  "compatibility_level": "L1",
644
644
  },
645
- "kimi-code": {
645
+ "kimi": {
646
646
  "commands": ["kimi"],
647
647
  "detect": "kimi --version",
648
648
  "invoke_modes": ["cli"],
649
649
  "env_path_override": "FACTORY_KIMI_PATH",
650
650
  "compatibility_level": "L2",
651
651
  },
652
- "hermes": {
653
- "commands": ["hermes"],
654
- "detect": "hermes --version",
652
+ "copilot": {
653
+ "commands": ["copilot"],
654
+ "detect": "copilot --version",
655
655
  "invoke_modes": ["cli"],
656
- "env_path_override": "FACTORY_HERMES_PATH",
657
- "compatibility_level": "L1",
656
+ "env_path_override": "FACTORY_COPILOT_PATH",
657
+ "compatibility_level": "L3",
658
658
  },
659
659
  }
660
660
 
@@ -711,6 +711,24 @@ class AgentDiscovery:
711
711
  Path("/usr/local/bin"),
712
712
  Path("/opt/homebrew/bin"),
713
713
  ]
714
+ # GitHub Copilot CLI — installed via VS Code extension into globalStorage
715
+ vscode_copilot = (
716
+ home / "Library" / "Application Support" / "Code" / "User"
717
+ / "globalStorage" / "github.copilot-chat" / "copilotCli"
718
+ )
719
+ extra_dirs.append(vscode_copilot)
720
+ for vs_variant in ("Code - Insiders", "VSCodium"):
721
+ extra_dirs.append(
722
+ home / "Library" / "Application Support" / vs_variant / "User"
723
+ / "globalStorage" / "github.copilot-chat" / "copilotCli"
724
+ )
725
+ if sys.platform == "linux":
726
+ # GitHub Copilot CLI on Linux VSCode
727
+ for config_dir in (
728
+ home / ".config" / "Code" / "User" / "globalStorage" / "github.copilot-chat" / "copilotCli",
729
+ home / ".config" / "Code - Insiders" / "User" / "globalStorage" / "github.copilot-chat" / "copilotCli",
730
+ ):
731
+ extra_dirs.append(config_dir)
714
732
  # nvm (macOS + Linux)
715
733
  nvm_dir = os.environ.get("NVM_DIR", str(home / ".nvm"))
716
734
  nvm_path = Path(nvm_dir)
@@ -738,6 +756,14 @@ class AgentDiscovery:
738
756
  resolved = shutil.which(cmd)
739
757
  if resolved:
740
758
  version = await self._get_version(spec["detect"])
759
+ if version is None:
760
+ # Binary found but version check failed — it is a stub or
761
+ # not properly installed (e.g. copilot prompts to install).
762
+ logger.warning(
763
+ "Agent %s found at %s but version check failed — skipping",
764
+ agent_id, resolved,
765
+ )
766
+ continue
741
767
  available.append(DiscoveredAgent(
742
768
  agent_id=agent_id,
743
769
  command=resolved,
@@ -748,18 +774,43 @@ class AgentDiscovery:
748
774
  logger.info("Discovered agent: %s v%s (%s)", agent_id, version, resolved)
749
775
  return available
750
776
 
751
- async def _get_version(self, detect_cmd: str) -> str:
777
+ async def _get_version(self, detect_cmd: str) -> str | None:
778
+ """Run <detect_cmd> and return the first line of output as a version string.
779
+
780
+ Returns ``None`` if the command exits with a non-zero code, times out,
781
+ or produces output that doesn't look like a version number (e.g. an
782
+ interactive install prompt). Callers should treat ``None`` as
783
+ "binary found but not functional".
784
+ """
785
+ import re
752
786
  try:
753
787
  parts = detect_cmd.split()
754
788
  proc = await asyncio.create_subprocess_exec(
755
789
  *parts,
790
+ stdin=asyncio.subprocess.DEVNULL,
756
791
  stdout=asyncio.subprocess.PIPE,
757
792
  stderr=asyncio.subprocess.PIPE,
758
793
  )
759
- stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
760
- return stdout.decode().strip().split("\n")[0][:100]
794
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
795
+ if proc.returncode != 0:
796
+ return None
797
+ output = stdout.decode().strip().split("\n")[0][:100]
798
+ if not output:
799
+ # Some tools write their version to stderr (e.g. some Node CLIs)
800
+ output = stderr.decode().strip().split("\n")[0][:100]
801
+ # Reject non-version output such as interactive install prompts.
802
+ # A valid version string contains a digit sequence like 1.2.3 or v1.2.
803
+ # Use re.search so we match versions embedded in text like:
804
+ # 'Kimi Code 1.0.0', '@openai/codex 0.1.x', 'GitHub Copilot 1.2.3'
805
+ if not re.search(r'v?\d+[.\d]', output):
806
+ logger.warning(
807
+ "Version check for %r returned unexpected output: %r — treating as not available",
808
+ detect_cmd, output,
809
+ )
810
+ return None
811
+ return output
761
812
  except Exception:
762
- return "unknown"
813
+ return None
763
814
 
764
815
  @staticmethod
765
816
  async def _probe_bwrap_support() -> None:
@@ -1732,24 +1783,49 @@ class ProcessManager:
1732
1783
  error_messages.append(err.get("message", "turn failed"))
1733
1784
  elif isinstance(err, str):
1734
1785
  error_messages.append(err)
1786
+ # ── GitHub Copilot CLI event types ──────────────────────────────
1787
+ elif ev_type == "assistant.turn_end":
1788
+ has_turn_completed = True
1789
+ elif ev_type == "assistant.turn_start":
1790
+ has_assistant_events = True
1791
+ elif ev_type == "assistant.message":
1792
+ has_meaningful_content = True
1793
+ has_assistant_events = True
1794
+ msg_data = data.get("data") or {}
1795
+ if isinstance(msg_data.get("content"), str) and msg_data["content"].strip():
1796
+ has_result = True
1797
+ elif ev_type == "assistant.message_delta":
1798
+ has_meaningful_content = True
1799
+ # ── Generic / Claude "result" event ────────────────────────────
1735
1800
  elif ev_type == "result":
1736
- result_text = str(data.get("result", "") or "")
1737
- if data.get("is_error"):
1738
- err_text = result_text or str(data.get("error", "") or "result marked as error")
1739
- error_messages.append(err_text)
1740
- else:
1741
- # Structural check: if no tokens were consumed AND no assistant
1742
- # events appeared, the CLI never made an API call. The result
1743
- # text is a CLI-level error (e.g. "API Error: Connection error.")
1744
- # rather than the agent's actual work output.
1745
- tok_in = int(data.get("total_input_tokens", 0) or 0)
1746
- tok_out = int(data.get("total_output_tokens", 0) or 0)
1747
- no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
1748
- if no_api_call and result_text:
1749
- error_messages.append(result_text)
1750
- else:
1801
+ # Copilot result format: {"type":"result","exitCode":0,"usage":{...}}
1802
+ # Claude result format: {"type":"result","result":"...","is_error":false,...}
1803
+ if "exitCode" in data:
1804
+ # Copilot JSONL result
1805
+ exit_code = int(data.get("exitCode") or 0)
1806
+ if exit_code == 0:
1751
1807
  has_result = True
1752
1808
  has_meaningful_content = True
1809
+ else:
1810
+ error_messages.append(f"Copilot exited with code {exit_code}")
1811
+ else:
1812
+ result_text = str(data.get("result", "") or "")
1813
+ if data.get("is_error"):
1814
+ err_text = result_text or str(data.get("error", "") or "result marked as error")
1815
+ error_messages.append(err_text)
1816
+ else:
1817
+ # Structural check: if no tokens were consumed AND no assistant
1818
+ # events appeared, the CLI never made an API call. The result
1819
+ # text is a CLI-level error (e.g. "API Error: Connection error.")
1820
+ # rather than the agent's actual work output.
1821
+ tok_in = int(data.get("total_input_tokens", 0) or 0)
1822
+ tok_out = int(data.get("total_output_tokens", 0) or 0)
1823
+ no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
1824
+ if no_api_call and result_text:
1825
+ error_messages.append(result_text)
1826
+ else:
1827
+ has_result = True
1828
+ has_meaningful_content = True
1753
1829
  elif ev_type == "error":
1754
1830
  msg = data.get("message", "")
1755
1831
  if msg:
@@ -1895,10 +1971,15 @@ class ProcessManager:
1895
1971
  return f"Agent encountered errors without producing output: {error_messages[0]}"
1896
1972
 
1897
1973
  # ── Claude: JSON output mode but no result object and no content ──
1898
- if agent_id == "claude-code" and json_line_count > 0:
1974
+ if agent_id == "claude" and json_line_count > 0:
1899
1975
  if not has_result and not has_meaningful_content:
1900
1976
  return "Claude produced no result output"
1901
1977
 
1978
+ # ── Copilot: JSONL mode but no turn completion and no content ──
1979
+ if agent_id == "copilot" and json_line_count > 0:
1980
+ if not has_result and not has_meaningful_content:
1981
+ return "Copilot produced no result output (check GitHub authentication: run 'gh auth login')"
1982
+
1902
1983
  return None
1903
1984
 
1904
1985
  async def run_agent(
@@ -1915,7 +1996,7 @@ class ProcessManager:
1915
1996
 
1916
1997
  start_time = time.monotonic()
1917
1998
 
1918
- if agent.agent_id == "claude-code":
1999
+ if agent.agent_id == "claude":
1919
2000
  result = await self._run_claude(
1920
2001
  agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
1921
2002
  node_type=task.node_type,
@@ -1926,8 +2007,10 @@ class ProcessManager:
1926
2007
  result = await self._run_opencode(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1927
2008
  elif agent.agent_id == "gemini":
1928
2009
  result = await self._run_gemini(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1929
- elif agent.agent_id == "kimi-code":
2010
+ elif agent.agent_id == "kimi":
1930
2011
  result = await self._run_kimi_code(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
2012
+ elif agent.agent_id == "copilot":
2013
+ result = await self._run_copilot(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1931
2014
  else:
1932
2015
  result = await self._run_generic(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1933
2016
 
@@ -2386,6 +2469,101 @@ class ProcessManager:
2386
2469
  result.metrics.update(parsed_metrics)
2387
2470
  return result
2388
2471
 
2472
+ async def _run_copilot(
2473
+ self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
2474
+ on_chunk: Any = None,
2475
+ ) -> TaskResult:
2476
+ """Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
2477
+
2478
+ Uses TERM=dumb to suppress TTY-detection (copilot suspends when it
2479
+ can't acquire a pseudo-terminal). Requires GitHub login:
2480
+ ``gh auth login`` or GITHUB_TOKEN env var must be set.
2481
+
2482
+ Flags:
2483
+ -p / --prompt Non-interactive prompt (exits after completion).
2484
+ --output-format json JSONL stream of session events.
2485
+ --allow-all Grant all tool + path + URL permissions required
2486
+ for autonomous file-editing tasks.
2487
+ -C <dir> Change working directory before execution.
2488
+ """
2489
+ env = os.environ.copy()
2490
+ env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
2491
+
2492
+ model_override = os.environ.get("FACTORY_COPILOT_MODEL")
2493
+ reasoning = os.environ.get("FACTORY_COPILOT_REASONING", "medium")
2494
+
2495
+ cmd = [
2496
+ agent.command,
2497
+ "--output-format", "json",
2498
+ "--allow-all",
2499
+ "--effort", reasoning,
2500
+ "-C", str(cwd),
2501
+ "-p", prompt,
2502
+ ]
2503
+ if model_override:
2504
+ cmd = [agent.command, "--model", model_override] + cmd[1:]
2505
+
2506
+ try:
2507
+ proc = await asyncio.create_subprocess_exec(
2508
+ *cmd,
2509
+ stdout=asyncio.subprocess.PIPE,
2510
+ stderr=asyncio.subprocess.PIPE,
2511
+ cwd=str(cwd),
2512
+ env=env,
2513
+ limit=100 * 1024 * 1024,
2514
+ start_new_session=True,
2515
+ )
2516
+ self.active_processes[task_id] = proc
2517
+ stdout, stderr, returncode = await self._stream_process(
2518
+ proc, None, timeout, task_id, on_chunk
2519
+ )
2520
+
2521
+ # Parse copilot JSONL output for metrics
2522
+ metrics = self._parse_copilot_output(stdout)
2523
+
2524
+ # Copilot always exits 0 on normal completion; check result.exitCode
2525
+ # from the JSONL "result" event for a true success signal.
2526
+ copilot_exit = self._extract_copilot_exit_code(stdout)
2527
+ effective_rc = copilot_exit if copilot_exit is not None else returncode
2528
+
2529
+ if effective_rc == 0 and returncode == 0:
2530
+ return TaskResult(
2531
+ status="success",
2532
+ exit_code=0,
2533
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
2534
+ stderr=stderr[-10000:],
2535
+ metrics=metrics,
2536
+ )
2537
+ else:
2538
+ return TaskResult(
2539
+ status="failed",
2540
+ exit_code=effective_rc,
2541
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
2542
+ stderr=stderr[-10000:],
2543
+ error=f"Copilot exited with code {effective_rc}: {stderr[-500:]}",
2544
+ metrics=metrics,
2545
+ )
2546
+ except asyncio.TimeoutError:
2547
+ if task_id in self.active_processes:
2548
+ self.active_processes[task_id].kill()
2549
+ return TaskResult(
2550
+ status="failed", exit_code=-1, stdout="", stderr="",
2551
+ error=f"Timed out after {timeout}s",
2552
+ )
2553
+ except Exception as exc:
2554
+ logger.exception("Copilot stream error for task %s", task_id)
2555
+ if task_id in self.active_processes:
2556
+ try:
2557
+ self.active_processes[task_id].kill()
2558
+ except Exception:
2559
+ pass
2560
+ return TaskResult(
2561
+ status="failed", exit_code=-1, stdout="", stderr="",
2562
+ error=f"Stream processing error: {exc}",
2563
+ )
2564
+ finally:
2565
+ self.active_processes.pop(task_id, None)
2566
+
2389
2567
  async def _run_generic(
2390
2568
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
2391
2569
  on_chunk: Any = None,
@@ -2702,6 +2880,79 @@ class ProcessManager:
2702
2880
  metrics["token_output"] = output
2703
2881
  return metrics
2704
2882
 
2883
+ def _parse_copilot_output(self, stdout: str) -> dict:
2884
+ """Extract metrics from GitHub Copilot CLI JSONL output.
2885
+
2886
+ Copilot CLI (--output-format json) emits one JSON object per line.
2887
+ The key event types:
2888
+ - ``assistant.message`` -> content + model + outputTokens per turn
2889
+ - ``result`` -> exitCode + usage (premiumRequests,
2890
+ totalApiDurationMs, codeChanges)
2891
+
2892
+ Copilot is subscription-based and does NOT report USD cost or input
2893
+ tokens, so those fields are intentionally omitted.
2894
+ """
2895
+ metrics: dict[str, Any] = {}
2896
+ total_output_tokens = 0
2897
+ model_seen: str | None = None
2898
+
2899
+ for raw in stdout.strip().split("\n"):
2900
+ raw = raw.strip()
2901
+ if not raw:
2902
+ continue
2903
+ try:
2904
+ data = json.loads(raw)
2905
+ except json.JSONDecodeError:
2906
+ continue
2907
+ if not isinstance(data, dict):
2908
+ continue
2909
+
2910
+ ev_type = str(data.get("type", ""))
2911
+
2912
+ if ev_type == "assistant.message":
2913
+ msg_data = data.get("data") or {}
2914
+ out_tokens = msg_data.get("outputTokens")
2915
+ if out_tokens:
2916
+ total_output_tokens += int(out_tokens)
2917
+ if not model_seen and isinstance(msg_data.get("model"), str):
2918
+ model_seen = msg_data["model"]
2919
+
2920
+ elif ev_type == "result":
2921
+ usage = data.get("usage") or {}
2922
+ premium_reqs = usage.get("premiumRequests")
2923
+ if premium_reqs is not None:
2924
+ metrics["premium_requests"] = int(premium_reqs)
2925
+ api_ms = usage.get("totalApiDurationMs")
2926
+ if api_ms:
2927
+ metrics["api_duration_ms"] = int(api_ms)
2928
+ changes = usage.get("codeChanges") or {}
2929
+ if changes.get("linesAdded") or changes.get("linesRemoved"):
2930
+ metrics["lines_added"] = int(changes.get("linesAdded") or 0)
2931
+ metrics["lines_removed"] = int(changes.get("linesRemoved") or 0)
2932
+
2933
+ if total_output_tokens:
2934
+ metrics["token_output"] = total_output_tokens
2935
+ if model_seen:
2936
+ metrics["model"] = model_seen
2937
+ return metrics
2938
+
2939
+ @staticmethod
2940
+ def _extract_copilot_exit_code(stdout: str) -> int | None:
2941
+ """Extract the exitCode from Copilot JSONL ``result`` event."""
2942
+ for raw in reversed(stdout.strip().split("\n")):
2943
+ raw = raw.strip()
2944
+ if not raw:
2945
+ continue
2946
+ try:
2947
+ data = json.loads(raw)
2948
+ except json.JSONDecodeError:
2949
+ continue
2950
+ if isinstance(data, dict) and data.get("type") == "result":
2951
+ ec = data.get("exitCode")
2952
+ if ec is not None:
2953
+ return int(ec)
2954
+ return None
2955
+
2705
2956
  async def _collect_git_info(self, cwd: Path) -> dict:
2706
2957
  """Collect git diff stats from workspace."""
2707
2958
  info: dict[str, Any] = {}
@@ -2779,7 +3030,7 @@ class ProcessManager:
2779
3030
  """Collect git diff stats comparing HEAD vs merge-base with default branch.
2780
3031
 
2781
3032
  Uses merge-base to capture ALL changes on the feature branch, not just
2782
- the last commit (agents like claude-code may create multiple commits).
3033
+ the last commit (agents like claude may create multiple commits).
2783
3034
  Falls back to HEAD~1 if merge-base detection fails.
2784
3035
  """
2785
3036
  info: dict[str, Any] = {}
@@ -3140,7 +3391,7 @@ class TaskPoller:
3140
3391
  task_id=t["task_id"],
3141
3392
  graph_id=t["graph_id"],
3142
3393
  node_type=t["node_type"],
3143
- agent_type=t.get("agent_type", "claude-code"),
3394
+ agent_type=t.get("agent_type", "claude"),
3144
3395
  input_prompt=t.get("input_prompt", ""),
3145
3396
  input_data=t.get("input_data", {}),
3146
3397
  timeout_seconds=t.get("timeout_seconds", settings.AGENT_TIMEOUT),
@@ -3787,10 +4038,22 @@ class RuntimeDaemon:
3787
4038
  # 1. Find the right agent
3788
4039
  agent = self._select_agent(task.agent_type, task.fallback_chain)
3789
4040
  if not agent:
3790
- logger.error("No agent found for type '%s'", task.agent_type)
4041
+ _INSTALL_HINTS = {
4042
+ "claude": "npm install -g @anthropic-ai/claude-code",
4043
+ "codex": "npm install -g @openai/codex",
4044
+ "opencode": "curl -sSL https://opencode.ai/install | sh",
4045
+ "gemini": "npm install -g @google/gemini-cli",
4046
+ "kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
4047
+ "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
4048
+ }
4049
+ hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
4050
+ logger.error("No agent found for type '%s' on this runtime", task.agent_type)
3791
4051
  await reporter.report_complete(task.task_id, TaskResult(
3792
4052
  status="failed", exit_code=-1, stdout="", stderr="",
3793
- error=f"No agent CLI available for type '{task.agent_type}'",
4053
+ error=(
4054
+ f"Agent '{task.agent_type}' is not available on this runtime. "
4055
+ f"Install it: {hint}"
4056
+ ),
3794
4057
  ))
3795
4058
  return
3796
4059
 
@@ -3818,15 +4081,22 @@ class RuntimeDaemon:
3818
4081
  _line_buffer.extend(lines)
3819
4082
 
3820
4083
  async def _progress_ticker():
3821
- """Flush buffered output lines + update progress % every 5 s."""
4084
+ """Flush buffered output lines + update progress % every 1 s.
4085
+
4086
+ Using 1-second ticks keeps the UI responsive without flooding
4087
+ the backend. Empty ticks are skipped to reduce HTTP traffic.
4088
+ """
3822
4089
  import math as _math
3823
4090
  tick = 0
3824
4091
  while not progress_stop.is_set():
3825
- await asyncio.sleep(5)
4092
+ await asyncio.sleep(1)
3826
4093
  if progress_stop.is_set():
3827
4094
  break
4095
+ if not _line_buffer and tick < 3:
4096
+ tick += 1
4097
+ continue
3828
4098
  tick += 1
3829
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
4099
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
3830
4100
  pid = self.process_manager.active_processes.get(task.task_id)
3831
4101
  step = "running_agent"
3832
4102
  if pid:
@@ -3912,11 +4182,14 @@ class RuntimeDaemon:
3912
4182
  async def _progress_ticker2():
3913
4183
  tick = 0
3914
4184
  while not progress_stop2.is_set():
3915
- await asyncio.sleep(5)
4185
+ await asyncio.sleep(1)
3916
4186
  if progress_stop2.is_set():
3917
4187
  break
4188
+ if not _line_buffer and tick < 3:
4189
+ tick += 1
4190
+ continue
3918
4191
  tick += 1
3919
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
4192
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
3920
4193
  pid = self.process_manager.active_processes.get(task.task_id)
3921
4194
  step = f"running_agent:{agent.agent_id}"
3922
4195
  if pid:
@@ -4136,8 +4409,8 @@ class RuntimeDaemon:
4136
4409
  if agent.agent_id == fallback_id:
4137
4410
  return agent
4138
4411
 
4139
- # 2. Try any available agent not yet tried (prefer opencode > gemini > claude-code)
4140
- preferred_order = ["opencode", "gemini", "claude-code"]
4412
+ # 2. Try any available agent not yet tried (prefer opencode > copilot > gemini > claude)
4413
+ preferred_order = ["opencode", "copilot", "gemini", "claude"]
4141
4414
  for preferred_id in preferred_order:
4142
4415
  if preferred_id in tried:
4143
4416
  continue
@@ -4152,7 +4425,17 @@ class RuntimeDaemon:
4152
4425
  return None
4153
4426
 
4154
4427
  def _select_agent(self, agent_type: str, fallback_chain: list[str] | None = None) -> DiscoveredAgent | None:
4155
- """Find best matching agent for the requested type with fallback chain support."""
4428
+ """Find best matching agent for the requested type with fallback chain support.
4429
+
4430
+ Returns ``None`` when the requested agent is a known canonical ID but is not
4431
+ currently installed/discovered on this machine — callers should report a clear
4432
+ error rather than silently running a different agent.
4433
+
4434
+ The "any available agent" fallbacks (steps 3–4) only apply when ``agent_type``
4435
+ is NOT a recognized canonical ID (e.g. empty string, "auto", or an unknown
4436
+ custom identifier). This prevents silent substitution when the user explicitly
4437
+ selected an agent that is just not installed locally.
4438
+ """
4156
4439
  # 1. Exact match
4157
4440
  for agent in self.agents:
4158
4441
  if agent.agent_id == agent_type:
@@ -4168,11 +4451,18 @@ class RuntimeDaemon:
4168
4451
  logger.info("Using fallback agent '%s' (requested '%s')", fallback_id, agent_type)
4169
4452
  return agent
4170
4453
 
4171
- # 3. Fallback: first available L3 agent
4454
+ # 3. If the requested agent_type is a KNOWN canonical ID (registered in
4455
+ # AGENT_REGISTRY) but not discovered locally, return None so the caller
4456
+ # can surface a clear "agent not installed" error instead of silently
4457
+ # using whatever L3 agent happens to be available.
4458
+ if agent_type in AgentDiscovery.AGENT_REGISTRY:
4459
+ return None
4460
+
4461
+ # 4. Fallback: first available L3 agent (only for unrecognized/generic types)
4172
4462
  for agent in self.agents:
4173
4463
  if agent.compatibility_level == "L3":
4174
4464
  return agent
4175
- # 4. Any agent
4465
+ # 5. Any agent (last resort for unrecognized types)
4176
4466
  return self.agents[0] if self.agents else None
4177
4467
 
4178
4468
  # ── Layer 2: Validation Gate ──
@@ -4390,13 +4680,33 @@ class RuntimeDaemon:
4390
4680
  timeout=10,
4391
4681
  )
4392
4682
 
4393
- # 1. Select agent
4394
- agent_type = agent_override or "claude-code"
4683
+ # 1. Select agent — normalize legacy aliases to canonical IDs.
4684
+ # When no agent_override is specified (empty/None), pass an empty
4685
+ # string so _select_agent falls through to its "any available L3
4686
+ # agent" logic instead of hard-failing on the 'claude' default.
4687
+ _AGENT_ALIASES = {"claude": "claude", "kimi": "kimi"}
4688
+ agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "")
4395
4689
  agent = self._select_agent(agent_type, [])
4396
4690
  if not agent:
4691
+ _INSTALL_HINTS = {
4692
+ "claude": "npm install -g @anthropic-ai/claude-code",
4693
+ "codex": "npm install -g @openai/codex",
4694
+ "opencode": "curl -sSL https://opencode.ai/install | sh",
4695
+ "gemini": "npm install -g @google/gemini-cli",
4696
+ "kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
4697
+ "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
4698
+ }
4699
+ hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
4397
4700
  await conn.client.post(
4398
4701
  f"{reporter_url}/complete",
4399
- json={"status": "failed", "error": f"No agent CLI for '{agent_type}'", "failure_code": "no_agent"},
4702
+ json={
4703
+ "status": "failed",
4704
+ "error": (
4705
+ f"Agent '{agent_type}' is not available on this runtime. "
4706
+ f"Install it: {hint}"
4707
+ ),
4708
+ "failure_code": "no_agent",
4709
+ },
4400
4710
  timeout=10,
4401
4711
  )
4402
4712
  return
@@ -4405,7 +4715,11 @@ class RuntimeDaemon:
4405
4715
  full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
4406
4716
  fake_task = TaskInfo(
4407
4717
  task_id=job_id,
4408
- graph_id="",
4718
+ # Use job_id as graph_id so workspace_key is non-empty.
4719
+ # If graph_id="" then workspace_key="" and ws_path == project_dir
4720
+ # (Python: Path("x") / "" == Path("x")), causing git clone to
4721
+ # fail with "destination path already exists" on the second run.
4722
+ graph_id=job_id,
4409
4723
  node_type="ai_job",
4410
4724
  agent_type=agent_type,
4411
4725
  input_prompt=full_prompt,
@@ -4430,40 +4744,86 @@ class RuntimeDaemon:
4430
4744
  timeout=10,
4431
4745
  )
4432
4746
 
4433
- # 3. Run agent with prompt
4747
+ # 3. Run agent with prompt — stream output lines back to server in
4748
+ # real-time so the UI black box shows agent activity instead of
4749
+ # staying empty for the entire (potentially long) run.
4434
4750
  _line_buffer: list[str] = []
4751
+ _chunk_state = {"sent_count": 0, "last_flush": time.monotonic()}
4752
+
4753
+ async def _flush_output_to_server():
4754
+ pending = _line_buffer[_chunk_state["sent_count"]:]
4755
+ if not pending:
4756
+ return
4757
+ try:
4758
+ await conn.client.post(
4759
+ f"{reporter_url}/progress",
4760
+ json={"output_lines": pending, "agent_id": agent.agent_id},
4761
+ timeout=5,
4762
+ )
4763
+ except Exception:
4764
+ pass # never let streaming errors affect agent execution
4765
+ _chunk_state["sent_count"] += len(pending)
4766
+ _chunk_state["last_flush"] = time.monotonic()
4435
4767
 
4436
4768
  async def on_chunk(lines: list[str]):
4437
4769
  _line_buffer.extend(lines)
4770
+ now = time.monotonic()
4771
+ pending_count = len(_line_buffer) - _chunk_state["sent_count"]
4772
+ # Flush every 10 new lines or every 8 seconds, whichever first
4773
+ if pending_count >= 10 or (now - _chunk_state["last_flush"]) >= 8.0:
4774
+ await _flush_output_to_server()
4438
4775
 
4439
4776
  result = await self.process_manager.run_agent(
4440
4777
  agent, fake_task, workspace_path, on_chunk=on_chunk,
4441
4778
  )
4779
+ # Flush any remaining buffered lines after agent finishes
4780
+ await _flush_output_to_server()
4442
4781
 
4443
4782
  # 4. Auto-commit if successful
4783
+ input_ctx = aj.get("input_context", {})
4444
4784
  git_info = {}
4445
4785
  if result.status == "success" and result.files_changed:
4446
4786
  git_info = await self._auto_commit(workspace_path, fake_task)
4447
4787
 
4448
4788
  # 5. Report completion
4449
- output_content = result.stdout[-20000:] if result.stdout else ""
4789
+ # For deliverables: allow up to 200K chars (full document); others: last 20K
4790
+ max_content = 200000 if task_type == "deliverable_generate" else 20000
4791
+ output_content = (result.stdout or "")[-max_content:] if result.stdout else ""
4450
4792
  scripts: dict = {}
4451
4793
 
4452
- # Try to extract per-scenario scripts from output
4453
- scenario_ids = aj.get("input_context", {}).get("scenario_ids", [])
4794
+ scenario_ids = input_ctx.get("scenario_ids", [])
4454
4795
  if scenario_ids and output_content:
4455
- # Simple heuristic: if output is a single script, map it to first scenario
4456
- # Daemon-generated scripts may be multiple files in workspace
4796
+ # Primary: extract scripts using structured SCRIPT_START/END markers
4797
+ # inserted by poll_ai_jobs into the multi-scenario prompt.
4798
+ import re as _re
4457
4799
  for sid in scenario_ids:
4458
- # Check if daemon wrote test files to workspace
4459
- import glob
4460
- test_files = glob.glob(str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"), recursive=True)
4461
- if test_files:
4462
- try:
4463
- with open(test_files[0], "r") as f:
4464
- scripts[sid] = f.read()
4465
- except Exception:
4466
- pass
4800
+ pattern = (
4801
+ r"##\s*SCRIPT_START::" + _re.escape(sid)
4802
+ + r"\s*\n(.*?)\n##\s*SCRIPT_END::" + _re.escape(sid)
4803
+ )
4804
+ m = _re.search(pattern, output_content, _re.DOTALL)
4805
+ if m:
4806
+ scripts[sid] = m.group(1).strip()
4807
+
4808
+ # Fallback: if no markers found but only one scenario, treat
4809
+ # the entire output as that scenario's script.
4810
+ if not scripts and len(scenario_ids) == 1:
4811
+ scripts[scenario_ids[0]] = output_content.strip()
4812
+
4813
+ # Fallback: check workspace for test files named after scenario
4814
+ if not scripts:
4815
+ import glob as _glob
4816
+ for sid in scenario_ids:
4817
+ test_files = _glob.glob(
4818
+ str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"),
4819
+ recursive=True,
4820
+ )
4821
+ if test_files:
4822
+ try:
4823
+ with open(test_files[0], "r") as f:
4824
+ scripts[sid] = f.read()
4825
+ except Exception:
4826
+ pass
4467
4827
 
4468
4828
  complete_payload = {
4469
4829
  "status": "success" if result.status == "success" else "failed",
@@ -4683,7 +5043,7 @@ class RuntimeDaemon:
4683
5043
  async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
4684
5044
  """Auto-commit and push agent changes.
4685
5045
 
4686
- Some agents (e.g. claude-code) commit changes internally, so we must
5046
+ Some agents (e.g. claude) commit changes internally, so we must
4687
5047
  also push even when the working directory is clean.
4688
5048
 
4689
5049
  Before pushing, we rebase onto the latest ``origin/{default_branch}``
@@ -5075,12 +5435,18 @@ class RuntimeDaemon:
5075
5435
  self, workspace_path: Path, default_branch: str, task: TaskInfo,
5076
5436
  project_key: str = "default",
5077
5437
  ):
5078
- """Rebase the current branch onto ``origin/{default_branch}``.
5079
-
5080
- Strategy (3-tier):
5081
- 1. ``git rebase origin/{default_branch}`` cleanest; linear history.
5082
- 2. If rebase conflicts abort, try ``git merge`` instead.
5083
- 3. If merge conflicts use the AI agent to auto-resolve.
5438
+ """Integrate the current branch with ``origin/{default_branch}``.
5439
+
5440
+ Strategy:
5441
+ - If the current branch already exists on remote (was previously pushed):
5442
+ Skip rebase entirely and use merge only. Rebase rewrites commit
5443
+ SHAs of already-published commits, which creates divergence and
5444
+ requires a force-push. Many servers (e.g. Bitbucket Server with
5445
+ branch protection) forbid force-push, so we must preserve the
5446
+ existing remote history by using merge instead.
5447
+ - If the branch is new (first push): try rebase first for a clean
5448
+ linear history; fall back to merge on conflicts.
5449
+ - 3-tier merge fallback: merge → AI-assisted conflict resolution.
5084
5450
  """
5085
5451
  git = self.workspace_manager._git
5086
5452
  target = f"origin/{default_branch}"
@@ -5089,10 +5455,10 @@ class RuntimeDaemon:
5089
5455
  try:
5090
5456
  await git("fetch", "origin", cwd=workspace_path, timeout=300, project_key=project_key)
5091
5457
  except RuntimeError as exc:
5092
- logger.warning("Pre-push fetch failed: %s — skipping rebase", exc)
5458
+ logger.warning("Pre-push fetch failed: %s — skipping integration", exc)
5093
5459
  return
5094
5460
 
5095
- # Check if rebase is needed (any commits on origin/default ahead of us?)
5461
+ # Check if integration is needed (any commits on origin/default ahead of us?)
5096
5462
  try:
5097
5463
  behind = await git(
5098
5464
  "rev-list", "--count", f"HEAD..{target}", cwd=workspace_path,
@@ -5100,27 +5466,56 @@ class RuntimeDaemon:
5100
5466
  if behind.strip() == "0":
5101
5467
  logger.info("Branch is already up-to-date with %s", target)
5102
5468
  return
5103
- logger.info("Branch is %s commit(s) behind %s — rebasing", behind.strip(), target)
5469
+ logger.info("Branch is %s commit(s) behind %s — integrating", behind.strip(), target)
5104
5470
  except RuntimeError:
5105
- # Can't determine — proceed with rebase anyway
5471
+ # Can't determine — proceed anyway
5106
5472
  pass
5107
5473
 
5108
- # ── Tier 1: rebase ──
5474
+ # Determine if the current branch already exists on remote.
5475
+ # If it does, a rebase would rewrite the SHAs of already-pushed commits,
5476
+ # causing divergence that requires a force-push (often blocked by server
5477
+ # branch-protection rules). Use merge-only in that case.
5478
+ current_branch = ""
5479
+ remote_branch_exists = False
5109
5480
  try:
5110
- await git(
5111
- "-c", "user.name=Forgexa Agent",
5112
- "-c", "user.email=agent@forgexa.net",
5113
- "rebase", target,
5114
- cwd=workspace_path, timeout=120,
5115
- )
5116
- logger.info("Rebase onto %s succeeded", target)
5117
- return # done — clean linear history
5481
+ current_branch = (await git(
5482
+ "rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path,
5483
+ )).strip()
5118
5484
  except RuntimeError:
5119
- logger.info("Rebase onto %s had conflicts — aborting rebase", target)
5485
+ pass
5486
+ if current_branch and current_branch != "HEAD":
5120
5487
  try:
5121
- await git("rebase", "--abort", cwd=workspace_path)
5488
+ await git(
5489
+ "rev-parse", "--verify", f"origin/{current_branch}",
5490
+ cwd=workspace_path,
5491
+ )
5492
+ remote_branch_exists = True
5122
5493
  except RuntimeError:
5123
- pass # already aborted or not in rebase state
5494
+ remote_branch_exists = False
5495
+
5496
+ if not remote_branch_exists:
5497
+ # ── Tier 1: rebase (safe — branch not yet on remote) ──
5498
+ try:
5499
+ await git(
5500
+ "-c", "user.name=Forgexa Agent",
5501
+ "-c", "user.email=agent@forgexa.net",
5502
+ "rebase", target,
5503
+ cwd=workspace_path, timeout=120,
5504
+ )
5505
+ logger.info("Rebase onto %s succeeded", target)
5506
+ return # done — clean linear history
5507
+ except RuntimeError:
5508
+ logger.info("Rebase onto %s had conflicts — aborting rebase", target)
5509
+ try:
5510
+ await git("rebase", "--abort", cwd=workspace_path)
5511
+ except RuntimeError:
5512
+ pass # already aborted or not in rebase state
5513
+ else:
5514
+ logger.info(
5515
+ "Branch %s already exists on remote — skipping rebase to preserve "
5516
+ "published commit SHAs (force-push not required)",
5517
+ current_branch,
5518
+ )
5124
5519
 
5125
5520
  # ── Tier 2: merge ──
5126
5521
  try:
@@ -5354,6 +5749,79 @@ class RuntimeDaemon:
5354
5749
  return None
5355
5750
  except RuntimeError as exc:
5356
5751
  logger.error("Force-push (with lease) failed for %s: %s", branch, exc)
5752
+ exc_str = str(exc).lower()
5753
+ # Detect permanent server-side force-push prohibition
5754
+ # (e.g. Bitbucket Server branch-protection pre-receive hook).
5755
+ # In this case we must NOT retry with force — instead, recover
5756
+ # by resetting to the remote HEAD and cherry-picking only the
5757
+ # truly new commits, then doing a regular (non-force) push.
5758
+ force_push_blocked = (
5759
+ "force-pushing" in exc_str
5760
+ or "force pushing" in exc_str
5761
+ or "pre-receive hook declined" in exc_str
5762
+ )
5763
+ if force_push_blocked:
5764
+ logger.warning(
5765
+ "Remote has force-push disabled for branch %s — "
5766
+ "attempting cherry-pick recovery to avoid force-push",
5767
+ branch,
5768
+ )
5769
+ try:
5770
+ # Identify commits that are genuinely new (not
5771
+ # equivalent to any remote commit). git-cherry
5772
+ # lines prefixed with '+' are truly missing from
5773
+ # origin; '-' lines are already incorporated (same
5774
+ # patch, different SHA — result of prior rebase).
5775
+ cherry_out = (await git(
5776
+ "cherry", "HEAD", f"origin/{branch}",
5777
+ cwd=workspace_path,
5778
+ )).strip()
5779
+ new_shas = [
5780
+ line.split()[1]
5781
+ for line in cherry_out.splitlines()
5782
+ if line.startswith("+ ")
5783
+ ]
5784
+ if not new_shas:
5785
+ # Nothing genuinely new — remote is already
5786
+ # up-to-date, treat as success.
5787
+ logger.info(
5788
+ "Recovery: no truly new commits on %s — "
5789
+ "remote already has equivalent content",
5790
+ branch,
5791
+ )
5792
+ return None
5793
+ # Reset local branch to match remote exactly,
5794
+ # then replay only the new commits on top.
5795
+ await git(
5796
+ "reset", "--hard", f"origin/{branch}",
5797
+ cwd=workspace_path,
5798
+ )
5799
+ await git(
5800
+ "-c", "user.name=Forgexa Agent",
5801
+ "-c", "user.email=agent@forgexa.net",
5802
+ "cherry-pick", *new_shas,
5803
+ cwd=workspace_path,
5804
+ )
5805
+ # Now a regular push should succeed.
5806
+ await git(
5807
+ "push", "-u", "origin", branch,
5808
+ cwd=workspace_path, project_key=project_key,
5809
+ )
5810
+ logger.info(
5811
+ "Recovery push succeeded for branch %s "
5812
+ "(%d new commit(s) cherry-picked)",
5813
+ branch, len(new_shas),
5814
+ )
5815
+ return None
5816
+ except RuntimeError as recovery_exc:
5817
+ logger.error(
5818
+ "Cherry-pick recovery also failed for %s: %s",
5819
+ branch, recovery_exc,
5820
+ )
5821
+ return (
5822
+ f"Push failed: remote has force-push disabled "
5823
+ f"and cherry-pick recovery failed: {recovery_exc}"
5824
+ )
5357
5825
  return f"Push failed: {exc}"
5358
5826
  else:
5359
5827
  logger.error(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.7.8
3
+ Version: 1.8.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.7.8"
3
+ version = "1.8.4"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
File without changes
File without changes