forgexa-cli 1.7.6__tar.gz → 1.8.3__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.6
3
+ Version: 1.8.3
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.6"
2
+ __version__ = "1.8.3"
@@ -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.6"
335
+ DAEMON_VERSION = "1.8.3"
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)
@@ -1732,24 +1750,49 @@ class ProcessManager:
1732
1750
  error_messages.append(err.get("message", "turn failed"))
1733
1751
  elif isinstance(err, str):
1734
1752
  error_messages.append(err)
1753
+ # ── GitHub Copilot CLI event types ──────────────────────────────
1754
+ elif ev_type == "assistant.turn_end":
1755
+ has_turn_completed = True
1756
+ elif ev_type == "assistant.turn_start":
1757
+ has_assistant_events = True
1758
+ elif ev_type == "assistant.message":
1759
+ has_meaningful_content = True
1760
+ has_assistant_events = True
1761
+ msg_data = data.get("data") or {}
1762
+ if isinstance(msg_data.get("content"), str) and msg_data["content"].strip():
1763
+ has_result = True
1764
+ elif ev_type == "assistant.message_delta":
1765
+ has_meaningful_content = True
1766
+ # ── Generic / Claude "result" event ────────────────────────────
1735
1767
  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:
1768
+ # Copilot result format: {"type":"result","exitCode":0,"usage":{...}}
1769
+ # Claude result format: {"type":"result","result":"...","is_error":false,...}
1770
+ if "exitCode" in data:
1771
+ # Copilot JSONL result
1772
+ exit_code = int(data.get("exitCode") or 0)
1773
+ if exit_code == 0:
1751
1774
  has_result = True
1752
1775
  has_meaningful_content = True
1776
+ else:
1777
+ error_messages.append(f"Copilot exited with code {exit_code}")
1778
+ else:
1779
+ result_text = str(data.get("result", "") or "")
1780
+ if data.get("is_error"):
1781
+ err_text = result_text or str(data.get("error", "") or "result marked as error")
1782
+ error_messages.append(err_text)
1783
+ else:
1784
+ # Structural check: if no tokens were consumed AND no assistant
1785
+ # events appeared, the CLI never made an API call. The result
1786
+ # text is a CLI-level error (e.g. "API Error: Connection error.")
1787
+ # rather than the agent's actual work output.
1788
+ tok_in = int(data.get("total_input_tokens", 0) or 0)
1789
+ tok_out = int(data.get("total_output_tokens", 0) or 0)
1790
+ no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
1791
+ if no_api_call and result_text:
1792
+ error_messages.append(result_text)
1793
+ else:
1794
+ has_result = True
1795
+ has_meaningful_content = True
1753
1796
  elif ev_type == "error":
1754
1797
  msg = data.get("message", "")
1755
1798
  if msg:
@@ -1895,10 +1938,15 @@ class ProcessManager:
1895
1938
  return f"Agent encountered errors without producing output: {error_messages[0]}"
1896
1939
 
1897
1940
  # ── Claude: JSON output mode but no result object and no content ──
1898
- if agent_id == "claude-code" and json_line_count > 0:
1941
+ if agent_id == "claude" and json_line_count > 0:
1899
1942
  if not has_result and not has_meaningful_content:
1900
1943
  return "Claude produced no result output"
1901
1944
 
1945
+ # ── Copilot: JSONL mode but no turn completion and no content ──
1946
+ if agent_id == "copilot" and json_line_count > 0:
1947
+ if not has_result and not has_meaningful_content:
1948
+ return "Copilot produced no result output (check GitHub authentication: run 'gh auth login')"
1949
+
1902
1950
  return None
1903
1951
 
