@quecue/dev-notify 0.2.4 → 0.2.6
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 +17 -0
- package/CHANGELOG.md +17 -0
- package/README.en.md +6 -2
- package/README.md +6 -2
- package/SECURITY.en.md +3 -2
- package/SECURITY.md +3 -2
- package/dist/claude-code.js +58 -11
- package/dist/claude-hook.js +24 -5
- package/dist/cli.js +4 -2
- package/dist/i18n.js +36 -6
- package/dist/notification-diagnostics.js +49 -0
- package/dist/reply-engine.js +18 -5
- package/dist/reply-hooks.js +77 -11
- package/dist/reply-interactions.js +4 -4
- package/dist/reply-lock.js +28 -3
- package/dist/reply-outcomes.js +3 -0
- package/dist/reply-platform.js +58 -17
- package/dist/reply-state.js +2 -1
- package/dist/reply-telegram.js +2 -3
- package/package.json +1 -1
package/CHANGELOG.en.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](CHANGELOG.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.6 — 2026-11-07
|
|
6
|
+
|
|
7
|
+
- Distinguish unavailable session verification from confirmed exit: denied access, query failures, and damaged or incomplete registries pause remote input while retaining unsubmitted queues with their original expiry. New local input still cancels old queues; submitted or unknown-result input is never requeued.
|
|
8
|
+
- Hook readiness checks verify accessible Node/CLI files and POSIX Node execute permission. Status reports missing, denied, or invalid paths without claiming to prove actual hook execution or Windows execution-policy acceptance.
|
|
9
|
+
- Distinguish existing settings locks, denied write access, and other filesystem failures instead of describing permission errors as stale locks.
|
|
10
|
+
- When Windows denies a zero-signal process probe, use the fixed system `tasklist.exe` to confirm the exact PID read-only, avoiding false bridge unreadiness across elevation levels. No elevation or lock preemption; failed queries remain unknown, and only `ESRCH` establishes exit.
|
|
11
|
+
- Report reply-button skips separately for unverifiable sessions, missing state, working sessions, unobserved input, and changed process identity, without weakening input safety checks.
|
|
12
|
+
- Keep the last completed-notification button reason and channel API-acknowledgment summary locally per session so successful hook exit does not discard diagnostics. No body, token, or underlying exception is saved; diagnostic write failures do not trigger resending.
|
|
13
|
+
- Windows x64 / Node 24 regression totals 276 tests: 275 passed, 0 failed, and 1 opt-in native credential test skipped. The user confirmed reply-button recovery when normal and elevated sessions share a bridge; this does not establish acceptance of every terminal-input permission combination, macOS, or the minimum Node version.
|
|
14
|
+
|
|
15
|
+
## 0.2.5 — 2026-11-07
|
|
16
|
+
|
|
17
|
+
- Distinguish Telegram `getMe` failures during reply-button preparation, bridge unreadiness, and notification-delivery failures. Retain bounded API error codes and report safe delivery categories and API-acknowledged part counts, without exposing underlying exceptions or server bodies. Do not infer exact send error codes discarded by the communication library.
|
|
18
|
+
- Add isolated regressions for Telegram failure with successful buttonless Feishu delivery, bridge unreadiness with ordinary delivery on both channels, and recovery with buttons on both channels. No new retries, replays, or authorization.
|
|
19
|
+
- Cover explicit channel rejection, unknown exceptions, and successful API delivery followed by local message-mapping failure without incorrectly claiming non-delivery or resending. Windows x64 / Node 24 totals 247 tests: 246 passed, 0 failed, and 1 opt-in native credential test skipped; not macOS real-machine acceptance.
|
|
20
|
+
- This release improves diagnostics; it does not establish or fix the specific cause of historical missing notifications on any particular device.
|
|
21
|
+
|
|
5
22
|
## 0.2.4 — 2026-11-07
|
|
6
23
|
|
|
7
24
|
- 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.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
简体中文 | [English](CHANGELOG.en.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.6 — 2026-11-07
|
|
6
|
+
|
|
7
|
+
- 区分会话核验暂不可用与已确认退出:权限拒绝、查询失败、损坏或不完整注册表会暂停远程输入,保留尚未提交队列的原有效期;新本地输入仍取消旧队列,已提交或结果未知的输入绝不重新排队。
|
|
8
|
+
- hooks 就绪检查核验绑定的 Node/CLI 是否为可访问文件,POSIX 还检查 Node 执行权限;状态查询明确报告缺失、权限拒绝和无效路径,但不声称代替真实 hook 执行或 Windows 执行策略验收。
|
|
9
|
+
- 设置写入锁明确区分已有锁、写权限拒绝及其他文件系统错误,不再将权限错误提示为残留锁。
|
|
10
|
+
- Windows 的零信号进程探测被权限拒绝时,使用固定系统 `tasklist.exe` 只读核验精确 PID,避免普通会话将已提权桥接误判为未就绪;不提权、不抢锁,查询失败仍保持未知,只有 `ESRCH` 认定退出。
|
|
11
|
+
- 不再静默跳过回复按钮:分别记录会话无法核验、状态缺失、会话工作中、未观察到输入和进程身份变化,不放宽输入安全检查。
|
|
12
|
+
- 完成通知在本地按会话独立保存最后一次按钮原因和渠道 API 确认摘要,避免 hook 成功退出后丢失排障信息;不保存正文、Token 或原始异常。摘要保存失败不会重发通知。
|
|
13
|
+
- Windows x64/Node 24 回归共 276 项:275 通过、0 失败、1 项显式原生凭据测试跳过。普通权限与已提权会话共享桥接的按钮恢复已由用户实机确认;不代表实际终端输入的所有权限组合、macOS 或最低 Node 版本已完成验收。
|
|
14
|
+
|
|
15
|
+
## 0.2.5 — 2026-11-07
|
|
16
|
+
|
|
17
|
+
- 区分回复按钮的 Telegram `getMe` 身份查询失败、桥接未就绪与通知投递失败;保留有界 API 错误码,投递失败显示安全分类及 API 确认分段数,不输出底层异常或服务端正文。通讯库未保留的具体发送错误码不会猜测。
|
|
18
|
+
- 补充 Telegram 失败而飞书成功且无按钮、桥接未就绪仍发送双渠道普通通知、恢复后双渠道带按钮的隔离回归;不新增重试、补发或授权。
|
|
19
|
+
- 补充渠道明确拒绝、未知异常和 API 成功但本地消息映射失败的回归,避免误报未送达或重复发送。Windows x64/Node 24 共 247 项测试:246 通过、0 失败、1 项显式原生凭据测试跳过;不代表 macOS 实机验收。
|
|
20
|
+
- 本版改进故障诊断,不代表已确认或修复某台设备历史漏发的具体根因。
|
|
21
|
+
|
|
5
22
|
## 0.2.4 — 2026-11-07
|
|
6
23
|
|
|
7
24
|
- 修复回复桥接异常退出后残留 Bot 锁导致通知不再出现“回复 Claude”按钮:仅回收格式有效且已确认进程退出的锁;活进程、PID 复用、权限未知或损坏锁均不抢占,并串行化并发恢复。
|
package/README.en.md
CHANGED
|
@@ -176,6 +176,10 @@ Stop sends a request through a control file with a random instance identifier an
|
|
|
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
|
+
Version 0.2.6 pauses remote input when session identity cannot temporarily be verified, without treating query failure as process exit. Unsubmitted queued replies retain their original 5-minute expiry and resume attempts only after a later lifecycle event verifies the same identity. New local input still cancels old queues; submitted or unknown-result input is never requeued. Hook readiness also checks existing readable Node/CLI files and POSIX Node execute permission. This local snapshot cannot prove Windows execution policy, project/managed policy, or actual event execution. Settings-lock permission failures are reported separately from existing locks.
|
|
180
|
+
|
|
181
|
+
Version 0.2.6 saves the last completed-notification diagnostic per session at `<notification config>.reply-data/notification-diagnostics/<session ID>.json`, containing the timestamp, button reason code, and channel API-acknowledgment summaries, but no body, recipient, token, or underlying exception. It is not a historical message queue and is not used for replay. Sessions do not overwrite each other; each session retains the last written snapshot. Files are not automatically expired or deleted; clear them manually with the dedicated reply state. Windows still relies on directory ACLs.
|
|
182
|
+
|
|
179
183
|
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
184
|
|
|
181
185
|
## System credentials and compatibility
|
|
@@ -314,9 +318,9 @@ For other CLI examples, replace `dev-notify` with `node dist/cli.js`; no global
|
|
|
314
318
|
|
|
315
319
|
`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
320
|
|
|
317
|
-
For the 0.2.
|
|
321
|
+
For the 0.2.6 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
322
|
|
|
319
|
-
See [CHANGELOG.en.md](CHANGELOG.en.md) for verification results of published versions. The regression record for the 0.2.
|
|
323
|
+
See [CHANGELOG.en.md](CHANGELOG.en.md) for verification results of published versions. The regression record for the 0.2.6 source on Windows x64 with Node 24 is 276 tests: 275 passed, 0 failed, and 1 opt-in native credential test skipped. This record does not establish real-machine acceptance on every platform.
|
|
320
324
|
|
|
321
325
|
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
326
|
|
package/README.md
CHANGED
|
@@ -176,6 +176,10 @@ dev-notify reply run # 前台诊断启动/网络问题,不与 start 实
|
|
|
176
176
|
|
|
177
177
|
授权在 `<通知配置>.reply.json`,消息/会话/offset/队列/交互状态在 `<通知配置>.reply-data/`;Bot 锁在 `paths` 所示 data 目录的 `reply-bots/`。排队回复原文、问题、工具输入和待回传答案会短暂写入本地状态;提交前会移除普通回复原文;结构化交互保留到完成或确认超时,随后清除问题、参数与答案正文,短期保留结果收据和消息 ID,用于反馈及拒绝旧按钮。有效期检查只是逻辑判断,停机后文件不会自动擦除。POSIX 使用 0700/0600,Windows 依赖账号目录 ACL;同一用户的其他进程仍然可以读取。请不要提交、共享或打包这些文件(已加入忽略规则及 npm 白名单)。
|
|
178
178
|
|
|
179
|
+
0.2.6 在会话身份暂时无法核验时暂停远程输入,不把查询失败当作进程退出;尚未提交的排队回复保留原有 5 分钟有效期,仅在后续生命周期事件重新核验同一身份后恢复尝试。新的本地输入仍取消旧队列;已提交或结果未知的输入不会重新排队。hooks 就绪检查还核验 Node/CLI 文件存在、可读及 POSIX Node 执行权限;这只是本地快照,不保证 Windows 执行策略、项目/托管策略或真实事件执行成功。设置锁的权限错误与已有锁会分别报告。
|
|
180
|
+
|
|
181
|
+
0.2.6 会在 `<通知配置>.reply-data/notification-diagnostics/<会话 ID>.json` 保存每个会话最后一份完成通知诊断,包含时间、按钮原因代码和渠道 API 确认摘要,不包含正文、收件人、Token 或原始异常。该记录不是历史消息队列,不用于补发;不同会话互不覆盖,同一会话保留最后写入的一份。文件不会自动过期删除,随专属回复状态一起人工清理;Windows 仍依赖目录 ACL。
|
|
182
|
+
|
|
179
183
|
强杀可能留下锁。0.2.4可在启动时自动回收有效 Bot 锁,但必须先确认原 PID 已不存在;权限未知、PID 复用为活进程、坏锁及残留 `.recover` 恢复门闩一律保守拒绝。状态写入锁等其他锁不自动回收,需先确认对应进程已退出再人工处理。不能在进程运行时删除锁。`reply disable` 不会擅自删除历史状态;需要清除时,请先禁用或停止,确认后手工删除上述专属文件。清理凭据、移除 hooks 和删除回复状态是三项独立操作。
|
|
180
184
|
|
|
181
185
|
## 系统凭据与兼容性
|
|
@@ -314,9 +318,9 @@ npm pack --dry-run
|
|
|
314
318
|
|
|
315
319
|
`node-src/` 是 TypeScript 源码,`dist/` 是编译输出,`test-js/` 是测试。默认测试只用假凭据和模拟后端/网络;本机原生假条目往返测试需显式设置 `DEV_NOTIFY_NATIVE_TEST=1`,且只在 Windows 启用,使用随机 ID,结束后清理。
|
|
316
320
|
|
|
317
|
-
0.2.
|
|
321
|
+
0.2.6 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json;不包含通讯库副本、测试、真实配置、密文或依赖 tarball。运行时依赖由 npm 单独解析。已发布版本的包内容以对应版本为准。没有 postinstall、自动启动或自写下载脚本;预编译原生依赖由 npm 的平台 optional 包解析,**请勿使用 `--omit=optional`**。macOS x64/arm64 和 Windows x64/arm64 预编译包由依赖提供,提供包并不等同于本项目已完成实机验收。
|
|
318
322
|
|
|
319
|
-
各已发布版本的验证结果见 [CHANGELOG.md](CHANGELOG.md)。0.2.
|
|
323
|
+
各已发布版本的验证结果见 [CHANGELOG.md](CHANGELOG.md)。0.2.6 源码在 Windows x64、Node 24 下的回归记录为 276 项:275 通过、0 失败、1 项需显式启用的原生凭据测试跳过;该记录不代表所有平台的实机验收。
|
|
320
324
|
|
|
321
325
|
自动化覆盖临时 hooks、回复/授权/过期/防重放、单选/多选/自定义、单次权限 JSON、双端竞争、反馈异常及按接收人的重复提醒去重。实际 CLI 子进程完成后台 start → PreToolUse 登记 → PermissionRequest → 假 Bot 按钮 → JSON 答案 → 模拟 PostToolUse → 最终反馈 → 通知去重/后续工具提醒 → stop;该闭环使用模拟网络和进程元数据,不能替代实机输入验证。Windows 原生输入帮助类的自动化测试只做编译/布局验证,不会执行真实 Send。
|
|
322
326
|
|
package/SECURITY.en.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 dependency range and security boundaries of 0.2.
|
|
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.6, 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
7
|
## Communication dependency in current source
|
|
8
8
|
|
|
@@ -54,6 +54,7 @@ Reusing a dependency does not automatically share credentials or authorization,
|
|
|
54
54
|
|
|
55
55
|
- `reply setup` separately selects an explicit private-chat chat_id, which must be in the current notification list; group chats, non-private chats, senders whose chat_id does not match, bot messages, and unauthorized notification recipients do not gain input rights. Feishu provides only a Telegram deep link; knowing the link does not constitute authorization. `/chatid` only returns the sender's own private-chat ID and does not grant permission.
|
|
56
56
|
- Authorization is bound to the bot fingerprint, random generation, current valid user, and optional permission capability. Switching the token, revoking users, or re-authorizing does not inherit old routing; authorization is re-read before operations and before submission. Revocation cannot withdraw native input that has already started, nor eliminate every race between checking and calling.
|
|
57
|
+
- Version 0.2.6 also checks accessible Node/CLI hook files and POSIX Node execute permission, without executing those files as a probe. This cannot guarantee Windows execution-policy acceptance or actual loading by a running session. Unavailable session verification marks the session unverified and pauses buttons, input, and permission return without deleting unsubmitted queues; original expiry, new-local-input cancellation, explicit end, and anti-replay rules remain. Already registered submissions are not requeued after a subsequent query failure.
|
|
57
58
|
- Every authorization read checks the complete hooks bound to the current notification configuration; when lifecycle events are missing, Stop is asynchronous, or user-level hooks are disabled, replies are refused. Normal input also requires actually observing the session's UserPromptSubmit. User-level file checks cannot prove project/managed policy or that a running session has loaded the hooks; confirm in `/hooks`. No promise is made to eliminate all runtime races.
|
|
58
59
|
- After each segment is sent successfully, the message ID is saved to the session mapping; mapping failure reports partial failure and does not resend already-sent segments. The session must match PID/birth identifier/TTY; submission is refused on session switch, closure, or a non-unique target. Supported scope is only the Windows native claude console and macOS foreground claude with Ghostty/tmux; no routing guesses are made for WSL/SSH/VS Code terminals.
|
|
59
60
|
- Normal replies are only queued while Claude is working and are attempted after Stop; they are canceled on 5-minute expiry, new local input, or a target change. A single reply is at most 4000 Unicode characters; control characters and newlines are stripped and the text is entered once; it is not shell-executed, not truncated, and not automatically split into multiple tasks. Input is refused when the console is shared and multiple claude processes exist; a local user editing or switching terminals at the same time may still race, and native input and UI activity are not atomically mutually exclusive.
|
|
@@ -72,7 +73,7 @@ Reusing a dependency does not automatically share credentials or authorization,
|
|
|
72
73
|
## npm and testing
|
|
73
74
|
|
|
74
75
|
- 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 0.2.
|
|
76
|
+
- For the 0.2.6 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
77
|
- 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
78
|
- 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
79
|
- 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,7 +2,7 @@
|
|
|
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.
|
|
5
|
+
npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https://github.com/QueCue/dev-notify)。发布版本以 npm registry 为准,本文描述 0.2.6 的依赖范围与安全边界,不能仅依据源码版本认定安装包已发布。本项目尚未完成安全审计或双平台实机验收。
|
|
6
6
|
|
|
7
7
|
## 当前源码的通讯层依赖
|
|
8
8
|
|
|
@@ -54,6 +54,7 @@ npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https:/
|
|
|
54
54
|
|
|
55
55
|
- `reply setup` 单独选择明确的私聊 chat_id,且必须在当前通知名单中;群聊、非私聊、发送者与 chat_id 不一致、Bot 消息和未授权的通知接收人都不会获得输入权。飞书只提供 Telegram deep link,知道链接并不构成授权。`/chatid` 只返回发件人本人的私聊 ID,不授予权限。
|
|
56
56
|
- 授权绑定 Bot 指纹、随机代次、当前有效用户及可选的权限能力。切换 Token、撤销用户或重新授权时,不会继承旧路由;操作前及提交前都会重读授权。撤销无法撤回已经开始的原生输入,也无法消除检查与调用之间的全部竞态。
|
|
57
|
+
- 0.2.6 源码还检查 hooks 的 Node/CLI 文件可访问性及 POSIX Node 执行权限,但不执行这些文件来探测,不能保证 Windows 执行策略或运行会话实际加载。会话查询暂不可用时标记为未核验并暂停按钮、输入及权限回传,不以此删除未提交队列;原有效期、新本地输入取消、明确结束和防重放规则仍生效。已登记提交的输入即使随后查询失败也不会重新排队。
|
|
57
58
|
- 每次读取授权都会检查绑定当前通知配置的完整 hooks;缺少生命周期、Stop 异步或用户级禁用时,会拒绝回复。普通输入还要求实际观察到该会话的 UserPromptSubmit。用户级文件检查无法证明项目/托管策略或运行会话已经加载,请在 `/hooks` 确认;不会承诺消除所有运行时竞态。
|
|
58
59
|
- 消息每段发送成功后,会把消息 ID 保存到会话映射;映射失败会报告部分失败,不会重发已发送的段落。会话必须匹配 PID/出生标识/TTY;会话切换、关闭、目标不唯一时会拒绝。支持范围仅 Windows 原生 claude 控制台、macOS 前台 claude 的 Ghostty/tmux;不会为 WSL/SSH/VS Code 等终端猜测路由。
|
|
59
60
|
- 普通回复在工作中只排队,Stop 后再尝试;5 分钟过期、本地新输入或目标变化会取消。单条最多 4000 Unicode 字符,清理控制字符/换行后一次输入;不会按 shell 执行,不会截断,也不会自动拆成多个任务。控制台共享且有多个 claude 进程时会拒绝;本地用户同时编辑或切换终端仍可能竞争,原生输入与 UI 活动并非原子互斥。
|
|
@@ -72,7 +73,7 @@ npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https:/
|
|
|
72
73
|
## npm 与测试
|
|
73
74
|
|
|
74
75
|
- 运行时有原生凭据依赖,版本和平台 optional 包锁定于 `package-lock.json`。请保留 optional 依赖。项目没有 postinstall、自写二进制下载或后台启动脚本。
|
|
75
|
-
- 0.2.
|
|
76
|
+
- 0.2.6 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json。通讯库副本、测试、真实配置、日志、密文及依赖 tarball 不进入发布包;已发布版本以实际包内容为准。
|
|
76
77
|
- 第三方原生包带有其许可证与 Rust 依赖声明。发布及依赖升级时应核对供应链和许可;固定版本并不等同于完成审计。
|
|
77
78
|
- 默认测试使用假凭据、临时目录和模拟系统后端、网络。`DEV_NOTIFY_NATIVE_TEST=1` 会显式开启 Windows 原生测试,只创建本次随机 ID 的假条目,结束后清理,不读取用户现有条目。
|
|
78
79
|
- npm 卸载不会自动删除用户配置或凭据。需要清理时,请先运行交互清理命令;卸载生命周期脚本不会偷偷操作用户的系统存储。
|
package/dist/claude-code.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFile, mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
1
|
+
import { readFile, mkdir, open, rename, unlink, stat, access } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -62,7 +63,8 @@ function owned(hook) {
|
|
|
62
63
|
&& hook.args[4] === '--config' && typeof hook.args[5] === 'string';
|
|
63
64
|
}
|
|
64
65
|
function describe(file, data) {
|
|
65
|
-
const result = { file, stop: 0, notification: 0, lifecycle: 0, interaction: 0, others: 0, disabled: data.disableAllHooks === true,
|
|
66
|
+
const result = { file, stop: 0, notification: 0, lifecycle: 0, interaction: 0, others: 0, disabled: data.disableAllHooks === true,
|
|
67
|
+
configs: [], runtimeIssues: [] };
|
|
66
68
|
for (const event of EVENTS)
|
|
67
69
|
for (const group of data.hooks?.[event] ?? [])
|
|
68
70
|
for (const hook of group.hooks) {
|
|
@@ -83,9 +85,48 @@ function describe(file, data) {
|
|
|
83
85
|
}
|
|
84
86
|
return result;
|
|
85
87
|
}
|
|
88
|
+
async function runtimeIssues(data, options, config) {
|
|
89
|
+
const issues = [], checked = new Map();
|
|
90
|
+
const platform = options.platform ?? process.platform, paths = platform === 'win32' ? path.win32 : path.posix;
|
|
91
|
+
const normalize = (file) => platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
|
|
92
|
+
for (const event of EVENTS)
|
|
93
|
+
for (const group of data.hooks?.[event] ?? [])
|
|
94
|
+
for (const hook of group.hooks) {
|
|
95
|
+
if (!owned(hook) || config !== undefined && normalize(hook.args[5]) !== normalize(config))
|
|
96
|
+
continue;
|
|
97
|
+
for (const [kind, file] of [['node', hook.command], ['cli', hook.args[0]]]) {
|
|
98
|
+
const key = JSON.stringify([kind, file]);
|
|
99
|
+
if (!checked.has(key)) {
|
|
100
|
+
let reason = null;
|
|
101
|
+
if (typeof file !== 'string' || !paths.isAbsolute(file))
|
|
102
|
+
reason = 'invalid';
|
|
103
|
+
else
|
|
104
|
+
try {
|
|
105
|
+
if (!(await stat(file)).isFile())
|
|
106
|
+
reason = 'invalid';
|
|
107
|
+
else {
|
|
108
|
+
await (options.runtimeAccess ?? access)(file, constants.R_OK | (kind === 'node' && platform !== 'win32' ? constants.X_OK : 0));
|
|
109
|
+
// Windows access() 不足以验证 ACL;仅打开后关闭,不读取文件正文或执行程序。
|
|
110
|
+
const handle = await open(file, 'r');
|
|
111
|
+
await handle.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const code = error.code;
|
|
116
|
+
reason = code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unavailable';
|
|
117
|
+
}
|
|
118
|
+
checked.set(key, reason);
|
|
119
|
+
}
|
|
120
|
+
const reason = checked.get(key);
|
|
121
|
+
if (reason)
|
|
122
|
+
issues.push({ event, kind, reason });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return issues;
|
|
126
|
+
}
|
|
86
127
|
export async function claudeCodeStatus(options = {}) {
|
|
87
|
-
const file = settingsFile(options);
|
|
88
|
-
return describe(file,
|
|
128
|
+
const file = settingsFile(options), data = parseSettings(await snapshot(file));
|
|
129
|
+
return { ...describe(file, data), runtimeIssues: await runtimeIssues(data, options) };
|
|
89
130
|
}
|
|
90
131
|
export async function replyHooksReady(config, options = {}) {
|
|
91
132
|
const data = parseSettings(await snapshot(settingsFile(options)));
|
|
@@ -102,7 +143,7 @@ export async function replyHooksReady(config, options = {}) {
|
|
|
102
143
|
&& group.hooks.includes(matches[0])))
|
|
103
144
|
return false;
|
|
104
145
|
}
|
|
105
|
-
return
|
|
146
|
+
return (await runtimeIssues(data, options, config)).length === 0;
|
|
106
147
|
}
|
|
107
148
|
function merged(data, config, runtime) {
|
|
108
149
|
const target = structuredClone(data);
|
|
@@ -145,15 +186,21 @@ async function remove(file) {
|
|
|
145
186
|
throw error;
|
|
146
187
|
}
|
|
147
188
|
}
|
|
148
|
-
async function commit(file, before, target, prompts, replace = rename) {
|
|
149
|
-
|
|
189
|
+
async function commit(file, before, target, prompts, replace = rename, lockOpen = open) {
|
|
190
|
+
try {
|
|
191
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new ConfigError(t(['EACCES', 'EPERM'].includes(error.code ?? '') ? 'claude.settingsWriteDenied' : 'claude.lockFailed'));
|
|
195
|
+
}
|
|
150
196
|
const lockFile = file + '.dev-notify.lock';
|
|
151
197
|
let lock;
|
|
152
198
|
try {
|
|
153
|
-
lock = await
|
|
199
|
+
lock = await lockOpen(lockFile, 'wx', 0o600);
|
|
154
200
|
}
|
|
155
|
-
catch {
|
|
156
|
-
|
|
201
|
+
catch (error) {
|
|
202
|
+
const code = error.code;
|
|
203
|
+
throw new ConfigError(t(code === 'EEXIST' ? 'claude.lockUnavailable' : code === 'EACCES' || code === 'EPERM' ? 'claude.settingsWriteDenied' : 'claude.lockFailed'));
|
|
157
204
|
}
|
|
158
205
|
const temporary = path.join(path.dirname(file), `.dev-notify-${randomBytes(16).toString('hex')}.tmp`);
|
|
159
206
|
let created = false;
|
|
@@ -231,7 +278,7 @@ export async function configureClaudeCode(config, action, prompts, options = {})
|
|
|
231
278
|
prompts.out(t('claude.cancelled'));
|
|
232
279
|
return false;
|
|
233
280
|
}
|
|
234
|
-
await commit(file, before, target, prompts, options.replace);
|
|
281
|
+
await commit(file, before, target, prompts, options.replace, options.lockOpen);
|
|
235
282
|
prompts.out(action === 'install' ? t('claude.installed') : t('claude.removed'));
|
|
236
283
|
return true;
|
|
237
284
|
}
|
package/dist/claude-hook.js
CHANGED
|
@@ -6,6 +6,7 @@ import { t } from './i18n.js';
|
|
|
6
6
|
import { shouldNotify } from './notifications.js';
|
|
7
7
|
import { transcriptEndTime } from './hook-time.js';
|
|
8
8
|
import { findSessionName } from './reply-platform.js';
|
|
9
|
+
import { ReplyPreparationError, safeDeliveryReason, safeFailureReason, saveNotificationDiagnostic } from './notification-diagnostics.js';
|
|
9
10
|
import { lifecycleHook, interactionHook, attachReply, mapReplies, notificationCoverage } from './reply-hooks.js';
|
|
10
11
|
export const MAX_HOOK_BYTES = 8 * 1024 * 1024;
|
|
11
12
|
export async function readHookInput(input = process.stdin, limit = MAX_HOOK_BYTES) {
|
|
@@ -97,7 +98,9 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
97
98
|
const message = claudeHookMessage(bytes);
|
|
98
99
|
const data = JSON.parse(decodeUtf8(bytes));
|
|
99
100
|
const now = context.now?.() ?? new Date();
|
|
100
|
-
const
|
|
101
|
+
const diagnostic = { version: 1, updated: now.getTime(), reply: 'not-attempted', channels: {} };
|
|
102
|
+
const options = { send: context.send, ...context.reply, env: context.env, err: context.err, response: context.response,
|
|
103
|
+
replyDiagnostic: (code) => { diagnostic.reply = code; context.reply?.replyDiagnostic?.(code); } };
|
|
101
104
|
try {
|
|
102
105
|
await lifecycleHook(data, configFile, options);
|
|
103
106
|
await interactionHook(data, configFile, options);
|
|
@@ -139,8 +142,10 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
139
142
|
try {
|
|
140
143
|
binding = await attachReply(notification, data, configFile, options);
|
|
141
144
|
}
|
|
142
|
-
catch {
|
|
143
|
-
|
|
145
|
+
catch (error) {
|
|
146
|
+
diagnostic.reply = error instanceof ReplyPreparationError ? 'getme-failed' : 'check-failed';
|
|
147
|
+
diagnostic.replyReason = error instanceof ReplyPreparationError ? error.message : safeFailureReason(error);
|
|
148
|
+
context.err(error instanceof ReplyPreparationError ? error.message : t('hook.replyNotReady'));
|
|
144
149
|
}
|
|
145
150
|
let covered = { telegram: [], feishu: false };
|
|
146
151
|
try {
|
|
@@ -167,11 +172,25 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
167
172
|
if (binding)
|
|
168
173
|
mapReplies(plan, binding);
|
|
169
174
|
const result = await context.send(plan, config);
|
|
175
|
+
diagnostic.channels[channel] = { status: result.ok ? 'complete' : 'incomplete', sent: result.sent, planned: result.planned,
|
|
176
|
+
...(!result.ok ? { reason: safeDeliveryReason(result.error) } : {}) };
|
|
170
177
|
if (!result.ok)
|
|
171
|
-
context.err(t('hook.
|
|
178
|
+
context.err(t('hook.sendIncompleteDetail', {
|
|
179
|
+
channel, sent: result.sent, planned: result.planned, reason: safeDeliveryReason(result.error),
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
diagnostic.channels[channel] = { status: 'failed', reason: safeFailureReason(error) };
|
|
184
|
+
context.err(t('hook.sendFailedDetail', { channel, reason: safeFailureReason(error) }));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (data.hook_event_name === 'Stop') {
|
|
188
|
+
diagnostic.updated = Date.now();
|
|
189
|
+
try {
|
|
190
|
+
await saveNotificationDiagnostic(configFile, data.session_id, diagnostic);
|
|
172
191
|
}
|
|
173
192
|
catch {
|
|
174
|
-
context.err(t('hook.
|
|
193
|
+
context.err(t('hook.diagnosticWriteFailed'));
|
|
175
194
|
}
|
|
176
195
|
}
|
|
177
196
|
if (!channels)
|
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.
|
|
19
|
+
export const VERSION = '0.2.6';
|
|
20
20
|
const helpText = () => t('cli.help', { version: VERSION });
|
|
21
21
|
function rawLang(argv) {
|
|
22
22
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -147,8 +147,10 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
147
147
|
context.out(t('cli.integrationStatusFile', { file: status.file }));
|
|
148
148
|
context.out(t('cli.integrationStatusCounts', { stop: status.stop, notification: status.notification,
|
|
149
149
|
lifecycle: status.lifecycle, interaction: status.interaction, others: status.others }));
|
|
150
|
+
for (const issue of status.runtimeIssues)
|
|
151
|
+
context.out(t('cli.integrationRuntimeIssue', { event: issue.event, kind: issue.kind, reason: issue.reason }));
|
|
150
152
|
context.out(status.disabled ? t('cli.integrationDisabled') : t('cli.integrationStatusNote'));
|
|
151
|
-
return status.stop === 1 && status.notification === 1 && status.lifecycle === 3 && status.interaction === 5 && !status.disabled ? 0 : 1;
|
|
153
|
+
return status.stop === 1 && status.notification === 1 && status.lifecycle === 3 && status.interaction === 5 && !status.disabled && !status.runtimeIssues.length ? 0 : 1;
|
|
152
154
|
}
|
|
153
155
|
const prompts = context.prompts ?? (values.yes
|
|
154
156
|
? { out: context.out, text: async () => '', secret: async () => '' }
|
package/dist/i18n.js
CHANGED
|
@@ -266,7 +266,11 @@ const zh = {
|
|
|
266
266
|
'claude.settingsInvalidJson': 'Claude Code 设置不是合法的 UTF-8 JSON,没有覆盖。',
|
|
267
267
|
'claude.settingsInvalidShape': 'Claude Code 设置或 hooks 结构无效,没有覆盖。',
|
|
268
268
|
'claude.hooksInvalidShape': 'Claude Code hook 数组结构无效,没有覆盖。',
|
|
269
|
-
'claude.lockUnavailable': '
|
|
269
|
+
'claude.lockUnavailable': 'Claude Code 设置写入锁已存在。请确认没有安装进程在运行,再核实是否为残留的 .dev-notify.lock。',
|
|
270
|
+
'claude.settingsWriteDenied': '没有权限写入 Claude Code 设置目录或创建锁文件;设置未改动。请检查目录所有者和访问权限,不要据此删除锁或自动提权。',
|
|
271
|
+
'claude.lockFailed': '无法准备 Claude Code 设置写入目录或锁文件;设置未改动。请检查路径、磁盘和文件系统状态,不将此错误当作残留锁。',
|
|
272
|
+
'cli.integrationRuntimeIssue': '{event} hook 的 {kind} 入口不可用(原因代码:{reason});不会判为就绪,请修复安装路径或访问权限。',
|
|
273
|
+
'reply.sessionUnavailable': '暂时无法可靠核验会话(可能为权限不足或查询异常);暂停远程输入,不将其认定为会话已退出。未提交的排队回复仅保留至原有效期,新本地输入仍会取消它。',
|
|
270
274
|
'claude.changedDuringConfirm': 'Claude Code 设置在确认期间发生了变化,没有覆盖,请重试。',
|
|
271
275
|
'claude.changedDuringSave': 'Claude Code 设置在保存期间发生了变化,没有覆盖,请重试。',
|
|
272
276
|
'claude.tempCleanupFailed': '临时文件清理失败。安装结束后请删除 .dev-notify-*.tmp。',
|
|
@@ -305,7 +309,18 @@ const zh = {
|
|
|
305
309
|
'hook.notificationFallback': 'Claude Code 正在等待你的确认或输入,请回到终端处理。',
|
|
306
310
|
'hook.interactionFailed': 'Claude Code 的回复状态或交互处理失败。没有自动选择答案或批准权限,请在本机处理。',
|
|
307
311
|
'hook.sessionNameFailed': '读取不到 Claude Code 会话名称,通知底部将省略主题。',
|
|
308
|
-
'hook.replyNotReady': '
|
|
312
|
+
'hook.replyNotReady': '回复按钮准备失败(会话、授权、本地状态或桥接检查);仍会尝试向各渠道发送普通通知,不代表 Telegram 已送达。',
|
|
313
|
+
'hook.replyIdentityFailed': '回复按钮准备失败:Telegram getMe 身份查询失败({reason});仍会尝试发送不带按钮的普通通知。',
|
|
314
|
+
'hook.diagnosticWriteFailed': '本次通知的本地诊断摘要未能保存;不影响已发送的通知,不会因此重发。',
|
|
315
|
+
'reply.buttonSkipped': '本次通知不带回复按钮(原因代码:{reason});会话身份及输入跟踪检查没有放宽。',
|
|
316
|
+
'hook.sendIncompleteDetail': '{channel}:通知投递或发送后处理未全部完成(API 确认 {sent}/{planned} 段;{reason})。整条消息不会自动重发。',
|
|
317
|
+
'hook.sendFailedDetail': '{channel}:通知发送流程失败({reason})。整条消息不会自动重发。',
|
|
318
|
+
'diagnostic.network': '网络请求失败或响应无效(可能为超时或连接异常,无法进一步区分)',
|
|
319
|
+
'diagnostic.rejected': '渠道明确拒绝请求(当前接口未保留具体错误码)',
|
|
320
|
+
'diagnostic.unknown': '原因未分类;为避免泄密,不显示底层错误',
|
|
321
|
+
'diagnostic.telegramCode': 'Telegram API 错误码 {code}',
|
|
322
|
+
'diagnostic.telegramUnknown': 'Telegram API 返回失败,错误码未知',
|
|
323
|
+
'diagnostic.httpCode': 'HTTP 状态码 {code}',
|
|
309
324
|
'hook.coverageFailed': '没有核验到选择题通知的覆盖情况,将保留普通权限提醒。',
|
|
310
325
|
'hook.sendIncomplete': '{channel}:Claude Code 通知没有完整发送。整条消息不会自动重发。',
|
|
311
326
|
'hook.sendFailed': '{channel}:Claude Code 通知发送失败。请检查配置、系统凭据和网络;整条消息不会自动重发。',
|
|
@@ -314,7 +329,7 @@ const zh = {
|
|
|
314
329
|
'reply.lockUnavailable': '无法获取回复状态写入锁。',
|
|
315
330
|
'reply.lockBusy': '回复状态锁正忙。请确认相关进程已经退出,再处理残留锁。',
|
|
316
331
|
'reply.invalidPolicy': '回复授权配置无效,远程输入已停用。',
|
|
317
|
-
'reply.hooksMissing': '回复所需的 hooks
|
|
332
|
+
'reply.hooksMissing': '回复所需的 hooks 缺失、入口不可访问、时序不安全或已禁用。请重新运行 integration claude-code install,并在 /hooks 中确认加载。',
|
|
318
333
|
'reply.botChanged': 'Telegram Bot 已变更或不可用,请重新运行 reply setup 授权。',
|
|
319
334
|
'reply.usersRevoked': '授权用户不在当前 Telegram 接收名单中,远程输入已停用。',
|
|
320
335
|
'reply.authorizationChanged': '回复授权已撤销或发生变化,没有写入旧状态。',
|
|
@@ -764,7 +779,11 @@ secrets are never written in plaintext.`,
|
|
|
764
779
|
'claude.settingsInvalidJson': 'Claude Code settings are not valid UTF-8 JSON and were not overwritten.',
|
|
765
780
|
'claude.settingsInvalidShape': 'Claude Code settings or hooks structure is invalid and was not overwritten.',
|
|
766
781
|
'claude.hooksInvalidShape': 'The Claude Code hook array structure is invalid and was not overwritten.',
|
|
767
|
-
'claude.lockUnavailable': '
|
|
782
|
+
'claude.lockUnavailable': 'The Claude Code settings write lock already exists. Confirm no installation process is running, then verify whether .dev-notify.lock is stale.',
|
|
783
|
+
'claude.settingsWriteDenied': 'Permission denied writing the Claude Code settings directory or creating its lock. Settings were not changed. Check ownership and access; do not delete locks or elevate automatically.',
|
|
784
|
+
'claude.lockFailed': 'Could not prepare the Claude Code settings directory or write lock. Settings were not changed. Check paths, disk, and filesystem state; this does not establish a stale lock.',
|
|
785
|
+
'cli.integrationRuntimeIssue': 'The {event} hook {kind} entry is unavailable (reason code: {reason}); it is not ready. Repair its installation path or access permissions.',
|
|
786
|
+
'reply.sessionUnavailable': 'The session cannot currently be verified reliably (possibly permissions or a query failure). Remote input is paused; this is not considered session exit. Unsubmitted queued replies retain their original expiry and are still canceled by new local input.',
|
|
768
787
|
'claude.changedDuringConfirm': 'Claude Code settings changed during confirmation and were not overwritten. Try again.',
|
|
769
788
|
'claude.changedDuringSave': 'Claude Code settings changed while saving and were not overwritten. Try again.',
|
|
770
789
|
'claude.tempCleanupFailed': 'Temporary file cleanup failed. Delete .dev-notify-*.tmp after installation.',
|
|
@@ -803,7 +822,18 @@ secrets are never written in plaintext.`,
|
|
|
803
822
|
'hook.notificationFallback': 'Claude Code is waiting for your confirmation or input. Return to the terminal to handle it.',
|
|
804
823
|
'hook.interactionFailed': 'Claude Code reply state or interaction handling failed. No answer or permission was selected automatically; handle it locally.',
|
|
805
824
|
'hook.sessionNameFailed': 'Could not read the Claude Code session name; the topic will be omitted from the notification footer.',
|
|
806
|
-
'hook.replyNotReady': '
|
|
825
|
+
'hook.replyNotReady': 'Reply button preparation failed during session, authorization, local-state, or bridge checks; normal notifications will still be attempted on each channel. This does not mean Telegram delivery succeeded.',
|
|
826
|
+
'hook.replyIdentityFailed': 'Reply button preparation failed: Telegram getMe identity lookup failed ({reason}); normal notifications without buttons will still be attempted.',
|
|
827
|
+
'hook.diagnosticWriteFailed': 'Could not save the local diagnostic summary. Sent notifications are unaffected and will not be resent.',
|
|
828
|
+
'reply.buttonSkipped': 'This notification has no reply button (reason code: {reason}); session identity and input-tracking checks remain enforced.',
|
|
829
|
+
'hook.sendIncompleteDetail': '{channel}: delivery or post-send processing did not fully complete (API acknowledged {sent}/{planned} parts; {reason}). The message will not be resent automatically.',
|
|
830
|
+
'hook.sendFailedDetail': '{channel}: notification sending failed ({reason}). The message will not be resent automatically.',
|
|
831
|
+
'diagnostic.network': 'Network request failed or response was invalid (possibly a timeout or connection failure; cannot distinguish further)',
|
|
832
|
+
'diagnostic.rejected': 'The channel explicitly rejected the request (the current interface does not retain its exact error code)',
|
|
833
|
+
'diagnostic.unknown': 'Unclassified reason; underlying error withheld to protect secrets',
|
|
834
|
+
'diagnostic.telegramCode': 'Telegram API error code {code}',
|
|
835
|
+
'diagnostic.telegramUnknown': 'Telegram API failed with an unknown error code',
|
|
836
|
+
'diagnostic.httpCode': 'HTTP status {code}',
|
|
807
837
|
'hook.coverageFailed': 'Could not verify question-notification coverage; the general permission reminder will be kept.',
|
|
808
838
|
'hook.sendIncomplete': '{channel}: the Claude Code notification was not fully sent. The message will not be resent automatically.',
|
|
809
839
|
'hook.sendFailed': '{channel}: the Claude Code notification failed to send. Check configuration, system credentials, and network; the message will not be resent automatically.',
|
|
@@ -812,7 +842,7 @@ secrets are never written in plaintext.`,
|
|
|
812
842
|
'reply.lockUnavailable': 'Could not acquire the reply state write lock.',
|
|
813
843
|
'reply.lockBusy': 'The reply state lock is busy. Confirm the relevant processes have exited, then handle the leftover lock.',
|
|
814
844
|
'reply.invalidPolicy': 'The reply authorization configuration is invalid; remote input is disabled.',
|
|
815
|
-
'reply.hooksMissing': 'Required reply hooks are missing, timed unsafely, or disabled. Re-run integration claude-code install and confirm loading in /hooks.',
|
|
845
|
+
'reply.hooksMissing': 'Required reply hooks are missing, inaccessible, timed unsafely, or disabled. Re-run integration claude-code install and confirm loading in /hooks.',
|
|
816
846
|
'reply.botChanged': 'The Telegram bot changed or is unavailable. Re-run reply setup to authorize again.',
|
|
817
847
|
'reply.usersRevoked': 'Authorized users are no longer in the current Telegram recipient list; remote input is disabled.',
|
|
818
848
|
'reply.authorizationChanged': 'Reply authorization was revoked or changed; old state was not written.',
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { HttpError, TelegramApiError, TransportError } from '@quecue/notify-core';
|
|
2
|
+
import { ConfigError } from './config.js';
|
|
3
|
+
import { t } from './i18n.js';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { atomicJson, replyDirectory } from './reply-state.js';
|
|
6
|
+
export async function saveNotificationDiagnostic(file, session, value) {
|
|
7
|
+
if (typeof session !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(session))
|
|
8
|
+
return;
|
|
9
|
+
// 每会话只保留最后一份完成通知摘要;不记录正文、收件人、Token 或原始异常。
|
|
10
|
+
await atomicJson(path.join(replyDirectory(file), 'notification-diagnostics', session + '.json'), value);
|
|
11
|
+
}
|
|
12
|
+
// 仅保留类型和有界状态码;不输出底层异常、URL、Token 或服务端正文。
|
|
13
|
+
export class TelegramRequestError extends ConfigError {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code) {
|
|
16
|
+
super(t(code === 409 ? 'reply.telegramConflict' : 'reply.telegramRequestFailed'));
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function safeFailureReason(error) {
|
|
21
|
+
if (error instanceof TelegramRequestError || error instanceof TelegramApiError) {
|
|
22
|
+
const code = error.code;
|
|
23
|
+
return Number.isSafeInteger(code) && code >= 100 && code <= 599
|
|
24
|
+
? t('diagnostic.telegramCode', { code }) : t('diagnostic.telegramUnknown');
|
|
25
|
+
}
|
|
26
|
+
if (error instanceof HttpError) {
|
|
27
|
+
const code = error.status;
|
|
28
|
+
return Number.isSafeInteger(code) && code >= 100 && code <= 599
|
|
29
|
+
? t('diagnostic.httpCode', { code }) : t('diagnostic.network');
|
|
30
|
+
}
|
|
31
|
+
if (error instanceof TransportError)
|
|
32
|
+
return t('diagnostic.network');
|
|
33
|
+
return t('diagnostic.unknown');
|
|
34
|
+
}
|
|
35
|
+
export class ReplyPreparationError extends ConfigError {
|
|
36
|
+
constructor(error) {
|
|
37
|
+
super(t('hook.replyIdentityFailed', { reason: safeFailureReason(error) }));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// 公共库目前只返回安全文本而非错误类型。仅匹配本地已知值,不转发任意字符串。
|
|
41
|
+
export function safeDeliveryReason(error) {
|
|
42
|
+
for (const key of ['channels.network', 'channels.noResponse', 'channels.responseTooLarge', 'channels.notJsonObject']) {
|
|
43
|
+
if (error === t(key))
|
|
44
|
+
return t('diagnostic.network');
|
|
45
|
+
}
|
|
46
|
+
if (error === t('channels.rejected'))
|
|
47
|
+
return t('diagnostic.rejected');
|
|
48
|
+
return t('diagnostic.unknown');
|
|
49
|
+
}
|
package/dist/reply-engine.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ConfigError } from './config.js';
|
|
|
3
3
|
import { t } from './i18n.js';
|
|
4
4
|
import { digest, targetEpoch, stateTransaction, TTL } from './reply-state.js';
|
|
5
5
|
import { handleInteractionCallback, handleInteractionText, showInteraction } from './reply-interactions.js';
|
|
6
|
-
import { injectReply, sanitizeReply, validateTarget } from './reply-platform.js';
|
|
6
|
+
import { injectReply, sanitizeReply, validateTarget, SessionLookupError } from './reply-platform.js';
|
|
7
7
|
import { replyHint } from './reply-telegram.js';
|
|
8
8
|
export const sessionEpoch = targetEpoch;
|
|
9
9
|
export const textHash = (text) => digest(text.replace(/\s+/g, ' ').trim());
|
|
@@ -17,7 +17,7 @@ function privateUser(message, sender) {
|
|
|
17
17
|
export async function resolveEntry(access, key, context) {
|
|
18
18
|
const resolved = await stateTransaction(access, state => {
|
|
19
19
|
const entry = state.entries[key], target = entry && state.sessions[entry.session];
|
|
20
|
-
return entry && target && (target.tracked === true || !!entry.request) && entry.epoch === sessionEpoch(target) && Date.now() - entry.updated < TTL ? { entry, target } : null;
|
|
20
|
+
return entry && target && target.unverified !== true && (target.tracked === true || !!entry.request) && entry.epoch === sessionEpoch(target) && Date.now() - entry.updated < TTL ? { entry, target } : null;
|
|
21
21
|
});
|
|
22
22
|
if (!resolved || !await (context.validate ?? (target => validateTarget(target)))(resolved.target))
|
|
23
23
|
return null;
|
|
@@ -141,7 +141,7 @@ async function processAuthorizedUpdate(access, update, context) {
|
|
|
141
141
|
const id = randomBytes(16).toString('hex');
|
|
142
142
|
const queued = await stateTransaction(access, state => {
|
|
143
143
|
const target = state.sessions[resolved.target.session];
|
|
144
|
-
if (!target || sessionEpoch(target) !== resolved.entry.epoch)
|
|
144
|
+
if (!target || target.unverified === true || sessionEpoch(target) !== resolved.entry.epoch)
|
|
145
145
|
throw new ConfigError(t('reply.sessionChanged'));
|
|
146
146
|
if (state.pending[target.session] && Date.now() - state.pending[target.session].updated < 5 * 60 * 1000)
|
|
147
147
|
return null;
|
|
@@ -160,7 +160,8 @@ async function processAuthorizedUpdate(access, update, context) {
|
|
|
160
160
|
}
|
|
161
161
|
export async function drainReplies(access, context) {
|
|
162
162
|
const queued = await stateTransaction(access, state => Object.entries(state.pending)
|
|
163
|
-
.filter(([session, value]) => value.phase === 'queued' && (
|
|
163
|
+
.filter(([session, value]) => value.phase === 'queued' && (Date.now() - value.updated > 5 * 60 * 1000
|
|
164
|
+
|| (!state.sessions[session]?.working && state.sessions[session]?.unverified !== true)))
|
|
164
165
|
.map(([session, pending]) => ({ target: state.sessions[session], pending })));
|
|
165
166
|
for (const item of queued) {
|
|
166
167
|
const { target, pending } = item;
|
|
@@ -177,7 +178,7 @@ export async function drainReplies(access, context) {
|
|
|
177
178
|
const text = pending.text;
|
|
178
179
|
const claimed = await stateTransaction(access, state => {
|
|
179
180
|
const actual = state.pending[target.session], active = state.sessions[target.session];
|
|
180
|
-
if (!actual || actual.id !== pending.id || actual.phase !== 'queued' || active?.working || sessionEpoch(active) !== sessionEpoch(target))
|
|
181
|
+
if (!actual || actual.id !== pending.id || actual.phase !== 'queued' || active?.working || active?.unverified === true || sessionEpoch(active) !== sessionEpoch(target))
|
|
181
182
|
return false;
|
|
182
183
|
actual.phase = 'submitted';
|
|
183
184
|
delete actual.text;
|
|
@@ -193,6 +194,18 @@ export async function drainReplies(access, context) {
|
|
|
193
194
|
await replyHint(context.api, pending.chat, t('reply.forwardedTitle'), t('reply.forwardedBody'), pending.message);
|
|
194
195
|
}
|
|
195
196
|
catch (error) {
|
|
197
|
+
if (error instanceof SessionLookupError) {
|
|
198
|
+
const paused = await stateTransaction(access, state => {
|
|
199
|
+
const value = state.pending[target.session], active = state.sessions[target.session];
|
|
200
|
+
if (value?.id !== pending.id || value.phase !== 'queued')
|
|
201
|
+
return false;
|
|
202
|
+
if (active && sessionEpoch(active) === pending.epoch)
|
|
203
|
+
active.unverified = true;
|
|
204
|
+
return true;
|
|
205
|
+
});
|
|
206
|
+
if (paused)
|
|
207
|
+
continue; // 未开始注入的任务保留原TTL;已提交/结果未知的任务绝不重新排队。
|
|
208
|
+
}
|
|
196
209
|
await stateTransaction(access, state => {
|
|
197
210
|
const value = state.pending[target.session];
|
|
198
211
|
if (value?.id === pending.id) {
|
package/dist/reply-hooks.js
CHANGED
|
@@ -2,16 +2,35 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { ConfigError } from './config.js';
|
|
3
3
|
import { t } from './i18n.js';
|
|
4
4
|
import { accessReplies, botKey, digest, stateTransaction, targetEpoch } from './reply-state.js';
|
|
5
|
-
import {
|
|
5
|
+
import { lookupSession, SessionLookupError } from './reply-platform.js';
|
|
6
|
+
import { processState } from './reply-lock.js';
|
|
6
7
|
import { textHash } from './reply-engine.js';
|
|
7
8
|
import { telegramApi, replyHint } from './reply-telegram.js';
|
|
8
9
|
import { bridgeStatus, startBridge } from './reply-bridge.js';
|
|
10
|
+
import { ReplyPreparationError } from './notification-diagnostics.js';
|
|
9
11
|
import { createInteraction, questionsContent, showInteraction, waitInteraction } from './reply-interactions.js';
|
|
10
12
|
import { makePlan, deliver } from './channels.js';
|
|
11
13
|
import { completeToolCall, endSessionInteractions, finishInteraction, matchingCall, registerToolCall } from './reply-outcomes.js';
|
|
12
14
|
const apiFor = (access, options) => (options.api ?? (value => telegramApi(value.config.telegram.token)))(access);
|
|
15
|
+
async function inspectTarget(data, options) {
|
|
16
|
+
try {
|
|
17
|
+
if (options.sessionLookup)
|
|
18
|
+
return await options.sessionLookup(data.session_id);
|
|
19
|
+
if (options.session) {
|
|
20
|
+
const target = await options.session(data.session_id);
|
|
21
|
+
return target ? { status: 'found', target } : { status: 'missing' };
|
|
22
|
+
}
|
|
23
|
+
return await lookupSession(data.session_id, { env: options.env });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { status: 'unavailable' };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
13
29
|
async function getTarget(data, options) {
|
|
14
|
-
|
|
30
|
+
const result = await inspectTarget(data, options);
|
|
31
|
+
if (result.status === 'unavailable')
|
|
32
|
+
throw new SessionLookupError();
|
|
33
|
+
return result.status === 'found' ? result.target : null;
|
|
15
34
|
}
|
|
16
35
|
export async function lifecycleHook(data, file, options = {}) {
|
|
17
36
|
const event = data.hook_event_name;
|
|
@@ -22,15 +41,36 @@ export async function lifecycleHook(data, file, options = {}) {
|
|
|
22
41
|
return;
|
|
23
42
|
if (event === 'Stop' && data.stop_hook_active === true)
|
|
24
43
|
return;
|
|
25
|
-
const
|
|
44
|
+
const before = event === 'SessionEnd' ? null : await stateTransaction(access, state => state.sessions[data.session_id]);
|
|
45
|
+
const inspection = event === 'SessionEnd' ? null : await inspectTarget(data, options);
|
|
46
|
+
const target = inspection?.status === 'found' ? inspection.target : null;
|
|
47
|
+
const confirmedGone = inspection?.status === 'missing' && !!before && processState(before.pid) === 'dead';
|
|
48
|
+
if (!target && event !== 'SessionEnd')
|
|
49
|
+
options.err?.(t('reply.sessionUnavailable'));
|
|
26
50
|
const receipt = await stateTransaction(access, state => {
|
|
27
51
|
const previous = state.sessions[data.session_id];
|
|
28
|
-
if (event === 'SessionEnd' ||
|
|
52
|
+
if (event === 'SessionEnd' || (confirmedGone && previous && before && targetEpoch(previous) === targetEpoch(before))) {
|
|
29
53
|
delete state.sessions[data.session_id];
|
|
30
54
|
delete state.pending[data.session_id];
|
|
31
55
|
endSessionInteractions(state, data.session_id, t('reply.endSessionReason'));
|
|
32
56
|
return null;
|
|
33
57
|
}
|
|
58
|
+
if (!target) {
|
|
59
|
+
if (previous) {
|
|
60
|
+
previous.unverified = true;
|
|
61
|
+
previous.updated = Date.now();
|
|
62
|
+
}
|
|
63
|
+
// 观察到新的本地输入时仍取消旧排队任务,不能在恢复核验后抢先注入。
|
|
64
|
+
if (event === 'UserPromptSubmit' || event === 'SessionStart') {
|
|
65
|
+
if (state.pending[data.session_id]?.phase === 'queued')
|
|
66
|
+
delete state.pending[data.session_id];
|
|
67
|
+
if (previous)
|
|
68
|
+
previous.working = true;
|
|
69
|
+
}
|
|
70
|
+
if (event === 'Stop' || event === 'UserPromptSubmit' || event === 'SessionStart')
|
|
71
|
+
endSessionInteractions(state, data.session_id, t('reply.endTurnReason'));
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
34
74
|
// 同进程 / 同 TTY 已切换会话时,旧映射不再允许输入。
|
|
35
75
|
for (const [id, other] of Object.entries(state.sessions)) {
|
|
36
76
|
if (id !== target.session && (other.pid === target.pid || (target.tty && other.tty === target.tty))) {
|
|
@@ -84,7 +124,7 @@ export async function notificationCoverage(data, file, config, options = {}) {
|
|
|
84
124
|
return stateTransaction(access, state => {
|
|
85
125
|
const target = state.sessions[data.session_id];
|
|
86
126
|
const call = target?.latestCall && state.calls[target.latestCall];
|
|
87
|
-
if (!target || !call || call.session !== target.session || call.epoch !== targetEpoch(target) || call.tool !== 'AskUserQuestion'
|
|
127
|
+
if (!target || target.unverified === true || !call || call.session !== target.session || call.epoch !== targetEpoch(target) || call.tool !== 'AskUserQuestion'
|
|
88
128
|
|| typeof data.tool_use_id === 'string' && data.tool_use_id !== call.id
|
|
89
129
|
|| typeof data.tool_name === 'string' && data.tool_name !== call.tool
|
|
90
130
|
|| typeof data.prompt_id === 'string' && data.prompt_id !== call.prompt)
|
|
@@ -100,27 +140,53 @@ export async function notificationCoverage(data, file, config, options = {}) {
|
|
|
100
140
|
});
|
|
101
141
|
}
|
|
102
142
|
export async function attachReply(message, data, file, options = {}) {
|
|
143
|
+
if (data.hook_event_name !== 'Stop')
|
|
144
|
+
return null;
|
|
103
145
|
const access = await accessReplies(file, options.env);
|
|
104
|
-
if (!access
|
|
146
|
+
if (!access) {
|
|
147
|
+
options.replyDiagnostic?.('disabled');
|
|
105
148
|
return null;
|
|
149
|
+
}
|
|
150
|
+
const skip = (reason) => {
|
|
151
|
+
options.replyDiagnostic?.(reason);
|
|
152
|
+
options.err?.(t('reply.buttonSkipped', { reason }));
|
|
153
|
+
return null;
|
|
154
|
+
};
|
|
106
155
|
const target = await getTarget(data, options);
|
|
107
156
|
if (!target)
|
|
108
|
-
return
|
|
157
|
+
return skip('session-unverified');
|
|
109
158
|
const saved = await stateTransaction(access, state => state.sessions[target.session]);
|
|
110
|
-
if (!saved
|
|
111
|
-
return
|
|
159
|
+
if (!saved)
|
|
160
|
+
return skip('state-missing');
|
|
161
|
+
if (saved.unverified === true)
|
|
162
|
+
return skip('session-unverified');
|
|
163
|
+
if (saved.working)
|
|
164
|
+
return skip('session-working');
|
|
165
|
+
if (!saved.tracked)
|
|
166
|
+
return skip('input-unobserved');
|
|
167
|
+
if (targetEpoch(saved) !== targetEpoch(target))
|
|
168
|
+
return skip('identity-changed');
|
|
112
169
|
const ready = options.bridge ? await options.bridge(file, options.env ?? process.env)
|
|
113
170
|
: (await bridgeStatus(file)).ready || await startBridge(file, options.env);
|
|
114
171
|
if (!ready) {
|
|
172
|
+
options.replyDiagnostic?.('bridge-unready');
|
|
115
173
|
options.err?.(t('reply.bridgeNotReadyButton'));
|
|
116
174
|
return null;
|
|
117
175
|
}
|
|
118
|
-
const api = apiFor(access, options)
|
|
176
|
+
const api = apiFor(access, options);
|
|
177
|
+
let bot;
|
|
178
|
+
try {
|
|
179
|
+
bot = await api('getMe', {});
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
throw new ReplyPreparationError(error);
|
|
183
|
+
}
|
|
119
184
|
if (typeof bot?.username !== 'string' || !/^[A-Za-z0-9_]{5,64}$/.test(bot.username))
|
|
120
185
|
throw new ConfigError(t('reply.botUsernameInvalid'));
|
|
121
186
|
const token = randomBytes(16).toString('hex');
|
|
122
187
|
await stateTransaction(access, state => { state.entries['token:' + token] = { session: target.session, epoch: targetEpoch(target), updated: Date.now() }; });
|
|
123
188
|
message.reply = { token, url: `https://t.me/${bot.username}?start=${token}`, users: access.policy.users };
|
|
189
|
+
options.replyDiagnostic?.('attached');
|
|
124
190
|
return { access, token };
|
|
125
191
|
}
|
|
126
192
|
export function mapReplies(plan, binding) {
|
|
@@ -157,7 +223,7 @@ export async function interactionHook(data, file, options = {}) {
|
|
|
157
223
|
? await stateTransaction(access, state => state.sessions[data.session_id]) : null;
|
|
158
224
|
// 登记阶段复用生命周期元数据;真正开启远程交互时仍重新核验进程身份。
|
|
159
225
|
const target = cached ?? await getTarget(data, options);
|
|
160
|
-
if (!target) {
|
|
226
|
+
if (!target || target.unverified === true) {
|
|
161
227
|
options.err?.(t('reply.noSession'));
|
|
162
228
|
return;
|
|
163
229
|
}
|
|
@@ -31,7 +31,7 @@ export async function createInteraction(access, target, kind, input, call) {
|
|
|
31
31
|
answers: {}, selected: [], index: 0, call, receipts: [], messages: [] };
|
|
32
32
|
await stateTransaction(access, state => {
|
|
33
33
|
const current = state.sessions[target.session];
|
|
34
|
-
if (!current || targetEpoch(current) !== value.epoch)
|
|
34
|
+
if (!current || current.unverified === true || targetEpoch(current) !== value.epoch)
|
|
35
35
|
throw new ConfigError(t('reply.targetChanged'));
|
|
36
36
|
if (call) {
|
|
37
37
|
const bound = state.calls[call];
|
|
@@ -150,7 +150,7 @@ export async function handleInteractionCallback(access, callback, chat, api) {
|
|
|
150
150
|
const [, id, index, operation] = match;
|
|
151
151
|
const outcome = await stateTransaction(access, state => {
|
|
152
152
|
const value = state.requests[id], target = value && state.sessions[value.session];
|
|
153
|
-
if (!active(value) || value.index !== Number(index) || !target || targetEpoch(target) !== value.epoch)
|
|
153
|
+
if (!active(value) || value.index !== Number(index) || !target || target.unverified === true || targetEpoch(target) !== value.epoch)
|
|
154
154
|
return 'expired';
|
|
155
155
|
if (operation === 'l') {
|
|
156
156
|
finishInteraction(value, { kind: 'not-applied', reason: 'closed', text: t('reply.closedLocal') });
|
|
@@ -218,7 +218,7 @@ export async function handleInteractionText(access, id, chat, message, api) {
|
|
|
218
218
|
const outcome = await stateTransaction(access, state => {
|
|
219
219
|
const value = state.requests[id], target = value && state.sessions[value.session];
|
|
220
220
|
const entry = state.entries[`tg:${chat}:${message.reply_to_message.message_id}`];
|
|
221
|
-
if (!active(value) || !target || targetEpoch(target) !== value.epoch || entry?.question !== value.index)
|
|
221
|
+
if (!active(value) || !target || target.unverified === true || targetEpoch(target) !== value.epoch || entry?.question !== value.index)
|
|
222
222
|
return 'expired';
|
|
223
223
|
const question = value.questions[value.index]?.question;
|
|
224
224
|
if (value.kind === 'permission') {
|
|
@@ -248,7 +248,7 @@ export async function waitInteraction(access, value, fresh, options = {}) {
|
|
|
248
248
|
const request = state.requests[value.id], target = state.sessions[value.session];
|
|
249
249
|
if (!request || request.result || !target || targetEpoch(target) !== value.epoch || (request.call && (!state.calls[request.call] || state.calls[request.call].ended)))
|
|
250
250
|
return { ended: true };
|
|
251
|
-
if (!request.response || request.claimed || options.signal?.aborted)
|
|
251
|
+
if (target.unverified === true || !request.response || request.claimed || options.signal?.aborted)
|
|
252
252
|
return null;
|
|
253
253
|
request.claimed = true;
|
|
254
254
|
// 回传后等待独立的完成事件确认;不能在这里删除请求或报告成功。
|
package/dist/reply-lock.js
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
import { open, readFile, unlink, lstat } from 'node:fs/promises';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import { ConfigError } from './config.js';
|
|
3
5
|
import { t } from './i18n.js';
|
|
4
|
-
|
|
6
|
+
function windowsProcessExists(pid) {
|
|
7
|
+
const executable = path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tasklist.exe');
|
|
8
|
+
const result = spawnSync(executable, ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
|
|
9
|
+
shell: false, windowsHide: true, encoding: 'utf8', timeout: 5000, maxBuffer: 65536,
|
|
10
|
+
});
|
|
11
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string')
|
|
12
|
+
return false;
|
|
13
|
+
// 只认可精确 PID 的 CSV 行;没有结果、权限不足或异常输出均不证明进程死亡。
|
|
14
|
+
return result.stdout.split(/\r?\n/).some(line => /^"[^"]+","(\d+)"/.exec(line.trim())?.[1] === String(pid));
|
|
15
|
+
}
|
|
16
|
+
export function processState(pid, options = {}) {
|
|
17
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
18
|
+
return 'unknown';
|
|
5
19
|
try {
|
|
6
|
-
process.kill(
|
|
20
|
+
(options.signal ?? (value => process.kill(value, 0)))(pid);
|
|
7
21
|
return 'alive';
|
|
8
22
|
}
|
|
9
23
|
catch (error) {
|
|
10
|
-
|
|
24
|
+
const code = error.code;
|
|
25
|
+
if (code === 'ESRCH')
|
|
26
|
+
return 'dead';
|
|
27
|
+
// Windows 的 signal=0 也可能被跨提权等级权限拒绝;改用只读枚举确认存活,不提权。
|
|
28
|
+
if ((options.platform ?? process.platform) === 'win32' && (code === 'EPERM' || code === 'EACCES')) {
|
|
29
|
+
try {
|
|
30
|
+
if ((options.windowsQuery ?? windowsProcessExists)(pid))
|
|
31
|
+
return 'alive';
|
|
32
|
+
}
|
|
33
|
+
catch { /* 仍无法确认则保守处理。 */ }
|
|
34
|
+
}
|
|
35
|
+
return 'unknown';
|
|
11
36
|
}
|
|
12
37
|
}
|
|
13
38
|
// 只有 ESRCH 可证明进程已退出;权限不足、PID复用、坏文件都不允许抢占。
|
package/dist/reply-outcomes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { digest, stateTransaction, targetEpoch } from './reply-state.js';
|
|
2
2
|
import { t } from './i18n.js';
|
|
3
|
+
import { ConfigError } from './config.js';
|
|
3
4
|
import { replyHint } from './reply-telegram.js';
|
|
4
5
|
// PermissionRequest 没有 tool_use_id:只接受唯一且尚未结束的同输入调用,歧义时保留本机处理。
|
|
5
6
|
function canonical(value) {
|
|
@@ -61,6 +62,8 @@ export async function registerToolCall(access, target, data) {
|
|
|
61
62
|
if (state.calls[key])
|
|
62
63
|
return;
|
|
63
64
|
const previous = state.sessions[target.session];
|
|
65
|
+
if (previous?.unverified === true || target.unverified === true)
|
|
66
|
+
throw new ConfigError(t('reply.sessionUnavailable'));
|
|
64
67
|
state.sessions[target.session] = { ...target, working: true, latestCall: key, tracked: previous?.tracked === true && targetEpoch(previous) === targetEpoch(target) };
|
|
65
68
|
state.calls[key] = { id: data.tool_use_id, session: target.session, epoch: targetEpoch(target), tool: data.tool_name,
|
|
66
69
|
fingerprint: inputFingerprint(data.tool_input), prompt: typeof data.prompt_id === 'string' ? data.prompt_id : undefined, updated: Date.now() };
|
package/dist/reply-platform.js
CHANGED
|
@@ -5,6 +5,15 @@ import { homedir } from 'node:os';
|
|
|
5
5
|
import { ConfigError } from './config.js';
|
|
6
6
|
import { t } from './i18n.js';
|
|
7
7
|
import { claudeSettingsPath } from './claude-code.js';
|
|
8
|
+
import { processState } from './reply-lock.js';
|
|
9
|
+
export class SessionLookupError extends ConfigError {
|
|
10
|
+
constructor() { super(t('reply.sessionUnavailable')); }
|
|
11
|
+
}
|
|
12
|
+
function unavailable(strict) {
|
|
13
|
+
if (strict)
|
|
14
|
+
throw new SessionLookupError();
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
8
17
|
export const runCommand = (command, args, input = '') => new Promise((resolve, reject) => {
|
|
9
18
|
const child = spawn(command, args, { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
10
19
|
let stdout = '', overflow = false;
|
|
@@ -37,31 +46,50 @@ export function sanitizeReply(text) {
|
|
|
37
46
|
throw new ConfigError(t('reply.emptyAfterCleanup'));
|
|
38
47
|
return value;
|
|
39
48
|
}
|
|
40
|
-
export async function processIdentity(pid, platform = process.platform, runner = runCommand) {
|
|
49
|
+
export async function processIdentity(pid, platform = process.platform, runner = runCommand, strict = false) {
|
|
41
50
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
42
51
|
return null;
|
|
43
52
|
try {
|
|
44
53
|
if (platform === 'win32') {
|
|
45
54
|
const result = await powershell(READ_PROCESS, { pid }, runner);
|
|
55
|
+
if (result.code === 3)
|
|
56
|
+
return null; // 已明确查到的进程不是 Claude,旧绑定无效。
|
|
46
57
|
if (result.code !== 0)
|
|
47
|
-
return null;
|
|
58
|
+
return processState(pid) === 'dead' ? null : unavailable(strict);
|
|
48
59
|
const d = JSON.parse(result.stdout);
|
|
49
|
-
return typeof d.birth === 'string' && /^\d+$/.test(d.birth) ? { birth: d.birth, tty: '' } :
|
|
60
|
+
return typeof d.birth === 'string' && /^\d+$/.test(d.birth) ? { birth: d.birth, tty: '' } : unavailable(strict);
|
|
50
61
|
}
|
|
51
62
|
if (platform === 'darwin') {
|
|
52
63
|
const result = await runner('/bin/ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'tty=', '-o', 'comm=']);
|
|
53
64
|
const match = result.stdout.trim().match(/^(\S+\s+\S+\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+(\S+)\s+(.+)$/);
|
|
54
|
-
if (result.code !== 0
|
|
65
|
+
if (result.code !== 0)
|
|
66
|
+
return processState(pid) === 'dead' ? null : unavailable(strict);
|
|
67
|
+
if (!match)
|
|
68
|
+
return unavailable(strict);
|
|
69
|
+
if (path.posix.basename(match[3]) !== 'claude')
|
|
55
70
|
return null;
|
|
71
|
+
if (!/^ttys\d+$/.test(match[2]))
|
|
72
|
+
return unavailable(strict);
|
|
56
73
|
const groups = await runner('/bin/ps', ['-p', String(pid), '-o', 'pgid=', '-o', 'tpgid=']);
|
|
57
74
|
const group = groups.stdout.trim().match(/^(\d+)\s+(\d+)$/);
|
|
58
75
|
if (groups.code !== 0 || !group || group[1] !== group[2])
|
|
59
|
-
return
|
|
76
|
+
return unavailable(strict);
|
|
60
77
|
return { birth: match[1].replace(/\s+/g, ' '), tty: '/dev/' + match[2] };
|
|
61
78
|
}
|
|
62
79
|
}
|
|
63
|
-
catch {
|
|
64
|
-
|
|
80
|
+
catch {
|
|
81
|
+
return unavailable(strict);
|
|
82
|
+
}
|
|
83
|
+
return unavailable(strict);
|
|
84
|
+
}
|
|
85
|
+
export async function lookupSession(session, options = {}) {
|
|
86
|
+
try {
|
|
87
|
+
const target = await findSession(session, { ...options, strict: true });
|
|
88
|
+
return target ? { status: 'found', target } : { status: 'missing' };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { status: 'unavailable' };
|
|
92
|
+
}
|
|
65
93
|
}
|
|
66
94
|
export async function findSession(session, options = {}) {
|
|
67
95
|
if (!session || !/^[A-Za-z0-9_-]{1,128}$/.test(session))
|
|
@@ -69,28 +97,39 @@ export async function findSession(session, options = {}) {
|
|
|
69
97
|
const directory = path.join(path.dirname(claudeSettingsPath(options.env, options.home ?? homedir())), 'sessions');
|
|
70
98
|
let names;
|
|
71
99
|
try {
|
|
72
|
-
names = await readdir(directory);
|
|
100
|
+
names = await (options.readDirectory ?? readdir)(directory);
|
|
73
101
|
}
|
|
74
|
-
catch {
|
|
75
|
-
return null;
|
|
102
|
+
catch (error) {
|
|
103
|
+
return error.code === 'ENOENT' ? null : unavailable(options.strict === true);
|
|
76
104
|
}
|
|
77
105
|
const matches = [];
|
|
106
|
+
let incomplete = false;
|
|
78
107
|
for (const name of names) {
|
|
79
108
|
if (!/^\d+\.json$/.test(name))
|
|
80
109
|
continue;
|
|
81
110
|
try {
|
|
82
|
-
const bytes = await readFile(path.join(directory, name));
|
|
83
|
-
if (bytes.length > 128 * 1024)
|
|
111
|
+
const bytes = await (options.readSessionFile ?? readFile)(path.join(directory, name));
|
|
112
|
+
if (bytes.length > 128 * 1024) {
|
|
113
|
+
incomplete = true;
|
|
84
114
|
continue;
|
|
115
|
+
}
|
|
85
116
|
const d = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
86
|
-
if (d.sessionId
|
|
117
|
+
if (!d || typeof d.sessionId !== 'string' || !Number.isSafeInteger(d.pid) || d.pid <= 0 || d.pid !== Number(name.slice(0, -5))) {
|
|
118
|
+
incomplete = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (d.sessionId === session)
|
|
87
122
|
matches.push(d);
|
|
88
123
|
}
|
|
89
|
-
catch {
|
|
124
|
+
catch {
|
|
125
|
+
incomplete = true;
|
|
126
|
+
}
|
|
90
127
|
}
|
|
91
|
-
if (matches.length
|
|
128
|
+
if (incomplete || matches.length > 1)
|
|
129
|
+
return unavailable(options.strict === true);
|
|
130
|
+
if (!matches.length)
|
|
92
131
|
return null;
|
|
93
|
-
const d = matches[0], identity = await processIdentity(d.pid, options.platform, options.runner);
|
|
132
|
+
const d = matches[0], identity = await processIdentity(d.pid, options.platform, options.runner, options.strict === true);
|
|
94
133
|
if (!identity)
|
|
95
134
|
return null;
|
|
96
135
|
return { session, pid: d.pid, ...identity, title: typeof d.name === 'string' ? Array.from(d.name).slice(0, 60).join('') : '',
|
|
@@ -124,7 +163,9 @@ export async function findSessionName(session, options = {}) {
|
|
|
124
163
|
return '';
|
|
125
164
|
}
|
|
126
165
|
export async function validateTarget(target, options = {}) {
|
|
127
|
-
|
|
166
|
+
if (target.unverified === true)
|
|
167
|
+
throw new SessionLookupError();
|
|
168
|
+
const current = await findSession(target.session, { ...options, strict: true });
|
|
128
169
|
return current !== null && current.pid === target.pid && current.birth === target.birth && current.tty === target.tty;
|
|
129
170
|
}
|
|
130
171
|
const WINDOWS_INJECT = `$ErrorActionPreference='Stop';[Console]::InputEncoding=[Text.UTF8Encoding]::new($false);$x=[Console]::In.ReadToEnd()|ConvertFrom-Json;$p=Get-Process -Id $x.pid;if($p.ProcessName -ne 'claude' -or $p.StartTime.ToUniversalTime().Ticks.ToString() -ne $x.birth){exit 3}
|
package/dist/reply-state.js
CHANGED
|
@@ -127,7 +127,8 @@ export async function stateTransaction(access, work) {
|
|
|
127
127
|
}
|
|
128
128
|
for (const [key, value] of Object.entries(state.sessions)) {
|
|
129
129
|
if (value.session !== key || !Number.isSafeInteger(value.pid) || value.pid <= 0 || typeof value.birth !== 'string'
|
|
130
|
-
|| typeof value.tty !== 'string' || typeof value.working !== 'boolean'
|
|
130
|
+
|| typeof value.tty !== 'string' || typeof value.working !== 'boolean'
|
|
131
|
+
|| value.unverified !== undefined && typeof value.unverified !== 'boolean')
|
|
131
132
|
delete state.sessions[key];
|
|
132
133
|
}
|
|
133
134
|
for (const [key, value] of Object.entries(state.entries)) {
|
package/dist/reply-telegram.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createTelegramApi, TelegramApiError } from '@quecue/notify-core';
|
|
|
2
2
|
import { requestJson } from './channels.js';
|
|
3
3
|
import { ConfigError } from './config.js';
|
|
4
4
|
import { t } from './i18n.js';
|
|
5
|
+
import { TelegramRequestError } from './notification-diagnostics.js';
|
|
5
6
|
export function telegramApi(token, request = requestJson) {
|
|
6
7
|
const call = createTelegramApi(token, { request });
|
|
7
8
|
return async (method, payload) => {
|
|
@@ -12,9 +13,7 @@ export function telegramApi(token, request = requestJson) {
|
|
|
12
13
|
}
|
|
13
14
|
catch (error) {
|
|
14
15
|
if (error instanceof TelegramApiError) {
|
|
15
|
-
|
|
16
|
-
throw new ConfigError(t('reply.telegramConflict'));
|
|
17
|
-
throw new ConfigError(t('reply.telegramRequestFailed'));
|
|
16
|
+
throw new TelegramRequestError(error.code);
|
|
18
17
|
}
|
|
19
18
|
throw error;
|
|
20
19
|
}
|