forgexa-cli 1.62.4__tar.gz → 1.63.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.
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/PKG-INFO +1 -1
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/agent_core.py +152 -36
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/daemon.py +31 -23
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/pyproject.toml +1 -1
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/README.md +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/setup.cfg +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.62.4 → forgexa_cli-1.63.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.63.0"
|
|
@@ -48,6 +48,7 @@ import logging
|
|
|
48
48
|
import os
|
|
49
49
|
import platform
|
|
50
50
|
import re
|
|
51
|
+
import shlex
|
|
51
52
|
import shutil
|
|
52
53
|
import subprocess
|
|
53
54
|
import tempfile
|
|
@@ -862,8 +863,11 @@ class AgentDiscovery:
|
|
|
862
863
|
"""Reject a Windows-side agent before its version probe can run via WSL interop."""
|
|
863
864
|
if sys.platform != "linux" or not os.environ.get("WSL_DISTRO_NAME"):
|
|
864
865
|
return False
|
|
865
|
-
|
|
866
|
-
|
|
866
|
+
try:
|
|
867
|
+
resolved = Path(path).resolve()
|
|
868
|
+
except (OSError, RuntimeError):
|
|
869
|
+
return True
|
|
870
|
+
if resolved.suffix.lower() in {".exe", ".cmd", ".bat", ".ps1", ".com"}:
|
|
867
871
|
return True
|
|
868
872
|
parts = resolved.parts
|
|
869
873
|
if len(parts) >= 3 and parts[1] == "mnt" and len(parts[2]) == 1 and parts[2].isalpha():
|
|
@@ -872,15 +876,80 @@ class AgentDiscovery:
|
|
|
872
876
|
try:
|
|
873
877
|
mounts = Path("/proc/mounts").read_text(encoding="utf-8")
|
|
874
878
|
except OSError:
|
|
875
|
-
return
|
|
879
|
+
return True
|
|
876
880
|
for line in mounts.splitlines():
|
|
877
881
|
fields = line.split()
|
|
878
882
|
if len(fields) >= 3 and fields[2] in {"9p", "drvfs"}:
|
|
879
|
-
mount = Path(
|
|
883
|
+
mount = Path(re.sub(r"\\([0-7]{3})", lambda value: chr(int(value[1], 8)), fields[1]))
|
|
880
884
|
if resolved == mount or mount in resolved.parents:
|
|
881
885
|
return True
|
|
882
886
|
return False
|
|
883
887
|
|
|
888
|
+
@staticmethod
|
|
889
|
+
def _wsl_execution_issue(
|
|
890
|
+
path: str, env: dict[str, str] | None = None,
|
|
891
|
+
) -> str | None:
|
|
892
|
+
"""Validate the selected WSL CLI and declared interpreter without spawning.
|
|
893
|
+
|
|
894
|
+
This is same-side admission, not a sandbox for the CLI's own commands.
|
|
895
|
+
Native Windows and non-WSL Linux retain their existing launch behavior.
|
|
896
|
+
"""
|
|
897
|
+
if sys.platform != "linux" or not os.environ.get("WSL_DISTRO_NAME"):
|
|
898
|
+
return None
|
|
899
|
+
issue = "WSL Agent requires an available Linux-side CLI and interpreter; Windows interop or unverifiable tools are refused. Install the CLI inside the selected WSL distribution."
|
|
900
|
+
candidate = path
|
|
901
|
+
seen: set[str] = set()
|
|
902
|
+
for _ in range(4):
|
|
903
|
+
if not Path(candidate).is_absolute():
|
|
904
|
+
return issue
|
|
905
|
+
if AgentDiscovery._wsl_windows_executable(candidate):
|
|
906
|
+
return issue
|
|
907
|
+
try:
|
|
908
|
+
resolved = Path(candidate).resolve(strict=True)
|
|
909
|
+
if str(resolved) in seen or not resolved.is_file():
|
|
910
|
+
return issue
|
|
911
|
+
seen.add(str(resolved))
|
|
912
|
+
with resolved.open("rb") as stream:
|
|
913
|
+
header = stream.read(512)
|
|
914
|
+
except (OSError, RuntimeError):
|
|
915
|
+
return issue
|
|
916
|
+
if header.startswith(b"MZ"):
|
|
917
|
+
return issue
|
|
918
|
+
if not header.startswith(b"#!"):
|
|
919
|
+
return None
|
|
920
|
+
try:
|
|
921
|
+
line = header.split(b"\n", 1)[0][2:].decode("utf-8", errors="strict")
|
|
922
|
+
parts = shlex.split(line)
|
|
923
|
+
except (UnicodeError, ValueError):
|
|
924
|
+
return issue
|
|
925
|
+
if not parts or not Path(parts[0]).is_absolute():
|
|
926
|
+
return issue
|
|
927
|
+
interpreter = parts[0]
|
|
928
|
+
if Path(interpreter).name == "env":
|
|
929
|
+
if AgentDiscovery._wsl_windows_executable(interpreter):
|
|
930
|
+
return issue
|
|
931
|
+
try:
|
|
932
|
+
with Path(interpreter).open("rb") as stream:
|
|
933
|
+
if stream.read(4) != b"\x7fELF":
|
|
934
|
+
return issue
|
|
935
|
+
except OSError:
|
|
936
|
+
return issue
|
|
937
|
+
arguments = parts[1:]
|
|
938
|
+
if arguments[:1] == ["-S"]:
|
|
939
|
+
arguments = arguments[1:]
|
|
940
|
+
if not arguments or arguments[0].startswith("-") or "=" in arguments[0]:
|
|
941
|
+
return issue
|
|
942
|
+
search_path = (env if env is not None else os.environ).get("PATH", "")
|
|
943
|
+
# Relative/empty PATH entries resolve against the child's cwd,
|
|
944
|
+
# not this host's cwd. Refuse rather than check another tool.
|
|
945
|
+
if not search_path or any(not Path(entry).is_absolute() for entry in search_path.split(os.pathsep)):
|
|
946
|
+
return issue
|
|
947
|
+
interpreter = shutil.which(arguments[0], path=search_path)
|
|
948
|
+
if not interpreter:
|
|
949
|
+
return issue
|
|
950
|
+
candidate = interpreter
|
|
951
|
+
return issue
|
|
952
|
+
|
|
884
953
|
async def discover(self) -> list[DiscoveredAgent]:
|
|
885
954
|
self._expand_path()
|
|
886
955
|
# Probe bwrap support once at discovery time and log a clear warning
|
|
@@ -892,8 +961,9 @@ class AgentDiscovery:
|
|
|
892
961
|
cmd = custom_path or spec["commands"][0]
|
|
893
962
|
resolved = shutil.which(cmd)
|
|
894
963
|
if resolved:
|
|
895
|
-
|
|
896
|
-
|
|
964
|
+
issue = await asyncio.to_thread(self._wsl_execution_issue, resolved)
|
|
965
|
+
if issue:
|
|
966
|
+
logger.warning("Agent %s has an unavailable Linux-side WSL toolchain; skipping", agent_id)
|
|
897
967
|
continue
|
|
898
968
|
version = await self._get_version(spec["detect"], resolved)
|
|
899
969
|
if version is None and "detect_alt" in spec:
|
|
@@ -1239,12 +1309,14 @@ def _kill_proc(proc: asyncio.subprocess.Process) -> None:
|
|
|
1239
1309
|
(npm, yarn, ssh, git, etc.) spawned by the agent stay alive, keep pipes
|
|
1240
1310
|
open, and exhaust system resources. ``os.killpg`` sends SIGKILL to the
|
|
1241
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.
|
|
1242
1314
|
"""
|
|
1243
1315
|
try:
|
|
1244
1316
|
if sys.platform != "win32":
|
|
1245
1317
|
import signal as _signal
|
|
1246
1318
|
try:
|
|
1247
|
-
os.killpg(
|
|
1319
|
+
os.killpg(proc.pid, _signal.SIGKILL)
|
|
1248
1320
|
except (ProcessLookupError, PermissionError, OSError):
|
|
1249
1321
|
pass
|
|
1250
1322
|
else:
|
|
@@ -1252,6 +1324,7 @@ def _kill_proc(proc: asyncio.subprocess.Process) -> None:
|
|
|
1252
1324
|
_subprocess.run(
|
|
1253
1325
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1254
1326
|
capture_output=True,
|
|
1327
|
+
timeout=5,
|
|
1255
1328
|
**_agent_process_group_kwargs(),
|
|
1256
1329
|
)
|
|
1257
1330
|
except Exception:
|
|
@@ -1977,10 +2050,24 @@ def claude_base_env(
|
|
|
1977
2050
|
return env
|
|
1978
2051
|
|
|
1979
2052
|
|
|
2053
|
+
def _mirror_skill_directory(source: Path, destination: Path) -> None:
|
|
2054
|
+
if source.resolve() == destination.resolve():
|
|
2055
|
+
return
|
|
2056
|
+
try:
|
|
2057
|
+
if destination.is_symlink() or destination.is_file():
|
|
2058
|
+
destination.unlink()
|
|
2059
|
+
elif destination.is_dir():
|
|
2060
|
+
shutil.rmtree(destination)
|
|
2061
|
+
if source.is_dir():
|
|
2062
|
+
shutil.copytree(source, destination)
|
|
2063
|
+
except OSError as exc:
|
|
2064
|
+
logger.debug("Agent skill mirror skipped %s -> %s: %s", source, destination, exc)
|
|
2065
|
+
|
|
2066
|
+
|
|
1980
2067
|
def prepare_claude_environment(
|
|
1981
2068
|
sandbox_root: Path | None = None,
|
|
1982
2069
|
) -> dict[str, str]:
|
|
1983
|
-
"""Prepare an isolated Claude home and mirror
|
|
2070
|
+
"""Prepare an isolated Claude home and mirror configuration and skills.
|
|
1984
2071
|
|
|
1985
2072
|
When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
|
|
1986
2073
|
directory. When None, falls back to the shared legacy path under ~/.forgexa.
|
|
@@ -1990,8 +2077,8 @@ def prepare_claude_environment(
|
|
|
1990
2077
|
Claude use its default config, cache, and session locations under a
|
|
1991
2078
|
writable task sandbox.
|
|
1992
2079
|
|
|
1993
|
-
Mirror
|
|
1994
|
-
Code's current credential store. Runtime state such as session-env,
|
|
2080
|
+
Mirror stable authentication, settings and installed skill bundles, including
|
|
2081
|
+
Claude Code's current credential store. Runtime state such as session-env,
|
|
1995
2082
|
projects, task locks, and history must never cross into an isolated daemon
|
|
1996
2083
|
invocation. Do not set CLAUDE_CODE_DEBUG_LOGS_DIR:
|
|
1997
2084
|
Claude Code 2.1.63 treats a directory supplied through that variable as
|
|
@@ -2028,6 +2115,9 @@ def prepare_claude_environment(
|
|
|
2028
2115
|
source, dest, exc,
|
|
2029
2116
|
)
|
|
2030
2117
|
|
|
2118
|
+
for relative_root in (Path(".claude/skills"), Path(".agents/skills")):
|
|
2119
|
+
_mirror_skill_directory(Path.home() / relative_root, sandbox_root / relative_root)
|
|
2120
|
+
|
|
2031
2121
|
env = {
|
|
2032
2122
|
"HOME": str(sandbox_root),
|
|
2033
2123
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
|
|
@@ -2375,7 +2465,7 @@ def deepseek_harness_build_info(binary: str) -> dict:
|
|
|
2375
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):
|
|
2376
2466
|
raise RuntimeError("DeepSeek Harness requires Node 22.19+ or Node 24+")
|
|
2377
2467
|
digest = hashlib.sha256()
|
|
2378
|
-
digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-
|
|
2468
|
+
digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-4".encode())
|
|
2379
2469
|
extensions = [manifest.parent for manifest in (roots[0] / "node_modules" / "@deepseek-ai").glob("dsh*/package.json")]
|
|
2380
2470
|
for root in sorted(set([*roots, *extensions])):
|
|
2381
2471
|
files = [root / "package.json"] + sorted((root / "lib").rglob("*.js"))
|
|
@@ -2500,10 +2590,20 @@ def deepseek_harness_overlay(*, cwd: Path, credentials_path: Path, provider: str
|
|
|
2500
2590
|
{"id": "tools", "config": {"mode": "native"}},
|
|
2501
2591
|
{"id": "session-log-deepseek", "config": {"enabled": False}},
|
|
2502
2592
|
{"id": "plugin-package-inventory-deepseek", "config": {"enabled": False}},
|
|
2593
|
+
{"id": "skill-filesystem", "disabled": False, "config": {
|
|
2594
|
+
"dshHome": str(Path(os.environ.get("DSH_HOME", str(Path.home() / ".dsh"))).expanduser().resolve()),
|
|
2595
|
+
"watch": False,
|
|
2596
|
+
}},
|
|
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
|
+
}},
|
|
2503
2603
|
]
|
|
2504
2604
|
disabled = (
|
|
2505
2605
|
"session-telemetry-otel", "hmr", "plugin-manager", "tool-plugin-manager",
|
|
2506
|
-
"llm-pi-ai", "
|
|
2606
|
+
"llm-pi-ai", "tool-subagent-fork",
|
|
2507
2607
|
"tool-subagent-control", "tool-subagent-list-agents", "tool-workflow",
|
|
2508
2608
|
"goal-round-driver", "command-goal", "tool-goal", "command-feedback", "tool-web",
|
|
2509
2609
|
)
|
|
@@ -2748,7 +2848,7 @@ def mimo_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
|
2748
2848
|
"MIMOCODE_PURE": "true",
|
|
2749
2849
|
"MIMOCODE_DISABLE_DEFAULT_PLUGINS": "true",
|
|
2750
2850
|
"MIMOCODE_DISABLE_PROJECT_CONFIG": "true",
|
|
2751
|
-
"MIMOCODE_DISABLE_EXTERNAL_SKILLS": "
|
|
2851
|
+
"MIMOCODE_DISABLE_EXTERNAL_SKILLS": "false",
|
|
2752
2852
|
"MIMOCODE_DISABLE_LSP_DOWNLOAD": "true",
|
|
2753
2853
|
"MIMOCODE_DISABLE_CLAUDE_CODE_MCP": "true",
|
|
2754
2854
|
"MIMOCODE_DISABLE_AUTOUPDATE": "true",
|
|
@@ -2770,8 +2870,8 @@ def mimo_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
|
2770
2870
|
"git * reset --hard*": "deny",
|
|
2771
2871
|
},
|
|
2772
2872
|
"external_directory": "deny",
|
|
2773
|
-
"task": "
|
|
2774
|
-
"skill": "deny",
|
|
2873
|
+
"task": "allow",
|
|
2874
|
+
"skill": {"*": "allow", "compose:*": "deny"},
|
|
2775
2875
|
"webfetch": "deny",
|
|
2776
2876
|
"websearch": "deny",
|
|
2777
2877
|
"codesearch": "deny",
|
|
@@ -2886,16 +2986,16 @@ def build_qwen_command(
|
|
|
2886
2986
|
) -> list[str]:
|
|
2887
2987
|
"""Run Qwen Code headlessly with its user-managed local profile.
|
|
2888
2988
|
|
|
2889
|
-
Qwen Code owns authentication, endpoint, and
|
|
2890
|
-
|
|
2891
|
-
without replacing the user's
|
|
2989
|
+
Qwen Code owns authentication, endpoint, model and skill discovery. The
|
|
2990
|
+
environment policy disables hooks and MCP; CLI overrides disable extensions
|
|
2991
|
+
without suppressing native skills or replacing the user's profile.
|
|
2892
2992
|
"""
|
|
2893
2993
|
cmd = [
|
|
2894
2994
|
binary,
|
|
2895
2995
|
"--output-format", "stream-json",
|
|
2896
2996
|
"--include-partial-messages",
|
|
2897
2997
|
"--approval-mode", approval_mode,
|
|
2898
|
-
"--
|
|
2998
|
+
"--extensions", "",
|
|
2899
2999
|
]
|
|
2900
3000
|
if sandbox:
|
|
2901
3001
|
cmd.append("--sandbox")
|
|
@@ -2971,6 +3071,9 @@ def zcode_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
|
2971
3071
|
storage.chmod(0o700)
|
|
2972
3072
|
except OSError as exc:
|
|
2973
3073
|
raise RuntimeError(f"could not prepare isolated ZCode profile: {exc}") from exc
|
|
3074
|
+
source_home = Path(source_env.get("HOME") or source_env.get("USERPROFILE") or str(Path.home())).expanduser()
|
|
3075
|
+
for relative_root in (Path(".zcode/skills"), Path(".agents/skills")):
|
|
3076
|
+
_mirror_skill_directory(source_home / relative_root, root / relative_root)
|
|
2974
3077
|
safe_keys = {
|
|
2975
3078
|
"PATH", "PATHEXT", "SystemRoot", "WINDIR", "COMSPEC",
|
|
2976
3079
|
"TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_CTYPE", "TERM",
|
|
@@ -3073,9 +3176,9 @@ def zcode_build_verification_issue(binary: str | Path, env: dict[str, str] | Non
|
|
|
3073
3176
|
return None
|
|
3074
3177
|
|
|
3075
3178
|
|
|
3076
|
-
ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write")
|
|
3179
|
+
ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Skill", "Agent", "Task")
|
|
3077
3180
|
ZCODE_DENIED_TOOLS = (
|
|
3078
|
-
"Bash", "MCP", "Plugin", "
|
|
3181
|
+
"Bash", "MCP", "Plugin", "WebFetch", "WebSearch",
|
|
3079
3182
|
)
|
|
3080
3183
|
|
|
3081
3184
|
|
|
@@ -3152,11 +3255,8 @@ def agent_execution_verification_issue(agent_id: str, binary: str) -> str | None
|
|
|
3152
3255
|
return None
|
|
3153
3256
|
|
|
3154
3257
|
|
|
3155
|
-
_FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash")
|
|
3258
|
+
_FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill", "Agent", "Task")
|
|
3156
3259
|
_FORGEXA_CODING_AGENT_DENIED_TOOLS = (
|
|
3157
|
-
"Agent",
|
|
3158
|
-
"Task",
|
|
3159
|
-
"Skill",
|
|
3160
3260
|
"SlashCommand",
|
|
3161
3261
|
"WebFetch",
|
|
3162
3262
|
"WebSearch",
|
|
@@ -3229,8 +3329,8 @@ def build_qoder_command(
|
|
|
3229
3329
|
"""Run Qoder CLI through its isolated headless stream-json protocol.
|
|
3230
3330
|
|
|
3231
3331
|
Qoder receives model selection through its official ``QODER_MODEL``
|
|
3232
|
-
environment variable.
|
|
3233
|
-
|
|
3332
|
+
environment variable. Settings sources stay disabled to isolate hooks and
|
|
3333
|
+
plugins; installed skills remain available through the managed profile.
|
|
3234
3334
|
"""
|
|
3235
3335
|
cmd = [
|
|
3236
3336
|
binary,
|
|
@@ -3382,7 +3482,7 @@ def _link_qoder_profile_entry(
|
|
|
3382
3482
|
|
|
3383
3483
|
|
|
3384
3484
|
def qoder_managed_profile_dir(env: dict[str, str] | None = None) -> Path:
|
|
3385
|
-
"""Create a stable Qoder profile containing
|
|
3485
|
+
"""Create a stable Qoder profile containing auth, model metadata and skills."""
|
|
3386
3486
|
source_env = os.environ if env is None else env
|
|
3387
3487
|
source_root = _qoder_source_profile_root(source_env)
|
|
3388
3488
|
managed_root = _qoder_managed_profile_root(source_root)
|
|
@@ -3393,13 +3493,13 @@ def qoder_managed_profile_dir(env: dict[str, str] | None = None) -> Path:
|
|
|
3393
3493
|
except OSError as exc:
|
|
3394
3494
|
raise RuntimeError(f"could not create managed Qoder profile: {exc}") from exc
|
|
3395
3495
|
|
|
3396
|
-
for name in (".auth", ".models", "installation_id"):
|
|
3496
|
+
for name in (".auth", ".models", "installation_id", "skills"):
|
|
3397
3497
|
_link_qoder_profile_entry(source_root, managed_root, name)
|
|
3398
3498
|
return managed_root
|
|
3399
3499
|
|
|
3400
3500
|
|
|
3401
3501
|
def qoder_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
3402
|
-
"""Constrain Qoder while
|
|
3502
|
+
"""Constrain Qoder while preserving user-managed auth, model and skills."""
|
|
3403
3503
|
env = dict(env)
|
|
3404
3504
|
local_model = qoder_local_model(env)
|
|
3405
3505
|
managed_profile = qoder_managed_profile_dir(env)
|
|
@@ -3425,9 +3525,8 @@ def qwen_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
|
3425
3525
|
"""Return a constrained environment without replacing Qwen's local profile.
|
|
3426
3526
|
|
|
3427
3527
|
Authentication, endpoint, and model selection remain entirely under the
|
|
3428
|
-
user's Qwen Code configuration.
|
|
3429
|
-
|
|
3430
|
-
the Forgexa-managed process.
|
|
3528
|
+
user's Qwen Code configuration. A system override isolates executable
|
|
3529
|
+
customizations without disabling installed skills.
|
|
3431
3530
|
"""
|
|
3432
3531
|
env = dict(env)
|
|
3433
3532
|
for key in (
|
|
@@ -3439,7 +3538,23 @@ def qwen_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
|
3439
3538
|
"QWEN_CODE_SYSTEM_SETTINGS_PATH",
|
|
3440
3539
|
):
|
|
3441
3540
|
env.pop(key, None)
|
|
3541
|
+
settings_dir = Path.home() / ".forgexa" / "qwen"
|
|
3542
|
+
settings_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
3543
|
+
settings_path = settings_dir / "settings.json"
|
|
3544
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=settings_dir, delete=False) as temporary:
|
|
3545
|
+
json.dump({
|
|
3546
|
+
"disableAllHooks": True,
|
|
3547
|
+
"mcp": {"excluded": ["*"], "serverCommand": ""},
|
|
3548
|
+
"tools": {"discoveryCommand": "", "callCommand": ""},
|
|
3549
|
+
"skills": {"disabledLevels": ["extension"]},
|
|
3550
|
+
}, temporary)
|
|
3551
|
+
temporary_path = Path(temporary.name)
|
|
3552
|
+
try:
|
|
3553
|
+
os.replace(temporary_path, settings_path)
|
|
3554
|
+
finally:
|
|
3555
|
+
temporary_path.unlink(missing_ok=True)
|
|
3442
3556
|
env.update({
|
|
3557
|
+
"QWEN_CODE_SYSTEM_SETTINGS_PATH": str(settings_path),
|
|
3443
3558
|
"QWEN_USAGE_STATISTICS_ENABLED": "0",
|
|
3444
3559
|
"QWEN_TELEMETRY_ENABLED": "0",
|
|
3445
3560
|
"QWEN_TELEMETRY_LOG_PROMPTS": "0",
|
|
@@ -3546,9 +3661,10 @@ def build_copilot_command(
|
|
|
3546
3661
|
|
|
3547
3662
|
``prompt_via_stdin`` omits ``-p`` so the caller pipes the prompt into
|
|
3548
3663
|
stdin instead - verified live on CLI 1.0.86, where piped stdin is a full
|
|
3549
|
-
non-interactive prompt source. The
|
|
3550
|
-
|
|
3551
|
-
|
|
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.
|
|
3552
3668
|
"""
|
|
3553
3669
|
cmd = [
|
|
3554
3670
|
binary,
|
|
@@ -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.
|
|
1199
|
+
DAEMON_VERSION = "1.63.0"
|
|
1200
1200
|
|
|
1201
1201
|
|
|
1202
1202
|
def _detect_client_type() -> str:
|
|
@@ -6525,10 +6525,11 @@ class ProcessManager:
|
|
|
6525
6525
|
_last_activity_at: list[float] = [time.monotonic()]
|
|
6526
6526
|
|
|
6527
6527
|
# Write prompt and close stdin so the agent knows input is done.
|
|
6528
|
-
if
|
|
6528
|
+
if proc.stdin:
|
|
6529
6529
|
try:
|
|
6530
|
-
|
|
6531
|
-
|
|
6530
|
+
if stdin_input:
|
|
6531
|
+
proc.stdin.write(stdin_input)
|
|
6532
|
+
await proc.stdin.drain()
|
|
6532
6533
|
except Exception:
|
|
6533
6534
|
pass
|
|
6534
6535
|
proc.stdin.close()
|
|
@@ -7668,7 +7669,8 @@ class ProcessManager:
|
|
|
7668
7669
|
explicitly configured supported token environment variable.
|
|
7669
7670
|
|
|
7670
7671
|
Flags:
|
|
7671
|
-
|
|
7672
|
+
stdin Full non-interactive prompt, avoiding argv limits.
|
|
7673
|
+
-p / --prompt Short task-file instruction for Windows wrappers.
|
|
7672
7674
|
--output-format json JSONL stream of session events.
|
|
7673
7675
|
--allow-all Grant all tool + path + URL permissions required
|
|
7674
7676
|
for autonomous file-editing tasks.
|
|
@@ -7768,11 +7770,13 @@ class ProcessManager:
|
|
|
7768
7770
|
_effective_prompt, _task_file = _redirect_prompt_through_task_file(
|
|
7769
7771
|
cwd, prompt, task_id, "Copilot",
|
|
7770
7772
|
)
|
|
7773
|
+
_stdin_input = None if _task_file is not None else prompt.encode("utf-8")
|
|
7771
7774
|
|
|
7772
7775
|
cmd = agent_core.build_copilot_command(
|
|
7773
7776
|
agent.command,
|
|
7774
7777
|
cwd=cwd,
|
|
7775
7778
|
prompt=_effective_prompt,
|
|
7779
|
+
prompt_via_stdin=_stdin_input is not None,
|
|
7776
7780
|
model=model_override,
|
|
7777
7781
|
reasoning=reasoning if not _retry_without_effort else None,
|
|
7778
7782
|
disable_builtin_mcps=disable_builtin_mcps,
|
|
@@ -7786,9 +7790,20 @@ class ProcessManager:
|
|
|
7786
7790
|
# reproduce failures manually and diagnose CLI version incompatibilities.
|
|
7787
7791
|
_cmd_display = " ".join(
|
|
7788
7792
|
f'"{a}"' if " " in a else a
|
|
7789
|
-
for a in cmd
|
|
7790
|
-
)
|
|
7791
|
-
|
|
7793
|
+
for a in (cmd if _stdin_input is not None else cmd[:-1])
|
|
7794
|
+
)
|
|
7795
|
+
if _stdin_input is None:
|
|
7796
|
+
_cmd_display += ' "<prompt>"'
|
|
7797
|
+
logger.debug(
|
|
7798
|
+
"Copilot invocation for task %s: %s (prompt via %s)",
|
|
7799
|
+
task_id, _cmd_display, "stdin" if _stdin_input is not None else "task file",
|
|
7800
|
+
)
|
|
7801
|
+
logger.info(
|
|
7802
|
+
"Copilot task %s prompt transport: %s (%d UTF-8 bytes)",
|
|
7803
|
+
task_id,
|
|
7804
|
+
"stdin" if _stdin_input is not None else "task file",
|
|
7805
|
+
len(_stdin_input) if _stdin_input is not None else len(prompt.encode("utf-8")),
|
|
7806
|
+
)
|
|
7792
7807
|
|
|
7793
7808
|
# On Windows, CREATE_NO_WINDOW ensures GetConsoleWindow() returns NULL for
|
|
7794
7809
|
# the child process, even when the parent (daemon or Tauri app) has a console
|
|
@@ -7796,9 +7811,9 @@ class ProcessManager:
|
|
|
7796
7811
|
# check via GetConsoleWindow(): if a console window is visible, it uses only
|
|
7797
7812
|
# the first line of -p as a brief context note and reports "No task was
|
|
7798
7813
|
# provided" for the rest of the prompt — silently discarding the full task.
|
|
7799
|
-
#
|
|
7814
|
+
# A pipe or DEVNULL covers the primary check (GetConsoleMode on stdin);
|
|
7800
7815
|
# CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
|
|
7801
|
-
# headless environment and processes
|
|
7816
|
+
# headless environment and processes the complete task specification.
|
|
7802
7817
|
proc: asyncio.subprocess.Process | None = None
|
|
7803
7818
|
|
|
7804
7819
|
async def _terminate_copilot_process(
|
|
@@ -7817,18 +7832,11 @@ class ProcessManager:
|
|
|
7817
7832
|
*cmd,
|
|
7818
7833
|
stdout=asyncio.subprocess.PIPE,
|
|
7819
7834
|
stderr=asyncio.subprocess.PIPE,
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
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,
|
|
7835
|
+
stdin=(
|
|
7836
|
+
asyncio.subprocess.PIPE
|
|
7837
|
+
if _stdin_input is not None
|
|
7838
|
+
else asyncio.subprocess.DEVNULL
|
|
7839
|
+
),
|
|
7832
7840
|
cwd=str(cwd),
|
|
7833
7841
|
env=env,
|
|
7834
7842
|
limit=100 * 1024 * 1024,
|
|
@@ -7836,7 +7844,7 @@ class ProcessManager:
|
|
|
7836
7844
|
)
|
|
7837
7845
|
self.active_processes[task_id] = proc
|
|
7838
7846
|
stdout, stderr, returncode = await self._stream_process(
|
|
7839
|
-
proc,
|
|
7847
|
+
proc, _stdin_input, timeout, task_id, on_chunk,
|
|
7840
7848
|
workspace_path=cwd,
|
|
7841
7849
|
stream_stderr=stream_stderr,
|
|
7842
7850
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|