forgexa-cli 1.58.3__tar.gz → 1.59.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.
Files changed (29) hide show
  1. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/_local_bind.py +57 -0
  4. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/daemon.py +49 -10
  5. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/main.py +99 -24
  6. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/pyproject.toml +1 -1
  8. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/README.md +0 -0
  9. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/_build_config.py +0 -0
  10. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/agent_core.py +0 -0
  11. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/autoupgrade.py +0 -0
  12. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli/zcode_protocol.py +0 -0
  15. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/setup.cfg +0 -0
  21. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.58.3 → forgexa_cli-1.59.0}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.58.3
3
+ Version: 1.59.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-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.58.3"
2
+ __version__ = "1.59.0"
@@ -11,11 +11,13 @@ changes. See docs/designs/forgexa-ide-design.md §6.1/§6.2/§6.4.
11
11
  """
12
12
  from __future__ import annotations
13
13
 
14
+ import contextlib
14
15
  import json
15
16
  import os
16
17
  import re
17
18
  import subprocess
18
19
  from pathlib import Path
20
+ from typing import Iterator
19
21
 
20
22
 
21
23
  def local_workspaces_mapping_path() -> Path:
@@ -44,6 +46,61 @@ def save_local_workspaces_mapping(mapping: dict) -> None:
44
46
  tmp_path.replace(path)
45
47
 
46
48
 
49
+ @contextlib.contextmanager
50
+ def local_workspaces_mapping_lock() -> Iterator[None]:
51
+ """Advisory cross-process lock for read-modify-write cycles on the
52
+ mapping file (multi-workspace parallel design §6.1/§6.3). Shares the
53
+ `local-workspaces.json.lock` path with the Desktop Rust writer (fd-lock
54
+ crate); without it, a CLI bind-local and a Desktop link running
55
+ concurrently could each drop the other's just-written entry. Best-effort:
56
+ platforms without fcntl/msvcrt degrade to lock-free (the atomic rename
57
+ still prevents torn files).
58
+ """
59
+ path = local_workspaces_mapping_path()
60
+ lock_path = path.with_name(path.name + ".lock")
61
+ try:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ lock_file = open(lock_path, "a+")
64
+ except OSError:
65
+ yield
66
+ return
67
+
68
+ lock_kind = None
69
+ try:
70
+ try:
71
+ import fcntl
72
+
73
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
74
+ lock_kind = "fcntl"
75
+ except ImportError:
76
+ try:
77
+ import msvcrt
78
+
79
+ lock_file.seek(0)
80
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
81
+ lock_kind = "msvcrt"
82
+ except (ImportError, OSError):
83
+ pass
84
+ yield
85
+ finally:
86
+ if lock_kind == "fcntl":
87
+ try:
88
+ import fcntl
89
+
90
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
91
+ except (ImportError, OSError):
92
+ pass
93
+ elif lock_kind == "msvcrt":
94
+ try:
95
+ import msvcrt
96
+
97
+ lock_file.seek(0)
98
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
99
+ except (ImportError, OSError):
100
+ pass
101
+ lock_file.close()
102
+
103
+
47
104
  _SSH_SCHEME_RE = re.compile(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
48
105
  _HTTP_SCHEME_RE = re.compile(r"^https?://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
49
106
  _SCP_LIKE_RE = re.compile(r"^[^@/]+@([^:/]+):(.+?)(?:\.git)?/?$")
@@ -1196,7 +1196,7 @@ except (ImportError, ModuleNotFoundError):
1196
1196
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1197
1197
  # Kept in sync with pyproject.toml version via bump-version.sh.
1198
1198
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1199
- DAEMON_VERSION = "1.58.3"
1199
+ DAEMON_VERSION = "1.59.0"
1200
1200
 
1201
1201
 
1202
1202
  def _detect_client_type() -> str:
@@ -8507,6 +8507,7 @@ class ProgressReporter:
8507
8507
  files_changed: list[str] | None = None,
8508
8508
  lines_added: int = 0,
8509
8509
  lines_removed: int = 0,
8510
+ execution_attempt_id: str | None = None,
8510
8511
  ) -> bool:
8511
8512
  output_lines = [
8512
8513
  *self._pending_output_lines.get(task_id, []),
@@ -8537,6 +8538,7 @@ class ProgressReporter:
8537
8538
  "files_changed": files_changed,
8538
8539
  "lines_added": lines_added,
8539
8540
  "lines_removed": lines_removed,
8541
+ "execution_attempt_id": execution_attempt_id,
8540
8542
  },
8541
8543
  timeout=10,
8542
8544
  )
@@ -9017,7 +9019,7 @@ class HeartbeatService:
9017
9019
  "active_tasks": self._active_tasks,
9018
9020
  "available_agents": self._agents,
9019
9021
  "capabilities": {
9020
- "local_workspace_bindings": {"protocol_version": 3},
9022
+ "local_workspace_bindings": {"protocol_version": 4},
9021
9023
  "local_commit_and_push": {"protocol_version": 1},
9022
9024
  "local_fresh_branch_reset": {
9023
9025
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
@@ -9387,7 +9389,7 @@ class ServerConnection:
9387
9389
  "capabilities": {
9388
9390
  "platform": platform.platform(),
9389
9391
  "python_version": platform.python_version(),
9390
- "local_workspace_bindings": {"protocol_version": 3},
9392
+ "local_workspace_bindings": {"protocol_version": 4},
9391
9393
  "local_commit_and_push": {"protocol_version": 1},
9392
9394
  "local_fresh_branch_reset": {
9393
9395
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
@@ -9807,7 +9809,7 @@ class ServerConnection:
9807
9809
  "capabilities": {
9808
9810
  "platform": platform.platform(),
9809
9811
  "python_version": platform.python_version(),
9810
- "local_workspace_bindings": {"protocol_version": 3},
9812
+ "local_workspace_bindings": {"protocol_version": 4},
9811
9813
  "local_commit_and_push": {"protocol_version": 1},
9812
9814
  "local_fresh_branch_reset": {
9813
9815
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
@@ -12023,7 +12025,7 @@ class RuntimeDaemon:
12023
12025
  task.task_id,
12024
12026
  project_lock_key,
12025
12027
  )
12026
- await reporter.report_progress(task.task_id, 1, "waiting_for_execution_lock")
12028
+ await reporter.report_progress(task.task_id, 1, "waiting_for_execution_lock", execution_attempt_id=task.execution_attempt_id)
12027
12029
  await project_lock.acquire()
12028
12030
  workspace_execution_lock: _WorkspaceFileLock | None = None
12029
12031
  local_bind_file_lock = None
@@ -12083,7 +12085,7 @@ class RuntimeDaemon:
12083
12085
  ))
12084
12086
  return
12085
12087
 
12086
- await reporter.report_progress(task.task_id, 3, "waiting_for_workspace_lock")
12088
+ await reporter.report_progress(task.task_id, 3, "waiting_for_workspace_lock", execution_attempt_id=task.execution_attempt_id)
12087
12089
  workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
12088
12090
  task.project,
12089
12091
  task,
@@ -12118,7 +12120,7 @@ class RuntimeDaemon:
12118
12120
  return
12119
12121
 
12120
12122
  # 2. Prepare workspace
12121
- await reporter.report_progress(task.task_id, 5, "preparing_workspace")
12123
+ await reporter.report_progress(task.task_id, 5, "preparing_workspace", execution_attempt_id=task.execution_attempt_id)
12122
12124
  workspace_path = await self.workspace_manager.prepare_workspace(
12123
12125
  task.project, task, runtime_id=conn.runtime_id,
12124
12126
  )
@@ -12137,6 +12139,7 @@ class RuntimeDaemon:
12137
12139
  8,
12138
12140
  "preparing_attachments",
12139
12141
  output_lines=["[daemon] Preparing task attachments"],
12142
+ execution_attempt_id=task.execution_attempt_id,
12140
12143
  )
12141
12144
  attachment_staging_dir = await self._stage_task_attachments(task, conn)
12142
12145
  except _TaskAttachmentDownloadError as exc:
@@ -12146,6 +12149,7 @@ class RuntimeDaemon:
12146
12149
  8,
12147
12150
  "attachment_download_failed",
12148
12151
  output_lines=["[daemon] Required task attachment could not be downloaded"],
12152
+ execution_attempt_id=task.execution_attempt_id,
12149
12153
  )
12150
12154
  await report_task_complete(TaskResult(
12151
12155
  status="failed",
@@ -12186,7 +12190,7 @@ class RuntimeDaemon:
12186
12190
  )
12187
12191
 
12188
12192
  # 3. Run agent with real-time output streaming + periodic progress heartbeat
12189
- await reporter.report_progress(task.task_id, 10, "running_agent")
12193
+ await reporter.report_progress(task.task_id, 10, "running_agent", execution_attempt_id=task.execution_attempt_id)
12190
12194
 
12191
12195
  # Buffer cloud output or local-bind activity summaries between
12192
12196
  # ticks so the server accumulates a real-time, safe display log.
@@ -12225,6 +12229,7 @@ class RuntimeDaemon:
12225
12229
  progress_percent,
12226
12230
  current_step,
12227
12231
  activity_lines=output_lines,
12232
+ execution_attempt_id=task.execution_attempt_id,
12228
12233
  )
12229
12234
  else:
12230
12235
  await reporter.report_progress(
@@ -12232,6 +12237,7 @@ class RuntimeDaemon:
12232
12237
  progress_percent,
12233
12238
  current_step,
12234
12239
  output_lines=output_lines,
12240
+ execution_attempt_id=task.execution_attempt_id,
12235
12241
  )
12236
12242
 
12237
12243
  async def _progress_ticker():
@@ -12352,6 +12358,7 @@ class RuntimeDaemon:
12352
12358
  task.task_id, 10,
12353
12359
  f"agent_fallback: retrying with {agent.agent_id}",
12354
12360
  output_lines=[f"[daemon] Agent {fallback_label}, switching to {agent.agent_id}"],
12361
+ execution_attempt_id=task.execution_attempt_id,
12355
12362
  )
12356
12363
 
12357
12364
  # Re-run with fallback agent
@@ -12601,6 +12608,7 @@ class RuntimeDaemon:
12601
12608
  f"[daemon] Agent {post_validation_failure_label} during validation, "
12602
12609
  f"switching to {agent.agent_id}",
12603
12610
  ],
12611
+ execution_attempt_id=task.execution_attempt_id,
12604
12612
  )
12605
12613
  result = await self.process_manager.run_agent(
12606
12614
  agent, task, workspace_path, on_chunk=on_output_chunk,
@@ -12874,7 +12882,7 @@ class RuntimeDaemon:
12874
12882
  )
12875
12883
 
12876
12884
  if result.status == "success" and task.critic_review_required:
12877
- await reporter.report_progress(task.task_id, 95, "running_critic_review")
12885
+ await reporter.report_progress(task.task_id, 95, "running_critic_review", execution_attempt_id=task.execution_attempt_id)
12878
12886
  result.critic_review = await self._run_runtime_critic_review(
12879
12887
  task,
12880
12888
  agent,
@@ -12893,7 +12901,7 @@ class RuntimeDaemon:
12893
12901
  or "validation_retry_exhausted"
12894
12902
  )
12895
12903
  result.execution_attempt_id = task.execution_attempt_id
12896
- await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed")
12904
+ await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed", execution_attempt_id=task.execution_attempt_id)
12897
12905
  await report_task_complete(result)
12898
12906
 
12899
12907
  logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
@@ -15763,6 +15771,10 @@ class RuntimeDaemon:
15763
15771
  broadly `git add -A`s and may rebase/force-push onto the latest
15764
15772
  default branch), this adapter never touches the user's real
15765
15773
  directory beyond a plain stage+commit+fast-forward-push:
15774
+ - re-verifies the directory is still on the task's workspace_branch
15775
+ before staging and again before pushing: if the user switched
15776
+ branches while the agent ran, the task fails closed instead of
15777
+ committing/pushing the changes onto the wrong branch
15766
15778
  - refuses to commit if any changed path (staged, or already
15767
15779
  committed by the agent itself since before_sha) looks like a
15768
15780
  secret (.env, private keys, etc.) — fails the task closed rather
@@ -15779,6 +15791,26 @@ class RuntimeDaemon:
15779
15791
  """
