@liwu1995/afu-context 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -16,26 +16,39 @@ In a long session, the same tool output can travel with every model request. AFU
16
16
 
17
17
  ## Quick start
18
18
 
19
- Install and sign in to your host first. You need npm and Node.js **22.19+ (22.x) or 24+**. Pick one row, run the install command in your terminal, then use the launch command.
19
+ Install and sign in to your host first. You need npm, Node.js **22.19+ (22.x) or 24+** and Python **3.11+** on `PATH`. Pick one row, run the install command in your terminal, then use the launch command.
20
20
 
21
21
  | Host | System | Install | Launch after installation |
22
22
  | --- | --- | --- | --- |
23
- | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes @liwu1995/afu-context@next install claude` | `claude` |
24
- | Claude Code | Windows 10/11¹ | `npx.cmd --yes "@liwu1995/afu-context@next" install claude` | `claude` |
25
- | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes @liwu1995/afu-context@next install codex` | `codex` |
26
- | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes @liwu1995/afu-context@next install pi` | `pi` |
27
- | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes @liwu1995/afu-context@next install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` |
23
+ | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install claude` | `claude` |
24
+ | Claude Code | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "@liwu1995/afu-context@0.1.3" install claude` | `claude` |
25
+ | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install codex` | `codex` |
26
+ | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install pi` | `pi` |
27
+ | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` |
28
28
 
29
- The macOS packages target Apple Silicon (ARM64), and the Linux packages target x64. Both platforms have been tested and include Python. Start a new session for Claude Code or Codex. Pi loads the extension in new sessions. Replace `YOUR_PROFILE` with the DSH profile to integrate, such as `headless` or `web`. For web, you can also launch with `dsh web`. To remove just one profile, run `npx --yes @liwu1995/afu-context@next remove dsh --profile YOUR_PROFILE`.
29
+ Supported systems are macOS on Apple Silicon (ARM64) and Linux x64. The first `afu` command sets up a private Python environment from your Python 3.11+, which takes about half a minute. On macOS, Claude Code and Codex were tested end to end with real models (install, chat with compaction, upgrade, remove, launchd autostart). On Linux, Claude Code was tested with a real model and Codex with a local fixture; user-systemd autostart was not tested. Start a new session for Claude Code or Codex. Pi loads the extension in new sessions. Replace `YOUR_PROFILE` with the DSH profile to integrate, such as `headless` or `web`. For web, you can also launch with `dsh web`. To remove just one profile, run `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 remove dsh --profile YOUR_PROFILE`.
30
30
 
31
31
  ¹ On Windows, use PowerShell, Python **3.11+** and a Claude Code version with PowerShell hook support. Native Claude Code is implemented and awaits Windows testing. Native Codex is unsupported, and Pi and DSH are unverified on Windows.
32
32
 
33
+ The commands pin **0.1.3** from the public npm registry so installation does not depend on a mirror carrying the latest release. Use 0.1.3 or later; earlier versions lack the fixes in this release. If npm reports `with a date before`, check `before` and `min-release-age`. This version was published at **PUBLISH_TIME_UTC**. Retry after a rolling cooldown expires. For a fixed cutoff, the effective policy must allow that publication date.
34
+
33
35
  <details>
34
- <summary>Linux manual startup and Pi / DSH launch options</summary>
36
+ <summary>Startup after reboot and Pi / DSH launch options</summary>
35
37
 
36
- On Linux without user systemd, append `--no-autostart` to the Claude Code or Codex install command. Run it again after a reboot to start the gateway.
38
+ Claude Code and Codex start the AFU gateway from their session hooks, so it comes back with the first session after a reboot. Installation also registers a login service when the system allows it. If system policy refuses it (for example an endpoint-security rule on `~/Library/LaunchAgents`, or Linux without user systemd), installation continues and prints a notice; no reinstall is needed after reboot. Codex runs only hooks you have trusted: open `codex` once after installing and trust the AFU hooks when asked.
37
39
 
38
- For Pi **0.84.4**, pass `--provider` and `--model` on the Pi command line. DSH was tested with **0.1.2-rc.1** and Cordis **4.0.2**. If a web profile reports an incompatible HMR API, run `npx --yes @liwu1995/afu-context@next dsh --web-compat -- --profile web`.
40
+ For Pi **0.84.4**, pass `--provider` and `--model` on the Pi command line. DSH was tested with **0.1.2-rc.1** and Cordis **4.0.2**. If a web profile reports an incompatible HMR API, run `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 dsh --web-compat -- --profile web`.
41
+
42
+ </details>
43
+
44
+ <details>
45
+ <summary>Known issues and network notes</summary>
46
+
47
+ - npm prints `EBADENGINE` on Node.js 22.18 or older. The requirement is 22.19+ (22.x) or 24+; upgrade Node.js before installing.
48
+ - If the host reaches its model through a proxy, start the host with its usual `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`. AFU records the proxy seen by the installer or by the host's session hook and the gateway uses it for later requests. If `/afu:status` reports the upstream as unreachable, start the host from a shell with the proxy set.
49
+ - Codex through AFU occasionally stalls for about three minutes before reconnecting. The cause is not yet confirmed.
50
+ - Codex conversations keep working across install and removal: AFU routes Codex's built-in OpenAI provider (`openai_base_url`) instead of adding a provider. Upgrading from 0.1.2 rebinds the conversations created under its `afu_native` provider to `openai` so they can be resumed again (in the CLI and Codex Desktop); a conversation still open is skipped and handled on the next upgrade or install.
51
+ - Removal restores your original file byte for byte when you made no other change. If you edited the file after installing, removal reverts only the AFU entries and keeps your edits.
39
52
 
40
53
  </details>
41
54
 
@@ -59,16 +72,18 @@ In a LongBench V2 replay of 39 history questions (20 agent histories and 19 dial
59
72
 
60
73
  ## Web dashboard
61
74
 
62
- For Codex, check the integration status, then open the dashboard and select your session. For another host, replace `codex` with `claude`, `pi` or `dsh`.
75
+ Inside a session, type `/afu:status` in Claude Code or `/prompts:afu-status` in Codex. The reply shows whether AFU is connected, the current context against the compaction threshold, and how many compactions have been applied. Installation adds these commands and removal deletes them.
76
+
77
+ From a terminal, check the integration status, then open the dashboard and select your session. For another host, replace `codex` with `claude`, `pi` or `dsh`; `--brief` prints the same short lines as the slash commands.
63
78
 
64
79
  ```sh