1904
1952
  async def run_agent(
@@ -1915,7 +1963,7 @@ class ProcessManager:
1915
1963
 
1916
1964
  start_time = time.monotonic()
1917
1965
 
1918
- if agent.agent_id == "claude-code":
1966
+ if agent.agent_id == "claude":
1919
1967
  result = await self._run_claude(
1920
1968
  agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
1921
1969
  node_type=task.node_type,
@@ -1926,8 +1974,10 @@ class ProcessManager:
1926
1974
  result = await self._run_opencode(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1927
1975
  elif agent.agent_id == "gemini":
1928
1976
  result = await self._run_gemini(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1929
- elif agent.agent_id == "kimi-code":
1977
+ elif agent.agent_id == "kimi":
1930
1978
  result = await self._run_kimi_code(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1979
+ elif agent.agent_id == "copilot":
1980
+ result = await self._run_copilot(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1931
1981
  else:
1932
1982
  result = await self._run_generic(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1933
1983
 
@@ -2386,6 +2436,101 @@ class ProcessManager:
2386
2436
  result.metrics.update(parsed_metrics)
2387
2437
  return result
2388
2438
 
2439
+ async def _run_copilot(
2440
+ self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
2441
+ on_chunk: Any = None,
2442
+ ) -> TaskResult:
2443
+ """Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
2444
+
2445
+ Uses TERM=dumb to suppress TTY-detection (copilot suspends when it
2446
+ can't acquire a pseudo-terminal). Requires GitHub login:
2447
+ ``gh auth login`` or GITHUB_TOKEN env var must be set.
2448
+
2449
+ Flags:
2450
+ -p / --prompt Non-interactive prompt (exits after completion).
2451
+ --output-format json JSONL stream of session events.
2452
+ --allow-all Grant all tool + path + URL permissions required
2453
+ for autonomous file-editing tasks.
2454
+ -C <dir> Change working directory before execution.
2455
+ """
2456
+ env = os.environ.copy()
2457
+ env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
2458
+
2459
+ model_override = os.environ.get("FACTORY_COPILOT_MODEL")
2460
+ reasoning = os.environ.get("FACTORY_COPILOT_REASONING", "medium")
2461
+
2462
+ cmd = [
2463
+ agent.command,
2464
+ "--output-format", "json",
2465
+ "--allow-all",
2466
+ "--effort", reasoning,
2467
+ "-C", str(cwd),
2468
+ "-p", prompt,
2469
+ ]
2470
+ if model_override:
2471
+ cmd = [agent.command, "--model", model_override] + cmd[1:]
2472
+
2473
+ try:
2474
+ proc = await asyncio.create_subprocess_exec(
2475
+ *cmd,
2476
+ stdout=asyncio.subprocess.PIPE,
2477
+ stderr=asyncio.subprocess.PIPE,
2478
+ cwd=str(cwd),
2479
+ env=env,
2480
+ limit=100 * 1024 * 1024,
2481
+ start_new_session=True,
2482
+ )
2483
+ self.active_processes[task_id] = proc
2484
+ stdout, stderr, returncode = await self._stream_process(
2485
+ proc, None, timeout, task_id, on_chunk
2486
+ )
2487
+
2488
+ # Parse copilot JSONL output for metrics
2489
+ metrics = self._parse_copilot_output(stdout)
2490
+
2491
+ # Copilot always exits 0 on normal completion; check result.exitCode
2492
+ # from the JSONL "result" event for a true success signal.
2493
+ copilot_exit = self._extract_copilot_exit_code(stdout)
2494
+ effective_rc = copilot_exit if copilot_exit is not None else returncode
2495
+
2496
+ if effective_rc == 0 and returncode == 0:
2497
+ return TaskResult(
2498
+ status="success",
2499
+ exit_code=0,
2500
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
2501
+ stderr=stderr[-10000:],
2502
+ metrics=metrics,
2503
+ )
2504
+ else:
2505
+ return TaskResult(
2506
+ status="failed",
2507
+ exit_code=effective_rc,
2508
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
2509
+ stderr=stderr[-10000:],
2510
+ error=f"Copilot exited with code {effective_rc}: {stderr[-500:]}",
2511
+ metrics=metrics,
2512
+ )
2513
+ except asyncio.TimeoutError:
2514
+ if task_id in self.active_processes:
2515
+ self.active_processes[task_id].kill()
2516
+ return TaskResult(
2517
+ status="failed", exit_code=-1, stdout="", stderr="",
2518
+ error=f"Timed out after {timeout}s",
2519
+ )
2520
+ except Exception as exc:
2521
+ logger.exception("Copilot stream error for task %s", task_id)
2522
+ if task_id in self.active_processes:
2523
+ try:
2524
+ self.active_processes[task_id].kill()
2525
+ except Exception:
2526
+ pass
2527
+ return TaskResult(
2528
+ status="failed", exit_code=-1, stdout="", stderr="",
2529
+ error=f"Stream processing error: {exc}",
2530
+ )
2531
+ finally:
2532
+ self.active_processes.pop(task_id, None)
2533
+
2389
2534
  async def _run_generic(
2390
2535
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
2391
2536
  on_chunk: Any = None,
@@ -2702,6 +2847,79 @@ class ProcessManager:
2702
2847
  metrics["token_output"] = output
2703
2848
  return metrics
2704
2849
 
2850
+ def _parse_copilot_output(self, stdout: str) -> dict:
2851
+ """Extract metrics from GitHub Copilot CLI JSONL output.
2852
+
2853
+ Copilot CLI (--output-format json) emits one JSON object per line.
2854
+ The key event types:
2855
+ - ``assistant.message`` -> content + model + outputTokens per turn
2856
+ - ``result`` -> exitCode + usage (premiumRequests,
2857
+ totalApiDurationMs, codeChanges)
2858
+
2859
+ Copilot is subscription-based and does NOT report USD cost or input
2860
+ tokens, so those fields are intentionally omitted.
2861
+ """
2862
+ metrics: dict[str, Any] = {}
2863
+ total_output_tokens = 0
2864
+ model_seen: str | None = None
2865
+
2866
+ for raw in stdout.strip().split("\n"):
2867
+ raw = raw.strip()
2868
+ if not raw:
2869
+ continue
2870
+ try:
2871
+ data = json.loads(raw)
2872
+ except json.JSONDecodeError:
2873
+ continue
2874
+ if not isinstance(data, dict):
2875
+ continue
2876
+
2877
+ ev_type = str(data.get("type", ""))
2878
+
2879
+ if ev_type == "assistant.message":
2880
+ msg_data = data.get("data") or {}
2881
+ out_tokens = msg_data.get("outputTokens")
2882
+ if out_tokens:
2883
+ total_output_tokens += int(out_tokens)
2884
+ if not model_seen and isinstance(msg_data.get("model"), str):
2885
+ model_seen = msg_data["model"]
2886
+
2887
+ elif ev_type == "result":
2888
+ usage = data.get("usage") or {}
2889
+ premium_reqs = usage.get("premiumRequests")
2890
+ if premium_reqs is not None:
2891
+ metrics["premium_requests"] = int(premium_reqs)
2892
+ api_ms = usage.get("totalApiDurationMs")
2893
+ if api_ms:
2894
+ metrics["api_duration_ms"] = int(api_ms)
2895
+ changes = usage.get("codeChanges") or {}
2896
+ if changes.get("linesAdded") or changes.get("linesRemoved"):
2897
+ metrics["lines_added"] = int(changes.get("linesAdded") or 0)
2898
+ metrics["lines_removed"] = int(changes.get("linesRemoved") or 0)
2899
+
2900
+ if total_output_tokens:
2901
+ metrics["token_output"] = total_output_tokens
2902
+ if model_seen:
2903
+ metrics["model"] = model_seen
2904
+ return metrics
2905
+
2906
+ @staticmethod
2907
+ def _extract_copilot_exit_code(stdout: str) -> int | None:
2908
+ """Extract the exitCode from Copilot JSONL ``result`` event."""
2909
+ for raw in reversed(stdout.strip().split("\n")):
2910
+ raw = raw.strip()
2911
+ if not raw:
2912
+ continue
2913
+ try:
2914
+ data = json.loads(raw)
2915
+ except json.JSONDecodeError:
2916
+ continue
2917
+ if isinstance(data, dict) and data.get("type") == "result":
2918
+ ec = data.get("exitCode")
2919
+ if ec is not None:
2920
+ return int(ec)
2921
+ return None
2922
+
2705
2923
  async def _collect_git_info(self, cwd: Path) -> dict:
2706
2924
  """Collect git diff stats from workspace."""
2707
2925
  info: dict[str, Any] = {}
@@ -2779,7 +2997,7 @@ class ProcessManager:
2779
2997
  """Collect git diff stats comparing HEAD vs merge-base with default branch.
2780
2998
 
2781
2999
  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).
3000
+ the last commit (agents like claude may create multiple commits).
2783
3001
  Falls back to HEAD~1 if merge-base detection fails.
2784
3002
  """
2785
3003
  info: dict[str, Any] = {}
@@ -3140,7 +3358,7 @@ class TaskPoller:
3140
3358
  task_id=t["task_id"],
3141
3359
  graph_id=t["graph_id"],
3142
3360
  node_type=t["node_type"],
3143
- agent_type=t.get("agent_type", "claude-code"),
3361
+ agent_type=t.get("agent_type", "claude"),
3144
3362
  input_prompt=t.get("input_prompt", ""),
3145
3363
  input_data=t.get("input_data", {}),
3146
3364
  timeout_seconds=t.get("timeout_seconds", settings.AGENT_TIMEOUT),
@@ -3787,10 +4005,22 @@ class RuntimeDaemon:
3787
4005
  # 1. Find the right agent
3788
4006
  agent = self._select_agent(task.agent_type, task.fallback_chain)
3789
4007
  if not agent:
3790
- logger.error("No agent found for type '%s'", task.agent_type)
4008
+ _INSTALL_HINTS = {
4009
+ "claude": "npm install -g @anthropic-ai/claude-code",
4010
+ "codex": "npm install -g @openai/codex",
4011
+ "opencode": "curl -sSL https://opencode.ai/install | sh",
4012
+ "gemini": "npm install -g @google/gemini-cli",
4013
+ "kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
4014
+ "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
4015
+ }
4016
+ hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
4017
+ logger.error("No agent found for type '%s' on this runtime", task.agent_type)
3791
4018
  await reporter.report_complete(task.task_id, TaskResult(
3792
4019
  status="failed", exit_code=-1, stdout="", stderr="",
3793
- error=f"No agent CLI available for type '{task.agent_type}'",
4020
+ error=(
4021
+ f"Agent '{task.agent_type}' is not available on this runtime. "
4022
+ f"Install it: {hint}"
4023
+ ),
3794
4024
  ))
3795
4025
  return
3796
4026
 
@@ -3818,15 +4048,22 @@ class RuntimeDaemon:
3818
4048
  _line_buffer.extend(lines)
3819
4049
 
3820
4050
  async def _progress_ticker():
3821
- """Flush buffered output lines + update progress % every 5 s."""
4051
+ """Flush buffered output lines + update progress % every 1 s.
4052
+
4053
+ Using 1-second ticks keeps the UI responsive without flooding
4054
+ the backend. Empty ticks are skipped to reduce HTTP traffic.
4055
+ """
3822
4056
  import math as _math
3823
4057
  tick = 0
3824
4058
  while not progress_stop.is_set():
3825
- await asyncio.sleep(5)
4059
+ await asyncio.sleep(1)
3826
4060
  if progress_stop.is_set():
3827
4061
  break
4062
+ if not _line_buffer and tick < 3:
4063
+ tick += 1
4064
+ continue
3828
4065
  tick += 1
3829
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
4066
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
3830
4067
  pid = self.process_manager.active_processes.get(task.task_id)
3831
4068
  step = "running_agent"
3832
4069
  if pid:
@@ -3912,11 +4149,14 @@ class RuntimeDaemon:
3912
4149
  async def _progress_ticker2():
3913
4150
  tick = 0
3914
4151
  while not progress_stop2.is_set():
3915
- await asyncio.sleep(5)
4152
+ await asyncio.sleep(1)
3916
4153
  if progress_stop2.is_set():
3917
4154
  break
4155
+ if not _line_buffer and tick < 3:
4156
+ tick += 1
4157
+ continue
3918
4158
  tick += 1
3919
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
4159
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
3920
4160
  pid = self.process_manager.active_processes.get(task.task_id)
3921
4161
  step = f"running_agent:{agent.agent_id}"
3922
4162
  if pid:
@@ -4136,8 +4376,8 @@ class RuntimeDaemon:
4136
4376
  if agent.agent_id == fallback_id:
4137
4377
  return agent
4138
4378
 
4139
- # 2. Try any available agent not yet tried (prefer opencode > gemini > claude-code)
4140
- preferred_order = ["opencode", "gemini", "claude-code"]
4379
+ # 2. Try any available agent not yet tried (prefer opencode > copilot > gemini > claude)
4380
+ preferred_order = ["opencode", "copilot", "gemini", "claude"]
4141
4381
  for preferred_id in preferred_order:
4142
4382
  if preferred_id in tried:
4143
4383
  continue
@@ -4152,7 +4392,17 @@ class RuntimeDaemon:
4152
4392
  return None
4153
4393
 
4154
4394
  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."""
4395
+ """Find best matching agent for the requested type with fallback chain support.
4396
+
4397
+ Returns ``None`` when the requested agent is a known canonical ID but is not
4398
+ currently installed/discovered on this machine — callers should report a clear
4399
+ error rather than silently running a different agent.
4400
+
4401
+ The "any available agent" fallbacks (steps 3–4) only apply when ``agent_type``
4402
+ is NOT a recognized canonical ID (e.g. empty string, "auto", or an unknown
4403
+ custom identifier). This prevents silent substitution when the user explicitly
4404
+ selected an agent that is just not installed locally.
4405
+ """
4156
4406
  # 1. Exact match
4157
4407
  for agent in self.agents:
4158
4408
  if agent.agent_id == agent_type:
@@ -4168,11 +4418,18 @@ class RuntimeDaemon:
4168
4418
  logger.info("Using fallback agent '%s' (requested '%s')", fallback_id, agent_type)
4169
4419
  return agent
4170
4420
 
4171
- # 3. Fallback: first available L3 agent
4421
+ # 3. If the requested agent_type is a KNOWN canonical ID (registered in
4422
+ # AGENT_REGISTRY) but not discovered locally, return None so the caller
4423
+ # can surface a clear "agent not installed" error instead of silently
4424
+ # using whatever L3 agent happens to be available.
4425
+ if agent_type in AgentDiscovery.AGENT_REGISTRY:
4426
+ return None
4427
+
4428
+ # 4. Fallback: first available L3 agent (only for unrecognized/generic types)
4172
4429
  for agent in self.agents:
4173
4430
  if agent.compatibility_level == "L3":
4174
4431
  return agent
4175
- # 4. Any agent
4432
+ # 5. Any agent (last resort for unrecognized types)
4176
4433
  return self.agents[0] if self.agents else None
4177
4434
 
4178
4435
  # ── Layer 2: Validation Gate ──
@@ -4390,13 +4647,30 @@ class RuntimeDaemon:
4390
4647
  timeout=10,
4391
4648
  )
4392
4649
 
4393
- # 1. Select agent
4394
- agent_type = agent_override or "claude-code"
4650
+ # 1. Select agent — normalize legacy aliases to canonical IDs
4651
+ _AGENT_ALIASES = {"claude": "claude", "kimi": "kimi"}
4652
+ agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "claude")
4395
4653
  agent = self._select_agent(agent_type, [])
4396
4654
  if not agent:
4655
+ _INSTALL_HINTS = {
4656
+ "claude": "npm install -g @anthropic-ai/claude-code",
4657
+ "codex": "npm install -g @openai/codex",
4658
+ "opencode": "curl -sSL https://opencode.ai/install | sh",
4659
+ "gemini": "npm install -g @google/gemini-cli",
4660
+ "kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
4661
+ "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
4662
+ }
4663
+ hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
4397
4664
  await conn.client.post(
4398
4665
  f"{reporter_url}/complete",
4399
- json={"status": "failed", "error": f"No agent CLI for '{agent_type}'", "failure_code": "no_agent"},
4666
+ json={
4667
+ "status": "failed",
4668
+ "error": (
4669
+ f"Agent '{agent_type}' is not available on this runtime. "
4670
+ f"Install it: {hint}"
4671
+ ),
4672
+ "failure_code": "no_agent",
4673
+ },
4400
4674
  timeout=10,
4401
4675
  )
4402
4676
  return
@@ -4683,7 +4957,7 @@ class RuntimeDaemon:
4683
4957
  async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
4684
4958
  """Auto-commit and push agent changes.
4685
4959
 
4686
- Some agents (e.g. claude-code) commit changes internally, so we must
4960
+ Some agents (e.g. claude) commit changes internally, so we must
4687
4961
  also push even when the working directory is clean.
4688
4962
 
4689
4963
  Before pushing, we rebase onto the latest ``origin/{default_branch}``
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.7.6
3
+ Version: 1.8.3
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.6"
3
+ version = "1.8.3"
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