afucontext 0.0.0-stage → 0.1.17

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 (32) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +158 -2
  3. package/README.zh-CN.md +159 -0
  4. package/bin/afu.mjs +127 -0
  5. package/bin/cache-lease.mjs +16 -0
  6. package/bin/host-autoload.py +546 -0
  7. package/bin/install-guide.mjs +56 -0
  8. package/bin/install-process.mjs +19 -0
  9. package/bin/platform.mjs +74 -0
  10. package/bin/prebuilt.mjs +64 -0
  11. package/bin/runtime.mjs +115 -0
  12. package/package.json +29 -4
  13. package/platform-dependencies.json +15 -0
  14. package/platform-locks/dsh/package-lock.json +425 -0
  15. package/platform-locks/dsh/package.json +16 -0
  16. package/platform-locks/pi/package-lock.json +1789 -0
  17. package/platform-locks/pi/package.json +9 -0
  18. package/python/afu_context_local-0.1.17-py3-none-any.whl +0 -0
  19. package/python/constraints.txt +25 -0
  20. package/release.json +7 -0
  21. package/scripts/dsh-overlay.mjs +105 -0
  22. package/scripts/dsh-presets.mjs +49 -0
  23. package/scripts/dsh-web-probe.mjs +14 -0
  24. package/src/afu_host_integrations/dsh/dist/commands.js +28 -0
  25. package/src/afu_host_integrations/dsh/dist/index.js +548 -0
  26. package/src/afu_host_integrations/pi/dist/index.js +565 -0
  27. package/src/afu_host_integrations/shared/runtime-client/dist/commands.js +40 -0
  28. package/src/afu_host_integrations/shared/runtime-client/dist/host-worker.js +170 -0
  29. package/src/afu_host_integrations/shared/runtime-client/dist/index.js +243 -0
  30. package/src/afu_host_integrations/shared/runtime-client/dist/prefix.js +12 -0
  31. package/src/afu_host_integrations/shared/runtime-client/dist/protocol.js +79 -0
  32. package/src/afu_host_integrations/shared/runtime-client/dist/telemetry.js +78 -0
@@ -0,0 +1,16 @@
1
+ import {mkdirSync,writeFileSync,rmSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {homedir} from 'node:os';
4
+ import {randomUUID,createHash} from 'node:crypto';
5
+ import {recoverLock} from './runtime.mjs';
6
+ export async function holdCache(build,env=process.env){
7
+ if(!/^[a-f0-9]{64}$/.test(build))throw Error('Invalid build identity');
8
+ const base=env.AFU_RUNTIME_DIR??join(homedir(),'.local/share/afu-context/runtimes');
9
+ mkdirSync(base,{recursive:true,mode:0o700});
10
+ const guard=join(base,'.gc-lock');const end=Date.now()+10000;
11
+ while(true){try{mkdirSync(guard);writeFileSync(join(guard,'owner.json'),JSON.stringify({pid:process.pid}));break;}catch(e){if(e.code!=='EEXIST')throw e;recoverLock(guard);if(Date.now()>end)throw Error('CACHE_GC_BUSY');await new Promise(r=>setTimeout(r,50));}}
12
+ const file=join(base,'.clients',randomUUID()+'.json');
13
+ const state=env.AFU_STATE_DIR??join(homedir(),'.local/share/afu-context');
14
+ try{mkdirSync(join(base,'.owners'),{recursive:true,mode:0o700});writeFileSync(join(base,'.owners',createHash('sha256').update(state).digest('hex')+'.json'),JSON.stringify({state}),{mode:0o600});mkdirSync(join(base,'.clients'),{recursive:true,mode:0o700});writeFileSync(file,JSON.stringify({pid:process.pid,build}),{mode:0o600});}finally{rmSync(guard,{recursive:true,force:true});}
15
+ const release=()=>rmSync(file,{force:true});process.once('exit',release);return release;
16
+ }
@@ -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
+ }