forgexa-cli 1.62.5__tar.gz → 1.63.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.
Files changed (29) hide show
  1. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/PKG-INFO +1 -1
  2. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/agent_core.py +19 -12
  4. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/daemon.py +199 -79
  5. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/pyproject.toml +1 -1
  7. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/README.md +0 -0
  8. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli/zcode_protocol.py +0 -0
  15. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/setup.cfg +0 -0
  21. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.62.5 → forgexa_cli-1.63.1}/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.62.5
3
+ Version: 1.63.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-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.62.5"
2
+ __version__ = "1.63.1"
@@ -1309,12 +1309,14 @@ def _kill_proc(proc: asyncio.subprocess.Process) -> None:
1309
1309
  (npm, yarn, ssh, git, etc.) spawned by the agent stay alive, keep pipes
1310
1310
  open, and exhaust system resources. ``os.killpg`` sends SIGKILL to the
1311
1311
  whole process group, reliably cleaning up all descendants.
1312
+ Owned agents start a new session, so their PID remains the process-group
1313
+ ID even after the leader exits.
1312
1314
  """
1313
1315
  try:
1314
1316
  if sys.platform != "win32":
1315
1317
  import signal as _signal
1316
1318
  try:
1317
- os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
1319
+ os.killpg(proc.pid, _signal.SIGKILL)
1318
1320
  except (ProcessLookupError, PermissionError, OSError):
1319
1321
  pass
1320
1322
  else:
@@ -1322,6 +1324,7 @@ def _kill_proc(proc: asyncio.subprocess.Process) -> None:
1322
1324
  _subprocess.run(
1323
1325
  ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
1324
1326
  capture_output=True,
1327
+ timeout=5,
1325
1328
  **_agent_process_group_kwargs(),
1326
1329
  )
1327
1330
  except Exception:
@@ -2462,7 +2465,7 @@ def deepseek_harness_build_info(binary: str) -> dict:
2462
2465
  if result.returncode or not re.fullmatch(r"\d+\.\d+\.\d+", node_version) or parsed is None or not ((parsed[0] == 22 and parsed >= (22, 19, 0)) or parsed[0] >= 24):
2463
2466
  raise RuntimeError("DeepSeek Harness requires Node 22.19+ or Node 24+")
2464
2467
  digest = hashlib.sha256()
2465
- digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-3".encode())
2468
+ digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-4".encode())
2466
2469
  extensions = [manifest.parent for manifest in (roots[0] / "node_modules" / "@deepseek-ai").glob("dsh*/package.json")]
2467
2470
  for root in sorted(set([*roots, *extensions])):
2468
2471
  files = [root / "package.json"] + sorted((root / "lib").rglob("*.js"))
@@ -2592,10 +2595,15 @@ def deepseek_harness_overlay(*, cwd: Path, credentials_path: Path, provider: str
2592
2595
  "watch": False,
2593
2596
  }},
2594
2597
  {"id": "tool-skill", "disabled": False},
2598
+ {"id": "tool-subagent", "disabled": False, "config": {
2599
+ "provider": "spawn", "enableRunInBackground": False,
2600
+ "backgroundMode": "one-shot", "maxDepth": 1,
2601
+ "modelSelectionSettings": False,
2602
+ }},
2595
2603
  ]
2596
2604
  disabled = (
2597
2605
  "session-telemetry-otel", "hmr", "plugin-manager", "tool-plugin-manager",
2598
- "llm-pi-ai", "tool-subagent", "tool-subagent-fork",
2606
+ "llm-pi-ai", "tool-subagent-fork",
2599
2607
  "tool-subagent-control", "tool-subagent-list-agents", "tool-workflow",
2600
2608
  "goal-round-driver", "command-goal", "tool-goal", "command-feedback", "tool-web",
2601
2609
  )
@@ -2862,7 +2870,7 @@ def mimo_base_env(env: dict[str, str]) -> dict[str, str]:
2862
2870
  "git * reset --hard*": "deny",
2863
2871
  },
2864
2872
  "external_directory": "deny",
2865
- "task": "deny",
2873
+ "task": "allow",
2866
2874
  "skill": {"*": "allow", "compose:*": "deny"},
2867
2875
  "webfetch": "deny",
2868
2876
  "websearch": "deny",
@@ -3168,9 +3176,9 @@ def zcode_build_verification_issue(binary: str | Path, env: dict[str, str] | Non
3168
3176
  return None
3169
3177
 
3170
3178
 
3171
- ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Skill")
3179
+ ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Skill", "Agent", "Task")
3172
3180
  ZCODE_DENIED_TOOLS = (
3173
- "Bash", "MCP", "Plugin", "Task", "WebFetch", "WebSearch",
3181
+ "Bash", "MCP", "Plugin", "WebFetch", "WebSearch",
3174
3182
  )
3175
3183
 
3176
3184
 
@@ -3247,10 +3255,8 @@ def agent_execution_verification_issue(agent_id: str, binary: str) -> str | None
3247
3255
  return None
3248
3256
 
3249
3257
 
3250
- _FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill")
3258
+ _FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill", "Agent", "Task")
3251
3259
  _FORGEXA_CODING_AGENT_DENIED_TOOLS = (
3252
- "Agent",
3253
- "Task",
3254
3260
  "SlashCommand",
3255
3261
  "WebFetch",
3256
3262
  "WebSearch",
@@ -3655,9 +3661,10 @@ def build_copilot_command(
3655
3661
 
3656
3662
  ``prompt_via_stdin`` omits ``-p`` so the caller pipes the prompt into
3657
3663
  stdin instead - verified live on CLI 1.0.86, where piped stdin is a full
3658
- non-interactive prompt source. The host uses it when the argv form would
3659
- exceed the platform command-line limit (inlined attachments can push the
3660
- prompt past Windows' 32k-char CreateProcess ceiling).
3664
+ non-interactive prompt source. The daemon uses it for native launchers
3665
+ to avoid single-argument and total command-line limits; Windows batch
3666
+ wrappers retain task-file redirection. The host uses it when the argv
3667
+ form would exceed the platform command-line limit.
3661
3668
  """