65
- npx --yes @liwu1995/afu-context@next status codex
66
- npx --yes @liwu1995/afu-context@next ui
80
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 status codex --brief
81
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 ui
67
82
  ```
68
83
 
69
84
  The dashboard distinguishes **request observed → summary ready → summary applied**. For Claude Code and Codex, application requires a complete successful response to a request carrying the summary. For Pi and DSH it means the host committed the summary to session history. The dashboard also shows summary duration and provider-reported cache usage.
70
85
 
71
- Start in **Overview** for active sessions, summary duration and application counts. Open a **session** to follow a summary through its timeline to the application receipt and inspect reported cache usage. Open **Diagnostics** to investigate missing evidence, errors or runtime versions.
86
+ **Overview** shows what AFU saved you: estimated tokens saved, recorded conversation wait and how many compactions took effect, followed by your sessions and the state of their latest compaction. Open a **session** to see the context drop after each compaction, how long each one took and what it saved, plus cache hit rates. Integrations, versions and issues live on the **Status** page. The dashboard supports light/dark themes and English/Chinese.
72
87
 
73
88
  ## Models and thresholds
74
89
 
@@ -80,21 +95,21 @@ All four integrations reuse the conversation’s model and authentication. The d
80
95
  For a Codex model with a known 200,000-token window, the first command sets a 30% trigger. The second restores automatic settings.
81
96
 
82
97
  ```sh
83
- npx --yes @liwu1995/afu-context@next install codex --context-window 200000 --ratio 0.3
84
- npx --yes @liwu1995/afu-context@next install codex --context-window auto --ratio auto
98
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install codex --context-window 200000 --ratio 0.3
99
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install codex --context-window auto --ratio auto
85
100
  ```
86
101
 
87
102
  </details>
88
103
 
89
- For frequent use, run `npm install -g @liwu1995/afu-context@next` and use `afu` in place of the full `npx` invocation. For example, `afu ui` opens the dashboard, and `afu ui --prices ./afu-prices.json` uses your price file to estimate costs.
104
+ For frequent use, run `npm install -g --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3` and use `afu` in place of the full `npx` invocation. For example, `afu ui` opens the dashboard, and `afu ui --prices ./afu-prices.json` uses your price file to estimate costs.
90
105
 
91
106
  ## Upgrade and remove
92
107
 
93
108
  After upgrading a Claude Code or Codex integration, start a new session to use the new gateway. To uninstall, remove the host integration first, then restart the host. Use the appropriate command below.
94
109
 
95
110
  ```sh
96
- npx --yes @liwu1995/afu-context@next upgrade codex
97
- npx --yes @liwu1995/afu-context@next remove codex
111
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 upgrade codex
112
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 remove codex
98
113
  ```
99
114
 
100
115
  Use `remove pi` or `remove dsh` for those integrations. In Windows PowerShell, use `npx.cmd` and double-quote the package name.
package/README.zh-CN.md CHANGED
@@ -16,26 +16,39 @@
16
16
 
17
17
  ## 快速开始
18
18
 
19
- 先安装并登录要使用的宿主,准备 npm 和 Node.js **22.19+(22.x)或 24+**。选一行,在终端执行安装命令,然后用最后一列的命令启动。
19
+ 先安装并登录要使用的宿主,准备 npm、Node.js **22.19+(22.x)或 24+** 和 Python **3.11+**(需在 `PATH` 中)。选一行,在终端执行安装命令,然后用最后一列的命令启动。
20
20
 
21
21
  | 宿主 | 系统 | 一键安装 | 安装后启动 |
22
22
  | --- | --- | --- | --- |
23
- | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes @liwu1995/afu-context@next install claude` | `claude` |
24
- | Claude Code | Windows 10/11¹ | `npx.cmd --yes "@liwu1995/afu-context@next" install claude` | `claude` |
25
- | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes @liwu1995/afu-context@next install codex` | `codex` |
26
- | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes @liwu1995/afu-context@next install pi` | `pi` |
27
- | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes @liwu1995/afu-context@next install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` |
23
+ | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install claude` | `claude` |
24
+ | Claude Code | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "@liwu1995/afu-context@0.1.3" install claude` | `claude` |
25
+ | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install codex` | `codex` |
26
+ | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install pi` | `pi` |
27
+ | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` |
28
28
 
