forgexa-cli 1.11.0__tar.gz → 1.11.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.11.0
3
+ Version: 1.11.1
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.11.0"
2
+ __version__ = "1.11.1"
@@ -404,7 +404,7 @@ except (ImportError, ModuleNotFoundError):
404
404
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
405
405
  # Kept in sync with pyproject.toml version via bump-version.sh.
406
406
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
407
- DAEMON_VERSION = "1.11.0"
407
+ DAEMON_VERSION = "1.11.1"
408
408
 
409
409
 
410
410
  def _detect_client_type() -> str:
@@ -841,12 +841,16 @@ class AgentDiscovery:
841
841
  extra_dirs.append(versions[0])
842
842
  else:
843
843
  # macOS + Linux shared paths
844
+ # Kimi Code 0.18.0+ standalone installer puts the binary here;
845
+ # KIMI_CODE_HOME overrides the data dir (officially documented).
846
+ kimi_home = Path(os.environ.get("KIMI_CODE_HOME", home / ".kimi-code"))
844
847
  extra_dirs += [
845
848
  home / ".local" / "bin", # pip / pipx installs, Claude Code
846
849
  home / ".opencode" / "bin", # opencode
847
850
  home / ".cargo" / "bin", # Rust / cargo installs
848
851
  home / ".bun" / "bin", # bun
849
852
  home / ".volta" / "bin", # volta (node version manager)
853
+ kimi_home / "bin", # Kimi Code 0.18.0+ (standalone installer)
850
854
  ]
851
855
  if is_mac:
852
856
  # Homebrew Intel + Apple Silicon
@@ -1212,7 +1216,8 @@ class WorkspaceManager:
1212
1216
  if not is_fresh_start:
1213
1217
  try:
1214
1218
  await self._git(
1215
- "fetch", "origin", branch_name,
1219
+ "fetch", "origin",
1220
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1216
1221
  cwd=ws_path, project_key=project_key,
1217
1222
  )
1218
1223
  except RuntimeError:
@@ -2491,6 +2496,48 @@ class ProcessManager:
2491
2496
  changed_paths.update(self._normalize_repo_paths(paths))
2492
2497
  return bool(required_paths & changed_paths)
2493
2498
 
2499
+ def _recover_timeout_from_workspace_changes(
2500
+ self,
2501
+ task: TaskInfo,
2502
+ workspace_path: Path,
2503
+ result: TaskResult,
2504
+ pre_commit_git: dict,
2505
+ ) -> bool:
2506
+ """Recover absolute-timeout failures after validating workspace output."""
2507
+ error_lower = (result.error or "").lower()
2508
+ is_absolute_timeout = (
2509
+ "absolute limit" in error_lower or "timed out after" in error_lower
2510
+ )
2511
+ changed_files = list(pre_commit_git.get("files_changed") or [])
2512
+ if not is_absolute_timeout or not changed_files:
2513
+ return False
2514
+ if not self.process_manager.has_meaningful_agent_output(result):
2515
+ return False
2516
+
2517
+ if result.files_changed:
2518
+ result.files_changed = sorted(set(result.files_changed) | set(changed_files))
2519
+ else:
2520
+ result.files_changed = changed_files
2521
+ if not result.lines_added:
2522
+ result.lines_added = int(pre_commit_git.get("lines_added", 0) or 0)
2523
+ if not result.lines_removed:
2524
+ result.lines_removed = int(pre_commit_git.get("lines_removed", 0) or 0)
2525
+
2526
+ if not self.process_manager._has_required_deliverable_updates(
2527
+ task,
2528
+ changed_files,
2529
+ result.files_changed,
2530
+ ):
2531
+ return False
2532
+
2533
+ validation_issues = self._validate_outputs(workspace_path, task, result)
2534
+ if validation_issues:
2535
+ result.metrics["timeout_recovery_validation_issues"] = validation_issues
2536
+ return False
2537
+
2538
+ result.metrics["recovered_from_timeout_uncommitted_changes"] = True
2539
+ return True
2540
+
2494
2541
  def _build_prompt(self, task: TaskInfo) -> str:
2495
2542
  """Build the prompt to send to the agent.
2496
2543
 
@@ -2542,6 +2589,7 @@ class ProcessManager:
2542
2589
  task_id: str,
2543
2590
  on_chunk: Any,
2544
2591
  workspace_path: "Path | None" = None,
2592
+ stream_stderr: bool = False,
2545
2593
  ) -> tuple[str, str, int]:
2546
2594
  """Stream stdout line-by-line from a subprocess, flushing to on_chunk.
2547
2595
 
@@ -2551,6 +2599,12 @@ class ProcessManager:
2551
2599
  - Stdout is read line-by-line so callers receive output in real time.
2552
2600
  - Stderr is consumed concurrently via a background task to avoid pipe
2553
2601
  deadlock when the process fills the stderr buffer.
2602
+ - When ``stream_stderr`` is True, stderr lines are also forwarded to
2603
+ ``on_chunk`` in real time and merged into the returned stdout text.
2604
+ This is required for agents (e.g. Kimi Code 0.18.0+) that write all
2605
+ real-time progress to stderr, reserving stdout for a buffered final
2606
+ answer. Stderr activity also resets the idle timer so the agent is
2607
+ not killed while actively producing output.
2554
2608
  - on_chunk(lines) is called with each decoded line so the caller can
2555
2609
  forward to the progress reporter without waiting for completion.
2556
2610
  - Idle timeout: if the agent produces no stdout for AGENT_IDLE_TIMEOUT
@@ -2582,7 +2636,28 @@ class ProcessManager:
2582
2636
  stderr_chunks: list[bytes] = []
2583
2637
 
2584
2638
  async def _read_stderr():
2585
- if proc.stderr:
2639
+ if not proc.stderr:
2640
+ return
2641
+ if stream_stderr:
2642
+ # Agent (e.g. Kimi Code 0.18.0+) writes real-time progress to
2643
+ # stderr. Read line-by-line and forward to on_chunk so the
2644
+ # user sees output immediately. Also reset the idle timer so
2645
+ # the agent is not killed while actively working on stderr.
2646
+ while True:
2647
+ line_bytes = await proc.stderr.readline()
2648
+ if not line_bytes:
2649
+ break
2650
+ stderr_chunks.append(line_bytes)
2651
+ line = line_bytes.decode(errors="replace").rstrip("\n")
2652
+ if line:
2653
+ _last_activity_at[0] = time.monotonic()
2654
+ stdout_lines.append(line)
2655
+ if on_chunk:
2656
+ try:
2657
+ await on_chunk([line])
2658
+ except Exception:
2659
+ pass
2660
+ else:
2586
2661
  data = await proc.stderr.read()
2587
2662
  stderr_chunks.append(data)
2588
2663
 
@@ -2960,11 +3035,31 @@ class ProcessManager:
2960
3035
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
2961
3036
  on_chunk: Any = None,
2962
3037
  ) -> TaskResult:
2963
- """Run Kimi Code CLI in non-interactive, auto-approved mode."""
2964
- cmd = [agent.command, "--print", "--yolo", "-p", prompt]
2965
- result = await self._run_cli(cmd, cwd, timeout, task_id, on_chunk=on_chunk)
3038
+ """Run Kimi Code CLI in non-interactive prompt mode.
3039
+
3040
+ Kimi Code 0.18.0+ removed ``--print`` and refuses to combine ``--yolo``
3041
+ or ``--auto`` with ``-p``. In prompt mode auto-approval is implicit,
3042
+ so a bare ``-p <prompt>`` is the correct non-interactive invocation.
3043
+
3044
+ Kimi 0.18.0+ writes all real-time progress (thinking, tool calls,
3045
+ status) to **stderr**, reserving stdout for a buffered final answer.
3046
+ We therefore stream stderr through ``on_chunk`` so the user sees
3047
+ output in real time instead of a blank panel until process exit.
3048
+ """
3049
+ cmd = [agent.command, "-p", prompt]
3050
+ result = await self._run_cli(
3051
+ cmd, cwd, timeout, task_id, on_chunk=on_chunk,
3052
+ stream_stderr=True,
3053
+ )
2966
3054
  parsed_metrics = self._parse_agent_jsonl_output(result.stdout)
2967
3055
  result.metrics.update(parsed_metrics)
