@quecue/dev-notify 0.2.2 → 0.2.4

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/CHANGELOG.en.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  English | [简体中文](CHANGELOG.md)
4
4
 
5
+ ## 0.2.4 — 2026-11-07
6
+
7
+ - Recover valid bot locks belonging to confirmed exited processes so a crashed reply bridge does not permanently remove Reply to Claude buttons. Live processes, reused PIDs, unknown permissions, and damaged locks are not preempted; concurrent recovery is serialized.
8
+ - Maintain an independent bridge heartbeat and distinguish process liveness from readiness. Bound startup waiting at 15 seconds and ignore errors left by an exited instance while a new instance starts.
9
+ - Restore an authorized but stopped bridge during `update` maintenance; wait for the old process to exit rather than treating a stale heartbeat as an exit. After npm installation, verify the current entry's package version and report a mismatch instead of maintaining hooks from an old Node/npm installation.
10
+ - Add dead-lock, concurrent recovery, actual isolated child-process termination, heartbeat, stale-error, and update-maintenance regressions. Current totals: 237 tests, 236 passed and 1 opt-in native credential test skipped; not acceptance on every platform.
11
+
12
+ ## 0.2.3 — 2026-11-07
13
+
14
+ - Change `@quecue/notify-core` from exact `0.1.0` to the compatible range `^0.1.0`, allowing stable releases `>=0.1.0 <0.2.0`. The lockfile still pins the tested `0.1.0`; update package validation and tests accordingly. This does not change the published `0.2.2`.
15
+ - Regression on Windows x64 / Node 24 totals 219 tests: 218 passed, 0 failed, and 1 opt-in native credential test skipped; this does not establish real-machine acceptance on every platform.
16
+
5
17
  ## 0.2.2 — 2026-11-07
6
18
 
7
19
  Communication dependency extraction, Windows file-replacement fixes, and documentation updates. Actual version availability is authoritative on the npm registry; the changes below are not included in `0.2.1`.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  简体中文 | [English](CHANGELOG.en.md)
4
4
 
5
+ ## 0.2.4 — 2026-11-07
6
+
7
+ - 修复回复桥接异常退出后残留 Bot 锁导致通知不再出现“回复 Claude”按钮:仅回收格式有效且已确认进程退出的锁;活进程、PID 复用、权限未知或损坏锁均不抢占,并串行化并发恢复。
8
+ - 独立续写桥接心跳,区分“进程存活”和“就绪”;启动等待延长为有界 15 秒,不再被上次已退出实例的错误提前中断。
9
+ - `update` 维护恢复已授权但未运行的桥接;重启前等待旧进程真正退出,而非依据过期心跳提前启动。npm 安装结束后核验当前入口对应的包版本,若更新了另一 Node/npm 安装则明确失败,不从旧入口改写 hooks。
10
+ - 新增死亡锁、并发恢复、真实隔离子进程强杀、心跳、旧错误及升级维护回归;当前共 237 项测试,236 通过、1 项显式原生凭据测试跳过,不代表所有平台实机验收。
11
+
12
+ ## 0.2.3 — 2026-11-07
13
+
14
+ - 将 `@quecue/notify-core` 依赖由精确 `0.1.0` 改为兼容范围 `^0.1.0`,允许 `>=0.1.0 <0.2.0` 的稳定版本。锁文件仍固定已验证的 `0.1.0`;同步发布校验和测试。此调整不改变已发布的 `0.2.2`。
15
+ - Windows x64/Node 24 回归共 219 项:218 通过、0 失败、1 项显式原生凭据测试跳过;不代表各平台实机验收。
16
+
5
17
  ## 0.2.2 — 2026-11-07
6
18
 
7
19
  通讯层依赖提取、Windows 文件替换修复与文档更新。实际版本可用性以 npm registry 为准;以下变更不包含在 `0.2.1` 中。
package/README.en.md CHANGED
@@ -6,11 +6,11 @@ Cross-platform developer task notification tool written in **TypeScript / Node.j
6
6
 