29
- macOS 指 Apple Silicon(ARM64),Linux 指 x64。这两种平台均已验证,自带 Python 运行时。Claude Code 和 Codex 安装后新开会话。Pi 扩展在新开的会话自动加载。将 DSH 的 `YOUR_PROFILE` 换成要接入的 profile,例如 `headless` 或 `web`。选择 web 后也可以用 `dsh web` 启动。仅移除某个 profile 时运行 `npx --yes @liwu1995/afu-context@next remove dsh --profile YOUR_PROFILE`。
29
+ 支持 Apple Silicon(ARM64)的 macOS 和 x64 Linux。首次执行 `afu` 会用本机 Python 3.11+ 创建专用运行环境,约半分钟。macOS 上 Claude Code 和 Codex 已用真实模型完成端到端验证(安装、对话压缩、升级、移除、launchd 自启);Linux 上 Claude Code 用真实模型验证,Codex 用本地模拟服务验证,用户级 systemd 自启尚未验证。Claude Code 和 Codex 安装后新开会话。Pi 扩展在新开的会话自动加载。将 DSH 的 `YOUR_PROFILE` 换成要接入的 profile,例如 `headless` 或 `web`。选择 web 后也可以用 `dsh web` 启动。仅移除某个 profile 时运行 `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 remove dsh --profile YOUR_PROFILE`。
30
30
 
31
31
  ¹ Windows 使用 PowerShell,需本机 Python **3.11+** 和支持 PowerShell Hook 的 Claude Code。Windows 原生 Claude Code 已实现,待实机验收。Codex 未支持,Pi 和 DSH 未验收。
32
32
 
33
+ 安装命令固定使用公网 npm 上的 **0.1.3**,避免镜像尚未同步最新版本。请使用 0.1.3 或更新版本;更早的版本不含本版修复。如果报错含 `with a date before`,请核对 npm 的 `before` 或 `min-release-age`。此版本发布于 **PUBLISH_TIME_UTC**。若使用滚动冷却期,等该版本满足期限后重试;若设置了固定截止日期,需使用允许安装该版本的策略。
34
+
33
35
  <details>
34
- <summary>Linux 手动启动与 Pi / DSH 启动参数</summary>
36
+ <summary>重启后的启动方式与 Pi / DSH 启动参数</summary>
35
37
 
36
- Linux 没有用户级 systemd 时,在 Claude Code 或 Codex 安装命令末尾加 `--no-autostart`,系统重启后重新执行同一命令启动网关。
38
+ Claude Code 和 Codex 会在会话启动时通过 hook 拉起 AFU 网关,重启电脑后打开第一个会话即可恢复。系统允许时,安装还会注册开机自启;若被系统策略拒绝(例如终端安全软件禁止写入 `~/Library/LaunchAgents`,或 Linux 没有用户级 systemd),安装会继续并给出提示,重启后无需重新安装。Codex 只执行你信任过的 hook:安装后打开一次 `codex`,在提示中信任 AFU 的 hook。
37
39
 