15780
15792
  git = self.workspace_manager._git
15781
15793
  try:
15794
+ target_branch = task.workspace_branch
15795
+ if not target_branch:
15796
+ return {"commit_error": (
15797
+ "Refusing to commit: no workspace_branch recorded for this "
15798
+ "local_bind task — cannot verify the directory is still on "
15799
+ "the task's branch."
15800
+ )}
15801
+ current_branch = (
15802
+ await git("rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path)
15803
+ ).strip()
15804
+ if current_branch != target_branch:
15805
+ return {"commit_error": (
15806
+ f"Refusing to commit: local directory is on branch "
15807
+ f"'{current_branch}', but this task started on "
15808
+ f"'{target_branch}'. The branch was switched while the "
15809
+ "task was running (outside Forgexa). Switch back to "
15810
+ f"'{target_branch}' and review the working tree yourself, "
15811
+ "then retry."
15812
+ )}
15813
+
15782
15814
  await self._discard_transient_untracked_runtime_files(workspace_path)
15783
15815
  status_output = await git("status", "--porcelain", cwd=workspace_path)
15784
15816
  dirty_paths = [line[3:] for line in status_output.splitlines() if line.strip()]
@@ -15898,6 +15930,13 @@ class RuntimeDaemon:
15898
15930
  return {"push_error": f"failed to read current branch: {exc}"}
15899
15931
  if not current_branch or current_branch == "HEAD":
15900
15932
  return {"push_error": "not on a branch (detached HEAD) — refusing to push"}
15933
+ if current_branch != target_branch:
15934
+ return {"push_error": (
15935
+ f"Refusing to push: local directory moved to branch "
15936
+ f"'{current_branch}' after commit; this task ran on "
15937
+ f"'{target_branch}'. Nothing was pushed — switch back to "
15938
+ f"'{target_branch}' and retry."
15939
+ )}
15901
15940
 
15902
15941
  try:
15903
15942
  await git(
@@ -3152,6 +3152,7 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3152
3152
  from forgexa_cli._local_bind import (
3153
3153
  load_local_workspaces_mapping,
3154
3154
  local_git_remote_identity,
3155
+ local_workspaces_mapping_lock,
3155
3156
  save_local_workspaces_mapping,
3156
3157
  )
3157
3158
 
@@ -3201,10 +3202,74 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3201
3202
  print("Error: could not resolve current user (are you logged in?)", file=sys.stderr)
3202
3203
  sys.exit(1)
3203
3204
 
3204
- binding = _post(
3205
- f"/projects/{args.project}/local-workspace-bindings",
3206
- {"runtime_id": runtime["id"]},
3207
- )
3205
+ # Reuse the binding this directory is already mapped to when there is
3206
+ # one — the server never sees paths, so directory identity is a local
3207
+ # question (multi-workspace parallel design §6.1). Only create when the
3208
+ # mapping has no candidate or the server-side binding is gone/revoked;
3209
+ # this keeps a re-link from piling up duplicate bindings for the same
3210
+ # directory on projects that allow several bindings per runtime.
3211
+ mapping = load_local_workspaces_mapping()
3212
+ local_path_str = str(local_path)
3213
+ binding = None
3214
+ server_bindings = None
3215
+ for mapping_id, entry in mapping.items():
3216
+ entry_path = str(entry.get("local_path") or "")
3217
+ try:
3218
+ entry_path = str(Path(entry_path).expanduser().resolve())
3219
+ except OSError:
3220
+ pass
3221
+ if (
3222
+ entry_path == local_path_str
3223
+ and entry.get("project_id") == args.project
3224
+ and entry.get("runtime_id") == runtime["id"]
3225
+ and entry.get("user_id") == user_id
3226
+ ):
3227
+ if server_bindings is None:
3228
+ server_bindings = _get(f"/projects/{args.project}/local-workspace-bindings")
3229
+ existing = next(
3230
+ (
3231
+ b
3232
+ for b in server_bindings
3233
+ if isinstance(b, dict) and b.get("id") == mapping_id
3234
+ ),
3235
+ None,
3236
+ )
3237
+ if isinstance(existing, dict) and existing.get("status") in ("pending", "active", "stale"):
3238
+ binding = existing
3239
+ break
3240
+
3241
+ # Directory↔project bijection (multi-workspace design §7.1): one physical
3242
+ # directory belongs to exactly one project at a time. Re-binding it to a
3243
+ # different project swaps ownership (the old binding goes stale) — refuse
3244
+ # without an explicit --force so picking the wrong project can never
3245
+ # masquerade as "adding another workspace".
3246
+ for entry in mapping.values():
3247
+ entry_path = str(entry.get("local_path") or "")
3248
+ try:
3249
+ entry_path = str(Path(entry_path).expanduser().resolve())
3250
+ except OSError:
3251
+ pass
3252
+ if entry_path == local_path_str and entry.get("project_id") != args.project:
3253
+ if not getattr(args, "force", False):
3254
+ print(
3255
+ f"Error: {local_path} is currently bound to project {entry.get('project_id')}. "
3256
+ "One directory belongs to exactly one project at a time; re-binding releases the old "
3257
+ "binding (it turns stale). If this folder really should change projects, re-run with "
3258
+ "--force. To add another workspace of the SAME project, bind the other folder instead.",
3259
+ file=sys.stderr,
3260
+ )
3261
+ sys.exit(1)
3262
+ print(
3263
+ f"Warning: re-binding {local_path} from project {entry.get('project_id')} to {args.project}",
3264
+ file=sys.stderr,
3265
+ )
3266
+ break
3267
+
3268
+ if binding is None:
3269
+ binding = _post(
3270
+ f"/projects/{args.project}/local-workspace-bindings",
3271
+ {"runtime_id": runtime["id"], "multi_binding_capable": True},
3272
+ )
3208
3273
  binding_id = binding.get("id")
3209
3274
  if not binding_id:
3210
3275
  print("Error: failed to create local workspace binding", file=sys.stderr)
@@ -3223,8 +3288,6 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3223
3288
  )
3224
3289
  sys.exit(1)
3225
3290
 
3226
- mapping = load_local_workspaces_mapping()
3227
- local_path_str = str(local_path)
3228
3291
  # A folder can only ever be validly bound to one binding_id at a time -
3229
3292
  # drop any other (necessarily stale) entry already pointing at this same
3230
3293
  # path first. Without this, a stale entry left behind by an earlier
@@ -3232,21 +3295,24 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3232
3295
  # verification that timed out client-side) can linger alongside the new
3233
3296
  # one, and agent_host.py's `_match_local_binding_id` (a plain path scan
3234
3297
  # with no tie-breaking) can resolve to the wrong, orphaned binding_id
3235
- # instead of the one just verified.
3236
- mapping = {
3237
- key: entry
3238
- for key, entry in mapping.items()
3239
- if key == binding_id or entry.get("local_path") != local_path_str
3240
- }
3241
- mapping[binding_id] = {
3242
- "project_id": args.project,
3243
- "user_id": user_id,
3244
- "runtime_id": runtime["id"],
3245
- "repo_identity": identity,
3246
- "local_path": str(local_path),
3247
- "share_agent_output": True,
3248
- }
3249
- save_local_workspaces_mapping(mapping)
3298
+ # instead of the one just verified. The whole read-modify-write runs
3299
+ # under the cross-process mapping lock shared with the Desktop writer.
3300
+ with local_workspaces_mapping_lock():
3301
+ mapping = load_local_workspaces_mapping()
3302
+ mapping = {
3303
+ key: entry
3304
+ for key, entry in mapping.items()
3305
+ if key == binding_id or entry.get("local_path") != local_path_str
3306
+ }
3307
+ mapping[binding_id] = {
3308
+ "project_id": args.project,
3309
+ "user_id": user_id,
3310
+ "runtime_id": runtime["id"],
3311
+ "repo_identity": identity,
3312
+ "local_path": str(local_path),
3313
+ "share_agent_output": True,
3314
+ }
3315
+ save_local_workspaces_mapping(mapping)
3250
3316
 
3251
3317
  _post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
3252
3318
  deadline = time.monotonic() + 35
@@ -3302,9 +3368,12 @@ def cmd_project_unbind_local(args: argparse.Namespace) -> None:
3302
3368
 
3303
3369
  binding_id, entry = matches[0]
3304
3370
  _delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3305
- mapping = load_local_workspaces_mapping() # re-read in case of concurrent writes
3306
- mapping.pop(binding_id, None)
3307
- save_local_workspaces_mapping(mapping)
3371
+ from forgexa_cli._local_bind import local_workspaces_mapping_lock
3372
+
3373
+ with local_workspaces_mapping_lock(): # re-read under the cross-process lock
3374
+ mapping = load_local_workspaces_mapping()
3375
+ mapping.pop(binding_id, None)
3376
+ save_local_workspaces_mapping(mapping)
3308
3377
  print(f"Unbound {entry.get('local_path', '')} from project {args.project} (binding {binding_id[:8]})")
3309
3378
 
3310
3379
 
@@ -3924,6 +3993,12 @@ def main() -> None:
3924
3993
  bl.add_argument("path", help="Absolute path to your local working directory")
3925
3994
  bl.add_argument("--project", required=True, help="Project ID")
3926
3995
  bl.add_argument("--runtime", default=None, help="Personal runtime ID prefix (if you have more than one)")
3996
+ bl.add_argument(
3997
+ "--force",
3998
+ action="store_true",
3999
+ help="Allow re-binding a directory that currently belongs to a DIFFERENT project "
4000
+ "(the old binding turns stale on the daemon's next validation cycle)",
4001
+ )
3927
4002
  ul = proj_sub.add_parser("unbind-local", help="Revoke a local directory binding")
3928
4003
  ul.add_argument("--project", required=True, help="Project ID")
3929
4004
  ul.add_argument("--binding", default=None, help="Binding ID prefix (if you have more than one for this project)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.58.3
3
+ Version: 1.59.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-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.58.3"
3
+ version = "1.59.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 = "MIT"
File without changes
File without changes