7
7
  **The npm package name is `@quecue/dev-notify`, with general notifications, Claude Code remote replies, and local/Telegram two-sided question answering and result feedback.** Source repository: [QueCue/dev-notify](https://github.com/QueCue/dev-notify). For changes and upgrade notes, see [CHANGELOG.en.md](CHANGELOG.en.md); published versions and distribution tags are authoritative on the npm registry, and source changes do not constitute a release.
8
8
 
9
- ### Communication dependency in 0.2.2 source
9
+ ### Communication dependency in current source
10
10
 
11
- The current 0.2.2 source extracts Telegram/Feishu communication into the standard npm runtime dependency `@quecue/notify-core`, pinned to exactly `0.1.0`. Source: [QueCue/notify-core](https://github.com/QueCue/notify-core). This structure does not describe the published `0.2.1` package.
11
+ Starting with 0.2.3, the standard npm runtime dependency is `@quecue/notify-core` with range `^0.1.0`, allowing stable releases `>=0.1.0 <0.2.0`. Source: [QueCue/notify-core](https://github.com/QueCue/notify-core). The published `0.2.2` still requires exact `0.1.0`; actual version availability is authoritative on the npm registry.
12
12
 
13
- npm installs the dependency; builds compile only this application without copying the communication library or linking sibling source. npm users do not install the library manually. `@quecue/notify-core@0.1.0` has been published through GitHub Actions. The lockfile pins its registry source and integrity; use `npm ci --ignore-scripts` for source development.
13
+ npm installs the dependency; builds compile only this application without copying the communication library or linking sibling source. npm users do not install the library manually. `@quecue/notify-core@0.1.0` has been published through GitHub Actions. The lockfile still pins the tested `0.1.0`, its registry source and integrity. Source development uses `npm ci --ignore-scripts` for exact restoration; widening the range does not automatically update the lockfile. Normal installation permits compatible releases but does not guarantee a fresh download or the newest patch on every upgrade.
14
14
 
15
15
  ## Installation and usage
16
16
 
@@ -53,7 +53,7 @@ dev-notify update --yes # skip confirmation (suitable for scripts)
53
53
  dev-notify update --no-post # update the package only, skip hooks/bridge maintenance
54
54
  ```
55
55
 
56
- Actual integrity verification, registry configuration, permission handling, and native dependency resolution are still handled by npm. After a successful install, `update` automatically reinstalls the Claude Code hooks bound to the current configuration (when they exist and the configuration matches) and restarts a running reply bridge; use `--no-post` to skip this when not needed. As of 0.1.3, `update` performs the same maintenance even when already up to date, so the same command can complete post-upgrade cleanup.
56
+ Actual integrity verification, registry configuration, permission handling, and native dependency resolution are still handled by npm. Version 0.2.4 verifies that the current command entry matches the target version after installation. If npm updated another Node/npm installation, it reports a mismatch instead of maintaining from the old entry. Once verified, `update` reinstalls Claude Code hooks bound to the current configuration (when they exist and match) and restores or restarts the authorized reply bridge; use `--no-post` to skip. As of 0.1.3, `update` performs the same maintenance even when already up to date, so the same command can complete post-upgrade cleanup.
57
57
 
58
58
  ## Features
59
59
 
@@ -172,11 +172,11 @@ dev-notify reply disable # revoke authorization and request stop after confir
172
172
  dev-notify reply run # start in the foreground to diagnose startup/network issues; do not run in parallel with a start instance
173
173
  ```
174
174
 
175
- Stop sends a request through a control file with a random instance identifier and does not force-kill based on an old PID. System input that has already started cannot be withdrawn. `status` reflects only the local process and heartbeat; Telegram end-to-end and real Claude still require separate acceptance.
175
+ Stop sends a request through a control file with a random instance identifier and does not force-kill based on an old PID. System input that has already started cannot be withdrawn. Version 0.2.4 maintains an independent heartbeat and separates liveness from readiness: an expired heartbeat is not an exited process. Startup waits at most 15 seconds and is not interrupted by an exited instance's old error. `status` still reflects only local state; Telegram end-to-end and real Claude require separate acceptance.
176
176
 
177
177
  Authorization is in `<notification config>.reply.json`; messages/sessions/offset/queue/interaction state in `<notification config>.reply-data/`; the bot lock in `reply-bots/` under the data directory shown by `paths`. Queued reply text, questions, tool input, and answers pending return are briefly written to local state; normal reply text is removed before submission; structured interactions are retained until completion or confirmation timeout, after which question, argument, and answer text are cleared, while result receipts and message IDs are retained briefly for feedback and for rejecting old buttons. Validity checks are logical decisions only; files are not automatically erased after shutdown. POSIX uses 0700/0600; Windows relies on account directory ACLs; other processes of the same user can still read them. Do not commit, share, or package these files (already added to ignore rules and kept out of the npm whitelist).
178
178
 
179
- Force-kill may leave locks; first confirm that the relevant bridge/writer process has exited, then handle residual locks manually, and do not delete them while the process is running. `reply disable` does not delete historical state on its own; to clear it, first disable or stop, then manually delete the dedicated files after confirmation. Clearing credentials, removing hooks, and deleting reply state are three independent operations.
179
+ Force-kill may leave locks. Version 0.2.4 can recover a valid bot lock at startup only after confirming its PID no longer exists. Unknown permissions, PIDs reused by a live process, damaged locks, and leftover `.recover` recovery gates fail closed. Other locks such as state-write locks are not recovered automatically; confirm their owning process has exited before handling them manually. Never delete a live process's lock. `reply disable` does not delete historical state on its own; to clear it, first disable or stop, then manually delete the dedicated files after confirmation. Clearing credentials, removing hooks, and deleting reply state are three independent operations.
180
180
 
181
181
  ## System credentials and compatibility
182
182
 
@@ -314,9 +314,9 @@ For other CLI examples, replace `dev-notify` with `node dist/cli.js`; no global
314
314
 
315
315
  `node-src/` is TypeScript source, `dist/` is compiled output, and `test-js/` is tests. Default tests use only fake credentials and mock backends/network; the local native fake-entry round-trip test requires explicitly setting `DEV_NOTIFY_NATIVE_TEST=1`, is enabled only on Windows, uses random IDs, and cleans up afterward.
316
316
 
317
- For the current 0.2.2 source, the package whitelist includes only this application's compiled CLI JavaScript, README, SECURITY, CHANGELOG, LICENSE and package.json. Library copies, tests, real configuration, ciphertext and dependency tarballs are excluded. npm resolves runtime dependencies separately. Published package contents depend on the corresponding version. There is no postinstall, auto-start, or self-written download script; prebuilt native dependencies are resolved by npm's platform optional packages, **do not use `--omit=optional`**. macOS x64/arm64 and Windows x64/arm64 prebuilt packages are provided by dependencies; providing packages does not mean this project has completed real-machine acceptance.
317
+ For the 0.2.4 source, the package whitelist includes only this application's compiled CLI JavaScript, README, SECURITY, CHANGELOG, LICENSE and package.json. Library copies, tests, real configuration, ciphertext and dependency tarballs are excluded. npm resolves runtime dependencies separately. Published package contents depend on the corresponding version. There is no postinstall, auto-start, or self-written download script; prebuilt native dependencies are resolved by npm's platform optional packages, **do not use `--omit=optional`**. macOS x64/arm64 and Windows x64/arm64 prebuilt packages are provided by dependencies; providing packages does not mean this project has completed real-machine acceptance.
318
318
 
319
- See [CHANGELOG.en.md](CHANGELOG.en.md) for verification results of published versions. The regression record for this 0.2.2 source on Windows x64 with Node 24 is 219 tests: 218 passed, 0 failed, and 1 opt-in native credential test skipped. This record does not describe verification of the published npm package.
319
+ See [CHANGELOG.en.md](CHANGELOG.en.md) for verification results of published versions. The regression record for the 0.2.4 source on Windows x64 with Node 24 is 237 tests: 236 passed, 0 failed, and 1 opt-in native credential test skipped. This record does not establish real-machine acceptance on every platform.
320
320
 
321
321
  Automated coverage includes temporary hooks, reply/authorization/expiry/anti-replay, single-select/multi-select/custom input, one-time permission JSON, two-sided races, feedback exceptions, and per-recipient duplicate reminder suppression. The actual CLI subprocess completes the loop of background start → PreToolUse registration → PermissionRequest → fake bot button → JSON answer → simulated PostToolUse → final feedback → notification deduplication/subsequent tool reminder → stop; this loop uses mock network and process metadata and cannot replace real-machine input verification. Automated tests for the Windows native input helper class only verify compilation/layout and do not perform a real Send.
322
322
 
package/README.md CHANGED
@@ -6,11 +6,11 @@
6
6
 
7
7
  **npm 包名为 `@quecue/dev-notify`,支持通用通知、Claude Code 远程回复,以及本机与 Telegram 双端答题和结果反馈。** 源码仓库:[QueCue/dev-notify](https://github.com/QueCue/dev-notify)。版本变更与升级说明见 [CHANGELOG.md](CHANGELOG.md);公开版本和发布标签以 npm registry 为准,源码更新不等同于已发布。
8
8
 
9
- ### 0.2.2 源码的通讯层依赖
9
+ ### 当前源码的通讯层依赖
10
10
 
11
- 当前 0.2.2 源码将 Telegram/飞书通讯层提取为标准 npm 运行时依赖 `@quecue/notify-core`,精确版本为 `0.1.0`,源码见 [QueCue/notify-core](https://github.com/QueCue/notify-core)。此结构不代表已发布的 `0.2.1` 包。
11
+ 从 0.2.3 起使用标准 npm 运行时依赖 `@quecue/notify-core`,范围为 `^0.1.0`,允许 `>=0.1.0 <0.2.0` 的稳定版本,源码见 [QueCue/notify-core](https://github.com/QueCue/notify-core)。已发布的 `0.2.2` 仍精确依赖 `0.1.0`;版本实际可用性以 npm registry 为准。
12
12
 
13
- 依赖由 npm 安装,构建只编译本应用,不复制通讯库或链接兄弟仓库源码。npm 用户无需手动安装通讯库。`@quecue/notify-core@0.1.0` 已通过 GitHub Actions 发布,锁文件固定其 registry 来源和 integrity;源码开发使用 `npm ci --ignore-scripts` 还原依赖。
13
+ 依赖由 npm 安装,构建只编译本应用,不复制通讯库或链接兄弟仓库源码。npm 用户无需手动安装通讯库。`@quecue/notify-core@0.1.0` 已通过 GitHub Actions 发布,锁文件仍固定已验证的 `0.1.0`、registry 来源和 integrity;源码开发使用 `npm ci --ignore-scripts` 精确还原,不会因为版本范围放宽自动更新锁文件。正常安装允许兼容版本,不保证每次升级都会重新下载依赖或强制选择最新补丁。
14
14
 
15
15
  ## 安装与使用
16
16
 
@@ -53,7 +53,7 @@ dev-notify update --yes # 跳过确认(适合脚本)
53
53
  dev-notify update --no-post # 只更新包,不执行 hooks/桥接维护
54
54
  ```
55
55
 
56
- 实际的完整性校验、registry 配置、权限处理和原生依赖解析仍由 npm 完成。安装成功后,`update` 会自动重新安装绑定当前配置的 Claude Code hooks(已存在且配置匹配时),并重启正在运行的回复桥接;不需要时可用 `--no-post` 跳过。在 0.1.3 及以后,即使已经是最新版本,`update` 也会执行同样的维护,因此可以用同一条命令完成升级后的收尾。
56
+ 实际的完整性校验、registry 配置、权限处理和原生依赖解析仍由 npm 完成。0.2.4会在安装后核验当前命令入口确实对应目标版本;若 npm 更新了另一份 Node/npm 安装,会明确报错而不从旧入口维护。核验通过后,`update` 会重新安装绑定当前配置的 Claude Code hooks(已存在且配置匹配时),并恢复或重启已授权回复桥接;不需要时可用 `--no-post` 跳过。在 0.1.3 及以后,即使已经是最新版本,`update` 也会执行同样的维护,因此可以用同一条命令完成升级后的收尾。
57
57
 
58
58
  ## 已提供的功能
59
59
 
@@ -172,11 +172,11 @@ dev-notify reply disable # 确认后撤销授权并请求停止;彻底关
172
172
  dev-notify reply run # 前台诊断启动/网络问题,不与 start 实例并行
173
173
  ```
174
174
 
175
- 停止通过实例随机标识的控制文件请求,不会根据旧 PID 强制结束进程。已经开始的系统输入无法撤回。`status` 只反映本地进程和心跳,Telegram 端到端和真实 Claude 仍需另行验收。
175
+ 停止通过实例随机标识的控制文件请求,不会根据旧 PID 强制结束进程。已经开始的系统输入无法撤回。0.2.4使用独立心跳区分进程存活与就绪;心跳过期不会被当作进程已退出,启动最多等待 15 秒且不被旧实例错误提前中断。`status` 仍只反映本地状态,Telegram 端到端和真实 Claude 需另行验收。
176
176
 
177
177
  授权在 `<通知配置>.reply.json`,消息/会话/offset/队列/交互状态在 `<通知配置>.reply-data/`;Bot 锁在 `paths` 所示 data 目录的 `reply-bots/`。排队回复原文、问题、工具输入和待回传答案会短暂写入本地状态;提交前会移除普通回复原文;结构化交互保留到完成或确认超时,随后清除问题、参数与答案正文,短期保留结果收据和消息 ID,用于反馈及拒绝旧按钮。有效期检查只是逻辑判断,停机后文件不会自动擦除。POSIX 使用 0700/0600,Windows 依赖账号目录 ACL;同一用户的其他进程仍然可以读取。请不要提交、共享或打包这些文件(已加入忽略规则及 npm 白名单)。
178
178
 
179
- 强杀可能留下锁;请先确认相应桥接/写入进程已经退出,再手工处理残留锁,不要在进程运行中删除。`reply disable` 不会擅自删除历史状态;需要清除时,请先禁用或停止,确认后手工删除上述专属文件。清理凭据、移除 hooks 和删除回复状态是三项独立操作。
179
+ 强杀可能留下锁。0.2.4可在启动时自动回收有效 Bot 锁,但必须先确认原 PID 已不存在;权限未知、PID 复用为活进程、坏锁及残留 `.recover` 恢复门闩一律保守拒绝。状态写入锁等其他锁不自动回收,需先确认对应进程已退出再人工处理。不能在进程运行时删除锁。`reply disable` 不会擅自删除历史状态;需要清除时,请先禁用或停止,确认后手工删除上述专属文件。清理凭据、移除 hooks 和删除回复状态是三项独立操作。
180
180
 
181
181
  ## 系统凭据与兼容性
182
182
 
@@ -314,9 +314,9 @@ npm pack --dry-run
314
314
 
315
315
  `node-src/` 是 TypeScript 源码,`dist/` 是编译输出,`test-js/` 是测试。默认测试只用假凭据和模拟后端/网络;本机原生假条目往返测试需显式设置 `DEV_NOTIFY_NATIVE_TEST=1`,且只在 Windows 启用,使用随机 ID,结束后清理。
316
316
 
317
- 当前 0.2.2 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json;不包含通讯库副本、测试、真实配置、密文或依赖 tarball。运行时依赖由 npm 单独解析。已发布版本的包内容以对应版本为准。没有 postinstall、自动启动或自写下载脚本;预编译原生依赖由 npm 的平台 optional 包解析,**请勿使用 `--omit=optional`**。macOS x64/arm64 和 Windows x64/arm64 预编译包由依赖提供,提供包并不等同于本项目已完成实机验收。
317
+ 0.2.4 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json;不包含通讯库副本、测试、真实配置、密文或依赖 tarball。运行时依赖由 npm 单独解析。已发布版本的包内容以对应版本为准。没有 postinstall、自动启动或自写下载脚本;预编译原生依赖由 npm 的平台 optional 包解析,**请勿使用 `--omit=optional`**。macOS x64/arm64 和 Windows x64/arm64 预编译包由依赖提供,提供包并不等同于本项目已完成实机验收。
318
318
 
319
- 各已发布版本的验证结果见 [CHANGELOG.md](CHANGELOG.md)。当前 0.2.2 源码在 Windows x64、Node 24 下的回归记录为 219 项:218 通过、0 失败、1 项需显式启用的原生凭据测试跳过;该记录不代表已发布 npm 包的验证结果。
319
+ 各已发布版本的验证结果见 [CHANGELOG.md](CHANGELOG.md)。0.2.4 源码在 Windows x64、Node 24 下的回归记录为 237 项:236 通过、0 失败、1 项需显式启用的原生凭据测试跳过;该记录不代表所有平台的实机验收。
320
320
 
321
321
  自动化覆盖临时 hooks、回复/授权/过期/防重放、单选/多选/自定义、单次权限 JSON、双端竞争、反馈异常及按接收人的重复提醒去重。实际 CLI 子进程完成后台 start → PreToolUse 登记 → PermissionRequest → 假 Bot 按钮 → JSON 答案 → 模拟 PostToolUse → 最终反馈 → 通知去重/后续工具提醒 → stop;该闭环使用模拟网络和进程元数据,不能替代实机输入验证。Windows 原生输入帮助类的自动化测试只做编译/布局验证,不会执行真实 Send。
322
322
 
package/SECURITY.en.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  English | [简体中文](SECURITY.md)
4
4
 
5
- The npm package name is `@quecue/dev-notify`, with public source at [QueCue/dev-notify](https://github.com/QueCue/dev-notify). Published versions are authoritative on the npm registry; this document describes the 0.2.2 source, and a source version alone does not establish that an installation package has been published. This project has not completed a security audit or real-machine acceptance on both platforms.
5
+ The npm package name is `@quecue/dev-notify`, with public source at [QueCue/dev-notify](https://github.com/QueCue/dev-notify). Published versions are authoritative on the npm registry; this document describes the dependency range and security boundaries of 0.2.4, and a source version alone does not establish that an installation package has been published. This project has not completed a security audit or real-machine acceptance on both platforms.
6
6
 
7
- ## Communication dependency in 0.2.2 source
7
+ ## Communication dependency in current source
8
8
 
9
- The current 0.2.2 source uses the standard npm runtime dependency `@quecue/notify-core`, pinned to exactly `0.1.0`, with source at [QueCue/notify-core](https://github.com/QueCue/notify-core). Builds do not embed the library or link sibling source; npm installs it automatically. This does not describe the published `0.2.1` package. The library's `0.1.0` release was published through its own GitHub Actions OIDC identity; the lockfile pins the official registry source and SHA512. Neither provenance nor integrity verification constitutes a security audit.
9
+ Starting with 0.2.3, the standard npm runtime dependency is `@quecue/notify-core` with compatible range `^0.1.0` (stable releases `>=0.1.0 <0.2.0`), with source at [QueCue/notify-core](https://github.com/QueCue/notify-core). Builds do not embed the library or link sibling source; npm installs it automatically. The published `0.2.2` still requires exact `0.1.0`; actual version availability is authoritative on the npm registry. The lockfile still pins the tested `0.1.0` with its official registry source and SHA512 for exact `npm ci` restoration; normal installation permits compatible releases. A permitted version range is not a security guarantee, and neither provenance nor integrity verification constitutes a security audit.
10
10
 
11
11
  Reusing a dependency does not automatically share credentials or authorization, and a bot's `getUpdates` must still have only one consumer. Upgrade dependencies only from trusted sources; tarball integrity checks in the lockfile do not establish source authenticity or constitute a security audit.
12
12
 
@@ -67,12 +67,12 @@ Reusing a dependency does not automatically share credentials or authorization,
67
67
  - Questions, options, tool arguments, reply text, and answers may contain sensitive information; after enabling, questions and the corresponding requests are sent to authorized private chats and configured Feishu, with no automatic redaction. Queued text, request input, answers pending return, and session paths are persistently written to dedicated local state; 0600/0700 cannot replace Windows ACL and cannot defend against same-user processes. Logical TTLs do not automatically erase files after power loss.
68
68
  - The dedicated files are `<config>.reply.json`, `<config>.reply-data/`, and the data directory `reply-bots/`, already added to ignore rules and not in the npm whitelist. Disabling does not delete historical state on its own; to clear it, disable and stop first, then confirm deletion of the dedicated files. The bot token exists only in system credentials, environment variables, or memory and is not written to reply state.
69
69
  - The bridge does not listen on ports or configure system auto-start. After user authorization, it can be started manually or on demand by hooks; it exits after 24 hours without updates, and also after 5 consecutive polling failures or a 409 conflict. It does not delete webhooks or force-kill old or other instances; cross-machine, cross-implementation bot exclusivity must be ensured by the user. This tool's lock only constrains instances that follow the same local lock directory.
70
- - Stop sends a control request using a random instance identifier and does not kill processes based on an old PID; stop does not revoke authorization, and later hooks may restart the bridge. Use disable for a complete shutdown; native calls cannot be withdrawn once started. Heartbeat/PID are only status snapshots; network connectivity needs separate verification; crash-residual locks require confirming that the process has exited before manual handling, and the program does not automatically seize locks.
70
+ - Stop sends a control request using a random instance identifier and does not kill processes based on an old PID; stop does not revoke authorization, and later hooks may restart the bridge. Use disable for a complete shutdown; native calls cannot be withdrawn once started. Version 0.2.4 separates process liveness from heartbeat readiness; an expired heartbeat is not grounds for preemption. A valid bot lock is recovered only when its PID is confirmed absent, with an exclusive recovery gate serializing recovery. Live processes (including reused PIDs), unknown permissions, damaged locks, and leftover recovery gates fail closed; other locks such as state-write locks still require manual handling after exit confirmation. Heartbeat/PID remain status snapshots, not proof of network connectivity.
71
71
 
72
72
  ## npm and testing
73
73
 
74
74
  - Runtime has a native credential dependency, with the version and platform optional packages pinned in `package-lock.json`. Keep optional dependencies. There is no postinstall, self-written binary download, or background startup script.
75
- - For the current 0.2.2 source, the npm whitelist includes only this application's compiled CLI JS, README, SECURITY, CHANGELOG, LICENSE and package.json. Library copies, tests, real configuration, logs, ciphertext and dependency tarballs are excluded. Published versions are governed by their actual package contents.
75
+ - For the 0.2.4 source, the npm whitelist includes only this application's compiled CLI JS, README, SECURITY, CHANGELOG, LICENSE and package.json. Library copies, tests, real configuration, logs, ciphertext and dependency tarballs are excluded. Published versions are governed by their actual package contents.
76
76
  - Third-party native packages carry their own licenses and Rust dependency declarations. Check the supply chain and licenses for releases and dependency upgrades; pinned versions are not equivalent to a completed audit.
77
77
  - Default tests use fake credentials, temporary directories, and mock system backends/network. `DEV_NOTIFY_NATIVE_TEST=1` explicitly enables Windows native tests, creates only fake entries with random IDs for this run, cleans up afterward, and does not read the user's existing entries.
78
78
  - npm uninstall does not automatically delete user configuration or credentials. To clean up, first run the interactive cleanup command; uninstall lifecycle scripts will not secretly manipulate the user's system storage.
package/SECURITY.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  简体中文 | [English](SECURITY.en.md)
4
4
 
5
- npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https://github.com/QueCue/dev-notify)。发布版本以 npm registry 为准,本文描述 0.2.2 源码,不能仅依据源码版本认定安装包已发布。本项目尚未完成安全审计或双平台实机验收。
5
+ npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https://github.com/QueCue/dev-notify)。发布版本以 npm registry 为准,本文描述 0.2.4 的依赖范围与安全边界,不能仅依据源码版本认定安装包已发布。本项目尚未完成安全审计或双平台实机验收。
6
6
 
7
- ## 0.2.2 源码的通讯层依赖
7
+ ## 当前源码的通讯层依赖
8
8
 
9
- 当前 0.2.2 源码使用标准 npm 运行时依赖 `@quecue/notify-core`,精确版本 `0.1.0`,源码见 [QueCue/notify-core](https://github.com/QueCue/notify-core)。构建不嵌入通讯库、不链接兄弟仓库源码,npm 自动安装该依赖;这不是已发布 `0.2.1` 的结构说明。通讯库 `0.1.0` 已通过独立 GitHub Actions OIDC 发行,锁文件固定正式 registry 来源和 SHA512;包的 provenance 或完整性校验均不等于安全审计。
9
+ 从 0.2.3 起使用标准 npm 运行时依赖 `@quecue/notify-core`,兼容范围 `^0.1.0`(稳定版本 `>=0.1.0 <0.2.0`),源码见 [QueCue/notify-core](https://github.com/QueCue/notify-core)。构建不嵌入通讯库、不链接兄弟仓库源码,npm 自动安装该依赖。已发布的 `0.2.2` 仍精确依赖 `0.1.0`;版本实际可用性以 npm registry 为准。锁文件仍固定已验证 `0.1.0` 的正式 registry 来源和 SHA512,`npm ci` 精确还原;普通安装可接受兼容版本。允许的版本范围不是安全保证,包的 provenance 或完整性校验也不等于安全审计。
10
10
 
11
11
  依赖复用不会自动共享凭据或授权,同一个 Bot 的 `getUpdates` 仍只能由一个消费者使用。依赖只从可信来源升级;锁文件的 tarball integrity 校验不等于来源认证或安全审计。
12
12
 
@@ -67,12 +67,12 @@ npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https:/
67
67
  - 问题、选项、工具参数、回复正文和答案可能包含敏感信息;启用后,问题和相应请求会发送到授权私聊及已配置飞书,无法自动脱敏。排队文本、请求输入、待回传答案及会话路径会持久写入专属本地状态;0600/0700 无法替代 Windows ACL,也无法防御同一用户的进程。逻辑 TTL 不会在断电后自动擦除文件。
68
68
  - 专属文件为 `<config>.reply.json`、`<config>.reply-data/` 与数据目录 `reply-bots/`,已加入忽略规则,且不在 npm 白名单中。禁用不会擅自删除历史状态;需要清除时,请先禁用并停止,再确认删除专属文件。Bot Token 只存在于系统凭据、环境变量或内存中,不会写入回复状态。
69
69
  - 桥接不监听端口,也不配置系统自启动。用户授权后可手动或由 hook 按需启动;24 小时没有更新会退出,连续轮询失败 5 次或遇到 409 冲突也会退出。不会删除 webhook,也不会强制结束旧版或其他实例;跨机器、跨实现的 Bot 独占需要由用户保证。本工具的锁只能约束遵守相同本地锁目录的实例。
70
- - stop 使用随机实例标识发送控制请求,不会依据旧 PID 结束进程;stop 不撤销授权,后续 hook 仍可重启。彻底关闭请使用 disable;原生调用一旦开始就无法撤回。心跳/PID 只是状态快照,网络连通性需要单独验证;崩溃残留的锁需要先核实进程已退出,再人工处理,程序不会自动抢占锁。
70
+ - stop 使用随机实例标识发送控制请求,不会依据旧 PID 结束进程;stop 不撤销授权,后续 hook 仍可重启。彻底关闭请使用 disable;原生调用一旦开始就无法撤回。0.2.4区分进程存活和心跳就绪,心跳过期不作为抢占依据;有效 Bot 锁仅在原 PID 确认不存在时自动回收,并通过独占恢复门闩串行化。活进程(包括 PID 复用)、权限未知、坏锁和残留恢复门闩不抢占;状态写入锁等其他锁仍需人工确认后处理。心跳/PID 只是状态快照,网络连通性需单独验证。
71
71
 
72
72
  ## npm 与测试
73
73
 
74
74
  - 运行时有原生凭据依赖,版本和平台 optional 包锁定于 `package-lock.json`。请保留 optional 依赖。项目没有 postinstall、自写二进制下载或后台启动脚本。
75
- - 当前 0.2.2 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json。通讯库副本、测试、真实配置、日志、密文及依赖 tarball 不进入发布包;已发布版本以实际包内容为准。
75
+ - 0.2.4 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json。通讯库副本、测试、真实配置、日志、密文及依赖 tarball 不进入发布包;已发布版本以实际包内容为准。
76
76
  - 第三方原生包带有其许可证与 Rust 依赖声明。发布及依赖升级时应核对供应链和许可;固定版本并不等同于完成审计。
77
77
  - 默认测试使用假凭据、临时目录和模拟系统后端、网络。`DEV_NOTIFY_NATIVE_TEST=1` 会显式开启 Windows 原生测试,只创建本次随机 ID 的假条目,结束后清理,不读取用户现有条目。
78
78
  - npm 卸载不会自动删除用户配置或凭据。需要清理时,请先运行交互清理命令;卸载生命周期脚本不会偷偷操作用户的系统存储。
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { startBridge, stopBridge, bridgeStatus, runBridge } from './reply-bridge
16
16
  import { updatePackage } from './update.js';
17
17
  import { postUpdate } from './maintenance.js';
18
18
  import { localeName, parseLocale, resolveLocale, setLocale, t } from './i18n.js';
19
- export const VERSION = '0.2.2';
19
+ export const VERSION = '0.2.4';
20
20
  const helpText = () => t('cli.help', { version: VERSION });
21
21
  function rawLang(argv) {
22
22
  for (let i = 0; i < argv.length; i++) {
package/dist/i18n.js CHANGED
@@ -237,11 +237,12 @@ const zh = {
237
237
  'update.maintenanceNote': '将执行 Claude Code hooks 和回复桥接维护。',
238
238
  'update.versions': '当前版本 {current},npm 最新版本 {latest}。',
239
239
  'update.checkHint': '运行 dev-notify update 更新。',
240
- 'update.postNote': '更新完成后会自动重新安装绑定当前配置的 Claude Code hooks,并重启正在运行的回复桥接;可用 --no-post 跳过。',
240
+ 'update.postNote': '更新并核验命令入口后,会重新安装绑定当前配置的 Claude Code hooks,并恢复或重启已授权回复桥接;可用 --no-post 跳过。',
241
241
  'update.cancelled': '已取消,没有更新。',
242
242
  'update.running': '运行 npm install -g {name}@{latest} ...',
243
243
  'update.failed': '更新没有完成。请检查 npm 权限,或手动运行 npm install -g {name}@latest。',
244
244
  'update.updated': '已更新到 {latest}。请重新打开终端后运行 dev-notify --version 确认。',
245
+ 'update.entryMismatch': 'npm 安装已结束,但当前命令入口未更新到 {latest},可能使用了不同 Node/npm 安装目录。未从旧入口改写 hooks 或重启桥接。请在目标 Node 环境重新打开终端,核对 dev-notify --version 后运行 dev-notify update 完成维护。',
245
246
  'maintenance.runFailed': '无法运行更新后的 dev-notify。请重新打开终端后运行 dev-notify --version 检查。',
246
247
  'maintenance.reasonHint': '请运行 dev-notify integration claude-code status 或 dev-notify reply status 检查。',
247
248
  'maintenance.hooksCheckFailed': '无法检查 Claude Code hooks:{reason}',
@@ -253,6 +254,7 @@ const zh = {
253
254
  'maintenance.hooksFailedReason': 'Claude Code hooks 更新失败:{reason}',
254
255
  'maintenance.bridgeCheckFailed': '无法检查回复桥接:{reason}',
255
256
  'maintenance.restartingBridge': '重启回复桥接 ...',
257
+ 'maintenance.startingBridge': '恢复已授权但未运行的回复桥接 ...',
256
258
  'maintenance.bridgeStillRunning': '回复桥接仍在退出,请稍后运行 dev-notify reply start。',
257
259
  'maintenance.bridgeRestarted': '回复桥接已重启。',
258
260
  'maintenance.bridgeStartFailed': '回复桥接启动失败。',
@@ -477,7 +479,7 @@ const zh = {
477
479
  'reply.telegramRequestFailed': 'Telegram 桥接请求失败;不输出服务端正文或秘密',
478
480
  'reply.bridgeAuthFirst': '远程回复尚未授权,请先运行 reply setup。',
479
481
  'reply.bridgeNotAuthorized': '远程回复尚未授权。',
480
- 'reply.bridgeLocked': '当前用户已经有同一个 Bot 的桥接在运行,或残留了锁文件。请确认进程退出后,再处理 reply-bots 中对应的锁。',
482
+ 'reply.bridgeLocked': '同一个 Bot 的桥接锁仍被占用,或不能安全确认其归属。仅确认进程已退出的有效残留锁会自动恢复;活进程、权限未知或损坏锁不会被抢占,请运行 reply status 检查。',
481
483
  'reply.webhookConflict': '这个 Bot 已经设置了 webhook,不能同时使用 getUpdates。请先移除 webhook;本工具不会自动删除。',
482
484
  'reply.pollFailedLog': 'Telegram 轮询失败;日志中没有记录密钥或服务端正文。',
483
485
  'reply.pollFailedFatal': 'Telegram 连续轮询失败 5 次,桥接已经退出。请检查网络、Token 或 API 冲突后重新启动。',
@@ -733,11 +735,12 @@ secrets are never written in plaintext.`,
733
735
  'update.maintenanceNote': 'Claude Code hooks and reply bridge maintenance will run.',
734
736
  'update.versions': 'Current version {current}; latest npm version {latest}.',
735
737
  'update.checkHint': 'Run dev-notify update to update.',
736
- 'update.postNote': 'After updating, Claude Code hooks bound to the current configuration will be reinstalled and a running reply bridge restarted; use --no-post to skip.',
738
+ 'update.postNote': 'After updating and verifying the command entry, Claude Code hooks bound to the current configuration will be reinstalled and the authorized reply bridge restored or restarted; use --no-post to skip.',
737
739
  'update.cancelled': 'Cancelled; no update was performed.',
738
740
  'update.running': 'Running npm install -g {name}@{latest} ...',
739
741
  'update.failed': 'The update did not complete. Check npm permissions, or run npm install -g {name}@latest manually.',
740
742
  'update.updated': 'Updated to {latest}. Reopen the terminal and run dev-notify --version to confirm.',
743
+ 'update.entryMismatch': 'npm finished, but the current command entry was not updated to {latest}; different Node/npm installation directories may be in use. Hooks and the bridge were not maintained from the old entry. Reopen a terminal in the intended Node environment, verify dev-notify --version, then run dev-notify update to finish maintenance.',
741
744
  'maintenance.runFailed': 'Could not run the updated dev-notify. Reopen the terminal and run dev-notify --version to check.',
742
745
  'maintenance.reasonHint': 'Run dev-notify integration claude-code status or dev-notify reply status to check.',
743
746
  'maintenance.hooksCheckFailed': 'Could not check Claude Code hooks: {reason}',
@@ -749,6 +752,7 @@ secrets are never written in plaintext.`,
749
752
  'maintenance.hooksFailedReason': 'Claude Code hooks update failed: {reason}',
750
753
  'maintenance.bridgeCheckFailed': 'Could not check the reply bridge: {reason}',
751
754
  'maintenance.restartingBridge': 'Restarting the reply bridge ...',
755
+ 'maintenance.startingBridge': 'Restoring the authorized reply bridge that is not running ...',
752
756
  'maintenance.bridgeStillRunning': 'The reply bridge is still exiting; run dev-notify reply start later.',
753
757
  'maintenance.bridgeRestarted': 'Reply bridge restarted.',
754
758
  'maintenance.bridgeStartFailed': 'The reply bridge failed to start.',
@@ -973,7 +977,7 @@ secrets are never written in plaintext.`,
973
977
  'reply.telegramRequestFailed': 'The Telegram bridge request failed; the server body and secrets are not printed',
974
978
  'reply.bridgeAuthFirst': 'Remote replies are not authorized yet. Run reply setup first.',
975
979
  'reply.bridgeNotAuthorized': 'Remote replies are not authorized yet.',
976
- 'reply.bridgeLocked': 'A bridge for the same bot is already running for this user, or a lock file was left behind. Confirm the process has exited, then handle the corresponding lock in reply-bots.',
980
+ 'reply.bridgeLocked': 'The bridge lock for the same bot is occupied or its ownership cannot be verified safely. Only valid locks belonging to confirmed exited processes are recovered automatically; live processes, unknown permissions, and damaged locks are not preempted. Run reply status to check.',
977
981
  'reply.webhookConflict': 'This bot already has a webhook and cannot use getUpdates at the same time. Remove the webhook first; this tool will not delete it automatically.',
978
982
  'reply.pollFailedLog': 'Telegram polling failed; no secrets or server body are written to the log.',
979
983
  'reply.pollFailedFatal': 'Telegram polling failed 5 times in a row; the bridge has exited. Check the network, token, or API conflicts, then start it again.',
@@ -6,6 +6,7 @@ import { ConfigError } from './config.js';
6
6
  import { t } from './i18n.js';
7
7
  import { claudeCodeStatus } from './claude-code.js';
8
8
  import { bridgeStatus } from './reply-bridge.js';
9
+ import { accessReplies } from './reply-state.js';
9
10
  function runCli(args) {
10
11
  const cli = fileURLToPath(new URL('./cli.js', import.meta.url));
11
12
  const result = spawnSync(process.execPath, [cli, ...args], { stdio: 'inherit', timeout: 300000 });
@@ -62,22 +63,34 @@ export async function postUpdate(options) {
62
63
  options.err(t('maintenance.bridgeCheckFailed', { reason: reason(error) }));
63
64
  failed = true;
64
65
  }
65
- if (bridgeState?.running) {
66
- options.out(t('maintenance.restartingBridge'));
66
+ let resume = bridgeState?.running === true;
67
+ if (bridgeState && !resume) {
67
68
  try {
68
- // 0 表示已停止或本来未运行;1 表示停止请求已登记,下面等待进程释放锁。
69
- run(['reply', 'stop', '--config', options.config]);
70
- let stillRunning = true;
71
- for (let i = 0; i < 20; i++) {
72
- try {
73
- stillRunning = (await bridge()).running;
74
- }
75
- catch {
76
- break;
69
+ resume = await (options.authorized ?? (async () => !!await accessReplies(options.config, options.env)))();
70
+ }
71
+ catch (error) {
72
+ options.err(t('maintenance.bridgeCheckFailed', { reason: reason(error) }));
73
+ failed = true;
74
+ }
75
+ }
76
+ if (bridgeState && resume) {
77
+ options.out(t(bridgeState.running ? 'maintenance.restartingBridge' : 'maintenance.startingBridge'));
78
+ try {
79
+ let stillRunning = bridgeState.running;
80
+ if (stillRunning) {
81
+ // 心跳过期不等于进程退出;必须等待活进程真正退出,才启动新版本。
82
+ run(['reply', 'stop', '--config', options.config]);
83
+ for (let i = 0; i < 20; i++) {
84
+ try {
85
+ stillRunning = (await bridge()).running;
86
+ }
87
+ catch {
88
+ break;
89
+ }
90
+ if (!stillRunning)
91
+ break;
92
+ await wait(500);
77
93
  }
78
- if (!stillRunning)
79
- break;
80
- await wait(500);
81
94
  }
82
95
  if (stillRunning) {
83
96
  options.err(t('maintenance.bridgeStillRunning'));
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdir, open, unlink, realpath } from 'node:fs/promises';
2
+ import { mkdir, unlink, realpath } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import path from 'node:path';
5
5
  import { randomBytes } from 'node:crypto';
@@ -8,6 +8,7 @@ import { appPaths, ConfigError, readConfigLanguage } from './config.js';
8
8
  import { resolveLocale, setLocale, t } from './i18n.js';
9
9
  import { accessReplies, atomicJson, readJson, replyDirectory, stateTransaction } from './reply-state.js';
10
10
  import { telegramApi } from './reply-telegram.js';
11
+ import { acquireBotLock, processState } from './reply-lock.js';
11
12
  import { drainReplies, processUpdate } from './reply-engine.js';
12
13
  import { drainInteractionFeedback } from './reply-outcomes.js';
13
14
  const leasePath = (file) => path.join(replyDirectory(file), 'bridge.json');
@@ -15,18 +16,16 @@ const controlPath = (file) => path.join(replyDirectory(file), 'stop.json');
15
16
  function alive(pid) {
16
17
  if (!Number.isSafeInteger(pid) || Number(pid) <= 0)
17
18
  return false;
18
- try {
19
- process.kill(Number(pid), 0);
20
- return true;
21
- }
22
- catch {
23
- return false;
24
- }
19
+ // 权限不足不能当作已退出,否则升级维护可能提前启动第二个消费者。
20
+ return processState(Number(pid)) !== 'dead';
21
+ }
22
+ function statusOf(lease) {
23
+ const running = !!lease && alive(lease.pid);
24
+ const fresh = !!lease && Number.isFinite(lease.updated) && Date.now() - lease.updated < 30000 && lease.updated <= Date.now() + 10000;
25
+ return { running, ready: lease?.ready === true && running && fresh && !lease.error && processState(lease.pid) === 'alive', error: lease?.error };
25
26
  }
26
27
  export async function bridgeStatus(file) {
27
- const lease = await readJson(leasePath(file));
28
- const running = !!lease && alive(lease.pid) && Date.now() - lease.updated < 30000;
29
- return { running, ready: !!lease?.ready && running, error: lease?.error };
28
+ return statusOf(await readJson(leasePath(file)));
30
29
  }
31
30
  export async function stopBridge(file) {
32
31
  const lease = await readJson(leasePath(file));
@@ -42,29 +41,30 @@ export async function stopBridge(file) {
42
41
  }
43
42
  return false;
44
43
  }
45
- export async function startBridge(file, env = process.env) {
44
+ export async function startBridge(file, env = process.env, deps = {}) {
46
45
  const access = await accessReplies(file, env);
47
46
  if (!access)
48
47
  throw new ConfigError(t('reply.bridgeAuthFirst'));
49
- const existing = await bridgeStatus(file);
48
+ const previous = await readJson(leasePath(file));
49
+ const existing = statusOf(previous);
50
50
  if (existing.running)
51
51
  return existing.ready;
52
52
  const node = await realpath(process.execPath), cli = await realpath(fileURLToPath(new URL('./cli.js', import.meta.url)));
53
53
  // 无 shell、无命令行秘密、无系统开机自启;worker 自己持有排他锁。
54
- const child = spawn(node, [cli, 'reply', 'run', '--config', path.resolve(file)], {
54
+ const child = (deps.spawn ?? spawn)(node, [cli, 'reply', 'run', '--config', path.resolve(file)], {
55
55
  detached: true, windowsHide: true, shell: false, stdio: 'ignore', env: { ...process.env, ...env },
56
56
  });
57
- let failed = false;
57
+ let failed = false, exited = false;
58
58
  child.once('error', () => { failed = true; });
59
+ child.once('exit', () => { exited = true; });
59
60
  child.unref();
60
- for (let attempt = 0; attempt < 20; attempt++) {
61
- await delay(150);
62
- if (failed)
63
- return false;
64
- const status = await bridgeStatus(file);
65
- if (status.ready)
61
+ // 实际网络启动可能超过3秒;只等待有界15秒,不因旧进程留下的error提前放弃。
62
+ for (let attempt = 0; attempt < 100; attempt++) {
63
+ await (deps.sleep ?? delay)(150);
64
+ const lease = await readJson(leasePath(file));
65
+ if (statusOf(lease).ready)
66
66
  return true;
67
- if (status.error)
67
+ if (failed || exited || lease?.pid === child.pid && lease?.owner !== previous?.owner && lease?.error)
68
68
  return false;
69
69
  }
70
70
  return false;
@@ -74,19 +74,17 @@ export async function runBridge(file, options = {}) {
74
74
  let access = await accessReplies(file, env);
75
75
  if (!access)
76
76
  throw new ConfigError(t('reply.bridgeNotAuthorized'));
77
+ const heartbeatMs = options.heartbeatMs ?? 5000;
78
+ if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 10 || heartbeatMs > 10000)
79
+ throw new ConfigError(t('reply.bridgeFailed'));
77
80
  const directory = replyDirectory(file);
78
81
  await mkdir(directory, { recursive: true, mode: 0o700 });
79
82
  const botDirectory = path.join(appPaths(process.platform, env).data, 'reply-bots');
80
83
  await mkdir(botDirectory, { recursive: true, mode: 0o700 });
81
84
  const lockPath = path.join(botDirectory, access.policy.bot + '.lock');
82
- let lock;
83
- try {
84
- lock = await open(lockPath, 'wx', 0o600);
85
- }
86
- catch {
87
- throw new ConfigError(t('reply.bridgeLocked'));
88
- }
89
- const owner = randomBytes(16).toString('hex'), lease = { pid: process.pid, owner, ready: false, updated: Date.now() };
85
+ const owner = randomBytes(16).toString('hex');
86
+ const lock = await acquireBotLock(lockPath, owner);
87
+ const lease = { pid: process.pid, owner, ready: false, updated: Date.now() };
90
88
  let stopping = false;
91
89
  const stop = () => { stopping = true; };
92
90
  process.on('SIGTERM', stop);
@@ -95,9 +93,27 @@ export async function runBridge(file, options = {}) {
95
93
  const log = options.log ?? (() => { });
96
94
  let lastActivity = Date.now();
97
95
  const isStopped = async () => stopping || options.stopped?.() === true || (await readJson(controlPath(file)))?.owner === owner;
96
+ let writes = Promise.resolve();
97
+ const saveLease = () => {
98
+ lease.updated = Date.now();
99
+ const snapshot = { ...lease };
100
+ const next = writes.then(() => atomicJson(leasePath(file), snapshot));
101
+ writes = next.catch(() => { });
102
+ return next;
103
+ };
104
+ let heartbeat;
105
+ let beating = false, heartbeatFailed = false;
106
+ const stopHeartbeat = () => { stopping = true; if (heartbeat)
107
+ clearInterval(heartbeat); };
98
108
  try {
99
- await lock.writeFile(JSON.stringify({ pid: process.pid, owner }));
100
- await atomicJson(leasePath(file), lease);
109
+ await saveLease();
110
+ heartbeat = setInterval(() => {
111
+ if (beating || stopping)
112
+ return;
113
+ beating = true;
114
+ void saveLease().catch(() => { heartbeatFailed = true; stopping = true; }).finally(() => { beating = false; });
115
+ }, heartbeatMs);
116
+ heartbeat.unref();
101
117
  let api = (options.api ?? (value => telegramApi(value.config.telegram.token)))(access);
102
118
  const webhook = await api('getWebhookInfo', {});
103
119
  if (webhook?.url)
@@ -114,8 +130,7 @@ export async function runBridge(file, options = {}) {
114
130
  });
115
131
  }
116
132
  lease.ready = true;
117
- lease.updated = Date.now();
118
- await atomicJson(leasePath(file), lease);
133
+ await saveLease();
119
134
  let iteration = 0, failures = 0;
120
135
  while (!await isStopped() && iteration++ < (options.maxIterations ?? Infinity)) {
121
136
  // 桥接是长驻进程,语言切换后要跟着配置文件走;否则后续消息会和 hook 进程的语言不一致。
@@ -145,8 +160,7 @@ export async function runBridge(file, options = {}) {
145
160
  log(t('reply.pollFailedLog'));
146
161
  if (++failures >= 5)
147
162
  throw new ConfigError(t('reply.pollFailedFatal'));
148
- lease.updated = Date.now();
149
- await atomicJson(leasePath(file), lease);
163
+ await saveLease();
150
164
  await (options.sleep ?? delay)(3000);
151
165
  continue;
152
166
  }
@@ -168,25 +182,32 @@ export async function runBridge(file, options = {}) {
168
182
  }
169
183
  if (updates.length)
170
184
  lastActivity = Date.now();
171
- lease.updated = Date.now();
172
- await atomicJson(leasePath(file), lease);
185
+ await saveLease();
173
186
  if (Date.now() - lastActivity > 24 * 60 * 60 * 1000)
174
187
  break;
175
188
  }
189
+ if (heartbeatFailed)
190
+ throw new ConfigError(t('reply.bridgeFailed'));
176
191
  }
177
192
  catch (error) {
193
+ stopHeartbeat();
178
194
  lease.ready = false;
179
195
  lease.error = error instanceof ConfigError ? error.message : t('reply.bridgeFailed');
180
- await atomicJson(leasePath(file), lease).catch(() => { });
196
+ await saveLease().catch(() => { });
181
197
  throw error;
182
198
  }
183
199
  finally {
200
+ stopHeartbeat();
201
+ await writes;
184
202
  process.off('SIGTERM', stop);
185
203
  process.off('SIGINT', stop);
186
- const current = await readJson(leasePath(file)).catch(() => null);
187
- if (current?.owner === owner && !lease.error)
188
- await unlink(leasePath(file)).catch(() => { });
189
- await lock.close();
190
- await unlink(lockPath);
204
+ try {
205
+ const current = await readJson(leasePath(file)).catch(() => null);
206
+ if (current?.owner === owner && !lease.error)
207
+ await unlink(leasePath(file)).catch(() => { });
208
+ }
209
+ finally {
210
+ await lock.release();
211
+ }
191
212
  }
192
213
  }
@@ -0,0 +1,116 @@
1
+ import { open, readFile, unlink, lstat } from 'node:fs/promises';
2
+ import { ConfigError } from './config.js';
3
+ import { t } from './i18n.js';
4
+ export function processState(pid) {
5
+ try {
6
+ process.kill(pid, 0);
7
+ return 'alive';
8
+ }
9
+ catch (error) {
10
+ return error.code === 'ESRCH' ? 'dead' : 'unknown';
11
+ }
12
+ }
13
+ // 只有 ESRCH 可证明进程已退出;权限不足、PID复用、坏文件都不允许抢占。
14
+ export async function acquireBotLock(file, owner, probe = processState) {
15
+ if (!/^[a-f0-9]{32}$/.test(owner))
16
+ throw new ConfigError(t('reply.bridgeLocked'));
17
+ const record = { pid: process.pid, owner };
18
+ const readRecord = async () => {
19
+ try {
20
+ const info = await lstat(file);
21
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 4096)
22
+ throw new Error();
23
+ const value = JSON.parse(await readFile(file, 'utf8'));
24
+ if (!Number.isSafeInteger(value?.pid) || value.pid <= 0 || typeof value.owner !== 'string' ||
25
+ !/^[a-f0-9]{32}$/.test(value.owner))
26
+ throw new Error();
27
+ return value;
28
+ }
29
+ catch (error) {
30
+ if (error.code === 'ENOENT')
31
+ return null;
32
+ throw new ConfigError(t('reply.bridgeLocked'));
33
+ }
34
+ };
35
+ const dead = (value) => {
36
+ if (!value)
37
+ return true;
38
+ try {
39
+ return probe(value.pid) === 'dead';
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ };
45
+ const create = async () => {
46
+ const handle = await open(file, 'wx', 0o600);
47
+ try {
48
+ await handle.writeFile(JSON.stringify(record));
49
+ await handle.sync();
50
+ }
51
+ catch (error) {
52
+ try {
53
+ await handle.close();
54
+ }
55
+ finally {
56
+ await unlink(file);
57
+ }
58
+ throw error;
59
+ }
60
+ let released = false;
61
+ return { async release() {
62
+ if (released)
63
+ return;
64
+ released = true;
65
+ try {
66
+ const current = await readRecord();
67
+ if (current?.pid === record.pid && current.owner === record.owner)
68
+ await unlink(file);
69
+ }
70
+ finally {
71
+ await handle.close();
72
+ }
73
+ } };
74
+ };
75
+ try {
76
+ return await create();
77
+ }
78
+ catch (error) {
79
+ if (error.code !== 'EEXIST')
80
+ throw new ConfigError(t('reply.bridgeLocked'));
81
+ }
82
+ if (!dead(await readRecord()))
83
+ throw new ConfigError(t('reply.bridgeLocked'));
84
+ // 串行化“检查死亡→删除→重新创建”,两个恢复者不能误删对方的新锁。
85
+ // 恢复门闩自身若损坏或残留则保守拒绝,不递归猜测并抢锁。
86
+ const recoveryFile = file + '.recover';
87
+ let gate;
88
+ try {
89
+ gate = await open(recoveryFile, 'wx', 0o600);
90
+ }
91
+ catch {
92
+ throw new ConfigError(t('reply.bridgeLocked'));
93
+ }
94
+ try {
95
+ await gate.writeFile(JSON.stringify(record));
96
+ const current = await readRecord();
97
+ if (!dead(current))
98
+ throw new ConfigError(t('reply.bridgeLocked'));
99
+ if (current)
100
+ await unlink(file);
101
+ try {
102
+ return await create();
103
+ }
104
+ catch {
105
+ throw new ConfigError(t('reply.bridgeLocked'));
106
+ }
107
+ }
108
+ finally {
109
+ try {
110
+ await gate.close();
111
+ }
112
+ finally {
113
+ await unlink(recoveryFile);
114
+ }
115
+ }
116
+ }
package/dist/update.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { ConfigError } from './config.js';
3
4
  import { t } from './i18n.js';
4
5
  import { terminalPrompts } from './prompts.js';
@@ -78,6 +79,15 @@ async function runMaintenance(options, updated) {
78
79
  options.out(t(updated ? 'update.postDoneUpdated' : 'update.postDone'));
79
80
  return 0;
80
81
  }
82
+ async function currentEntryInstalled(expected) {
83
+ try {
84
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
85
+ return pkg.name === NAME && pkg.version === expected;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
81
91
  export async function updatePackage(current, options) {
82
92
  if (!VERSION.test(current))
83
93
  throw new ConfigError(t('update.currentFormat'));
@@ -107,6 +117,8 @@ export async function updatePackage(current, options) {
107
117
  const result = (options.runner ?? npmRunner)(['install', '-g', `${NAME}@${latest}`], false);
108
118
  if (result.status !== 0)
109
119
  throw new ConfigError(t('update.failed', { name: NAME }));
120
+ if (!await (options.installed ?? currentEntryInstalled)(latest))
121
+ throw new ConfigError(t('update.entryMismatch', { latest }));
110
122
  options.out(t('update.updated', { latest }));
111
123
  return await runMaintenance(options, true);
112
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quecue/dev-notify",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "跨平台开发任务通知工具,支持 Telegram 与飞书",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@napi-rs/keyring": "2.1.2",
43
- "@quecue/notify-core": "0.1.0"
43
+ "@quecue/notify-core": "^0.1.0"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public",