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.
Files changed (113) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +1 -1
  3. package/bin/arka-claude +35 -4
  4. package/bin/arka-py +22 -7
  5. package/config/hooks/_lib/arka_python.ps1 +31 -0
  6. package/config/hooks/_lib/arka_python.sh +38 -0
  7. package/config/hooks/post-tool-use.ps1 +8 -0
  8. package/config/hooks/post-tool-use.sh +11 -5
  9. package/config/hooks/pre-tool-use.ps1 +11 -6
  10. package/config/hooks/pre-tool-use.sh +11 -6
  11. package/config/hooks/session-start.ps1 +10 -0
  12. package/config/hooks/session-start.sh +5 -0
  13. package/config/hooks/stop.ps1 +9 -1
  14. package/config/hooks/stop.sh +11 -5
  15. package/config/hooks/user-prompt-submit.ps1 +18 -0
  16. package/config/hooks/user-prompt-submit.sh +11 -5
  17. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  18. package/core/cognition/scheduler/daemon.py +7 -3
  19. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  20. package/core/forge/orchestrator.py +2 -1
  21. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  22. package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
  23. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  24. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  25. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  26. package/core/hooks/_shared.py +29 -9
  27. package/core/hooks/stop.py +4 -3
  28. package/core/hooks/user_prompt_submit.py +5 -4
  29. package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
  30. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  31. package/core/knowledge/indexer.py +1 -1
  32. package/core/knowledge/vector_store.py +10 -3
  33. package/core/runtime/__pycache__/model_router.cpython-312.pyc +0 -0
  34. package/core/runtime/__pycache__/model_router.cpython-314.pyc +0 -0
  35. package/core/runtime/__pycache__/model_routing_check.cpython-313.pyc +0 -0
  36. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  37. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  38. package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
  39. package/core/runtime/gateway/__init__.py +28 -0
  40. package/core/runtime/gateway/__main__.py +30 -0
  41. package/core/runtime/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  42. package/core/runtime/gateway/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/core/runtime/gateway/__pycache__/__init__.cpython-314.pyc +0 -0
  44. package/core/runtime/gateway/__pycache__/__main__.cpython-313.pyc +0 -0
  45. package/core/runtime/gateway/__pycache__/litellm_config.cpython-312.pyc +0 -0
  46. package/core/runtime/gateway/__pycache__/litellm_config.cpython-313.pyc +0 -0
  47. package/core/runtime/gateway/__pycache__/litellm_config.cpython-314.pyc +0 -0
  48. package/core/runtime/gateway/litellm_config.py +205 -0
  49. package/core/runtime/model_routing_check.py +71 -0
  50. package/core/runtime/model_routing_context.py +22 -1
  51. package/core/runtime/native_usage.py +2 -1
  52. package/core/shared/__pycache__/temp_paths.cpython-313.pyc +0 -0
  53. package/core/shared/__pycache__/temp_paths.cpython-314.pyc +0 -0
  54. package/core/shared/temp_paths.py +40 -0
  55. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  56. package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
  57. package/core/synapse/kb_cache.py +3 -2
  58. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  59. package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
  60. package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
  61. package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
  62. package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
  63. package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
  64. package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
  65. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  66. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  67. package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
  68. package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
  69. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  70. package/core/sync/agent_provisioner.py +5 -5
  71. package/core/sync/ai_mcp_decider.py +2 -2
  72. package/core/sync/content_syncer.py +14 -14
  73. package/core/sync/descriptor_syncer.py +2 -2
  74. package/core/sync/discovery.py +4 -4
  75. package/core/sync/engine.py +4 -4
  76. package/core/sync/manifest.py +1 -1
  77. package/core/sync/mcp_optimizer.py +5 -5
  78. package/core/sync/mcp_syncer.py +3 -3
  79. package/core/sync/policy_loader.py +1 -1
  80. package/core/sync/reporter.py +1 -1
  81. package/core/sync/settings_syncer.py +2 -2
  82. package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
  83. package/core/terminal/__pycache__/session_windows.cpython-313.pyc +0 -0
  84. package/core/terminal/session.py +66 -11
  85. package/core/terminal/session_windows.py +211 -0
  86. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  87. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  88. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  89. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  90. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  91. package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
  92. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  93. package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
  94. package/core/workflow/flow_authorization.py +2 -1
  95. package/core/workflow/flow_enforcer.py +2 -1
  96. package/core/workflow/marker_cache.py +2 -1
  97. package/core/workflow/research_gate.py +2 -1
  98. package/dashboard/app/pages/index.vue +6 -1
  99. package/installer/cli.js +5 -4
  100. package/installer/core-snapshot.js +53 -0
  101. package/installer/doctor.js +8 -3
  102. package/installer/index.js +29 -6
  103. package/installer/migrate.js +7 -4
  104. package/installer/update.js +13 -0
  105. package/package.json +1 -1
  106. package/pyproject.toml +8 -1
  107. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  108. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  109. package/scripts/dashboard-api.py +69 -33
  110. package/scripts/gateway-ensure.sh +93 -0
  111. package/scripts/knowledge-index.py +35 -2
  112. package/scripts/start-dashboard.ps1 +34 -14
  113. 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")
@@ -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
 
@@ -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 []
@@ -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(
@@ -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", {}),
@@ -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")
@@ -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
- session = TerminalSession(
255
- session_id=sid,
256
- shell=chosen_shell,
257
- cwd=chosen_cwd,
258
- cols=cols,
259
- rows=rows,
260
- scrollback_bytes=self.scrollback_bytes,
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
+ }
@@ -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 Path("/tmp/arkaos-flow-auth")
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 = Path("/tmp/arkaos-wf-required")
214
+ FLOW_REQUIRED_DIR = arkaos_temp_dir("arkaos-wf-required")
214
215
 
215
216
 
216
217
  @dataclass