38
- Pi **0.84.4** 可直接传 `--provider` 和 `--model`。DSH 已适配 **0.1.2-rc.1** 和 Cordis **4.0.2**。若 Web profile 提示 HMR API 不兼容,可使用 `npx --yes @liwu1995/afu-context@next dsh --web-compat -- --profile web`。
40
+ Pi **0.84.4** 可直接传 `--provider` 和 `--model`。DSH 已适配 **0.1.2-rc.1** 和 Cordis **4.0.2**。若 Web profile 提示 HMR API 不兼容,可使用 `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 dsh --web-compat -- --profile web`。
41
+
42
+ </details>
43
+
44
+ <details>
45
+ <summary>已知问题与网络说明</summary>
46
+
47
+ - Node.js 22.18 及更早版本安装时 npm 会提示 `EBADENGINE`。要求为 22.19+(22.x)或 24+,请先升级 Node.js。
48
+ - 宿主需要通过代理访问模型时,照常在设置了 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` 的终端启动宿主即可。AFU 会记录安装命令或宿主会话 hook 看到的代理,网关之后的请求都会使用它。若 `/afu:status` 提示上游不可达,请在设置了代理的终端重新启动宿主。
49
+ - 经 AFU 使用 Codex 时,偶尔会停顿约三分钟后重连,原因尚未确认。
50
+ - Codex 的历史会话在安装和移除前后都能继续:AFU 改的是 Codex 内置 OpenAI provider 的地址(`openai_base_url`),不再新增 provider。从 0.1.2 升级时,会把 0.1.2 在 `afu_native` provider 下创建的会话改回 `openai`,CLI 和 Codex Desktop 都能重新打开;仍在使用的会话会跳过,下次升级或安装时再处理。
51
+ - 移除时若你没有改过配置文件,会逐字节恢复原文件;若安装后改过,只撤销 AFU 的条目,保留你的修改。
39
52
 
40
53
  </details>
41
54
 
@@ -59,16 +72,18 @@ Pi **0.84.4** 可直接传 `--provider` 和 `--model`。DSH 已适配 **0.1.2-rc
59
72
 
60
73
  ## Web 面板:查看压缩是否生效
61
74
 
62
- 以 Codex 为例,先看接入状态,再打开面板查看刚才的会话。其它宿主将 `codex` 换成 `claude`、`pi` 或 `dsh`。
75
+ 在会话里,Claude Code 输入 `/afu:status`,Codex 输入 `/prompts:afu-status`,即可看到 AFU 是否已接入、当前上下文距离压缩触发线还有多少,以及已经生效的压缩次数。这两个命令随安装添加、随移除删除。
76
+
77
+ 在终端里,以 Codex 为例,先看接入状态,再打开面板查看刚才的会话。其它宿主将 `codex` 换成 `claude`、`pi` 或 `dsh`;加 `--brief` 输出与斜杠命令相同的简短结果。
63
78
 
64
79
  ```sh
65
- npx --yes @liwu1995/afu-context@next status codex
66
- npx --yes @liwu1995/afu-context@next ui
80
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 status codex --brief
81
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 ui
67
82
  ```
68
83
 
69
84
  面板分别记录**请求已接入 → 摘要已生成 → 摘要已应用**。Claude Code 和 Codex 的应用回执表示替换历史后的请求已收到完整成功响应。Pi 和 DSH 的回执表示宿主已把摘要写入会话历史。面板也展示摘要耗时和模型返回的缓存用量。
70
85
 
71
- 在**总览**查看活跃会话、摘要生成耗时和应用数量。点进一个**会话**,沿时间线找到摘要及后续的应用回执,再看对应请求的缓存用量。遇到失败或证据缺失时,打开**高级诊断**查看错误原因与运行版本。
86
+ **总览**先告诉你 AFU 帮你省了多少:节省的 token(估算)、记录到的对话等待时间和生效的压缩次数,下方列出每个会话及其最近一次压缩的状态。点进一个**会话**,可以看到上下文在每次压缩后回落、每次压缩的耗时和节省量,以及缓存命中率。接入情况、版本和异常记录在**运行状态**页查看。面板支持亮色/暗色主题和中英文。
72
87
 
73
88
  ## 模型与压缩阈值
74
89
 
@@ -80,21 +95,21 @@ npx --yes @liwu1995/afu-context@next ui
80
95
  下面以已知窗口为 200,000 tokens 的 Codex 模型为例。第一条将触发比例设为 30%,第二条恢复自动设置。
81
96
 
82
97
  ```sh
83
- npx --yes @liwu1995/afu-context@next install codex --context-window 200000 --ratio 0.3
84
- npx --yes @liwu1995/afu-context@next install codex --context-window auto --ratio auto
98
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install codex --context-window 200000 --ratio 0.3
99
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 install codex --context-window auto --ratio auto
85
100
  ```
86
101
 
87
102
  </details>
88
103
 
89
- 如果经常使用 AFU,可运行 `npm install -g @liwu1995/afu-context@next`,以后用 `afu` 代替完整的 `npx` 命令。例如,`afu ui` 打开面板,`afu ui --prices ./afu-prices.json` 使用自己的单价文件估算费用。
104
+ 如果经常使用 AFU,可运行 `npm install -g --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3`,以后用 `afu` 代替完整的 `npx` 命令。例如,`afu ui` 打开面板,`afu ui --prices ./afu-prices.json` 使用自己的单价文件估算费用。
90
105
 
91
106
  ## 升级与移除
92
107
 
93
108
  升级 Claude Code 或 Codex 接入后,新开会话即可使用新版网关。需要移除时,先撤销对应宿主的接入,再重启宿主。以下两条命令分别用于升级和移除。
94
109
 
95
110
  ```sh