3056
+ # Kimi 0.18.0+ stores token usage in the session wire.jsonl file,
3057
+ # not in CLI output. Fall back to parsing that file when the
3058
+ # stdout/stderr parsers found nothing.
3059
+ if not result.metrics.get("token_input") and not result.metrics.get("token_output"):
3060
+ wire_metrics = self._parse_kimi_wire_metrics(result.stdout)
3061
+ if wire_metrics:
3062
+ result.metrics.update(wire_metrics)
2968
3063
  return result
2969
3064
 
2970
3065
  async def _run_copilot(
@@ -3076,6 +3171,7 @@ class ProcessManager:
3076
3171
  stdin_input: str | None = None,
3077
3172
  on_chunk: Any = None,
3078
3173
  env: dict | None = None,
3174
+ stream_stderr: bool = False,
3079
3175
  ) -> TaskResult:
3080
3176
  try:
3081
3177
  proc = await asyncio.create_subprocess_exec(
@@ -3093,6 +3189,7 @@ class ProcessManager:
3093
3189
  stdout, stderr, returncode = await self._stream_process(
3094
3190
  proc, stdin_bytes, timeout, task_id, on_chunk,
3095
3191
  workspace_path=cwd,
3192
+ stream_stderr=stream_stderr,
3096
3193
  )
3097
3194
  status = "success" if returncode == 0 else "failed"
3098
3195
  return TaskResult(
@@ -3362,6 +3459,109 @@ class ProcessManager:
3362
3459
  metrics["token_output"] = output
3363
3460
  return metrics
3364
3461
 
3462
+ def _parse_kimi_wire_metrics(self, output: str) -> dict:
3463
+ """Extract token usage from Kimi Code 0.18.0+ session wire.jsonl.
3464
+
3465
+ Kimi 0.18.0+ (full architecture rewrite) no longer prints
3466
+ ``TokenUsage(...)`` repr lines to stdout/stderr. Instead, token
3467
+ usage is persisted in the session's ``wire.jsonl`` file as JSON
3468
+ events::
3469
+
3470
+ {"type":"usage.record",
3471
+ "model":"kimi-code/kimi-for-coding",
3472
+ "usage":{"inputOther":1241,"output":36,
3473
+ "inputCacheRead":14592,"inputCacheCreation":0},
3474
+ "usageScope":"turn"}
3475
+
3476
+ We extract the session id from the CLI output, resolve the session
3477
+ directory via ``session_index.jsonl``, then sum every
3478
+ ``usage.record`` event to get cumulative token counts.
3479
+ """
3480
+ import re as _re
3481
+ metrics: dict[str, Any] = {}
3482
+
3483
+ # 1. Extract session id from output — appears as:
3484
+ # "To resume this session: kimi -r session_<uuid>"
3485
+ # or JSON: "session_id":"session_<uuid>"
3486
+ session_match = _re.search(
3487
+ r"session_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
3488
+ output,
3489
+ )
3490
+ if not session_match:
3491
+ return metrics
3492
+ session_id = "session_" + session_match.group(1)
3493
+
3494
+ # 2. Resolve session directory via session_index.jsonl
3495
+ kimi_home = Path(os.environ.get("KIMI_CODE_HOME", Path.home() / ".kimi-code"))
3496
+ index_path = kimi_home / "session_index.jsonl"
3497
+ if not index_path.is_file():
3498
+ return metrics
3499
+
3500
+ session_dir: Path | None = None
3501
+ try:
3502
+ with open(index_path, encoding="utf-8", errors="replace") as fh:
3503
+ for line in fh:
3504
+ line = line.strip()
3505
+ if not line:
3506
+ continue
3507
+ try:
3508
+ entry = json.loads(line)
3509
+ except json.JSONDecodeError:
3510
+ continue
3511
+ if entry.get("sessionId") == session_id:
3512
+ sd = entry.get("sessionDir")
3513
+ if sd:
3514
+ session_dir = Path(sd)
3515
+ break
3516
+ except (OSError, IOError):
3517
+ return metrics
3518
+
3519
+ if not session_dir or not session_dir.is_dir():
3520
+ return metrics
3521
+
3522
+ # 3. Parse wire.jsonl for usage.record events
3523
+ wire_path = session_dir / "agents" / "main" / "wire.jsonl"
3524
+ if not wire_path.is_file():
3525
+ return metrics
3526
+
3527
+ total_input_other = 0
3528
+ total_output = 0
3529
+ total_cache_read = 0
3530
+ total_cache_creation = 0
3531
+ model: str | None = None
3532
+
3533
+ try:
3534
+ with open(wire_path, encoding="utf-8", errors="replace") as fh:
3535
+ for line in fh:
3536
+ line = line.strip()
3537
+ if not line:
3538
+ continue
3539
+ try:
3540
+ data = json.loads(line)
3541
+ except json.JSONDecodeError:
3542
+ continue
3543
+ if data.get("type") != "usage.record":
3544
+ continue
3545
+ usage = data.get("usage") or {}
3546
+ total_input_other += int(usage.get("inputOther") or 0)
3547
+ total_output += int(usage.get("output") or 0)
3548
+ total_cache_read += int(usage.get("inputCacheRead") or 0)
3549
+ total_cache_creation += int(usage.get("inputCacheCreation") or 0)
3550
+ if not model and data.get("model"):
3551
+ model = data["model"]
3552
+ except (OSError, IOError):
3553
+ return metrics
3554
+
3555
+ if total_output > 0 or total_input_other > 0 or total_cache_read > 0:
3556
+ metrics["token_input"] = (
3557
+ total_input_other + total_cache_read + total_cache_creation
3558
+ )
3559
+ metrics["token_output"] = total_output
3560
+ if model:
3561
+ metrics["model"] = model
3562
+
3563
+ return metrics
3564
+
3365
3565
  def _parse_copilot_output(self, stdout: str) -> dict:
3366
3566
  """Extract metrics from GitHub Copilot CLI JSONL output.
3367
3567
 
@@ -4527,7 +4727,7 @@ class RuntimeDaemon:
4527
4727
  "codex": "npm install -g @openai/codex",
4528
4728
  "opencode": "curl -sSL https://opencode.ai/install | sh",
4529
4729
  "gemini": "npm install -g @google/gemini-cli",
4530
- "kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
4730
+ "kimi": "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
4531
4731
  "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
4532
4732
  }
4533
4733
  hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
@@ -4876,30 +5076,48 @@ class RuntimeDaemon:
4876
5076
  or result.exit_code not in (None, -1) # crash: original guard
4877
5077
  )
4878
5078
  if can_attempt_recovery:
5079
+ original_exit_code = result.exit_code
4879
5080
  committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
4880
5081
  has_committed_changes = bool(committed_git.get("files_changed"))
4881
- has_no_uncommitted = not pre_commit_git.get("files_changed")
5082
+ has_uncommitted_changes = bool(pre_commit_git.get("files_changed"))
5083
+ has_no_uncommitted = not has_uncommitted_changes
4882
5084
  has_tokens = (
4883
5085
  int(result.metrics.get("token_input", 0) or 0)
4884
5086
  + int(result.metrics.get("token_output", 0) or 0)
4885
5087
  ) > 0
4886
5088
  has_meaningful_output = self.process_manager.has_meaningful_agent_output(result)
5089
+ recovered_from_workspace_timeout = (
5090
+ self._recover_timeout_from_workspace_changes(
5091
+ task, workspace_path, result, pre_commit_git,
5092
+ )
5093
+ if has_uncommitted_changes else False
5094
+ )
4887
5095
  # Timeout recovery requires stronger evidence: committed work + tokens.
4888
5096
  # Crash recovery (original): committed + (tokens OR meaningful output).
4889
5097
  sufficient_evidence = (
4890
- (has_committed_changes and has_no_uncommitted and has_tokens and has_meaningful_output)
5098
+ recovered_from_workspace_timeout
5099
+ or (has_committed_changes and has_no_uncommitted and has_tokens and has_meaningful_output)
4891
5100
  if is_timeout_failure
4892
5101
  else (has_committed_changes and has_no_uncommitted and (has_tokens or has_meaningful_output))
4893
5102
  )
4894
5103
  if sufficient_evidence:
4895
- _reason = "timed out but already committed changes" if is_timeout_failure else f"exited with code {result.exit_code}"
5104
+ _reason = (
5105
+ "timed out after producing valid workspace changes"
5106
+ if recovered_from_workspace_timeout
5107
+ else (
5108
+ "timed out but already committed changes"
5109
+ if is_timeout_failure else f"exited with code {original_exit_code}"
5110
+ )
5111
+ )
4896
5112
  logger.warning(
4897
5113
  "Task %s agent %s — recovering as success",
4898
5114
  task.task_id, _reason,
4899
5115
  )
4900
5116
  result.status = "success"
4901
5117
  result.error = ""
4902
- result.metrics["recovered_from_exit_code"] = result.exit_code
5118
+ result.exit_code = 0
5119
+ result.failure_code = ""
5120
+ result.metrics["recovered_from_exit_code"] = original_exit_code
4903
5121
 
4904
5122
  # 4.5 Layer 2: Validation gate — check outputs before committing
4905
5123
  if result.status == "success":
@@ -5060,6 +5278,10 @@ class RuntimeDaemon:
5060
5278
  result.lines_removed = result.git.get("lines_removed", 0)
5061
5279
  else:
5062
5280
  result.git = pre_commit_git
5281
+ if not result.files_changed and pre_commit_git.get("files_changed"):
5282
+ result.files_changed = pre_commit_git["files_changed"]
5283
+ result.lines_added = pre_commit_git.get("lines_added", 0)
5284
+ result.lines_removed = pre_commit_git.get("lines_removed", 0)
5063
5285
 
5064
5286
  # 6. Report completion (include actual agent used if different from requested)
5065
5287
  result.metrics["actual_agent"] = agent.agent_id
@@ -5385,7 +5607,7 @@ class RuntimeDaemon:
5385
5607
  "codex": "npm install -g @openai/codex",
5386
5608
  "opencode": "curl -sSL https://opencode.ai/install | sh",
5387
5609
  "gemini": "npm install -g @google/gemini-cli",
5388
- "kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
5610
+ "kimi": "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
5389
5611
  "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
5390
5612
  }
5391
5613
  hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
@@ -6283,6 +6505,23 @@ class RuntimeDaemon:
6283
6505
 
6284
6506
  return "\n".join(parts).rstrip()
6285
6507
 
6508
+ async def _fetch_branch_tracking_ref(
6509
+ self,
6510
+ workspace_path: Path,
6511
+ branch: str,
6512
+ project_key: str = "default",
6513
+ *,
6514
+ timeout: int = 120,
6515
+ ) -> None:
6516
+ """Refresh origin/<branch> explicitly, even inside single-branch clones."""
6517
+ await self.workspace_manager._git(
6518
+ "fetch", "origin",
6519
+ f"{branch}:refs/remotes/origin/{branch}",
6520
+ cwd=workspace_path,
6521
+ timeout=timeout,
6522
+ project_key=project_key,
6523
+ )
6524
+
6286
6525
  @staticmethod
6287
6526
  def _is_test_path(path: str) -> bool:
6288
6527
  p = path.lower()
@@ -7103,9 +7342,11 @@ class RuntimeDaemon:
7103
7342
  # fail with "non-fast-forward". This is the single most reliable
7104
7343
  # guard for cross-runtime / cross-machine collaboration scenarios.
7105
7344
  try:
7106
- await git(
7107
- "fetch", "origin", branch,
7108
- cwd=workspace_path, project_key=project_key,
7345
+ await self._fetch_branch_tracking_ref(
7346
+ workspace_path,
7347
+ branch,
7348
+ project_key,
7349
+ timeout=300,
7109
7350
  )
7110
7351
  except RuntimeError as _pre_push_fetch_exc:
7111
7352
  logger.warning(
@@ -7265,9 +7506,10 @@ class RuntimeDaemon:
7265
7506
  )
7266
7507
  try:
7267
7508
  # 1. Fetch the specific remote branch
7268
- await git(
7269
- "fetch", "origin", branch,
7270
- cwd=workspace_path,
7509
+ await self._fetch_branch_tracking_ref(
7510
+ workspace_path,
7511
+ branch,
7512
+ project_key,
7271
7513
  )
7272
7514
  # 2. Rebase local commits onto the updated remote HEAD.
7273
7515
  # Use -X theirs so that when the same file was modified by
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.11.0
3
+ Version: 1.11.1
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.11.0"
3
+ version = "1.11.1"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
File without changes
File without changes