3662
3669
  cmd = [
3663
3670
  binary,
@@ -114,7 +114,6 @@ _redirect_prompt_through_task_file = agent_core._redirect_prompt_through_task_fi
114
114
  _agent_process_group_kwargs = agent_core._agent_process_group_kwargs
115
115
  _parse_semver = agent_core._parse_semver
116
116
  _check_agent_min_version = agent_core._check_agent_min_version
117
- _kill_proc = agent_core._kill_proc
118
117
  _extract_output_signals = agent_core._extract_output_signals
119
118
  _should_scan_short_success_stdout = agent_core._should_scan_short_success_stdout
120
119
  _copilot_completed_without_result = agent_core._copilot_completed_without_result
@@ -126,6 +125,23 @@ _parse_kimi_wire_metrics = agent_core._parse_kimi_wire_metrics
126
125
  _parse_copilot_output = agent_core._parse_copilot_output
127
126
  _extract_copilot_exit_code = agent_core._extract_copilot_exit_code
128
127
 
128
+ # Only the fixed, admitted entry can select managed mode, never an env flag.
129
+ _wsl_policy_module = sys.modules.get("app.wsl_runtime_policy") or sys.modules.get("wsl_runtime_policy")
130
+ _MANAGED_WSL_POLICY = getattr(_wsl_policy_module, "ACTIVE_POLICY", None)
131
+
132
+
133
+ async def _create_subprocess_exec(*command, **kwargs):
134
+ if _MANAGED_WSL_POLICY is not None:
135
+ return await _MANAGED_WSL_POLICY.spawn(*command, **kwargs)
136
+ return await asyncio.create_subprocess_exec(*command, **kwargs)
137
+
138
+
139
+ def _kill_proc(proc) -> None:
140
+ if _MANAGED_WSL_POLICY is not None:
141
+ _MANAGED_WSL_POLICY.cancel_process(proc)
142
+ else:
143
+ agent_core._kill_proc(proc)
144
+
129
145
  # ── httpx dependency — robust auto-install for standalone environments ──
130
146
  # When running inside the backend package, httpx is a declared dependency and
131
147
  # always available. In standalone contexts (desktop AppImage/DMG/MSI, CLI
@@ -136,10 +152,13 @@ _extract_copilot_exit_code = agent_core._extract_copilot_exit_code
136
152
  # 3. Auto-install via pip --target to the cached deps dir
137
153
  # (bypasses PEP 668 / externally-managed-environment on modern distros)
138
154
  # 4. Friendly error with OS-specific instructions if all else fails
139
- _HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
155
+ _HTTPX_DEPS_DIR = (str(_MANAGED_WSL_POLICY.state / "deps") if _MANAGED_WSL_POLICY is not None
156
+ else os.path.join(str(Path.home()), ".forgexa", "daemon", "deps"))
140
157
 
141
158
 
142
159
  def _cli_config_path() -> Path:
160
+ if _MANAGED_WSL_POLICY is not None:
161
+ return _MANAGED_WSL_POLICY.state / "config.json"
143
162
  return Path.home() / ".forgexa" / "config"
144
163
 
145
164
 
@@ -230,10 +249,14 @@ def _local_runtime_registration_path() -> Path:
230
249
  nor workspace paths, so desktop clients can safely use it to avoid
231
250
  binding a local folder to a runtime on another machine.
232
251
  """
252
+ if _MANAGED_WSL_POLICY is not None:
253
+ return _MANAGED_WSL_POLICY.state / "runtime-registrations.json"
233
254
  return Path.home() / ".forgexa" / "daemon" / "runtime-registrations.json"
234
255
 
235
256
 
236
257
  def _desktop_daemon_restart_request_path(instance_id: str) -> Path:
258
+ if _MANAGED_WSL_POLICY is not None:
259
+ return _MANAGED_WSL_POLICY.state / "restart-requests" / f"{instance_id}.json"
237
260
  return (
238
261
  Path.home()
239
262
  / ".forgexa"
@@ -324,6 +347,8 @@ def _clear_local_runtime_registration_snapshot(instance_id: str) -> None:
324
347
 
325
348
 
326
349
  def _cli_credentials_path() -> Path:
350
+ if _MANAGED_WSL_POLICY is not None:
351
+ return _MANAGED_WSL_POLICY.state / "credentials.json"
327
352
  return Path.home() / ".forgexa" / "credentials.json"
328
353
 
329
354
 
@@ -374,6 +399,11 @@ def _migrate_legacy_cli_credentials(*, write_lock_held: bool = False) -> dict:
374
399
 
375
400
  def _load_cli_credentials(*, write_lock_held: bool = False) -> dict:
376
401
  """Load issuer-scoped credentials, migrating legacy config on first use."""
402
+ if _MANAGED_WSL_POLICY is not None:
403
+ return {_normalize_issuer_url(_MANAGED_WSL_POLICY.config.serverUrl): {
404
+ "runtime_credential": _MANAGED_WSL_POLICY.credential,
405
+ "runtime_id": _MANAGED_WSL_POLICY.runtime_id,
406
+ }}
377
407
  path = _cli_credentials_path()
378
408
  if path.exists():
379
409
  try:
@@ -920,6 +950,9 @@ def _validate_httpx_imports() -> tuple[bool, str]:
920
950
  # Actual import sequence
921
951
  _httpx_ok, _httpx_missing = _validate_httpx_imports()
922
952
 
953
+ if not _httpx_ok and _MANAGED_WSL_POLICY is not None:
954
+ raise RuntimeError("Managed WSL runtime dependency closure is incomplete")
955
+
923
956
  if not _httpx_ok:
924
957
  # Check cached deps directory (previous auto-install)
925
958
  if _HTTPX_DEPS_DIR not in sys.path:
@@ -997,6 +1030,8 @@ def _make_httpx_ssl_context():
997
1030
 
998
1031
  # ── Settings: graceful fallback when running standalone (outside backend package) ──
999
1032
  try:
1033
+ if _MANAGED_WSL_POLICY is not None:
1034
+ raise ModuleNotFoundError("Managed WSL uses admitted standalone settings")
1000
1035
  from app.config import settings
1001
1036
  except (ImportError, ModuleNotFoundError):
1002
1037
  # Running standalone (e.g., from desktop app bundle or forgexa-cli).
@@ -1196,7 +1231,7 @@ except (ImportError, ModuleNotFoundError):
1196
1231
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1197
1232
  # Kept in sync with pyproject.toml version via bump-version.sh.
1198
1233
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1199
- DAEMON_VERSION = "1.62.5"
1234
+ DAEMON_VERSION = "1.63.1"
1200
1235
 
1201
1236
 
1202
1237
  def _detect_client_type() -> str:
@@ -1234,7 +1269,8 @@ _CLIENT_TYPE = _detect_client_type()
1234
1269
  # so logs are visible on the terminal without duplication.
1235
1270
  # Set DAEMON_NO_CONSOLE_LOG=1 to suppress the stream handler even on a TTY
1236
1271
  # (used by `forgexa daemon start` foreground mode for clean UX).
1237
- _log_dir = Path.home() / ".forgexa" / "daemon"
1272
+ _log_dir = (_MANAGED_WSL_POLICY.state / "logs" if _MANAGED_WSL_POLICY is not None
1273
+ else Path.home() / ".forgexa" / "daemon")
1238
1274
  _log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
1239
1275
  # Ensure directory has restricted permissions even if it already existed
1240
1276
  _log_dir.chmod(0o700)
@@ -1512,6 +1548,8 @@ def _agent_dicts_for_wire(agents: "list[DiscoveredAgent]") -> list[dict]:
1512
1548
 
1513
1549
  def _resolve_git_command() -> str | None:
1514
1550
  """Return Git's executable, including common Windows installer locations."""
1551
+ if _MANAGED_WSL_POLICY is not None:
1552
+ return _MANAGED_WSL_POLICY.executable("git")
1515
1553
  resolved = shutil.which("git")
1516
1554
  if resolved or sys.platform != "win32":
1517
1555
  return resolved
@@ -2478,6 +2516,8 @@ def _local_workspaces_mapping_path() -> Path:
2478
2516
 
2479
2517
 
2480
2518
  def _load_local_workspaces_mapping() -> dict:
2519
+ if _MANAGED_WSL_POLICY is not None:
2520
+ return _MANAGED_WSL_POLICY.mapping()
2481
2521
  path = _local_workspaces_mapping_path()
2482
2522
  if not path.exists():
2483
2523
  return {}
@@ -2519,6 +2559,8 @@ _LOCAL_BIND_SHARED_OUTPUT_MAX_LINES = 5_000
2519
2559
 
2520
2560
 
2521
2561
  def _local_bind_output_log_path(task_id: str) -> Path:
2562
+ if _MANAGED_WSL_POLICY is not None:
2563
+ return _MANAGED_WSL_POLICY.output_path(task_id)
2522
2564
  return Path.home() / ".forgexa" / "local-bind-output" / f"{task_id}.log"
2523
2565
 
2524
2566
 
@@ -2530,6 +2572,9 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
2530
2572
  """
2531
2573
  if not lines:
2532
2574
  return
2575
+ if _MANAGED_WSL_POLICY is not None:
2576
+ _MANAGED_WSL_POLICY.append_output(task_id, lines)
2577
+ return
2533
2578
  try:
2534
2579
  path = _local_bind_output_log_path(task_id)
2535
2580
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -2544,6 +2589,8 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
2544
2589
 
2545
2590
  def _local_bind_output_sharing_enabled(binding_id: str | None) -> bool:
2546
2591
  """Return the private binding's automatic transcript-sharing preference."""
2592
+ if _MANAGED_WSL_POLICY is not None:
2593
+ return False
2547
2594
  if not binding_id:
2548
2595
  return False
2549
2596
  entry = _load_local_workspaces_mapping().get(binding_id)
@@ -2588,6 +2635,8 @@ def _read_local_bind_shared_output_snapshot(task_id: str) -> dict | None:
2588
2635
 
2589
2636
  def _prepare_local_bind_completion(task: "TaskInfo", result: "TaskResult") -> None:
2590
2637
  """Attach the automatic private snapshot before stripping normal local output."""
2638
+ if _MANAGED_WSL_POLICY is not None:
2639
+ result.shared_local_output = None
2591
2640
  if _local_bind_output_sharing_enabled(task.local_workspace_binding_id):
2592
2641
  result.shared_local_output = _read_local_bind_shared_output_snapshot(task.task_id)
2593
2642
  _filter_local_bind_task_result(result)
@@ -2719,6 +2768,11 @@ def _normalize_repo_identity(repo_url: str) -> str | None:
2719
2768
 
2720
2769
 
2721
2770
  async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id: str) -> str | None:
2771
+ if _MANAGED_WSL_POLICY is not None:
2772
+ _MANAGED_WSL_POLICY.check_mapping()
2773
+ expected = _MANAGED_WSL_POLICY.config.binding
2774
+ if runtime_id != _MANAGED_WSL_POLICY.runtime_id or not expected or any(binding.get(key) != value for key, value in expected.items()):
2775
+ return "mapping_mismatch"
2722
2776
  binding_id = str(binding.get("id") or "")
2723
2777
  entry = _load_local_workspaces_mapping().get(binding_id)
2724
2778
  if not binding_id or not isinstance(entry, dict):
@@ -2746,8 +2800,8 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
2746
2800
 
2747
2801
  proc = None
2748
2802
  try:
2749
- proc = await asyncio.create_subprocess_exec(
2750
- "git", "remote", "get-url", "origin",
2803
+ proc = await _create_subprocess_exec(
2804
+ _resolve_git_command() if _MANAGED_WSL_POLICY is not None else "git", "remote", "get-url", "origin",
2751
2805
  cwd=str(local_path),
2752
2806
  stdout=asyncio.subprocess.PIPE,
2753
2807
  stderr=asyncio.subprocess.DEVNULL,
@@ -2826,6 +2880,8 @@ def _try_acquire_local_workspace_file_lock(binding_id: str, workspace_path: Path
2826
2880
 
2827
2881
 
2828
2882
  def _release_local_workspace_file_lock(lock_file) -> None:
2883
+ if _MANAGED_WSL_POLICY is not None and lock_file is _MANAGED_WSL_POLICY.task_lock:
2884
+ return # Only verified empty-tree cleanup releases the inherited locks.
2829
2885
  agent_core.release_file_lock(lock_file)
2830
2886
 
2831
2887
 
@@ -3573,6 +3629,9 @@ class WorkspaceManager:
3573
3629
  fails closed with a "local_binding_unavailable:" prefixed error. See
3574
3630
  docs/designs/forgexa-ide-design.md §6.2.
3575
3631
  """
3632
+ if _MANAGED_WSL_POLICY is not None:
3633
+ _MANAGED_WSL_POLICY.validate_task(task, runtime_id)
3634
+ _MANAGED_WSL_POLICY.check_mapping()
3576
3635
  binding_id = task.local_workspace_binding_id
3577
3636
  if not binding_id:
3578
3637
  raise RuntimeError("local_binding_unavailable: task has no local_workspace_binding_id")
@@ -4069,7 +4128,7 @@ class WorkspaceManager:
4069
4128
  # Warn if the clone has uncommitted changes so the operator knows
4070
4129
  # what will be lost when it is removed (L3 fix).
4071
4130
  try:
4072
- st_proc = await asyncio.create_subprocess_exec(
4131
+ st_proc = await _create_subprocess_exec(
4073
4132
  "git", "status", "--porcelain",
4074
4133
  cwd=str(ws_path),
4075
4134
  stdout=asyncio.subprocess.PIPE,
@@ -4242,7 +4301,7 @@ class WorkspaceManager:
4242
4301
  """
4243
4302
  try:
4244
4303
  # Get the local HEAD SHA
4245
- local_proc = await asyncio.create_subprocess_exec(
4304
+ local_proc = await _create_subprocess_exec(
4246
4305
  "git", "rev-parse", "HEAD",
4247
4306
  cwd=str(repo_path),
4248
4307
  stdout=asyncio.subprocess.PIPE,
@@ -4271,7 +4330,7 @@ class WorkspaceManager:
4271
4330
  # If HEAD is ahead of the merge-base, we count local-only commits — any
4272
4331
  # count > 0 means we should NOT call the branch-r check.
4273
4332
  try:
4274
- ahead_proc = await asyncio.create_subprocess_exec(
4333
+ ahead_proc = await _create_subprocess_exec(
4275
4334
  "git", "rev-list", "--count", "--right-only", "origin...HEAD",
4276
4335
  cwd=str(repo_path),
4277
4336
  stdout=asyncio.subprocess.PIPE,
@@ -4293,7 +4352,7 @@ class WorkspaceManager:
4293
4352
  except Exception:
4294
4353
  pass # If the ahead count fails, proceed with the branch-r check
4295
4354
 
4296
- check_proc = await asyncio.create_subprocess_exec(
4355
+ check_proc = await _create_subprocess_exec(
4297
4356
  "git", "branch", "-r", "--contains", local_head,
4298
4357
  cwd=str(repo_path),
4299
4358
  stdout=asyncio.subprocess.PIPE,
@@ -5184,7 +5243,7 @@ class WorkspaceManager:
5184
5243
  last_branch_fetch_error: str | None = None
5185
5244
  for _check_attempt in range(3 if not fresh_start else 1):
5186
5245
  try:
5187
- check_proc = await asyncio.create_subprocess_exec(
5246
+ check_proc = await _create_subprocess_exec(
5188
5247
  "git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}",
5189
5248
  cwd=str(main_repo),
5190
5249
  stdout=asyncio.subprocess.PIPE,
@@ -5684,7 +5743,7 @@ class WorkspaceManager:
5684
5743
  # orphaned processes from keeping pipes alive.
5685
5744
  # Windows note: start_new_session creates a new console process group;
5686
5745
  # we use taskkill /T there instead of killpg.
5687
- proc = await asyncio.create_subprocess_exec(
5746
+ proc = await _create_subprocess_exec(
5688
5747
  git_command, *longpath_args, *git_prefix_args, *args,
5689
5748
  stdin=asyncio.subprocess.DEVNULL,
5690
5749
  stdout=asyncio.subprocess.PIPE,
@@ -5709,7 +5768,9 @@ class WorkspaceManager:
5709
5768
  # ssh grandchild becomes orphaned, keeps the stderr pipe open,
5710
5769
  # and proc.communicate() can never return EOF.
5711
5770
  try:
5712
- if sys.platform != "win32":
5771
+ if _MANAGED_WSL_POLICY is not None:
5772
+ _kill_proc(proc)
5773
+ elif sys.platform != "win32":
5713
5774
  import signal as _signal
5714
5775
  try:
5715
5776
  os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
@@ -6525,10 +6586,11 @@ class ProcessManager:
6525
6586
  _last_activity_at: list[float] = [time.monotonic()]
6526
6587
 
6527
6588
  # Write prompt and close stdin so the agent knows input is done.
6528
- if stdin_input and proc.stdin:
6589
+ if proc.stdin:
6529
6590
  try:
6530
- proc.stdin.write(stdin_input)
6531
- await proc.stdin.drain()
6591
+ if stdin_input:
6592
+ proc.stdin.write(stdin_input)
6593
+ await proc.stdin.drain()
6532
6594
  except Exception:
6533
6595
  pass
6534
6596
  proc.stdin.close()
@@ -6751,7 +6813,7 @@ class ProcessManager:
6751
6813
  env.update(self._prepare_claude_environment(sandbox_root=Path(_claude_home)))
6752
6814
 
6753
6815
  try:
6754
- proc = await asyncio.create_subprocess_exec(
6816
+ proc = await _create_subprocess_exec(
6755
6817
  *cmd,
6756
6818
  stdout=asyncio.subprocess.PIPE,
6757
6819
  stderr=asyncio.subprocess.PIPE,
@@ -7668,7 +7730,8 @@ class ProcessManager:
7668
7730
  explicitly configured supported token environment variable.
7669
7731
 
7670
7732
  Flags:
7671
- -p / --prompt Non-interactive prompt (exits after completion).
7733
+ stdin Full non-interactive prompt, avoiding argv limits.
7734
+ -p / --prompt Short task-file instruction for Windows wrappers.
7672
7735
  --output-format json JSONL stream of session events.
7673
7736
  --allow-all Grant all tool + path + URL permissions required
7674
7737
  for autonomous file-editing tasks.
@@ -7768,11 +7831,13 @@ class ProcessManager:
7768
7831
  _effective_prompt, _task_file = _redirect_prompt_through_task_file(
7769
7832
  cwd, prompt, task_id, "Copilot",
7770
7833
  )
7834
+ _stdin_input = None if _task_file is not None else prompt.encode("utf-8")
7771
7835
 
7772
7836
  cmd = agent_core.build_copilot_command(
7773
7837
  agent.command,
7774
7838
  cwd=cwd,
7775
7839
  prompt=_effective_prompt,
7840
+ prompt_via_stdin=_stdin_input is not None,
7776
7841
  model=model_override,
7777
7842
  reasoning=reasoning if not _retry_without_effort else None,
7778
7843
  disable_builtin_mcps=disable_builtin_mcps,
@@ -7786,9 +7851,20 @@ class ProcessManager:
7786
7851
  # reproduce failures manually and diagnose CLI version incompatibilities.
7787
7852
  _cmd_display = " ".join(
7788
7853
  f'"{a}"' if " " in a else a
7789
- for a in cmd[:-1] # omit the -p value (potentially huge/sensitive)
7790
- ) + ' -p "<prompt>"'
7791
- logger.debug("Copilot invocation for task %s: %s", task_id, _cmd_display)
7854
+ for a in (cmd if _stdin_input is not None else cmd[:-1])
7855
+ )
7856
+ if _stdin_input is None:
7857
+ _cmd_display += ' "<prompt>"'
7858
+ logger.debug(
7859
+ "Copilot invocation for task %s: %s (prompt via %s)",
7860
+ task_id, _cmd_display, "stdin" if _stdin_input is not None else "task file",
7861
+ )
7862
+ logger.info(
7863
+ "Copilot task %s prompt transport: %s (%d UTF-8 bytes)",
7864
+ task_id,
7865
+ "stdin" if _stdin_input is not None else "task file",
7866
+ len(_stdin_input) if _stdin_input is not None else len(prompt.encode("utf-8")),
7867
+ )
7792
7868
 
7793
7869
  # On Windows, CREATE_NO_WINDOW ensures GetConsoleWindow() returns NULL for
7794
7870
  # the child process, even when the parent (daemon or Tauri app) has a console
@@ -7796,9 +7872,9 @@ class ProcessManager:
7796
7872
  # check via GetConsoleWindow(): if a console window is visible, it uses only
7797
7873
  # the first line of -p as a brief context note and reports "No task was
7798
7874
  # provided" for the rest of the prompt — silently discarding the full task.
7799
- # stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
7875
+ # A pipe or DEVNULL covers the primary check (GetConsoleMode on stdin);
7800
7876
  # CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
7801
- # headless environment and processes -p as the complete task specification.
7877
+ # headless environment and processes the complete task specification.
7802
7878
  proc: asyncio.subprocess.Process | None = None
7803
7879
 
7804
7880
  async def _terminate_copilot_process(
@@ -7813,22 +7889,15 @@ class ProcessManager:
7813
7889
  pass
7814
7890
 
7815
7891
  try:
7816
- proc = await asyncio.create_subprocess_exec(
7892
+ proc = await _create_subprocess_exec(
7817
7893
  *cmd,
7818
7894
  stdout=asyncio.subprocess.PIPE,
7819
7895
  stderr=asyncio.subprocess.PIPE,
7820
- # stdin=DEVNULL is critical on Windows.
7821
- # Without it, the child process inherits the parent daemon's stdin.
7822
- # On Windows, the daemon often has a real console handle on stdin
7823
- # (e.g. from a CMD/PowerShell window or the Desktop app's console).
7824
- # Copilot CLI detects this via GetConsoleMode(GetStdHandle(STD_INPUT))
7825
- # and enters interactive/session-check mode, ignoring -p as a task:
7826
- # it checks the session todo table (empty), reports "no tracked work
7827
- # items", and waits for user input instead of executing the task.
7828
- # On Linux/macOS (daemon as service), stdin is typically /dev/null so
7829
- # the bug doesn't manifest. Explicit DEVNULL ensures non-interactive
7830
- # mode on all platforms, matching the intended headless operation.
7831
- stdin=asyncio.subprocess.DEVNULL,
7896
+ stdin=(
7897
+ asyncio.subprocess.PIPE
7898
+ if _stdin_input is not None
7899
+ else asyncio.subprocess.DEVNULL
7900
+ ),
7832
7901
  cwd=str(cwd),
7833
7902
  env=env,
7834
7903
  limit=100 * 1024 * 1024,
@@ -7836,7 +7905,7 @@ class ProcessManager:
7836
7905
  )
7837
7906
  self.active_processes[task_id] = proc
7838
7907
  stdout, stderr, returncode = await self._stream_process(
7839
- proc, None, timeout, task_id, on_chunk,
7908
+ proc, _stdin_input, timeout, task_id, on_chunk,
7840
7909
  workspace_path=cwd,
7841
7910
  stream_stderr=stream_stderr,
7842
7911
  )
@@ -8213,7 +8282,7 @@ class ProcessManager:
8213
8282
  proc = None
8214
8283
  try:
8215
8284
  cmd = _prepare_agent_command(cmd)
8216
- proc = await asyncio.create_subprocess_exec(
8285
+ proc = await _create_subprocess_exec(
8217
8286
  *cmd,
8218
8287
  stdout=asyncio.subprocess.PIPE,
8219
8288
  stderr=asyncio.subprocess.PIPE,
@@ -8305,7 +8374,7 @@ class ProcessManager:
8305
8374
  """Collect git diff stats from workspace."""
8306
8375
  info: dict[str, Any] = {}
8307
8376
  try:
8308
- proc = await asyncio.create_subprocess_exec(
8377
+ proc = await _create_subprocess_exec(
8309
8378
  "git", "diff", "--stat", "--cached",
8310
8379
  stdout=asyncio.subprocess.PIPE,
8311
8380
  stderr=asyncio.subprocess.PIPE,
@@ -8316,7 +8385,7 @@ class ProcessManager:
8316
8385
  stat_output = stdout.decode()
8317
8386
 
8318
8387
  # Also check unstaged
8319
- proc2 = await asyncio.create_subprocess_exec(
8388
+ proc2 = await _create_subprocess_exec(
8320
8389
  "git", "diff", "--stat",
8321
8390
  stdout=asyncio.subprocess.PIPE,
8322
8391
  stderr=asyncio.subprocess.PIPE,
@@ -8327,7 +8396,7 @@ class ProcessManager:
8327
8396
  stat_output += stdout2.decode()
8328
8397
 
8329
8398
  # Parse changed files
8330
- proc3 = await asyncio.create_subprocess_exec(
8399
+ proc3 = await _create_subprocess_exec(
8331
8400
  "git", "diff", "--name-only", "HEAD",
8332
8401
  stdout=asyncio.subprocess.PIPE,
8333
8402
  stderr=asyncio.subprocess.PIPE,
@@ -8339,7 +8408,7 @@ class ProcessManager:
8339
8408
  info["files_changed"] = files
8340
8409
 
8341
8410
  # Parse insertions/deletions
8342
- proc4 = await asyncio.create_subprocess_exec(
8411
+ proc4 = await _create_subprocess_exec(
8343
8412
  "git", "diff", "--shortstat", "HEAD",
8344
8413
  stdout=asyncio.subprocess.PIPE,
8345
8414
  stderr=asyncio.subprocess.PIPE,
@@ -8354,7 +8423,7 @@ class ProcessManager:
8354
8423
  info["lines_removed"] = int(removed.group(1)) if removed else 0
8355
8424
 
8356
8425
  # Get current branch
8357
- proc5 = await asyncio.create_subprocess_exec(
8426
+ proc5 = await _create_subprocess_exec(
8358
8427
  "git", "rev-parse", "--abbrev-ref", "HEAD",
8359
8428
  stdout=asyncio.subprocess.PIPE,
8360
8429
  stderr=asyncio.subprocess.PIPE,
@@ -8365,7 +8434,7 @@ class ProcessManager:
8365
8434
  info["branch"] = stdout5.decode().strip()
8366
8435
 
8367
8436
  # Get latest commit
8368
- proc6 = await asyncio.create_subprocess_exec(
8437
+ proc6 = await _create_subprocess_exec(
8369
8438
  "git", "rev-parse", "HEAD",
8370
8439
  stdout=asyncio.subprocess.PIPE,
8371
8440
  stderr=asyncio.subprocess.PIPE,
@@ -8411,7 +8480,7 @@ class ProcessManager:
8411
8480
  candidate_refs.append(ref)
8412
8481
 
8413
8482
  for candidate in candidate_refs:
8414
- proc_mb = await asyncio.create_subprocess_exec(
8483
+ proc_mb = await _create_subprocess_exec(
8415
8484
  "git", "merge-base", candidate, "HEAD",
8416
8485
  stdout=asyncio.subprocess.PIPE,
8417
8486
  stderr=asyncio.subprocess.PIPE,
@@ -8424,7 +8493,7 @@ class ProcessManager:
8424
8493
  break
8425
8494
 
8426
8495
  # Changed files since branch point
8427
- proc = await asyncio.create_subprocess_exec(
8496
+ proc = await _create_subprocess_exec(
8428
8497
  "git", "diff", "--name-only", base_ref, "HEAD",
8429
8498
  stdout=asyncio.subprocess.PIPE,
8430
8499
  stderr=asyncio.subprocess.PIPE,
@@ -8436,7 +8505,7 @@ class ProcessManager:
8436
8505
  info["files_changed"] = files
8437
8506
 
8438
8507
  # Lines added/removed since branch point
8439
- proc2 = await asyncio.create_subprocess_exec(
8508
+ proc2 = await _create_subprocess_exec(
8440
8509
  "git", "diff", "--shortstat", base_ref, "HEAD",
8441
8510
  stdout=asyncio.subprocess.PIPE,
8442
8511
  stderr=asyncio.subprocess.PIPE,
@@ -8451,7 +8520,7 @@ class ProcessManager:
8451
8520
  info["lines_removed"] = int(removed.group(1)) if removed else 0
8452
8521
 
8453
8522
  # Branch name
8454
- proc3 = await asyncio.create_subprocess_exec(
8523
+ proc3 = await _create_subprocess_exec(
8455
8524
  "git", "rev-parse", "--abbrev-ref", "HEAD",
8456
8525
  stdout=asyncio.subprocess.PIPE,
8457
8526
  stderr=asyncio.subprocess.PIPE,
@@ -8462,7 +8531,7 @@ class ProcessManager:
8462
8531
  info["branch"] = stdout3.decode().strip()
8463
8532
 
8464
8533
  # Commit SHA
8465
- proc4 = await asyncio.create_subprocess_exec(
8534
+ proc4 = await _create_subprocess_exec(
8466
8535
  "git", "rev-parse", "HEAD",
8467
8536
  stdout=asyncio.subprocess.PIPE,
8468
8537
  stderr=asyncio.subprocess.PIPE,
@@ -8779,7 +8848,7 @@ class RuntimePreflightMonitor:
8779
8848
  process group SIGKILL on POSIX) and waits for reaping before returning.
8780
8849
  """
8781
8850
  try:
8782
- proc = await asyncio.create_subprocess_exec(
8851
+ proc = await _create_subprocess_exec(
8783
8852
  *cmd,
8784
8853
  stdin=asyncio.subprocess.DEVNULL,
8785
8854
  stdout=asyncio.subprocess.PIPE,
@@ -8804,6 +8873,9 @@ class RuntimePreflightMonitor:
8804
8873
  @staticmethod
8805
8874
  def _kill_process_tree(proc):
8806
8875
  """Best-effort kill of the entire process subtree."""
8876
+ if _MANAGED_WSL_POLICY is not None:
8877
+ _kill_proc(proc)
8878
+ return
8807
8879
  try:
8808
8880
  if sys.platform == "win32":
8809
8881
  # Must be a BLOCKING call here: this is a plain (non-async)
@@ -9006,6 +9078,8 @@ class HeartbeatService:
9006
9078
  self._agents = [dict(agent) for agent in agents]
9007
9079
 
9008
9080
  async def _sync_local_workspace_bindings(self) -> None:
9081
+ if _MANAGED_WSL_POLICY is not None and _MANAGED_WSL_POLICY.task is not None:
9082
+ return # The task already retains mapping admission and the physical lock.
9009
9083
  try:
9010
9084
  response = await self.client.get(
9011
9085
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/verification",
@@ -9025,6 +9099,9 @@ class HeartbeatService:
9025
9099
  for binding in bindings:
9026
9100
  if not isinstance(binding, dict) or not binding.get("id"):
9027
9101
  continue
9102
+ if _MANAGED_WSL_POLICY is not None:
9103
+ await _MANAGED_WSL_POLICY.sync_binding(self, binding)
9104
+ continue
9028
9105
  failure_code = await _validate_local_workspace_binding(binding, self.runtime_id)
9029
9106
  if failure_code is None and binding.get("status") == "active":
9030
9107
  continue
@@ -9151,6 +9228,8 @@ class LogUploader:
9151
9228
 
9152
9229
  async def _upload(self):
9153
9230
  """Read daemon log tail and upload to server."""
9231
+ if _MANAGED_WSL_POLICY is not None:
9232
+ return # Private WSL logs are never uploaded, even when no task is active.
9154
9233
  try:
9155
9234
  if not DAEMON_LOG_PATH.exists():
9156
9235
  return
@@ -9344,6 +9423,11 @@ class ServerConnection:
9344
9423
  # honored here, which otherwise turns loopback calls into 502s.
9345
9424
  trust_env=False,
9346
9425
  )
9426
+ if _MANAGED_WSL_POLICY is not None:
9427
+ if (server_url != _MANAGED_WSL_POLICY.config.serverUrl or api_token != _MANAGED_WSL_POLICY.credential
9428
+ or daemon_id != _MANAGED_WSL_POLICY.daemon_id or hardware_id != _MANAGED_WSL_POLICY.hardware_id):
9429
+ raise ValueError("wsl_runtime_connection_invalid")
9430
+ self.client = _wsl_policy_module.PrivateClient(self.client, _MANAGED_WSL_POLICY)
9347
9431
  self.heartbeat: HeartbeatService | None = None
9348
9432
  self.poller: TaskPoller | None = None
9349
9433
  self.reporter: ProgressReporter | None = None
@@ -9901,6 +9985,8 @@ class ServerConnection:
9901
9985
  raise DaemonCredentialUpgradeRequiredError(code)
9902
9986
  resp.raise_for_status()
9903
9987
  data = resp.json()
9988
+ if _MANAGED_WSL_POLICY is not None and data.get("runtime_id") != _MANAGED_WSL_POLICY.runtime_id:
9989
+ raise DaemonCredentialUpgradeRequiredError("runtime_credential_runtime_mismatch")
9904
9990
  self.runtime_id = data["runtime_id"]
9905
9991
  logger.info("[%s] Registered with server. runtime_id=%s", self.label, self.runtime_id)
9906
9992
  self._publish_local_runtime_registration()
@@ -10599,23 +10685,26 @@ class RuntimeDaemon:
10599
10685
  _TASK_ATTACHMENT_MAX_COUNT = 20
10600
10686
  _TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
10601
10687
 
10602
- def __init__(self):
10688
+ def __init__(self, *, managed_policy=None):
10689
+ if managed_policy is not _MANAGED_WSL_POLICY:
10690
+ raise ValueError("wsl_runtime_entry_not_admitted")
10691
+ self.managed_policy = managed_policy
10603
10692
  # Compute stable hardware fingerprint from machine-id / MAC address.
10604
10693
  # This allows the server to identify the same physical machine even when
10605
10694
  # its IP address changes (e.g., DHCP reassignment, home vs. office).
10606
- self.hardware_id = get_hardware_id()
10695
+ self.hardware_id = managed_policy.hardware_id if managed_policy is not None else get_hardware_id()
10607
10696
 
10608
10697
  # daemon_id: prefer explicit config, fall back to hardware_id (stable),
10609
10698
  # then hostname. Using hardware_id as default instead of platform.node()
10610
10699
  # prevents duplicate registrations when the hostname changes.
10611
- self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
10612
- self.server_urls = settings.get_daemon_server_urls()
10613
- self.api_token = _configured_daemon_api_token()
10700
+ self.daemon_id = managed_policy.daemon_id if managed_policy is not None else settings.DAEMON_ID or self.hardware_id or platform.node()
10701
+ self.server_urls = [managed_policy.config.serverUrl] if managed_policy is not None else settings.get_daemon_server_urls()
10702
+ self.api_token = managed_policy.credential if managed_policy is not None else _configured_daemon_api_token()
10614
10703
  self._has_explicit_api_token = bool(self.api_token)
10615
- self.max_concurrent = settings.DAEMON_MAX_CONCURRENT
10704
+ self.max_concurrent = 1 if managed_policy is not None else settings.DAEMON_MAX_CONCURRENT
10616
10705
  self.poll_interval = settings.DAEMON_POLL_INTERVAL
10617
10706
  self.heartbeat_interval = settings.DAEMON_HEARTBEAT_INTERVAL
10618
- self.workspaces_root = settings.get_daemon_workspaces_root()
10707
+ self.workspaces_root = str(managed_policy.state / "workspaces") if managed_policy is not None else settings.get_daemon_workspaces_root()
10619
10708
 
10620
10709
  self.agents: list[DiscoveredAgent] = []
10621
10710
  self.active_tasks: dict[str, asyncio.Task] = {}
@@ -10668,12 +10757,16 @@ class RuntimeDaemon:
10668
10757
 
10669
10758
  async def _refresh_agents(self) -> None:
10670
10759
  """Re-discover local CLIs so upgrades reach the runtime heartbeat."""
10760
+ if self.managed_policy is not None and self.active_tasks:
10761
+ return
10671
10762
  try:
10672
10763
  refreshed_agents = await self.agent_discovery.discover()
10673
10764
  except Exception as exc:
10674
10765
  logger.warning("Agent inventory refresh failed: %s", exc)
10675
10766
  return
10676
10767
 
10768
+ if self.managed_policy is not None:
10769
+ refreshed_agents = [agent for agent in refreshed_agents if agent.agent_id in self.managed_policy.drivers]
10677
10770
  if refreshed_agents == self.agents:
10678
10771
  return
10679
10772
 
@@ -10717,6 +10810,8 @@ class RuntimeDaemon:
10717
10810
  )
10718
10811
 
10719
10812
  def _begin_desktop_restart_drain_if_requested(self) -> bool:
10813
+ if self.managed_policy is not None:
10814
+ return False
10720
10815
  target_version = _desktop_daemon_restart_target_version(
10721
10816
  self._local_runtime_instance_id
10722
10817
  )
@@ -11484,13 +11579,14 @@ class RuntimeDaemon:
11484
11579
  async def start(self):
11485
11580
  """Main entry point."""
11486
11581
  # Prevent multiple daemon instances on the same machine
11487
- self._acquire_lock()
11582
+ if self.managed_policy is None:
11583
+ self._acquire_lock()
11488
11584
  _write_local_runtime_registration_snapshot(
11489
11585
  self._local_runtime_instance_id,
11490
11586
  "starting",
11491
11587
  {},
11492
11588
  )
11493
- cleaned_processes = self._cleanup_stale_agent_processes()
11589
+ cleaned_processes = self._cleanup_stale_agent_processes() if self.managed_policy is None else 0
11494
11590
  if cleaned_processes:
11495
11591
  logger.warning(
11496
11592
  "Cleaned up %d orphaned Forgexa agent process group(s)",
@@ -11507,6 +11603,8 @@ class RuntimeDaemon:
11507
11603
 
11508
11604
  # 1. Discover agents
11509
11605
  self.agents = await self.agent_discovery.discover()
11606
+ if self.managed_policy is not None:
11607
+ self.agents = [agent for agent in self.agents if agent.agent_id in self.managed_policy.drivers]
11510
11608
  if not self.agents:
11511
11609
  logger.warning("No agent CLIs found. Daemon will run but cannot execute tasks.")
11512
11610
  else:
@@ -11601,7 +11699,8 @@ class RuntimeDaemon:
11601
11699
 
11602
11700
  # Clean up stale Copilot/Claude temp directories left by prior OOM-killed
11603
11701
  # daemon processes. This runs once at startup so /tmp doesn't fill up.
11604
- await self._cleanup_stale_tmp_dirs()
11702
+ if self.managed_policy is None:
11703
+ await self._cleanup_stale_tmp_dirs()
11605
11704
 
11606
11705
  # 3. Main loop
11607
11706
  try:
@@ -11649,9 +11748,10 @@ class RuntimeDaemon:
11649
11748
 
11650
11749
  # Opportunistic GC: remove old deploy-merge worktrees (throttled internally).
11651
11750
  try:
11652
- await self._cleanup_old_merge_worktrees(
11653
- self.connections[0] if self.connections else None
11654
- )
11751
+ if self.managed_policy is None:
11752
+ await self._cleanup_old_merge_worktrees(
11753
+ self.connections[0] if self.connections else None
11754
+ )
11655
11755
  except Exception:
11656
11756
  pass # GC failure must never block the poll loop
11657
11757
 
@@ -11707,7 +11807,7 @@ class RuntimeDaemon:
11707
11807
  )
11708
11808
  # Always poll AI jobs so server-requested cancellations can stop a
11709
11809
  # process that is itself consuming the last available slot.
11710
- ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks else []
11810
+ ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks and self.managed_policy is None else []
11711
11811
  _cancel_server_requested_execution_tasks(conn)
11712
11812
  _cancel_server_requested_ai_jobs(conn)
11713
11813
  total_active = len(self.active_tasks)
@@ -12051,8 +12151,23 @@ class RuntimeDaemon:
12051
12151
  async def report_task_complete(result: TaskResult) -> None:
12052
12152
  if is_local_bind:
12053
12153
  _prepare_local_bind_completion(task, result)
12154
+ if self.managed_policy is not None and not await self.managed_policy.finish_task():
12155
+ result.status = "failed"
12156
+ result.failure_code = "wsl_runtime_recovery_required"
12157
+ result.error = "Managed local writer cleanup is unconfirmed"
12054
12158
  await reporter.report_complete(task.task_id, result)
12055
12159
 
12160
+ if self.managed_policy is not None:
12161
+ try:
12162
+ await self.managed_policy.authorize_task(task, conn)
12163
+ except ValueError as exc:
12164
+ await report_task_complete(TaskResult(
12165
+ status="failed", exit_code=-1, stdout="", stderr="",
12166
+ error="Managed local dispatch was refused", failure_code=getattr(exc, "code", "wsl_runtime_dispatch_refused"),
12167
+ execution_attempt_id=task.execution_attempt_id,
12168
+ ))
12169
+ return
12170
+
12056
12171
  default_branch = str((task.project or {}).get("default_branch") or "").strip()
12057
12172
  node_before_sha = ""
12058
12173
  # Use workspace-level granularity (requirement_workflow_id or graph_id) so that
@@ -12082,9 +12197,8 @@ class RuntimeDaemon:
12082
12197
  # but this is a separate, complementary guard against two
12083
12198
  # DAEMON PROCESSES (e.g. a zombie/restarted instance) operating
12084
12199
  # on the same real directory at the same time.
12085
- local_bind_file_lock = _try_acquire_local_workspace_file_lock(
12086
- task.local_workspace_binding_id
12087
- )
12200
+ local_bind_file_lock = (self.managed_policy.task_lock if self.managed_policy is not None
12201
+ else _try_acquire_local_workspace_file_lock(task.local_workspace_binding_id))
12088
12202
  if local_bind_file_lock is None:
12089
12203
  project_lock.release()
12090
12204
  await report_task_complete(TaskResult(
@@ -12970,13 +13084,19 @@ class RuntimeDaemon:
12970
13084
  logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
12971
13085
 
12972
13086
  except Exception as e:
12973
- logger.exception("Task %s failed with exception", task.task_id)
13087
+ if self.managed_policy is not None:
13088
+ _append_local_bind_output_log(task.task_id, [str(e)])
13089
+ logger.warning("Managed local task failed: %s", getattr(e, "code", "local_task_failed"))
13090
+ else:
13091
+ logger.exception("Task %s failed with exception", task.task_id)
12974
13092
  await report_task_complete(TaskResult(
12975
13093
  status="failed", exit_code=-1, stdout="", stderr="",
12976
13094
  error=str(e),
12977
13095
  execution_attempt_id=task.execution_attempt_id,
12978
13096
  ))
12979
13097
  finally:
13098
+ if self.managed_policy is not None:
13099
+ await self.managed_policy.finish_task()
12980
13100
  if attachment_staging_dir is not None:
12981
13101
  shutil.rmtree(attachment_staging_dir, ignore_errors=True)
12982
13102
  if workspace_execution_lock is not None:
@@ -15633,7 +15753,7 @@ class RuntimeDaemon:
15633
15753
  # Unstage if it was already staged
15634
15754
  if is_staged_new:
15635
15755
  try:
15636
- proc = await asyncio.create_subprocess_exec(
15756
+ proc = await _create_subprocess_exec(
15637
15757
  "git", "restore", "--staged", file_path,
15638
15758
  cwd=str(workspace_path),
15639
15759
  stdout=asyncio.subprocess.PIPE,
@@ -15688,7 +15808,7 @@ class RuntimeDaemon:
15688
15808
  lost). That is legitimate, intentional work — not noise — so it
15689
15809
  must be allowed to commit; we only log it for audit visibility.
15690
15810
  """
15691
- proc = await asyncio.create_subprocess_exec(
15811
+ proc = await _create_subprocess_exec(
15692
15812
  "git", "diff", "--raw", "--no-abbrev", "--no-renames", "-z", *diff_args,
15693
15813
  cwd=str(workspace_path),
15694
15814
  stdout=asyncio.subprocess.PIPE,
@@ -15946,7 +16066,7 @@ class RuntimeDaemon:
15946
16066
  except RuntimeError:
15947
16067
  return {"commit_error": "Refusing to commit: the task baseline changed while preparing the commit"}
15948
16068
  _git_name, _git_email = _resolve_git_author(task.project)
15949
- proc = await asyncio.create_subprocess_exec(
16069
+ proc = await _create_subprocess_exec(
15950
16070
  "git", "commit", "-m", commit_msg,
15951
16071
  cwd=str(workspace_path),
15952
16072
  stdout=asyncio.subprocess.PIPE,
@@ -16110,7 +16230,7 @@ class RuntimeDaemon:
16110
16230
  return {"commit_error": mode_change_error}
16111
16231
 
16112
16232
  # Check for uncommitted changes
16113
- proc = await asyncio.create_subprocess_exec(
16233
+ proc = await _create_subprocess_exec(
16114
16234
  "git", "status", "--porcelain",
16115
16235
  cwd=str(workspace_path),
16116
16236
  stdout=asyncio.subprocess.PIPE,
@@ -16129,7 +16249,7 @@ class RuntimeDaemon:
16129
16249
  await self._remove_root_scratch_files(workspace_path, task, stdout.decode())
16130
16250
 
16131
16251
  # Stage all changes
16132
- proc = await asyncio.create_subprocess_exec(
16252
+ proc = await _create_subprocess_exec(
16133
16253
  "git", "add", "-A",
16134
16254
  cwd=str(workspace_path),
16135
16255
  stdout=asyncio.subprocess.PIPE,
@@ -16178,7 +16298,7 @@ class RuntimeDaemon:
16178
16298
  logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
16179
16299
  commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
16180
16300
  _git_name, _git_email = _resolve_git_author(task.project)
16181
- proc = await asyncio.create_subprocess_exec(
16301
+ proc = await _create_subprocess_exec(
16182
16302
  "git", "commit", "-m", commit_msg,
16183
16303
  cwd=str(workspace_path),
16184
16304
  stdout=asyncio.subprocess.PIPE,
@@ -16905,7 +17025,7 @@ class RuntimeDaemon:
16905
17025
  """Collect staged diff stats for building a rich commit message."""
16906
17026
  status_map: dict[str, str] = {}
16907
17027
 
16908
- proc = await asyncio.create_subprocess_exec(
17028
+ proc = await _create_subprocess_exec(
16909
17029
  "git", "diff", "--cached", "--name-status",
16910
17030
  cwd=str(cwd),
16911
17031
  stdout=asyncio.subprocess.PIPE,
@@ -16919,7 +17039,7 @@ class RuntimeDaemon:
16919
17039
  status_map[parts[1].strip()] = parts[0].strip()
16920
17040
 
16921
17041
  files: list[dict] = []
16922
- proc = await asyncio.create_subprocess_exec(
17042
+ proc = await _create_subprocess_exec(
16923
17043
  "git", "diff", "--cached", "--numstat",
16924
17044
  cwd=str(cwd),
16925
17045
  stdout=asyncio.subprocess.PIPE,
@@ -16948,7 +17068,7 @@ class RuntimeDaemon:
16948
17068
  for file_info in files[:20]:
16949
17069
  file_info["symbols"] = await self._extract_staged_file_symbols(cwd, file_info["path"])
16950
17070
 
16951
- proc = await asyncio.create_subprocess_exec(
17071
+ proc = await _create_subprocess_exec(
16952
17072
  "git", "diff", "--cached", "--stat",
16953
17073
  cwd=str(cwd),
16954
17074
  stdout=asyncio.subprocess.PIPE,
@@ -16977,7 +17097,7 @@ class RuntimeDaemon:
16977
17097
  )
16978
17098
 
16979
17099
  try:
16980
- proc = await asyncio.create_subprocess_exec(
17100
+ proc = await _create_subprocess_exec(
16981
17101
  "git", "diff", "--cached", "--unified=0", "--", filepath,
16982
17102
  cwd=str(cwd),
16983
17103
  stdout=asyncio.subprocess.PIPE,
@@ -18022,7 +18142,7 @@ class RuntimeDaemon:
18022
18142
  resolve_result = await self.process_manager.run_agent(agent, _cr_task, workspace_path)
18023
18143
 
18024
18144
  # 6. Check if conflicts are resolved
18025
- proc = await asyncio.create_subprocess_exec(
18145
+ proc = await _create_subprocess_exec(
18026
18146
  "git", "diff", "--name-only", "--diff-filter=U",
18027
18147
  cwd=str(workspace_path),
18028
18148
  stdout=asyncio.subprocess.PIPE,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.62.5
3
+ Version: 1.63.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-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.62.5"
3
+ version = "1.63.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 = "MIT"
File without changes
File without changes