arkaos 4.3.5 → 4.4.0
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.
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/bin/arka-claude +35 -4
- package/bin/arka-py +22 -7
- package/config/hooks/_lib/arka_python.ps1 +31 -0
- package/config/hooks/_lib/arka_python.sh +38 -0
- package/config/hooks/post-tool-use.ps1 +8 -0
- package/config/hooks/post-tool-use.sh +11 -5
- package/config/hooks/pre-tool-use.ps1 +11 -6
- package/config/hooks/pre-tool-use.sh +11 -6
- package/config/hooks/session-start.ps1 +10 -0
- package/config/hooks/session-start.sh +5 -0
- package/config/hooks/stop.ps1 +9 -1
- package/config/hooks/stop.sh +11 -5
- package/config/hooks/user-prompt-submit.ps1 +18 -0
- package/config/hooks/user-prompt-submit.sh +11 -5
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +7 -3
- package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/core/forge/orchestrator.py +2 -1
- package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/_shared.py +29 -9
- package/core/hooks/stop.py +4 -3
- package/core/hooks/user_prompt_submit.py +5 -4
- package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/indexer.py +1 -1
- package/core/knowledge/vector_store.py +10 -3
- package/core/runtime/__pycache__/model_router.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_check.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
- package/core/runtime/gateway/__init__.py +28 -0
- package/core/runtime/gateway/__main__.py +30 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__main__.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-312.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-314.pyc +0 -0
- package/core/runtime/gateway/litellm_config.py +205 -0
- package/core/runtime/model_routing_check.py +71 -0
- package/core/runtime/model_routing_context.py +22 -1
- package/core/runtime/native_usage.py +2 -1
- package/core/shared/__pycache__/temp_paths.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/temp_paths.cpython-314.pyc +0 -0
- package/core/shared/temp_paths.py +40 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
- package/core/synapse/kb_cache.py +3 -2
- package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
- package/core/sync/agent_provisioner.py +5 -5
- package/core/sync/ai_mcp_decider.py +2 -2
- package/core/sync/content_syncer.py +14 -14
- package/core/sync/descriptor_syncer.py +2 -2
- package/core/sync/discovery.py +4 -4
- package/core/sync/engine.py +4 -4
- package/core/sync/manifest.py +1 -1
- package/core/sync/mcp_optimizer.py +5 -5
- package/core/sync/mcp_syncer.py +3 -3
- package/core/sync/policy_loader.py +1 -1
- package/core/sync/reporter.py +1 -1
- package/core/sync/settings_syncer.py +2 -2
- package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/session_windows.cpython-313.pyc +0 -0
- package/core/terminal/session.py +66 -11
- package/core/terminal/session_windows.py +211 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
- package/core/workflow/flow_authorization.py +2 -1
- package/core/workflow/flow_enforcer.py +2 -1
- package/core/workflow/marker_cache.py +2 -1
- package/core/workflow/research_gate.py +2 -1
- package/dashboard/app/pages/index.vue +6 -1
- package/installer/cli.js +5 -4
- package/installer/core-snapshot.js +53 -0
- package/installer/doctor.js +8 -3
- package/installer/index.js +29 -6
- package/installer/migrate.js +7 -4
- package/installer/update.js +13 -0
- package/package.json +1 -1
- package/pyproject.toml +8 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +69 -33
- package/scripts/gateway-ensure.sh +93 -0
- package/scripts/knowledge-index.py +35 -2
- package/scripts/start-dashboard.ps1 +34 -14
- package/scripts/synapse-bridge.py +3 -1
|
@@ -36,7 +36,7 @@ def sync_project_content(project: Project) -> ContentSyncResult:
|
|
|
36
36
|
|
|
37
37
|
def _do_sync(project: Project) -> ContentSyncResult:
|
|
38
38
|
core = _core_root()
|
|
39
|
-
version = (core / "VERSION").read_text().strip()
|
|
39
|
+
version = (core / "VERSION").read_text(encoding="utf-8").strip()
|
|
40
40
|
project_claude = Path(project.path) / ".claude"
|
|
41
41
|
project_claude.mkdir(parents=True, exist_ok=True)
|
|
42
42
|
|
|
@@ -73,28 +73,28 @@ def _sync_claude_md(
|
|
|
73
73
|
unchanged: list[str],
|
|
74
74
|
errored: list[str],
|
|
75
75
|
) -> None:
|
|
76
|
-
base = (core / "config" / "user-claude.md").read_text()
|
|
76
|
+
base = (core / "config" / "user-claude.md").read_text(encoding="utf-8")
|
|
77
77
|
overlays_dir = core / "config" / "standards" / "claude-md-overlays"
|
|
78
78
|
overlays: list[str] = []
|
|
79
79
|
for stack in project.stack:
|
|
80
80
|
overlay = overlays_dir / f"{stack}.md"
|
|
81
81
|
if overlay.exists():
|
|
82
|
-
overlays.append(overlay.read_text())
|
|
82
|
+
overlays.append(overlay.read_text(encoding="utf-8"))
|
|
83
83
|
|
|
84
84
|
managed_content = "\n\n".join([base, *overlays]).strip()
|
|
85
85
|
target_file = project_claude / "CLAUDE.md"
|
|
86
|
-
target_text = target_file.read_text() if target_file.exists() else ""
|
|
86
|
+
target_text = target_file.read_text(encoding="utf-8") if target_file.exists() else ""
|
|
87
87
|
|
|
88
88
|
result = merge_managed_content(target_text, managed_content, version)
|
|
89
89
|
if result.status == "error":
|
|
90
90
|
errored.append(f"CLAUDE.md: {result.error}")
|
|
91
91
|
sidecar = target_file.with_suffix(".md.arkaos-new")
|
|
92
|
-
sidecar.write_text(managed_content)
|
|
92
|
+
sidecar.write_text(managed_content, encoding="utf-8")
|
|
93
93
|
return
|
|
94
94
|
if result.status == "unchanged":
|
|
95
95
|
unchanged.append("CLAUDE.md")
|
|
96
96
|
return
|
|
97
|
-
target_file.write_text(result.new_text)
|
|
97
|
+
target_file.write_text(result.new_text, encoding="utf-8")
|
|
98
98
|
updated.append("CLAUDE.md")
|
|
99
99
|
|
|
100
100
|
|
|
@@ -111,11 +111,11 @@ def _sync_rules(
|
|
|
111
111
|
dst.mkdir(parents=True, exist_ok=True)
|
|
112
112
|
for rule in src.glob("*.md"):
|
|
113
113
|
target = dst / rule.name
|
|
114
|
-
src_text = rule.read_text()
|
|
115
|
-
if target.exists() and target.read_text() == src_text:
|
|
114
|
+
src_text = rule.read_text(encoding="utf-8")
|
|
115
|
+
if target.exists() and target.read_text(encoding="utf-8") == src_text:
|
|
116
116
|
unchanged.append(f"rules/{rule.name}")
|
|
117
117
|
continue
|
|
118
|
-
target.write_text(src_text)
|
|
118
|
+
target.write_text(src_text, encoding="utf-8")
|
|
119
119
|
updated.append(f"rules/{rule.name}")
|
|
120
120
|
|
|
121
121
|
|
|
@@ -131,8 +131,8 @@ def _sync_hooks(
|
|
|
131
131
|
dst.mkdir(parents=True, exist_ok=True)
|
|
132
132
|
for hook in src.glob("*.sh"):
|
|
133
133
|
target = dst / hook.name
|
|
134
|
-
src_text = hook.read_text()
|
|
135
|
-
if target.exists() and target.read_text() == src_text:
|
|
134
|
+
src_text = hook.read_text(encoding="utf-8")
|
|
135
|
+
if target.exists() and target.read_text(encoding="utf-8") == src_text:
|
|
136
136
|
unchanged.append(f"hooks/{hook.name}")
|
|
137
137
|
continue
|
|
138
138
|
shutil.copy2(hook, target)
|
|
@@ -149,16 +149,16 @@ def _sync_constitution(
|
|
|
149
149
|
) -> None:
|
|
150
150
|
src = core / "config" / "constitution.yaml"
|
|
151
151
|
target = project_claude / "constitution-applicable.md"
|
|
152
|
-
data = yaml.safe_load(src.read_text()) or {}
|
|
152
|
+
data = yaml.safe_load(src.read_text(encoding="utf-8")) or {}
|
|
153
153
|
rules = data.get("rules", [])
|
|
154
154
|
lines = ["# ArkaOS Constitution — Applicable Rules", ""]
|
|
155
155
|
for rule in rules:
|
|
156
156
|
lines.append(f"- **{rule.get('name', '?')}** — {rule.get('level', '?')}")
|
|
157
157
|
body = "\n".join(lines) + "\n"
|
|
158
|
-
if target.exists() and target.read_text() == body:
|
|
158
|
+
if target.exists() and target.read_text(encoding="utf-8") == body:
|
|
159
159
|
unchanged.append("constitution-applicable.md")
|
|
160
160
|
return
|
|
161
|
-
target.write_text(body)
|
|
161
|
+
target.write_text(body, encoding="utf-8")
|
|
162
162
|
updated.append("constitution-applicable.md")
|
|
163
163
|
|
|
164
164
|
|
|
@@ -47,7 +47,7 @@ def sync_descriptor(project: Project) -> DescriptorSyncResult:
|
|
|
47
47
|
def _do_sync(project: Project) -> DescriptorSyncResult:
|
|
48
48
|
"""Execute the descriptor sync logic for a single project."""
|
|
49
49
|
desc_path = Path(project.descriptor_path) # type: ignore[arg-type]
|
|
50
|
-
text = desc_path.read_text()
|
|
50
|
+
text = desc_path.read_text(encoding="utf-8")
|
|
51
51
|
frontmatter, body = _split_frontmatter(text)
|
|
52
52
|
changes: list[str] = []
|
|
53
53
|
|
|
@@ -193,4 +193,4 @@ def _get_last_commit_days(project_path: str) -> int | None:
|
|
|
193
193
|
def _write_descriptor(desc_path: Path, frontmatter: dict, body: str) -> None:
|
|
194
194
|
"""Write updated frontmatter and preserved body back to the descriptor file."""
|
|
195
195
|
fm_text = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
|
|
196
|
-
desc_path.write_text(f"---\n{fm_text}---{body}")
|
|
196
|
+
desc_path.write_text(f"---\n{fm_text}---{body}", encoding="utf-8")
|
package/core/sync/discovery.py
CHANGED
|
@@ -21,7 +21,7 @@ def _detect_from_composer(project_path: Path) -> list[str]:
|
|
|
21
21
|
if not composer.exists():
|
|
22
22
|
return []
|
|
23
23
|
try:
|
|
24
|
-
data = json.loads(composer.read_text())
|
|
24
|
+
data = json.loads(composer.read_text(encoding="utf-8"))
|
|
25
25
|
require = data.get("require", {})
|
|
26
26
|
if "laravel/framework" in require:
|
|
27
27
|
return ["php", "laravel"]
|
|
@@ -35,7 +35,7 @@ def _detect_from_package_json(project_path: Path) -> list[str]:
|
|
|
35
35
|
if not pkg.exists():
|
|
36
36
|
return []
|
|
37
37
|
try:
|
|
38
|
-
data = json.loads(pkg.read_text())
|
|
38
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
39
39
|
deps = {
|
|
40
40
|
**data.get("dependencies", {}),
|
|
41
41
|
**data.get("devDependencies", {}),
|
|
@@ -100,7 +100,7 @@ def _parse_descriptor_frontmatter(text: str) -> dict:
|
|
|
100
100
|
def _read_descriptor_item(item: Path) -> dict:
|
|
101
101
|
"""Read a descriptor file and return its frontmatter as a dict."""
|
|
102
102
|
try:
|
|
103
|
-
return _parse_descriptor_frontmatter(item.read_text())
|
|
103
|
+
return _parse_descriptor_frontmatter(item.read_text(encoding="utf-8"))
|
|
104
104
|
except OSError:
|
|
105
105
|
return {}
|
|
106
106
|
|
|
@@ -187,7 +187,7 @@ def discover_from_ecosystems(ecosystems_file: Path) -> list[Project]:
|
|
|
187
187
|
if not ecosystems_file.exists():
|
|
188
188
|
return []
|
|
189
189
|
try:
|
|
190
|
-
data = json.loads(ecosystems_file.read_text())
|
|
190
|
+
data = json.loads(ecosystems_file.read_text(encoding="utf-8"))
|
|
191
191
|
except (json.JSONDecodeError, OSError):
|
|
192
192
|
return []
|
|
193
193
|
|
package/core/sync/engine.py
CHANGED
|
@@ -117,7 +117,7 @@ def _read_previous_version(arkaos_home: Path) -> str:
|
|
|
117
117
|
if not state_file.exists():
|
|
118
118
|
return "pending-sync"
|
|
119
119
|
try:
|
|
120
|
-
data = json.loads(state_file.read_text())
|
|
120
|
+
data = json.loads(state_file.read_text(encoding="utf-8"))
|
|
121
121
|
return data.get("version", "pending-sync") or "pending-sync"
|
|
122
122
|
except (json.JSONDecodeError, OSError):
|
|
123
123
|
return "pending-sync"
|
|
@@ -132,7 +132,7 @@ def _read_current_version(arkaos_home: Path) -> str:
|
|
|
132
132
|
if not version_file.exists():
|
|
133
133
|
return "unknown"
|
|
134
134
|
try:
|
|
135
|
-
return version_file.read_text().strip()
|
|
135
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
136
136
|
except OSError:
|
|
137
137
|
return "unknown"
|
|
138
138
|
|
|
@@ -143,7 +143,7 @@ def _read_repo_path(arkaos_home: Path) -> Path | None:
|
|
|
143
143
|
if not repo_path_file.exists():
|
|
144
144
|
return None
|
|
145
145
|
try:
|
|
146
|
-
raw = repo_path_file.read_text().strip()
|
|
146
|
+
raw = repo_path_file.read_text(encoding="utf-8").strip()
|
|
147
147
|
return Path(raw) if raw else None
|
|
148
148
|
except OSError:
|
|
149
149
|
return None
|
|
@@ -204,7 +204,7 @@ def _load_scan_dirs_from_profile(arkaos_home: Path) -> list[Path]:
|
|
|
204
204
|
if not profile_file.exists():
|
|
205
205
|
return []
|
|
206
206
|
try:
|
|
207
|
-
data = json.loads(profile_file.read_text())
|
|
207
|
+
data = json.loads(profile_file.read_text(encoding="utf-8"))
|
|
208
208
|
projects_dir_str = data.get("projectsDir", "")
|
|
209
209
|
if not projects_dir_str:
|
|
210
210
|
return []
|
package/core/sync/manifest.py
CHANGED
|
@@ -18,7 +18,7 @@ def load_features(features_dir: Path) -> list[FeatureSpec]:
|
|
|
18
18
|
for path in sorted(features_dir.iterdir()):
|
|
19
19
|
if path.suffix != ".yaml":
|
|
20
20
|
continue
|
|
21
|
-
data = yaml.safe_load(path.read_text())
|
|
21
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
22
22
|
features.append(FeatureSpec(**data))
|
|
23
23
|
|
|
24
24
|
return features
|
|
@@ -92,7 +92,7 @@ def _load_override(project_path: Path) -> tuple[dict, list[str]]:
|
|
|
92
92
|
if not override.exists():
|
|
93
93
|
return {}, []
|
|
94
94
|
try:
|
|
95
|
-
return yaml.safe_load(override.read_text()) or {}, []
|
|
95
|
+
return yaml.safe_load(override.read_text(encoding="utf-8")) or {}, []
|
|
96
96
|
except (yaml.YAMLError, OSError) as exc:
|
|
97
97
|
return {}, [f"override YAML parse error: {exc}"]
|
|
98
98
|
|
|
@@ -123,7 +123,7 @@ def _inject_env_vars(project_path: Path, vault_path: Path | None, project_name:
|
|
|
123
123
|
return []
|
|
124
124
|
|
|
125
125
|
try:
|
|
126
|
-
data = json.loads(mcp_file.read_text())
|
|
126
|
+
data = json.loads(mcp_file.read_text(encoding="utf-8"))
|
|
127
127
|
except (json.JSONDecodeError, OSError):
|
|
128
128
|
return [".mcp.json malformed; skipped env injection"]
|
|
129
129
|
|
|
@@ -137,7 +137,7 @@ def _inject_env_vars(project_path: Path, vault_path: Path | None, project_name:
|
|
|
137
137
|
changed, missing = _merge_env(servers, merged_env)
|
|
138
138
|
|
|
139
139
|
if changed:
|
|
140
|
-
mcp_file.write_text(json.dumps(data, indent=2) + "\n")
|
|
140
|
+
mcp_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
141
141
|
|
|
142
142
|
if missing:
|
|
143
143
|
_write_env_example(project_path, missing)
|
|
@@ -157,7 +157,7 @@ def _load_vault(path: Path) -> tuple[dict, list[str]]:
|
|
|
157
157
|
if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
158
158
|
return {}, ["vault permissions too permissive (group/world readable); secrets not injected"]
|
|
159
159
|
try:
|
|
160
|
-
return json.loads(path.read_text()), []
|
|
160
|
+
return json.loads(path.read_text(encoding="utf-8")), []
|
|
161
161
|
except (json.JSONDecodeError, OSError):
|
|
162
162
|
return {}, ["vault JSON parse error; secrets not injected"]
|
|
163
163
|
|
|
@@ -167,7 +167,7 @@ def _write_env_example(project_path: Path, missing: dict[str, str]) -> None:
|
|
|
167
167
|
for var, server in sorted(missing.items()):
|
|
168
168
|
lines.append(f"# required by {server}")
|
|
169
169
|
lines.append(f"{var}=")
|
|
170
|
-
(project_path / ".env.arkaos.example").write_text("\n".join(lines) + "\n")
|
|
170
|
+
(project_path / ".env.arkaos.example").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
171
171
|
|
|
172
172
|
|
|
173
173
|
def optimize_all_mcps(
|
package/core/sync/mcp_syncer.py
CHANGED
|
@@ -39,7 +39,7 @@ def load_registry(registry_path: Path) -> dict:
|
|
|
39
39
|
if not registry_path.exists():
|
|
40
40
|
return {}
|
|
41
41
|
try:
|
|
42
|
-
data = json.loads(registry_path.read_text())
|
|
42
|
+
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
43
43
|
return data.get("mcpServers", {})
|
|
44
44
|
except (json.JSONDecodeError, OSError):
|
|
45
45
|
return {}
|
|
@@ -142,7 +142,7 @@ def _read_current_mcps(mcp_file: Path) -> dict:
|
|
|
142
142
|
if not mcp_file.exists():
|
|
143
143
|
return {}
|
|
144
144
|
try:
|
|
145
|
-
data = json.loads(mcp_file.read_text())
|
|
145
|
+
data = json.loads(mcp_file.read_text(encoding="utf-8"))
|
|
146
146
|
return data.get("mcpServers", {})
|
|
147
147
|
except (json.JSONDecodeError, OSError):
|
|
148
148
|
return {}
|
|
@@ -252,4 +252,4 @@ def _build_merged(
|
|
|
252
252
|
def _write_mcp_json(mcp_file: Path, servers: dict) -> None:
|
|
253
253
|
"""Write the mcpServers dict to .mcp.json with 2-space indentation."""
|
|
254
254
|
payload = {"mcpServers": servers}
|
|
255
|
-
mcp_file.write_text(json.dumps(payload, indent=2) + "\n")
|
|
255
|
+
mcp_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
@@ -30,7 +30,7 @@ class PolicyDecision:
|
|
|
30
30
|
|
|
31
31
|
def load_policy(path: Path) -> Policy:
|
|
32
32
|
"""Load and parse an mcp-policy.yaml file."""
|
|
33
|
-
data = yaml.safe_load(path.read_text()) or {}
|
|
33
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
34
34
|
rules = [
|
|
35
35
|
PolicyRule(
|
|
36
36
|
match=r.get("match", {}),
|
package/core/sync/reporter.py
CHANGED
|
@@ -74,7 +74,7 @@ def write_sync_state(state_file: Path, report: SyncReport) -> None:
|
|
|
74
74
|
"skills_synced": len(report.skill_results),
|
|
75
75
|
"errors": report.errors,
|
|
76
76
|
}
|
|
77
|
-
state_file.write_text(json.dumps(state, indent=2))
|
|
77
|
+
state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
def format_report(report: SyncReport) -> str:
|
|
@@ -82,7 +82,7 @@ def _read_current_settings(settings_file: Path) -> dict:
|
|
|
82
82
|
if not settings_file.exists():
|
|
83
83
|
return {}
|
|
84
84
|
try:
|
|
85
|
-
return json.loads(settings_file.read_text())
|
|
85
|
+
return json.loads(settings_file.read_text(encoding="utf-8"))
|
|
86
86
|
except (json.JSONDecodeError, OSError):
|
|
87
87
|
return {}
|
|
88
88
|
|
|
@@ -118,4 +118,4 @@ def _build_merged_settings(current: dict, target_servers: list[str]) -> dict:
|
|
|
118
118
|
def _write_settings(settings_file: Path, data: dict) -> None:
|
|
119
119
|
"""Create parent dirs if needed and write settings as 2-space JSON."""
|
|
120
120
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
-
settings_file.write_text(json.dumps(data, indent=2) + "\n")
|
|
121
|
+
settings_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
Binary file
|
|
Binary file
|
package/core/terminal/session.py
CHANGED
|
@@ -21,16 +21,23 @@ Design notes
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
23
|
import errno
|
|
24
|
-
import fcntl
|
|
25
24
|
import os
|
|
26
|
-
import pty
|
|
27
25
|
import secrets
|
|
26
|
+
import shutil
|
|
28
27
|
import signal
|
|
29
28
|
import struct
|
|
30
|
-
import termios
|
|
31
29
|
import time
|
|
32
30
|
from typing import Any, Optional
|
|
33
31
|
|
|
32
|
+
try: # POSIX-only: the Windows backend lives in session_windows.py
|
|
33
|
+
import fcntl
|
|
34
|
+
import pty
|
|
35
|
+
import termios
|
|
36
|
+
_PTY_SUPPORTED = True
|
|
37
|
+
except ModuleNotFoundError:
|
|
38
|
+
fcntl = pty = termios = None # type: ignore[assignment]
|
|
39
|
+
_PTY_SUPPORTED = False
|
|
40
|
+
|
|
34
41
|
from core.terminal import audit
|
|
35
42
|
|
|
36
43
|
|
|
@@ -39,10 +46,47 @@ class SessionCapacityError(RuntimeError):
|
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
def _default_shell() -> str:
|
|
49
|
+
if os.name == "nt":
|
|
50
|
+
# Windows PowerShell renders reliably under ConPTY; bare cmd.exe can
|
|
51
|
+
# emit no prompt when the API is launched without an attached
|
|
52
|
+
# console, and the WindowsApps "pwsh" alias is a Store reparse point
|
|
53
|
+
# that misbehaves when spawned programmatically — so resolve the real
|
|
54
|
+
# System32 powershell.exe first.
|
|
55
|
+
return _resolve_windows_shell()
|
|
42
56
|
return os.environ.get("SHELL") or "/bin/zsh"
|
|
43
57
|
|
|
44
58
|
|
|
59
|
+
def _resolve_windows_shell() -> str:
|
|
60
|
+
"""Pick a Windows shell that works under ConPTY, skipping Store aliases."""
|
|
61
|
+
candidates = []
|
|
62
|
+
pwsh = shutil.which("pwsh")
|
|
63
|
+
if pwsh and "windowsapps" not in pwsh.lower():
|
|
64
|
+
candidates.append(pwsh)
|
|
65
|
+
powershell = shutil.which("powershell")
|
|
66
|
+
if powershell:
|
|
67
|
+
candidates.append(powershell)
|
|
68
|
+
candidates.append(os.environ.get("COMSPEC") or "cmd.exe")
|
|
69
|
+
return candidates[0]
|
|
70
|
+
|
|
71
|
+
|
|
45
72
|
def _default_cwd() -> str:
|
|
73
|
+
"""Open new terminals in the user's configured projectsDir, else home.
|
|
74
|
+
|
|
75
|
+
The dashboard sends no cwd, so without this a terminal lands in the
|
|
76
|
+
home directory rather than where the operator actually works. Reads
|
|
77
|
+
the existing ~/.arkaos/profile.json projectsDir; falls back to home
|
|
78
|
+
when it is unset or missing (unchanged behaviour for that case).
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
import json
|
|
82
|
+
profile = os.path.join(os.path.expanduser("~"), ".arkaos", "profile.json")
|
|
83
|
+
if os.path.isfile(profile):
|
|
84
|
+
with open(profile, encoding="utf-8") as fh:
|
|
85
|
+
projects_dir = json.load(fh).get("projectsDir")
|
|
86
|
+
if projects_dir and os.path.isdir(projects_dir):
|
|
87
|
+
return projects_dir
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
46
90
|
return os.path.expanduser("~")
|
|
47
91
|
|
|
48
92
|
|
|
@@ -251,14 +295,25 @@ class TerminalSessionManager:
|
|
|
251
295
|
sid = secrets.token_urlsafe(8)
|
|
252
296
|
chosen_shell = shell or _default_shell()
|
|
253
297
|
chosen_cwd = cwd or _default_cwd()
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
298
|
+
if not _PTY_SUPPORTED:
|
|
299
|
+
from core.terminal.session_windows import WindowsTerminalSession
|
|
300
|
+
session = WindowsTerminalSession(
|
|
301
|
+
session_id=sid,
|
|
302
|
+
shell=chosen_shell,
|
|
303
|
+
cwd=chosen_cwd,
|
|
304
|
+
cols=cols,
|
|
305
|
+
rows=rows,
|
|
306
|
+
scrollback_bytes=self.scrollback_bytes,
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
session = TerminalSession(
|
|
310
|
+
session_id=sid,
|
|
311
|
+
shell=chosen_shell,
|
|
312
|
+
cwd=chosen_cwd,
|
|
313
|
+
cols=cols,
|
|
314
|
+
rows=rows,
|
|
315
|
+
scrollback_bytes=self.scrollback_bytes,
|
|
316
|
+
)
|
|
262
317
|
self._sessions[sid] = session
|
|
263
318
|
audit.log_start(sid, chosen_shell, chosen_cwd)
|
|
264
319
|
return session
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Windows PTY session backend (ConPTY via pywinpty).
|
|
2
|
+
|
|
3
|
+
The POSIX backend in ``session.py`` builds on ``pty.fork`` + fcntl /
|
|
4
|
+
termios, none of which exist on Windows. This module provides the same
|
|
5
|
+
``TerminalSession`` surface on top of pywinpty's ConPTY wrapper, so the
|
|
6
|
+
dashboard terminal works on Windows. ``TerminalSessionManager.create``
|
|
7
|
+
selects it when ``os.name == "nt"``.
|
|
8
|
+
|
|
9
|
+
IO model
|
|
10
|
+
--------
|
|
11
|
+
pywinpty's ``read`` is blocking and there is no pollable fd, so the POSIX
|
|
12
|
+
approach (``loop.add_reader(master_fd)``) does not apply. Instead each
|
|
13
|
+
session owns one daemon reader thread that drains the pty for the
|
|
14
|
+
session's whole lifetime: it always appends to the bounded scrollback
|
|
15
|
+
(so a client reconnecting after navigating away replays what it missed)
|
|
16
|
+
and, when a WebSocket is attached, forwards each chunk to that single
|
|
17
|
+
``listener``. Decoupling the reader from the connection avoids two
|
|
18
|
+
readers racing on the same pty across a reload/supersede.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any, Callable, Optional
|
|
26
|
+
|
|
27
|
+
from core.terminal import audit
|
|
28
|
+
|
|
29
|
+
DEFAULT_SCROLLBACK_BYTES = 512 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WindowsTerminalSession:
|
|
33
|
+
"""A ConPTY-backed shell + the bookkeeping the manager relies on.
|
|
34
|
+
|
|
35
|
+
Mirrors the public surface of ``session.TerminalSession``:
|
|
36
|
+
``read``/``write``/``resize``/``scrollback``/``is_alive``/``kill``/
|
|
37
|
+
``to_dict`` plus the ``session_id``/``shell``/``cwd``/``exit_code``
|
|
38
|
+
attributes. ``master_fd`` is always ``-1`` — the dashboard WebSocket
|
|
39
|
+
handler uses that to pick the thread-listener pump instead of
|
|
40
|
+
``add_reader``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
session_id: str,
|
|
46
|
+
shell: str,
|
|
47
|
+
cwd: str,
|
|
48
|
+
cols: int = 120,
|
|
49
|
+
rows: int = 32,
|
|
50
|
+
scrollback_bytes: int = DEFAULT_SCROLLBACK_BYTES,
|
|
51
|
+
) -> None:
|
|
52
|
+
import winpty # lazy: the dependency is only needed on Windows
|
|
53
|
+
|
|
54
|
+
self.session_id = session_id
|
|
55
|
+
self.shell = shell
|
|
56
|
+
self.cwd = cwd
|
|
57
|
+
self.created_at = time.time()
|
|
58
|
+
self.last_activity = time.monotonic()
|
|
59
|
+
self.exit_code: Optional[int] = None
|
|
60
|
+
self.title = ""
|
|
61
|
+
self.scrollback_max = max(0, int(scrollback_bytes))
|
|
62
|
+
self._scrollback = bytearray()
|
|
63
|
+
self._closed = False
|
|
64
|
+
self.master_fd = -1 # no pollable fd on Windows
|
|
65
|
+
|
|
66
|
+
# ConPTY dimensions are (rows, cols).
|
|
67
|
+
self._proc = winpty.PtyProcess.spawn(
|
|
68
|
+
shell,
|
|
69
|
+
cwd=cwd,
|
|
70
|
+
dimensions=(max(1, int(rows)), max(1, int(cols))),
|
|
71
|
+
)
|
|
72
|
+
self.pid = self._proc.pid
|
|
73
|
+
|
|
74
|
+
self._listener: Optional[Callable[[bytes], None]] = None
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
77
|
+
self._reader.start()
|
|
78
|
+
|
|
79
|
+
# -- reader thread ------------------------------------------------------
|
|
80
|
+
def _read_loop(self) -> None:
|
|
81
|
+
"""Drain the pty for the session lifetime; feed scrollback + listener."""
|
|
82
|
+
while not self._closed:
|
|
83
|
+
try:
|
|
84
|
+
chunk = self._proc.read(8192)
|
|
85
|
+
except EOFError:
|
|
86
|
+
break
|
|
87
|
+
except OSError:
|
|
88
|
+
break
|
|
89
|
+
if not chunk:
|
|
90
|
+
if not self._proc_alive():
|
|
91
|
+
break
|
|
92
|
+
continue
|
|
93
|
+
raw = chunk.encode("utf-8", "replace") if isinstance(chunk, str) else bytes(chunk)
|
|
94
|
+
self.last_activity = time.monotonic()
|
|
95
|
+
# Record + capture the listener under the lock so attach() can
|
|
96
|
+
# snapshot scrollback and start receiving with no gap and no dup.
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._record(raw)
|
|
99
|
+
listener = self._listener
|
|
100
|
+
if listener is not None:
|
|
101
|
+
try:
|
|
102
|
+
listener(raw)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
self._closed = True
|
|
106
|
+
|
|
107
|
+
def attach(self, listener: Callable[[bytes], None]) -> bytes:
|
|
108
|
+
"""Atomically snapshot scrollback and register the live output sink.
|
|
109
|
+
|
|
110
|
+
Returns the scrollback to replay. Any chunk recorded after this
|
|
111
|
+
snapshot is delivered to ``listener`` (no gap, no duplicate).
|
|
112
|
+
"""
|
|
113
|
+
with self._lock:
|
|
114
|
+
snapshot = bytes(self._scrollback)
|
|
115
|
+
self._listener = listener
|
|
116
|
+
return snapshot
|
|
117
|
+
|
|
118
|
+
def set_listener(self, listener: Optional[Callable[[bytes], None]]) -> None:
|
|
119
|
+
"""Register (or clear with ``None``) the live output sink."""
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._listener = listener
|
|
122
|
+
|
|
123
|
+
def _proc_alive(self) -> bool:
|
|
124
|
+
try:
|
|
125
|
+
return self._proc.isalive()
|
|
126
|
+
except OSError:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
# -- output -------------------------------------------------------------
|
|
130
|
+
def read(self, max_bytes: int = 4096) -> bytes:
|
|
131
|
+
"""Compatibility shim: a session-owned thread does the real reading.
|
|
132
|
+
|
|
133
|
+
The POSIX backend is polled via ``read``; on Windows reading happens
|
|
134
|
+
in ``_read_loop``, so direct callers get an empty read.
|
|
135
|
+
"""
|
|
136
|
+
return b""
|
|
137
|
+
|
|
138
|
+
def _record(self, data: bytes) -> None:
|
|
139
|
+
if self.scrollback_max <= 0:
|
|
140
|
+
return
|
|
141
|
+
self._scrollback += data
|
|
142
|
+
if len(self._scrollback) > self.scrollback_max:
|
|
143
|
+
del self._scrollback[: -self.scrollback_max]
|
|
144
|
+
|
|
145
|
+
def scrollback(self) -> bytes:
|
|
146
|
+
return bytes(self._scrollback)
|
|
147
|
+
|
|
148
|
+
# -- input / control ----------------------------------------------------
|
|
149
|
+
def write(self, data: bytes) -> int:
|
|
150
|
+
if self._closed or not data:
|
|
151
|
+
return 0
|
|
152
|
+
text = (
|
|
153
|
+
data.decode("utf-8", "replace")
|
|
154
|
+
if isinstance(data, (bytes, bytearray))
|
|
155
|
+
else str(data)
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
self._proc.write(text)
|
|
159
|
+
except (OSError, EOFError):
|
|
160
|
+
self._closed = True
|
|
161
|
+
return 0
|
|
162
|
+
self.last_activity = time.monotonic()
|
|
163
|
+
return len(data)
|
|
164
|
+
|
|
165
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
166
|
+
if self._closed:
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
self._proc.setwinsize(max(1, int(rows)), max(1, int(cols)))
|
|
170
|
+
except OSError:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
def is_alive(self) -> bool:
|
|
174
|
+
if self._closed:
|
|
175
|
+
return False
|
|
176
|
+
if not self._proc_alive():
|
|
177
|
+
self.exit_code = getattr(self._proc, "exitstatus", None)
|
|
178
|
+
self._closed = True
|
|
179
|
+
return False
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
def kill(self, sig: Optional[int] = None) -> None:
|
|
183
|
+
self._closed = True
|
|
184
|
+
self._listener = None
|
|
185
|
+
try:
|
|
186
|
+
self._proc.terminate(force=True)
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
def _close_fd(self) -> None:
|
|
191
|
+
self._listener = None
|
|
192
|
+
if not self._closed:
|
|
193
|
+
try:
|
|
194
|
+
self._proc.terminate(force=True)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
self._scrollback.clear()
|
|
198
|
+
self._closed = True
|
|
199
|
+
|
|
200
|
+
def to_dict(self) -> dict[str, Any]:
|
|
201
|
+
idle_s = max(0.0, time.monotonic() - self.last_activity)
|
|
202
|
+
return {
|
|
203
|
+
"session_id": self.session_id,
|
|
204
|
+
"shell": self.shell,
|
|
205
|
+
"cwd": self.cwd,
|
|
206
|
+
"title": self.title or self.session_id,
|
|
207
|
+
"created_at": self.created_at,
|
|
208
|
+
"idle_seconds": round(idle_s, 1),
|
|
209
|
+
"alive": self.is_alive(),
|
|
210
|
+
"exit_code": self.exit_code,
|
|
211
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -42,6 +42,7 @@ from dataclasses import dataclass
|
|
|
42
42
|
from pathlib import Path
|
|
43
43
|
|
|
44
44
|
from core.shared import safe_session_id as _safe_session_id_module
|
|
45
|
+
from core.shared.temp_paths import arkaos_temp_dir
|
|
45
46
|
|
|
46
47
|
_safe_session_id = _safe_session_id_module.safe_session_id
|
|
47
48
|
|
|
@@ -63,7 +64,7 @@ class GraceState:
|
|
|
63
64
|
|
|
64
65
|
def _base_dir() -> Path:
|
|
65
66
|
override = os.environ.get("ARKA_FLOW_AUTH_DIR", "").strip()
|
|
66
|
-
return Path(override) if override else
|
|
67
|
+
return Path(override) if override else arkaos_temp_dir("arkaos-flow-auth")
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
def _auth_path(session_id: str) -> Path | None:
|
|
@@ -25,6 +25,7 @@ from datetime import datetime, timezone
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
from core.shared import safe_session_id as _safe_session_id_module
|
|
28
|
+
from core.shared.temp_paths import arkaos_temp_dir
|
|
28
29
|
from core.workflow import flow_authorization, marker_cache
|
|
29
30
|
|
|
30
31
|
try:
|
|
@@ -210,7 +211,7 @@ ASSISTANT_WINDOW = 20
|
|
|
210
211
|
CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
211
212
|
BYPASS_AUDIT_PATH = Path.home() / ".arkaos" / "audit" / "bypass.log"
|
|
212
213
|
TELEMETRY_PATH = Path.home() / ".arkaos" / "telemetry" / "enforcement.jsonl"
|
|
213
|
-
FLOW_REQUIRED_DIR =
|
|
214
|
+
FLOW_REQUIRED_DIR = arkaos_temp_dir("arkaos-wf-required")
|
|
214
215
|
|
|
215
216
|
|
|
216
217
|
@dataclass
|