afucontext 0.0.0-stage → 0.1.18
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/LICENSE +201 -0
- package/README.md +182 -2
- package/README.zh-CN.md +183 -0
- package/bin/afu.mjs +127 -0
- package/bin/cache-lease.mjs +16 -0
- package/bin/host-autoload.py +546 -0
- package/bin/install-guide.mjs +56 -0
- package/bin/install-process.mjs +19 -0
- package/bin/platform.mjs +74 -0
- package/bin/prebuilt.mjs +64 -0
- package/bin/runtime.mjs +115 -0
- package/package.json +29 -4
- package/platform-dependencies.json +15 -0
- package/platform-locks/dsh/package-lock.json +425 -0
- package/platform-locks/dsh/package.json +16 -0
- package/platform-locks/pi/package-lock.json +1789 -0
- package/platform-locks/pi/package.json +9 -0
- package/python/afu_context_local-0.1.18-py3-none-any.whl +0 -0
- package/python/constraints.txt +25 -0
- package/release.json +7 -0
- package/scripts/dsh-overlay.mjs +105 -0
- package/scripts/dsh-presets.mjs +49 -0
- package/scripts/dsh-web-probe.mjs +14 -0
- package/src/afu_host_integrations/dsh/dist/commands.js +28 -0
- package/src/afu_host_integrations/dsh/dist/index.js +548 -0
- package/src/afu_host_integrations/pi/dist/index.js +565 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/commands.js +40 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/host-worker.js +170 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/index.js +243 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/prefix.js +12 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/protocol.js +79 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/telemetry.js +78 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Install npm-hosted AFU extensions in Pi and DSH's normal discovery paths."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from afu_context import native_install as fs
|
|
14
|
+
from afu_context.diagnostics import state_root
|
|
15
|
+
from afu_context.management import report, validate_policy
|
|
16
|
+
from afu_context.host_command import host_command
|
|
17
|
+
from afu_context.platform_runtime import redirected
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def node_binary():
|
|
21
|
+
"""The Node.js running this launcher (npx), so Windows needs no PATH lookup."""
|
|
22
|
+
return os.environ.get("AFU_NODE") or shutil.which("node") or "node"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dsh_command():
|
|
26
|
+
"""argv that starts DSH, plus the path of its real entry script.
|
|
27
|
+
|
|
28
|
+
npm installs a POSIX symlink to lib/bin.js, but on Windows a dsh.cmd shim
|
|
29
|
+
that subprocess cannot run without a shell. Running node on the package's
|
|
30
|
+
own entry script works on both and needs no shell.
|
|
31
|
+
"""
|
|
32
|
+
found = shutil.which("dsh")
|
|
33
|
+
if not found:
|
|
34
|
+
raise ValueError("dsh is not installed")
|
|
35
|
+
if not WINDOWS():
|
|
36
|
+
return [found], os.path.realpath(found)
|
|
37
|
+
command = host_command(found, "dsh", platform="nt")
|
|
38
|
+
return command, command[-1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(*args, stdin=None):
|
|
42
|
+
args = [str(a) for a in args]
|
|
43
|
+
if args and args[0] == "node":
|
|
44
|
+
args[0] = node_binary()
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
args, input=stdin, capture_output=True, text=True, encoding="utf-8" if WINDOWS() else None, timeout=30
|
|
47
|
+
)
|
|
48
|
+
if result.returncode:
|
|
49
|
+
raise ValueError("Host configuration could not be loaded")
|
|
50
|
+
return result.stdout
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def WINDOWS():
|
|
54
|
+
return os.name == "nt"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_link(path):
|
|
58
|
+
"""A symlink, or on Windows a directory junction (both point elsewhere)."""
|
|
59
|
+
if WINDOWS():
|
|
60
|
+
return redirected(path)
|
|
61
|
+
if path.is_symlink():
|
|
62
|
+
return True
|
|
63
|
+
is_junction = getattr(path, "is_junction", None)
|
|
64
|
+
return bool(is_junction and is_junction())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_junction(target, link):
|
|
68
|
+
import _winapi
|
|
69
|
+
# COSEC: native junction API preserves literal paths without cmd expansion.
|
|
70
|
+
_winapi.CreateJunction(target, link)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def link_directory(link, target):
|
|
74
|
+
"""Point link at target. Windows uses a junction: no admin rights needed."""
|
|
75
|
+
if WINDOWS():
|
|
76
|
+
create_junction(str(target), str(link))
|
|
77
|
+
return
|
|
78
|
+
link.symlink_to(target, target_is_directory=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def unlink_directory(link):
|
|
82
|
+
if WINDOWS() and not link.is_symlink():
|
|
83
|
+
os.rmdir(link) # Removes the junction itself, never the target files.
|
|
84
|
+
else:
|
|
85
|
+
link.unlink()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_owned(file, content, marker):
|
|
89
|
+
if file.is_symlink():
|
|
90
|
+
raise ValueError("Refusing to replace an extension symlink")
|
|
91
|
+
if file.exists() and file.read_text(encoding="utf-8" if WINDOWS() else None) == content:
|
|
92
|
+
return
|
|
93
|
+
if file.exists() and not file.read_text(encoding="utf-8" if WINDOWS() else None).startswith(marker):
|
|
94
|
+
raise ValueError("Existing extension belongs to another installation")
|
|
95
|
+
fs.atomic(file, content)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Host releases AFU is built and tested against. Older releases lack hooks the
|
|
99
|
+
# adapters need (Pi below 0.84.3: agent_settled / session_compact_failed /
|
|
100
|
+
# buildContextEntries; DSH 0.1.x: a different session format).
|
|
101
|
+
SUPPORTED = {
|
|
102
|
+
"pi": {"package": "@earendil-works/pi-coding-agent", "min": (0, 84, 3), "below": (2, 0, 0),
|
|
103
|
+
"range": ">=0.84.3 <2", "upgrade": "npm install -g @earendil-works/pi-coding-agent"},
|
|
104
|
+
"dsh": {"package": "@deepseek-ai/dsh", "min": (0, 2, 2), "below": (0, 2, 3),
|
|
105
|
+
"range": "0.2.2", "upgrade": "npm install -g @deepseek-ai/dsh@0.2.2-rc.1"},
|
|
106
|
+
}
|
|
107
|
+
LEGACY_PI_PACKAGE = "@mariozechner/pi-coding-agent"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _version_tuple(text):
|
|
111
|
+
m = re.match(r"(\d+)\.(\d+)\.(\d+)", text or "")
|
|
112
|
+
return tuple(int(x) for x in m.groups()) if m else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def host_package(host):
|
|
116
|
+
"""(package name, version) of the host on PATH, read from its own package.json."""
|
|
117
|
+
found = shutil.which(host)
|
|
118
|
+
if not found:
|
|
119
|
+
return None, None
|
|
120
|
+
path = Path(os.path.realpath(found))
|
|
121
|
+
for parent in path.parents:
|
|
122
|
+
manifest = parent / "package.json"
|
|
123
|
+
if manifest.is_file():
|
|
124
|
+
try:
|
|
125
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
return None, None
|
|
128
|
+
if data.get("name") in (SUPPORTED[host]["package"], LEGACY_PI_PACKAGE):
|
|
129
|
+
return data["name"], data.get("version")
|
|
130
|
+
if parent.name == "node_modules":
|
|
131
|
+
break
|
|
132
|
+
return None, None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_host_version(host):
|
|
136
|
+
"""Refuse an unsupported host before anything is written. Returns the version."""
|
|
137
|
+
if os.environ.get("AFU_SKIP_HOST_VERSION_CHECK") == "1":
|
|
138
|
+
return None
|
|
139
|
+
spec = SUPPORTED[host]
|
|
140
|
+
name, version = host_package(host)
|
|
141
|
+
if name is None:
|
|
142
|
+
# Not an npm install AFU can read (a custom build or wrapper): no claim.
|
|
143
|
+
return None
|
|
144
|
+
if name == LEGACY_PI_PACKAGE:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"Pi {version} ({name}) is not supported; AFU needs {spec['package']} {spec['range']}. "
|
|
147
|
+
f"Upgrade with: npm uninstall -g {LEGACY_PI_PACKAGE} && {spec['upgrade']}")
|
|
148
|
+
current = _version_tuple(version)
|
|
149
|
+
if current is None or not (spec["min"] <= current < spec["below"]):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"{host} {version} is not supported; AFU needs {spec['package']} {spec['range']}. "
|
|
152
|
+
f"Upgrade with: {spec['upgrade']}")
|
|
153
|
+
return version
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def pi_loads_from_directory(extensions_dir, entry):
|
|
157
|
+
"""Whether Pi's directory discovery loads entry (Pi loads only index.ts/index.js,
|
|
158
|
+
or a package.json manifest, when the extensions directory has one)."""
|
|
159
|
+
if (extensions_dir / "package.json").is_file():
|
|
160
|
+
try:
|
|
161
|
+
manifest = json.loads((extensions_dir / "package.json").read_text(encoding="utf-8"))
|
|
162
|
+
listed = (manifest.get("pi") or {}).get("extensions") or []
|
|
163
|
+
if any((extensions_dir / item).resolve() == entry.resolve() for item in listed):
|
|
164
|
+
return True
|
|
165
|
+
if listed:
|
|
166
|
+
return False
|
|
167
|
+
except (OSError, ValueError, AttributeError):
|
|
168
|
+
pass
|
|
169
|
+
return not ((extensions_dir / "index.ts").exists() or (extensions_dir / "index.js").exists())
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def register_pi_setting(agent, entry):
|
|
173
|
+
"""List entry in <agent>/settings.json "extensions" when discovery would skip it.
|
|
174
|
+
|
|
175
|
+
Only AFU's own path is appended; other entries stay as they are. Returns
|
|
176
|
+
True when AFU added it (removal then takes it out again), False when it was
|
|
177
|
+
already listed by someone else or not needed.
|
|
178
|
+
"""
|
|
179
|
+
if pi_loads_from_directory(entry.parent, entry):
|
|
180
|
+
return False
|
|
181
|
+
path = agent / "settings.json"
|
|
182
|
+
config = fs.read(path) if path.exists() else {}
|
|
183
|
+
if not isinstance(config, dict):
|
|
184
|
+
raise ValueError("Pi settings.json is not a JSON object; user file preserved")
|
|
185
|
+
listed = config.get("extensions", [])
|
|
186
|
+
if not isinstance(listed, list):
|
|
187
|
+
raise ValueError("Pi settings.json extensions is not a list; user file preserved")
|
|
188
|
+
if any(isinstance(item, str) and Path(item).expanduser() == entry for item in listed):
|
|
189
|
+
return False
|
|
190
|
+
config["extensions"] = [*listed, str(entry)]
|
|
191
|
+
fs.write(path, config)
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def unregister_pi_setting(agent, entry):
|
|
196
|
+
path = agent / "settings.json"
|
|
197
|
+
config = fs.read(path) if path.exists() else {}
|
|
198
|
+
listed = config.get("extensions") if isinstance(config, dict) else None
|
|
199
|
+
if not isinstance(listed, list) or str(entry) not in listed:
|
|
200
|
+
return
|
|
201
|
+
config["extensions"] = [item for item in listed if item != str(entry)]
|
|
202
|
+
if not config["extensions"]:
|
|
203
|
+
config.pop("extensions")
|
|
204
|
+
fs.write(path, config)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def install_pi(root, adapter, settings, previous):
|
|
208
|
+
agent = (
|
|
209
|
+
Path(os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent"))
|
|
210
|
+
.expanduser()
|
|
211
|
+
.resolve()
|
|
212
|
+
)
|
|
213
|
+
entry = agent / "extensions" / "afu-context.ts"
|
|
214
|
+
if previous and str(entry) != previous["entry"]:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"Remove the previous Pi agent registration before switching directories"
|
|
217
|
+
)
|
|
218
|
+
if (
|
|
219
|
+
entry.exists()
|
|
220
|
+
and previous
|
|
221
|
+
and hashlib.sha256(entry.read_bytes()).hexdigest() != previous["digest"]
|
|
222
|
+
):
|
|
223
|
+
raise ValueError("Pi extension changed since installation; user file preserved")
|
|
224
|
+
marker = "// Managed by AFU " + root.as_uri() + "\n"
|
|
225
|
+
# The explicit launcher supplies its own extension; avoid installing hooks twice.
|
|
226
|
+
# A file: URL imports on every OS (a bare "C:\\..." path is not a valid specifier).
|
|
227
|
+
body = marker + "import register from " + json.dumps(Path(adapter).resolve().as_uri()) + ";\n"
|
|
228
|
+
body += "export default function(pi) {\n if (process.env.AFU_EXPLICIT_EXTENSION === 'pi' || (process.env.AFU_CONTEXT_COMPACT_WORKER_COMMAND && process.argv.some(a => a.replace(/\\\\/g, '/').endsWith('/afu_host_integrations/pi/dist/index.js')))) return;\n"
|
|
229
|
+
body += (
|
|
230
|
+
" Object.assign(process.env, "
|
|
231
|
+
+ json.dumps(settings)
|
|
232
|
+
+ ");\n register(pi);\n}\n"
|
|
233
|
+
)
|
|
234
|
+
write_owned(entry, body, marker)
|
|
235
|
+
added = register_pi_setting(agent, entry) or bool(previous and previous.get("settings_entry"))
|
|
236
|
+
return {"entry": str(entry), "digest": hashlib.sha256(body.encode()).hexdigest(),
|
|
237
|
+
**({"settings_entry": True} if added else {})}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def profile_manifest(command, entry, platform, profile):
|
|
241
|
+
tree = run(*command, "--profile", profile, "--dump-config")
|
|
242
|
+
info = json.loads(
|
|
243
|
+
run("node", platform / "scripts/dsh-web-probe.mjs", entry, profile)
|
|
244
|
+
)
|
|
245
|
+
return Path(info["profileDir"]) / "package.json", tree
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def install_dsh(root, platform, adapter, settings, profile, previous):
|
|
249
|
+
command, binary = dsh_command()
|
|
250
|
+
manifest, tree = profile_manifest(command, binary, platform, profile)
|
|
251
|
+
if previous and previous["manifest"] != str(manifest):
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"DSH profile directory changed; existing registration preserved"
|
|
254
|
+
)
|
|
255
|
+
profile_dir = manifest.parent
|
|
256
|
+
key = hashlib.sha256(str(profile_dir).encode()).hexdigest()[:20]
|
|
257
|
+
bundle_name = "afu-context-profile-" + key
|
|
258
|
+
bundle = root / "autoload" / key
|
|
259
|
+
entry = bundle / "provider.mjs"
|
|
260
|
+
link = profile_dir / "node_modules" / bundle_name
|
|
261
|
+
marker = "// Managed by AFU " + root.as_uri() + "\n"
|
|
262
|
+
original = manifest.read_text(encoding="utf-8" if WINDOWS() else None)
|
|
263
|
+
config = json.loads(original)
|
|
264
|
+
bundles = config["dsh"]["profile"]["bundles"]
|
|
265
|
+
deps = config.setdefault("dependencies", {})
|
|
266
|
+
if any(name.split("/")[-1] == "afu-dsh-plugin" for name in deps):
|
|
267
|
+
raise ValueError("AFU is already installed in this profile with dsh plugin add; remove the plugin first")
|
|
268
|
+
owned_dep = "file:" + str(bundle)
|
|
269
|
+
if is_link(link.parent):
|
|
270
|
+
raise ValueError("Cannot register a bundle in a shared node_modules symlink")
|
|
271
|
+
if (is_link(link) and link.resolve() != bundle.resolve()) or (
|
|
272
|
+
link.exists() and not is_link(link)
|
|
273
|
+
):
|
|
274
|
+
raise ValueError("Existing DSH bundle directory conflicts with AFU")
|
|
275
|
+
if bundle_name in deps and deps[bundle_name] != owned_dep:
|
|
276
|
+
raise ValueError("DSH bundle dependency has been edited")
|
|
277
|
+
if bundle_name in bundles and deps.get(bundle_name) != owned_dep:
|
|
278
|
+
raise ValueError("DSH bundle is not owned by this installation")
|
|
279
|
+
bundle.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
280
|
+
# Bundle membership is read on host startup. Never edit watched cordis.patch.yml.
|
|
281
|
+
# A registered bundle whose files are gone (state directory cleared) is
|
|
282
|
+
# rebuilt the same way as a new one.
|
|
283
|
+
if bundle_name not in bundles or not (bundle / "package.json").exists() or not (bundle / "cordis.patch.yml").exists():
|
|
284
|
+
if bundle_name in bundles:
|
|
285
|
+
bundles.remove(bundle_name)
|
|
286
|
+
deps.pop(bundle_name, None)
|
|
287
|
+
fs.write(manifest, config)
|
|
288
|
+
original = manifest.read_text(encoding="utf-8" if WINDOWS() else None)
|
|
289
|
+
tree = run(*command, "--profile", profile, "--dump-config")
|
|
290
|
+
patches = run(
|
|
291
|
+
"node",
|
|
292
|
+
platform / "scripts/dsh-overlay.mjs",
|
|
293
|
+
entry,
|
|
294
|
+
bundle,
|
|
295
|
+
binary,
|
|
296
|
+
stdin=tree,
|
|
297
|
+
)
|
|
298
|
+
preset = bundle / "presets.mjs"
|
|
299
|
+
if preset.exists():
|
|
300
|
+
old_arg = ", " + json.dumps(str(bundle), ensure_ascii=not WINDOWS()) + ");"
|
|
301
|
+
source = preset.read_text(encoding="utf-8" if WINDOWS() else None)
|
|
302
|
+
if old_arg not in source:
|
|
303
|
+
raise ValueError("Unsupported preset loader format")
|
|
304
|
+
cleanup = (
|
|
305
|
+
"import {mkdtempSync,rmSync} from 'node:fs';\n"
|
|
306
|
+
"import {tmpdir} from 'node:os';\nimport {join} from 'node:path';\n"
|
|
307
|
+
"const scratch=mkdtempSync(join(tmpdir(),'afu-dsh-presets-'));\n"
|
|
308
|
+
"process.once('exit',()=>rmSync(scratch,{recursive:true,force:true}));\n"
|
|
309
|
+
)
|
|
310
|
+
fs.atomic(preset, cleanup + source.replace(old_arg, ", scratch);"))
|
|
311
|
+
fs.atomic(bundle / "cordis.patch.yml", patches)
|
|
312
|
+
fs.write(
|
|
313
|
+
bundle / "package.json",
|
|
314
|
+
{
|
|
315
|
+
"name": bundle_name,
|
|
316
|
+
"version": "1.0.0",
|
|
317
|
+
"dsh": {"bundle": {"patch": "./cordis.patch.yml"}},
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
content = marker + "Object.assign(process.env," + json.dumps(settings) + ");\n"
|
|
321
|
+
# Extend the engine the running DSH ships (its session format changes
|
|
322
|
+
# between releases); fall back to AFU's bundled copy if it cannot be found.
|
|
323
|
+
content += (
|
|
324
|
+
"import {createRequire} from 'node:module';\n"
|
|
325
|
+
"import {pathToFileURL} from 'node:url';\n"
|
|
326
|
+
"try {\n"
|
|
327
|
+
" const host=createRequire(" + json.dumps(str(binary)) + ");\n"
|
|
328
|
+
" const load=name=>import(pathToFileURL(host.resolve(name)).href);\n"
|
|
329
|
+
" const [basic,llm]=await Promise.all([load('@deepseek-ai/dsh-compaction-basic'),load('@deepseek-ai/dsh-llm')]);\n"
|
|
330
|
+
" globalThis.__afuDshHost={BasicCompactionEngine:basic.BasicCompactionEngine,BlockAssembler:llm.BlockAssembler,createUserMessage:llm.createUserMessage};\n"
|
|
331
|
+
"} catch {}\n"
|
|
332
|
+
"export default (await import(" + json.dumps(adapter.as_uri()) + ")).default;\n"
|
|
333
|
+
)
|
|
334
|
+
write_owned(entry, content, marker)
|
|
335
|
+
# /afuctx-status and /afuctx-dashboard (overlay inserts commands.mjs beside the provider).
|
|
336
|
+
write_owned(bundle / "commands.mjs",
|
|
337
|
+
marker + "Object.assign(process.env," + json.dumps(settings) + ");\n"
|
|
338
|
+
+ "export * from " + json.dumps((Path(adapter).parent / "commands.js").as_uri()) + ";\n"
|
|
339
|
+
+ "export { default } from " + json.dumps((Path(adapter).parent / "commands.js").as_uri()) + ";\n",
|
|
340
|
+
marker)
|
|
341
|
+
if not link.exists():
|
|
342
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
link_directory(link, bundle)
|
|
344
|
+
if bundle_name not in bundles:
|
|
345
|
+
bundles.append(bundle_name)
|
|
346
|
+
deps[bundle_name] = owned_dep
|
|
347
|
+
if manifest.read_text(encoding="utf-8" if WINDOWS() else None) != original:
|
|
348
|
+
raise ValueError("Concurrent DSH configuration change")
|
|
349
|
+
fs.write(manifest, config)
|
|
350
|
+
try:
|
|
351
|
+
composed = run(*command, "--profile", profile, "--dump-config")
|
|
352
|
+
# The composed tree may print paths with either separator.
|
|
353
|
+
flat = composed.replace("\\\\", "/").replace("\\", "/")
|
|
354
|
+
if not any(
|
|
355
|
+
(str(p).replace("\\", "/") in flat or (WINDOWS() and p.as_uri() in composed))
|
|
356
|
+
for p in (entry, bundle / "presets.mjs")
|
|
357
|
+
):
|
|
358
|
+
raise ValueError("DSH did not resolve the AFU provider")
|
|
359
|
+
except Exception:
|
|
360
|
+
if fs.read(manifest) == config:
|
|
361
|
+
fs.atomic(manifest, original)
|
|
362
|
+
raise
|
|
363
|
+
return {"manifest": str(manifest), "bundle": str(bundle), "name": bundle_name}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def remove_record(host, record):
|
|
367
|
+
if host == "pi":
|
|
368
|
+
entry = Path(record["entry"])
|
|
369
|
+
if entry.exists():
|
|
370
|
+
if (
|
|
371
|
+
entry.is_symlink()
|
|
372
|
+
or hashlib.sha256(entry.read_bytes()).hexdigest() != record["digest"]
|
|
373
|
+
):
|
|
374
|
+
raise ValueError(
|
|
375
|
+
"Pi extension changed since installation; user file preserved"
|
|
376
|
+
)
|
|
377
|
+
entry.unlink()
|
|
378
|
+
if record.get("settings_entry"):
|
|
379
|
+
unregister_pi_setting(entry.parent.parent, entry)
|
|
380
|
+
return
|
|
381
|
+
manifest, name = Path(record["manifest"]), record["name"]
|
|
382
|
+
config = fs.read(manifest)
|
|
383
|
+
if not config:
|
|
384
|
+
return
|
|
385
|
+
bundles = config["dsh"]["profile"]["bundles"]
|
|
386
|
+
deps = config.get("dependencies", {})
|
|
387
|
+
if name in deps and deps[name] != "file:" + record["bundle"]:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
"DSH dependency changed since installation; user file preserved"
|
|
390
|
+
)
|
|
391
|
+
if name in bundles and name not in deps:
|
|
392
|
+
raise ValueError("DSH bundle ownership changed; user configuration preserved")
|
|
393
|
+
if name in bundles:
|
|
394
|
+
bundles.remove(name)
|
|
395
|
+
deps.pop(name, None)
|
|
396
|
+
fs.write(manifest, config)
|
|
397
|
+
# Keep bundle files for running sessions (some presets resolve lazily).
|
|
398
|
+
link = manifest.parent / "node_modules" / name
|
|
399
|
+
if is_link(link) and link.resolve() == Path(record["bundle"]).resolve():
|
|
400
|
+
unlink_directory(link)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main():
|
|
404
|
+
p = argparse.ArgumentParser()
|
|
405
|
+
p.add_argument("action", choices=["install", "upgrade", "remove", "status"])
|
|
406
|
+
p.add_argument("host", choices=["pi", "dsh"], nargs="?")
|
|
407
|
+
p.add_argument("--profile")
|
|
408
|
+
p.add_argument("--state", type=Path)
|
|
409
|
+
p.add_argument("--ratio")
|
|
410
|
+
p.add_argument("--context-window")
|
|
411
|
+
p.add_argument("--json", action="store_true")
|
|
412
|
+
p.add_argument("--runtime-only", action="store_true", help="prepare the runtime used by `dsh plugin add`; no profile is changed")
|
|
413
|
+
args = p.parse_args()
|
|
414
|
+
root = (args.state or state_root()).expanduser().resolve()
|
|
415
|
+
host = args.host
|
|
416
|
+
if args.runtime_only and (host != "dsh" or args.profile):
|
|
417
|
+
p.error("--runtime-only applies to dsh without --profile")
|
|
418
|
+
if args.profile and (
|
|
419
|
+
host != "dsh"
|
|
420
|
+
or not re.fullmatch(r"[\w-]+", args.profile)
|
|
421
|
+
or args.profile == "node_modules"
|
|
422
|
+
):
|
|
423
|
+
p.error("invalid DSH profile")
|
|
424
|
+
key = args.profile or ("web" if host == "dsh" else "user")
|
|
425
|
+
if args.action == "status":
|
|
426
|
+
data = report(host, root=root)
|
|
427
|
+
for install in data["installations"]:
|
|
428
|
+
name = install["host"]
|
|
429
|
+
receipt = fs.read(root / "integrations" / (name + ".json"))
|
|
430
|
+
if receipt.get("autoload"):
|
|
431
|
+
install.update(
|
|
432
|
+
mode="host",
|
|
433
|
+
launch=name if name == "pi" else "dsh --profile PROFILE",
|
|
434
|
+
registrations=sorted(receipt["autoload"]),
|
|
435
|
+
)
|
|
436
|
+
print(json.dumps(data))
|
|
437
|
+
return
|
|
438
|
+
path = root / "integrations" / (host + ".json")
|
|
439
|
+
with fs.lock(path.parent):
|
|
440
|
+
old = fs.read(path)
|
|
441
|
+
registrations = dict(old.get("autoload", {}))
|
|
442
|
+
previous = registrations.get(key, {})
|
|
443
|
+
if args.action == "remove":
|
|
444
|
+
for item in [key] if args.profile or host == "pi" else list(registrations):
|
|
445
|
+
if item in registrations and not args.runtime_only:
|
|
446
|
+
remove_record(host, registrations.pop(item))
|
|
447
|
+
plugin_runtime = bool(old.get("plugin_runtime"))
|
|
448
|
+
if host == "dsh" and not args.profile:
|
|
449
|
+
(root / "dsh-runtime.json").unlink(missing_ok=True)
|
|
450
|
+
plugin_runtime = False
|
|
451
|
+
fs.write(
|
|
452
|
+
path,
|
|
453
|
+
{
|
|
454
|
+
**old,
|
|
455
|
+
"host": host,
|
|
456
|
+
"autoload": registrations,
|
|
457
|
+
"plugin_runtime": plugin_runtime,
|
|
458
|
+
"removed": not registrations and not plugin_runtime,
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
else:
|
|
462
|
+
platform = Path(os.environ["AFU_INSTALL_ROOT"]).resolve()
|
|
463
|
+
adapter = platform / "src/afu_host_integrations" / host / "dist/index.js"
|
|
464
|
+
if not adapter.is_file():
|
|
465
|
+
raise ValueError("Adapter not installed")
|
|
466
|
+
|
|
467
|
+
def policy(raw, field, cast):
|
|
468
|
+
if raw is None:
|
|
469
|
+
return previous.get(
|
|
470
|
+
field, old.get(field) if not registrations else None
|
|
471
|
+
)
|
|
472
|
+
return None if raw == "auto" else cast(raw)
|
|
473
|
+
|
|
474
|
+
ratio = policy(args.ratio, "ratio", float)
|
|
475
|
+
window = policy(args.context_window, "window", int)
|
|
476
|
+
validate_policy(window, ratio)
|
|
477
|
+
if window and ratio and int(window * ratio) < 1:
|
|
478
|
+
raise ValueError("The configured threshold must be at least one token")
|
|
479
|
+
settings = {
|
|
480
|
+
"AFU_CONTEXT_COMPACT_WORKER_COMMAND": sys.executable,
|
|
481
|
+
"AFU_CONTEXT_COMPACT_WORKER_ARGS": json.dumps(
|
|
482
|
+
[
|
|
483
|
+
"-m",
|
|
484
|
+
"afu_context",
|
|
485
|
+
"compact",
|
|
486
|
+
"worker",
|
|
487
|
+
"--factory",
|
|
488
|
+
"afu_context.local_worker:create_plugin",
|
|
489
|
+
]
|
|
490
|
+
),
|
|
491
|
+
"AFU_CONTEXT_COMPACT_WORKER_CWD": str(platform),
|
|
492
|
+
"AFU_INSTALL_ROOT": str(platform),
|
|
493
|
+
"AFU_STATE_DIR": str(root),
|
|
494
|
+
"AFU_HOST_CONTEXT_WINDOW_OVERRIDE": str(window or "auto"),
|
|
495
|
+
}
|
|
496
|
+
if ratio is not None:
|
|
497
|
+
settings["AFU_TRIGGER_RATIO"] = str(ratio)
|
|
498
|
+
host_version = check_host_version(host)
|
|
499
|
+
settings["AFU_STATE_CLI"] = json.dumps([sys.executable, "-m", "afu_context.local_cli"])
|
|
500
|
+
if args.runtime_only:
|
|
501
|
+
fs.write(root / "dsh-runtime.json", {"adapter": adapter.as_uri(), "settings": settings})
|
|
502
|
+
elif host == "pi":
|
|
503
|
+
record = install_pi(root, adapter, settings, previous)
|
|
504
|
+
registrations[key] = {**record, "window": window, "ratio": ratio}
|
|
505
|
+
else:
|
|
506
|
+
record = install_dsh(root, platform, adapter, settings, key, previous)
|
|
507
|
+
registrations[key] = {**record, "window": window, "ratio": ratio}
|
|
508
|
+
fs.write(
|
|
509
|
+
path,
|
|
510
|
+
{
|
|
511
|
+
**old,
|
|
512
|
+
"host": host,
|
|
513
|
+
"mode": "host",
|
|
514
|
+
"removed": False,
|
|
515
|
+
"plugin_runtime": args.runtime_only or bool(old.get("plugin_runtime")),
|
|
516
|
+
**({"host_version": host_version} if host_version else {}),
|
|
517
|
+
"window": window,
|
|
518
|
+
"ratio": ratio,
|
|
519
|
+
"build": fs.read(platform / "release.json")["build"],
|
|
520
|
+
"version": fs.read(platform / "release.json").get("version"),
|
|
521
|
+
"autoload": registrations,
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
print(
|
|
525
|
+
json.dumps(
|
|
526
|
+
{
|
|
527
|
+
"host": host,
|
|
528
|
+
"installed": bool(registrations) or args.runtime_only,
|
|
529
|
+
"upgraded": args.action == "upgrade",
|
|
530
|
+
"mode": "host",
|
|
531
|
+
"registrations": sorted(registrations),
|
|
532
|
+
"launch": "pi" if host == "pi" else "dsh --profile " + key,
|
|
533
|
+
}
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
if __name__ == "__main__":
|
|
539
|
+
try:
|
|
540
|
+
main()
|
|
541
|
+
except (OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
|
|
542
|
+
sys.exit(
|
|
543
|
+
"AFU registration failed: " + str(error)
|
|
544
|
+
if type(error) is ValueError
|
|
545
|
+
else "AFU registration failed: " + type(error).__name__
|
|
546
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {spawnSync} from 'node:child_process';
|
|
2
|
+
import {accessSync,statSync,constants,mkdirSync,rmSync,mkdtempSync,readFileSync,realpathSync} from 'node:fs';
|
|
3
|
+
import {join,delimiter,resolve,dirname,relative,isAbsolute} from 'node:path';
|
|
4
|
+
import {homedir} from 'node:os';
|
|
5
|
+
import {createInterface} from 'node:readline/promises';
|
|
6
|
+
export function hostCommand(host,env=process.env,platform=process.platform){
|
|
7
|
+
if(platform!=='win32')return [host];
|
|
8
|
+
const packages={pi:'@earendil-works/pi-coding-agent',dsh:'@deepseek-ai/dsh'};
|
|
9
|
+
if(!packages[host])return [host];
|
|
10
|
+
for(const dir of (env.PATH??env.Path??'').split(delimiter)){
|
|
11
|
+
for(const ext of ['.exe','.cmd','.bat']){
|
|
12
|
+
const binary=join(dir,host+ext);
|
|
13
|
+
try{if(!statSync(binary).isFile())continue;}catch{continue;}
|
|
14
|
+
if(ext==='.exe')return [binary];
|
|
15
|
+
const folder=dirname(realpathSync(binary));
|
|
16
|
+
for(const base of [join(folder,'node_modules',packages[host]),join(folder,'..',packages[host])]){
|
|
17
|
+
let data;try{data=JSON.parse(readFileSync(join(base,'package.json'),'utf8'));}catch(e){if(e.code==='ENOENT')continue;throw e;}
|
|
18
|
+
const entry=typeof data.bin==='string'?data.bin:data.bin?.[host];
|
|
19
|
+
if(data.name!==packages[host]||typeof entry!=='string')continue;
|
|
20
|
+
const target=realpathSync(resolve(base,entry)),path=relative(realpathSync(base),target);
|
|
21
|
+
if(path==='..'||path.startsWith('..\\')||path.startsWith('../')||isAbsolute(path)||!statSync(target).isFile())throw Error('INVALID_HOST_ENTRY');
|
|
22
|
+
return [process.execPath,target];
|
|
23
|
+
}
|
|
24
|
+
throw Error('HOST_NPM_ENTRY_UNAVAILABLE');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw Error('HOST_EXECUTABLE_UNAVAILABLE');
|
|
28
|
+
}
|
|
29
|
+
export function hostAvailable(host,env=process.env){
|
|
30
|
+
return (env.PATH??env.Path??'').split(delimiter).some(dir=>['',...(process.platform==='win32'?['.exe','.cmd']:[])].some(ext=>{try{accessSync(join(dir,host+ext),constants.X_OK);return statSync(join(dir,host+ext)).isFile();}catch{return false;}}));
|
|
31
|
+
}
|
|
32
|
+
export async function guide(args,env=process.env){
|
|
33
|
+
if(args[0]!=='install'&&args[0]!=='upgrade')return args;
|
|
34
|
+
const hosts=['claude','codex','pi','dsh'];let host=hosts.includes(args[1])?args[1]:null;
|
|
35
|
+
if(!host){
|
|
36
|
+
const available=hosts.filter(h=>hostAvailable(h,env));
|
|
37
|
+
if(!available.length)throw Error('HOST_NOT_FOUND: install and sign into your host first');
|
|
38
|
+
if(available.length===1)host=available[0];
|
|
39
|
+
else{
|
|
40
|
+
if(!process.stdin.isTTY)throw Error('HOST_SELECTION_REQUIRED: use install '+available.join('|'));
|
|
41
|
+
const input=createInterface({input:process.stdin,output:process.stderr});
|
|
42
|
+
try{console.error(available.map((h,i)=>`${i+1}. ${h}`).join('\n'));host=available[Number(await input.question('Select host: '))-1];}finally{input.close();}
|
|
43
|
+
if(!host)throw Error('INVALID_HOST_SELECTION');
|
|
44
|
+
}
|
|
45
|
+
args=[args[0],host,...args.slice(1)];
|
|
46
|
+
}
|
|
47
|
+
if(!hostAvailable(host,env))throw Error('HOST_NOT_FOUND: '+host);
|
|
48
|
+
const at=args.indexOf('--state');const state=at>=0?resolve(args[at+1]):env.AFU_STATE_DIR??join(homedir(),'.local/share/afu-context');
|
|
49
|
+
mkdirSync(state,{recursive:true,mode:0o700});const check=mkdtempSync(join(state,'.write-check-'));rmSync(check,{recursive:true});
|
|
50
|
+
if(['claude','codex'].includes(host)&&!args.includes('--no-autostart')){
|
|
51
|
+
const probe=process.platform==='linux'?['systemctl',['--user','show-environment']]:process.platform==='darwin'?['launchctl',['print',`gui/${process.getuid()}`]]:null;
|
|
52
|
+
if(probe&&spawnSync(probe[0],probe[1],{env,stdio:'ignore',timeout:5000}).status!==0)throw Error(process.platform==='linux' ? `USER_SERVICE_UNAVAILABLE: no host settings changed. Enable your systemd user service, or rerun the same command with --no-autostart (example: afu ${args[0]} ${host} --no-autostart). Keep your existing --config-dir/--state and other options. Manual mode starts the gateway now but does not register login/reboot recovery; rerun it after reboot before using the host.` : 'USER_SERVICE_UNAVAILABLE: no host settings changed; enable your user service manager first');
|
|
53
|
+
}
|
|
54
|
+
console.error(`AFU: preflight passed for ${host}; preparing private runtime`);
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Bounded child lifetime, restricted to the process group created by this call. */
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
export function runInstall(command,args,env=process.env,onChild=()=>{}){
|
|
4
|
+
const ms=Number(env.AFU_INSTALL_TIMEOUT_MS??180000);
|
|
5
|
+
if(!Number.isFinite(ms)||ms<1||ms>600000)throw Error('INVALID_INSTALL_TIMEOUT');
|
|
6
|
+
return new Promise((resolve,reject)=>{
|
|
7
|
+
const child=spawn(command,args,{env,stdio:['inherit',2,2],shell:false,detached:process.platform!=='win32'});
|
|
8
|
+
let reason=null,killTimer;
|
|
9
|
+
const signal=s=>{if(!child.pid||child.exitCode!==null)return;try{process.platform==='win32'?child.kill(s):process.kill(-child.pid,s);}catch(e){if(e.code!=='ESRCH')throw e;}};
|
|
10
|
+
const cancel=code=>{if(reason)return;reason=code;signal('SIGTERM');killTimer=setTimeout(()=>signal('SIGKILL'),2000);};
|
|
11
|
+
const interrupted=()=>cancel('INSTALL_INTERRUPTED');
|
|
12
|
+
const timer=setTimeout(()=>cancel('INSTALL_TIMEOUT'),ms);
|
|
13
|
+
process.on('SIGINT',interrupted);process.on('SIGTERM',interrupted);
|
|
14
|
+
const cleanup=()=>{clearTimeout(timer);clearTimeout(killTimer);process.off('SIGINT',interrupted);process.off('SIGTERM',interrupted);};
|
|
15
|
+
child.once('error',()=>{cleanup();reject(Error('INSTALL_EXECUTABLE_UNAVAILABLE'));});
|
|
16
|
+
child.once('exit',code=>{cleanup();code===0&&!reason?resolve():reject(Error(reason??'INSTALL_FAILED_CHECK_DOWNLOAD_LOG'));});
|
|
17
|
+
try{if(child.pid)onChild(child.pid);}catch{cancel('INSTALL_OWNERSHIP_LOST');}
|
|
18
|
+
});
|
|
19
|
+
}
|