96
- npx --yes @liwu1995/afu-context@next upgrade codex
97
- npx --yes @liwu1995/afu-context@next remove codex
111
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 upgrade codex
112
+ npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.3 remove codex
98
113
  ```
99
114
 
100
115
  Pi 和 DSH 可分别用 `remove pi` 或 `remove dsh` 撤销。Windows PowerShell 请使用 `npx.cmd` 并将包名放在双引号中。
package/bin/afu.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import {spawn} from 'node:child_process';
4
4
  import {readFileSync} from 'node:fs';
5
5
  import {dirname,join,resolve} from 'node:path';
6
- import {homedir} from 'node:os';
6
+ import {homedir,constants} from 'node:os';
7
7
  import {fileURLToPath} from 'node:url';
8
8
  import {guide} from './install-guide.mjs';
9
9
  import {prepareRuntime} from './runtime.mjs';
@@ -15,11 +15,25 @@ const env={...process.env,AFU_INSTALL_ROOT:root};
15
15
  let args=process.argv.slice(2);
16
16
  if(['install','upgrade','status','remove','gc','native'].includes(args[0])){
17
17
  const at=args.indexOf('--state');if(at>=0&&args[at+1])env.AFU_STATE_DIR=resolve(args[at+1]);
18
+ const inline=args.find(a=>a.startsWith('--state='));if(inline&&inline.length>8)env.AFU_STATE_DIR=resolve(inline.slice(8));
18
19
  }
19
20
  let release;
21
+ // Ctrl-C already reaches the child through the shared foreground process group;
22
+ // relaying SIGINT again would deliver it twice. Only forward signals sent to
23
+ // this launcher alone, and only to our own child.
24
+ function relaySignals(child){
25
+ process.on('SIGINT',()=>{});
26
+ for(const signal of ['SIGTERM','SIGHUP'])process.on(signal,()=>child.kill(signal));
27
+ }
28
+ function exitCode(code,signal){
29
+ if(code!==null&&code!==undefined)return code;
30
+ return signal&&constants.signals[signal]?128+constants.signals[signal]:1;
31
+ }
20
32
  try{
21
- if(!args.length || (args.includes('--help') && (args.indexOf('--')<0 || args.indexOf('--help')<args.indexOf('--')))){
22
- console.log('AFU Context: install [claude|codex|pi|dsh], status, remove HOST, gc --dry-run, ui.\nRun npx @liwu1995/afu-context@next install to detect your installed host. Host login remains managed by the host.');
33
+ // Only the bare launcher prints its own banner; "afu ui --help" and
34
+ // "afu claude --help" belong to the subcommand or the host.
35
+ if(!args.length || (['--help','-h'].includes(args[0]))){
36
+ console.log('AFU Context: install [claude|codex|pi|dsh], status, remove HOST, gc --dry-run, ui.\nRun npx @liwu1995/afu-context@latest install to detect your installed host. Host login remains managed by the host.');
23
37
  process.exit(0);
24
38
  }
25
39
  args=await guide(args,env);
@@ -36,13 +50,14 @@ try{
36
50
  if(split<0 && args.length>1)throw Error('PLUGIN_REMOVED: pass host arguments after --');
37
51
  // COSEC: fixed host executable, argv array, no shell; no runtime downloads.
38
52
  const child=spawn(args[0],split<0?[]:args.slice(split+1),{stdio:'inherit',env,shell:false});
39
- for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
53
+ relaySignals(child);
40
54
  child.on('error',()=>{console.error('AFU: HOST_EXECUTABLE_UNAVAILABLE');process.exitCode=1;});
41
- child.on('exit',(code,signal)=>{process.exitCode=code??(signal?130:1);});
55
+ child.on('exit',(code,signal)=>{process.exitCode=exitCode(code,signal);});
42
56
  }else{
43
57
  const hostAction = ['pi','dsh'].includes(args[1]) && ['install','upgrade','remove','status'].includes(args[0]);
44
- const aggregateStatus = args[0]==='status' && args.slice(1).every(arg=>arg==='--json');
45
- const directHost = process.platform!=='win32' && (hostAction || aggregateStatus);
58
+ // Status always goes through one reporter (it understands autoload receipts,
59
+ // --json and --state); only Pi/DSH install/upgrade/remove need host-autoload.
60
+ const directHost = process.platform!=='win32' && hostAction && args[0]!=='status';
46
61
  if (directHost && ['install','upgrade'].includes(args[0])) {
47
62
  // Autoload paths must outlive npx eviction and the ordinary launcher cache GC.
48
63
  const state = env.AFU_STATE_DIR || join(homedir(), '.local/share/afu-context');
@@ -55,9 +70,8 @@ try{
55
70
  env.AFU_INSTALL_ROOT=await preparePlatform(root,manifest,args,env);
56
71
  const entry = directHost ? [join(root,'bin/host-autoload.py')] : ['-m','afu_context.local_cli'];
57
72
  const child=spawn(python,[...entry,...args],{stdio:'inherit',env});
58
- // Relay signals only to our own foreground child; never enumerate other sessions.
59
- for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
73
+ relaySignals(child);
60
74
  child.on('error',()=>{release?.();console.error('AFU failed to start');process.exitCode=1;});
61
- child.on('exit',(code,signal)=>{release?.();if(code===0&&['install','upgrade'].includes(args[0]))console.error(`AFU: installed ${args[1]}; ${['claude','codex'].includes(args[1])?'service checked; open a new '+args[1]+' session':directHost?'open a new host session':'launch with afu '+args[1]}. Waiting for the first observed request; compression is not yet verified.`);process.exitCode=code ?? (signal?130:1);});
75
+ child.on('exit',(code,signal)=>{release?.();if(code===0&&['install','upgrade'].includes(args[0]))console.error(`AFU: installed ${args[1]}; ${['claude','codex'].includes(args[1])?'service checked; open a new '+args[1]+' session':directHost?'open a new host session':'launch with afu '+args[1]}. Waiting for the first observed request; compression is not yet verified.`);process.exitCode=exitCode(code,signal);});
62
76
  }
63
77
  }catch(error){release?.();console.error('AFU: '+error.message);process.exitCode=1;}
package/bin/platform.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import {obtainPack} from './prebuilt.mjs';
2
2
  import {runInstall} from './install-process.mjs';
3
3
  /** Host-only dependencies live outside the installed npm package. */
4
- import {mkdirSync,readFileSync,writeFileSync,existsSync,rmSync,cpSync,renameSync} from 'node:fs';
4
+ import {mkdirSync,readFileSync,writeFileSync,existsSync,rmSync,cpSync,renameSync,realpathSync} from 'node:fs';
5
5
  import {join,dirname,delimiter} from 'node:path';
6
6
  import {homedir} from 'node:os';
7
7
 
@@ -56,7 +56,12 @@ export async function preparePlatform(root,manifest,args,env=process.env){
56
56
  if(manifest.prebuilt && process.platform!=='win32'){
57
57
  const payload=await obtainPack(root,manifest,host,env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token}),{mode:0o600}));
58
58
  cpSync(join(payload,'node_modules'),join(target,'node_modules'),{recursive:true,verbatimSymlinks:true});
59
- }else await runInstall(executable,[...prefix,'ci','--registry=https://registry.npmjs.org','--prefix',target,'--ignore-scripts','--no-audit','--no-fund'],env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token})));
59
+ }else {
60
+ // npm treats a Windows junction alias as a linked package. Use its real
61
+ // prefix so npm ci validates the shipped lock against the actual root.
62
+ const installTarget=process.platform==='win32'?realpathSync(target):target;
63
+ await runInstall(executable,[...prefix,'ci','--registry=https://registry.npmjs.org','--prefix',installTarget,'--ignore-scripts','--no-audit','--no-fund'],env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token})));
64
+ }
60
65
  // Import the real adapters: a package.json marker alone misses broken files
61
66
  // and corrupt transitive dependencies. No model request is made by imports.
62
67
  await runInstall(process.execPath,['--input-type=module','-e','await import(process.argv[1])',pathToFileURL(join(target,'src/afu_host_integrations',host,'dist/index.js')).href],env);
package/bin/prebuilt.mjs CHANGED
@@ -8,14 +8,14 @@ import {runInstall} from './install-process.mjs';
8
8
  export function packName(kind){
9
9
  if(!['runtime','pi','dsh'].includes(kind))throw Error('INVALID_PACK_KIND');
10
10
  if(!['darwin','linux','win32'].includes(process.platform)||!['arm64','x64'].includes(process.arch))throw Error('UNSUPPORTED_PLATFORM');
11
- return `@liwu1995/afu-context-${kind}-${process.platform}-${process.arch}`;
11
+ return `afu-context-${kind}-${process.platform}-${process.arch}`;
12
12
  }
13
13
  export function verifyPack(folder,manifest,kind){
14
14
  const info=JSON.parse(readFileSync(join(folder,'pack-manifest.json'),'utf8'));
15
15
  if(info.build!==manifest.build||info.kind!==kind||info.platform!==process.platform||info.arch!==process.arch)throw Error('PLATFORM_PACK_MISMATCH');
16
16
  const root=realpathSync(folder);
17
17
  const listed=new Set(Object.keys(info.files));
18
- function walk(dir){for(const entry of readdirSync(dir,{withFileTypes:true})){const file=join(dir,entry.name);if(entry.isDirectory())walk(file);else {const name=file.slice(root.length+1);if(!listed.has(name))throw Error('PACK_UNLISTED_FILE');if(lstatSync(file).isSymbolicLink()&&lstatSync(realpathSync(file)).isDirectory())throw Error('PACK_DIRECTORY_LINK');}}}
18
+ function walk(dir){for(const entry of readdirSync(dir,{withFileTypes:true})){const file=join(dir,entry.name);if(entry.isDirectory())walk(file);else {const name=file.slice(root.length+1).split(sep).join('/');if(!listed.has(name))throw Error('PACK_UNLISTED_FILE');if(lstatSync(file).isSymbolicLink()&&lstatSync(realpathSync(file)).isDirectory())throw Error('PACK_DIRECTORY_LINK');}}}
19
19
  walk(join(root,'payload'));
20
20
  // COSEC: signed npm integrity plus per-file release hashes; forbid escaping
21
21
  // paths/symlinks before copying any executable into the private runtime.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liwu1995/afu-context",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Local asynchronous context compression and diagnostics",
@@ -22,14 +22,5 @@
22
22
  "README.md",
23
23
  "README.zh-CN.md",
24
24
  "LICENSE"
25
- ],
26
- "optionalDependencies": {
27
- "@liwu1995/afu-context-runtime-darwin-arm64": "0.1.0",
28
- "@liwu1995/afu-context-runtime-linux-x64": "0.1.0"
29
- },
30
- "publishConfig": {
31
- "access": "public",
32
- "registry": "https://registry.npmjs.org",
33
- "tag": "next"
34
- }
25
+ ]
35
26
  }
@@ -3,6 +3,7 @@ aiohttp==3.14.5
3
3
  aiosignal==1.4.0
4
4
  annotated-types==0.8.0
5
5
  attrs==26.1.0
6
+ backports-zstd==1.8.0 ; python_full_version < '3.14'
6
7
  certifi==2026.10.23
7
8
  charset-normalizer==3.5.2
8
9
  frozenlist==1.8.0
package/release.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.1.0",
3
- "build": "edb9a28f8ba42bc4b9fa4858fa5438e90570cc442b79c8bc82c6b9bfd5fa3d66",
4
- "wheel": "afu_context_local-0.1.0-py3-none-any.whl",
5
- "prebuilt": true
2
+ "version": "0.1.3",
3
+ "build": "652e381c2625eef694391b79685cdd51ec0687a01e88f191b1c8e07c5bb43b29",
4
+ "wheel": "afu_context_local-0.1.3-py3-none-any.whl",
5
+ "prebuilt": false
6
6
  }
@@ -11,6 +11,11 @@ export class CompactDshEngine extends BasicCompactionEngine {
11
11
  handed = new WeakMap();
12
12
  handoff(agent, result) {
13
13
  const id = observationId(result);
14
+ const previous = this.handed.get(agent);
15
+ // A retried or manual compactRegion hands off again; the earlier summary
16
+ // was superseded rather than silently dropped.
17
+ if (previous && previous.id !== id)
18
+ this.log("summary_discarded", { session: sessionHash(agent.session.id), artifact_id: previous.id, reason: "superseded" });
14
19
  this.handed.set(agent, { id });
15
20
  this.log("summary_handoff", { session: sessionHash(agent.session.id), artifact_id: id });
16
21
  }
@@ -54,6 +59,9 @@ export class CompactDshEngine extends BasicCompactionEngine {
54
59
  });
55
60
  ctx.effect(() => async () => {
56
61
  this.log("runtime_stopped");
62
+ // Stop scheduling first so jobs cancelled by shutdown are not reported
63
+ // as preparation failures, then stop the host and let them settle.
64
+ this.idlePrecomputer.stop();
57
65
  await this.host.close();
58
66
  await this.idlePrecomputer.close();
59
67
  this.bindings.clear();
@@ -123,7 +131,16 @@ export class CompactDshEngine extends BasicCompactionEngine {
123
131
  }
124
132
  }
125
133
  async summarize(input, agent, signal) {
126
- const request = await this.boundRequest(agent, input.messages, "sync");
134
+ let request;
135
+ try {
136
+ request = await this.boundRequest(agent, input.messages, "sync");
137
+ }
138
+ catch {
139
+ // Model window unknown or binding limit reached: DSH's own summarizer
140
+ // must still run (this path is also used by manual compactNow).
141
+ signal?.throwIfAborted();
142
+ return super.summarize(input, agent, signal);
143
+ }
127
144
  const sessionId = String(request.session_id);
128
145
  const target = dshRoute(agent);
129
146
  const prepared = this.selectedResults.get(sessionId) ?? this.idlePrecomputer.latest(sessionId);
@@ -342,9 +359,13 @@ export class DshIdlePrecomputer {
342
359
  await Promise.all(this.running.values());
343
360
  }
344
361
  }
345
- async close() {
362
+ /** Stop accepting work; in-flight failures after this are expected. */
363
+ stop() {
346
364
  this.closed = true;
347
365
  this.pending.clear();
366
+ }
367
+ async close() {
368
+ this.stop();
348
369
  await this.drain();
349
370
  }
350
371
  start(sessionId) {
@@ -370,7 +391,8 @@ export class DshIdlePrecomputer {
370
391
  this.completed.set(sessionId, result);
371
392
  }
372
393
  catch (error) {
373
- this.onError(sessionId, error);
394
+ if (!this.closed)
395
+ this.onError(sessionId, error);
374
396
  }
375
397
  }
376
398
  }
@@ -44,9 +44,13 @@ export class HostCompactPool {
44
44
  }
45
45
  if (!active) {
46
46
  this.entries.delete(key);
47
- void old.client.close({ drain: false }).catch(() => undefined);
47
+ const closing = old.client.close({ drain: false }).catch(() => undefined);
48
+ if (process.platform === "win32")
49
+ await closing;
48
50
  }
49
51
  }
52
+ if (this.closed)
53
+ throw new Error("Host worker closed");
50
54
  let entry = this.entries.get(binding.key);
51
55
  if (!entry) {
52
56
  // Do not evict or interrupt another active session to admit a new one.
@@ -70,6 +74,8 @@ export class HostCompactPool {
70
74
  this.entries.set(binding.key, entry);
71
75
  }
72
76
  entry.binding = binding;
77
+ if (this.closed)
78
+ throw new Error("Host worker closed");
73
79
  const result = await entry.client.compact({ ...request, ...(binding.contextWindow === undefined ? {} : { token_budget: binding.contextWindow }), session_id: bindingKey([request.session_id, binding.key]) }, signal);
74
80
  if (!await binding.current())
75
81
  throw new Error("Host model changed");
@@ -144,11 +150,15 @@ export class HostCompactPool {
144
150
  }
145
151
  async close() {
146
152
  this.closed = true;
147
- await this.starting;
153
+ await this.starting?.catch(() => undefined);
148
154
  for (const controller of this.controllers)
149
155
  controller.abort();
150
- await Promise.allSettled([...this.entries.values()].map(e => e.client.close({ drain: false })));
151
- this.entries.clear();
156
+ // Entries registered by a compact() racing with close are closed here too.
157
+ while (this.entries.size) {
158
+ const entries = [...this.entries.values()];
159
+ this.entries.clear();
160
+ await Promise.allSettled(entries.map(e => e.client.close({ drain: false })));
161
+ }
152
162
  this.server?.closeAllConnections();
153
163
  if (this.server)
154
164
  await new Promise(resolve => this.server.close(() => resolve()));
@@ -50,6 +50,18 @@ export class CompactWorkerClient {
50
50
  this.child = undefined;
51
51
  this.lines = undefined;
52
52
  this.rejectAll(new CompactWorkerError("worker closed"));
53
+ if (process.platform === "win32" && child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
54
+ // Windows keeps the worker's cwd/files locked until the process exits.
55
+ await new Promise((resolve, reject) => {
56
+ const finished = () => { clearTimeout(kill); clearTimeout(deadline); resolve(); };
57
+ const kill = setTimeout(() => child.kill(), 5000);
58
+ const deadline = setTimeout(() => {
59
+ child.off("exit", finished);
60
+ reject(new CompactWorkerError("worker did not exit after shutdown"));
61
+ }, 10000);
62
+ child.once("exit", finished);
63
+ });
64
+ }
53
65
  }
54
66
  }
55
67
  call(method, params, signal, allowWhileClosing = false) {
@@ -98,6 +110,11 @@ export class CompactWorkerClient {
98
110
  stdio: ["pipe", "pipe", "pipe"],
99
111
  });
100
112
  this.child = child;
113
+ // A worker that dies mid-write emits EPIPE on stdin; without a listener
114
+ // Node treats that as fatal and takes the host process down with it.
115
+ child.stdin.on("error", (error) => {
116
+ this.rejectAll(new CompactWorkerError(`worker input closed: ${error.message}`));
117
+ });
101
118
  this.lines = createInterface({ input: child.stdout });
102
119
  this.lines.on("line", (line) => this.handleLine(line));
103
120
  child.stderr.on("data", (chunk) => {
@@ -108,6 +125,13 @@ export class CompactWorkerClient {
108
125
  process.stderr.write(`[afu_context compact worker] ${text}`);
109
126
  });
110
127
  child.on("error", (error) => {
128
+ // A spawn failure emits "error" without "exit": forget this child so
129
+ // the next call starts a fresh worker instead of writing to a dead pipe.
130
+ if (this.child === child) {
131
+ this.child = undefined;
132
+ this.lines?.close();
133
+ this.lines = undefined;
134
+ }
111
135
  this.rejectAll(new CompactWorkerError(`worker failed to start: ${error.message}`));
112
136
  });
113
137
  child.on("exit", (code, signal) => {
@@ -23,12 +23,25 @@ export function normalizeContent(value) {
23
23
  }
24
24
  return safeStringify(value);
25
25
  }
26
+ /** Same weights as the Python HeuristicTokenEstimator. */
27
+ function characterWeight(code) {
28
+ if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf))
29
+ return 1;
30
+ if ((code >= 0xac00 && code <= 0xd7af) || (code >= 0x1100 && code <= 0x11ff))
31
+ return 1;
32
+ if (code >= 0x3040 && code <= 0x30ff)
33
+ return 1;
34
+ if (code >= 0x1f000)
35
+ return 1;
36
+ if (code >= 0x0370)
37
+ return 0.5;
38
+ return 0.25;
39
+ }
26
40
  export function estimateContentTokens(content) {
27
41
  const text = typeof content === "string" ? content : JSON.stringify(content);
28
42
  let units = 0;
29
- for (const character of text) {
30
- units += character >= "\u4e00" && character <= "\u9fff" ? 1 : 0.25;
31
- }
43
+ for (const character of text)
44
+ units += characterWeight(character.codePointAt(0) ?? 0);
32
45
  return text.length === 0 ? 0 : Math.max(1, Math.ceil(units));
33
46
  }
34
47
  function isJsonObject(value) {