Metadata-Version: 2.4
Name: dingclaw
Version: 0.9.0
Summary: DingTalk digital-avatar runtime: reply as yourself, fail closed
Author: Zhuoyue
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/adongguo/dingclaw
Project-URL: Repository, https://github.com/adongguo/dingclaw
Project-URL: Issues, https://github.com/adongguo/dingclaw/issues
Keywords: dingtalk,dws,ai-agent,digital-avatar,automation
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Communications :: Chat
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest<10,>=7.3; extra == "test"
Requires-Dist: pytest-cov<8,>=4; extra == "test"
Requires-Dist: pytest-xdist<4,>=3.6; extra == "test"
Requires-Dist: jsonschema<5,>=4; extra == "test"
Provides-Extra: release
Requires-Dist: twine<7,>=6; extra == "release"
Dynamic: license-file

# DingClaw

**钉钉数字分身运行时。** 以本人身份在钉钉里代答——不是机器人账号，是「你」。

六十秒看完安装方式（0.9.0 已发布到 PyPI）：

```bash
pipx install 'dingclaw==0.9.0'                       # 或 uv tool install 'dingclaw==0.9.0'
dingclawd whoami                                      # 只读，认领你的两个不可变 ID
dingclawd init --owner-name <你的花名> --agent qoder  # 配置 + 工作域 + launchd（不加载），默认 summon 模式
dingclawd doctor                                      # 每条未满足的条件后面跟着清掉它的命令
```

装完它什么都不做：每一道对外发送的门禁都要你亲手看过真实响应再打开，`doctor` 的 `next_steps`
会按顺序列出那几条命令。完整流程见下文「安装」与源码包里的 `docs/交付手册.md`。

名字取自 `ding`（钉钉）+ `claw`。dingclaw 是**单渠道、单账号单实例、以本人身份、fail-closed** 的窄深实现。

### Additional organizations (M1/M2, source only)

`dws_profile` remains the primary organization. Optional `dws_extra_profiles` supplement only the same account's internal groups (at most four organizations); they do not duplicate account-level direct chats, DING, calendar or directory feeds. Mac, iPhone and TUI rows show the organization label, with `INTERNAL_GROUP` in the group section. Extra-organization conversations are read-only by default. After separate live acceptance, the owner can opt each organization into supported client sends and avatar replies with `dingclawd org enable-send --corp-id <corpId> --yes`; this runs real self-chat write probes before saving the hot `org_send_enabled` switch, requiring the send probes plus each IM write the primary has already attested. `org disable-send --corp-id <corpId> --yes` revokes it without a restart, including queued writes. Group management and cross-organization forwarding remain refused. Every organization, the primary included, has its own `org_settings` entry (ordinary tier ceiling and whether summons/tasks run there); `dingclawd org set --corp-id <corpId> [--tier-ceiling restricted|standard] [--privileged on|off] --yes` changes it from the next round. An organization added after the key exists starts without privileged runs. Each extra organization gets its own `@me` stream and focus slots for its internal groups, and an agent turn's `dws` accepts only that turn's organization. Lines the owner's account sends through DWS (including the Mac and iPhone panels) are never owner commands; use the avatar button instead. This is not a release or a live capability acceptance.

The owner logs in separately with `dws auth login --profile <corpId>`, then previews `dingclawd org add --corp-id <corpId> --label <label>` and repeats with `--yes` to save. Adding verifies the same DingTalk account; no credentials are copied. Organization membership changes require a daemon restart in a separately authorized maintenance window. See `docs/配置参考.md` and `docs/交付手册.md` for diagnostics and rollback.

服务命令 `dingclawd`，Python 包 `dingclaw`。这是一个本机常驻的轻量轮询服务。它每三分钟通过 `dws` 拉取钉钉增量消息，只把符合规则的新消息交给 CLI Agent，并通过本机已授权的 DWS 身份发送回复。默认使用当前用户身份，不需要注册钉钉应用；也可显式切换到机器人模式。

切换后端无需改代码：在配置的 `agents` 中填写 `name`、`tier`、`enabled` 与
`binary: "auto"`，每次加载配置时发现当前可用的 CLI 入口，再由程序生成对应模板；
也可填写绝对路径固定版本。自动发现支持 Claude Code、Codex、Qoder CLI、千问办公和 pi，
不自动启用后端或授予模板验证标记。Qoder 与千问办公之间用 `dingclawd agent use`
（或面板命令面板里的「切换回复的 provider 和模型」）一步切换，模型从账号的实时目录里选，
见 `docs/配置参考.md` 的「切换 provider 与模型」一节。Codex 已覆盖回复、识图、摘要和 DING 分类，
配置范例和首次验证见源码包里 `docs/配置参考.md` 的「切换到 Codex」一节。

默认策略：

- 接收全部单聊，但按不可变钉钉 ID 排除黑名单里的人；
- 群聊只处理明确 @ 当前用户的消息；
- 黑名单完全由运行实例的人自己配置，产品不内置任何具体人选；
- 忽略自己和已知机器人发出的消息，避免回复回路；
- 支持 Claude Code、Codex、Qoder CLI、千问办公（QwenWork，用其 App 自带的 CLI 与 App 登录态）、pi 五种 CLI Agent，交付默认 Qoder；Qoder 与千问办公可随时切换 provider 和模型，模型列表实时读取；pi 固定走 ChatGPT（Codex）订阅。五者都要在**运行它的那台机器上**跑过 `verify-template` 并由人工写入 `agent_templates_verified` 才能发送，只有 `claude:restricted` 是本仓库已回放验证的条目；
- 每一条发出去的消息都带 AI 标识：正文固定文案（覆盖全部发送路径）+ DWS 原生角标（仅纯文本路径有）；
  也可以把标识换成贴在自己那条消息上的文字表情（`ai_disclosure_mode=emotion`），但**只有流式卡片路径**能这么做——
  只有它存在「消息已建、正文还空」的一瞬，能让徽标先于正文落地；
- 可选在对方那条消息上贴「⌛分身回复中 → ✅分身已回复」状态标识（`presence_markers_enabled`）；
- 可选理解消息和引用里的附件（兼容键名仍为 `image_understanding_enabled`）：图片交给独立 `vision` tier，UTF-8 文本文件做有界提取，
  其他附件只保留类型与元数据；回复模型仍然 `--tools ""`，只拿得到不可信文本，不获得路径或文件权限；
- 默认只回复你**还没读过**的消息（`unread_gate_enabled`，默认开启）；关掉就是什么都回，
  此时防「对方改口」的三个装置从可选变成必需；
- 使用 SQLite 保存水位、去重、租约和发送状态；
- 发送结果不确定时标记为 `SEND_UNKNOWN`，不自动重发；
- 配置未完成、DWS 契约未验证时拒绝启用自动发送。

## Calls, meetings and file sending

Mac and iPhone conversation headers offer **通话与会议** with manual meeting-number/invitation entry. Handoff goes to the official DingTalk client on the current device; opening it does not prove a call connected or a meeting was joined. Directed calls and meeting creation remain unavailable until an official interface is verified. No calls, invitations or sharing happen automatically.

Files have an independent `file-send` contract: `dingclawd verify-contracts --probe-file-write` sends a small synthetic text file and PNG **as files**, only to the verified owner's self-chat, and requires delivery plus attachment readback. It does not upload native images, mute, react or recall. Review the evidence before optionally repeating with `--attest --yes`. Legacy `media-send` remains accepted; `sticker-send` does not authorize local uploads. Native image sending may still require application credentials. Both clients allow an explicit **图片作为文件发送** choice before sending, with no automatic fallback after an unknown outcome; refresh media capabilities in the confirmation sheet without reconnecting. New-client capability queries fail closed against an older core that does not support them. These source changes await the unified main build and installation.

## Automatic replies in summon mode

`auto_reply_users` and `auto_reply_groups` are explicit exceptions to `avatar_mode=summon`, not permission grants. A listed person's direct messages, or real mentions of the owner by any non-blocked sender in a listed group, can be answered without a summon. Typed mentions and mentions of everyone do not qualify. Replies keep the existing standard/restricted tier; blacklist, robot/self exclusion, mute, unread/snapshot failure, application-message and 15-minute age gates still apply. Explicit owner commands and colleague summons keep their original behavior. In `auto` mode the lists do not change behavior; doctor reports a warning.

Mac: Settings › 分身 › 自动回复, or a person/group detail. iPhone: business settings or a person/group/conversation detail, with explicit `settings.read` / `settings.manage` grants and online confirmation. Both edit the same revision-checked config as `dingclawd auto-reply add/remove/list/prune/configure`; TUI has no new editor. The switch defaults to true with empty lists; switching it requires confirmation and never deletes entries.

Schedules are weekly windows in `owner_timezone`, evaluated at message send time; empty means all day. Expiry is also evaluated at send time. Expired entries stay until manual pruning. Invalid sections disable only list-based automatic replies and warn, not the entire instance. See [configuration](https://github.com/adongguo/dingclaw/blob/main/docs/配置参考.md#automatic-reply-lists) for keys and CLI examples. These source changes do not claim an installed, built or released feature.

## Pinned-group latches

In the conversation lens, “置顶” contains only DingTalk-pinned rooms that are latched or still hold messages you have not marked read. An unlatched room returns to its type's group only when you mark it read by hand — `R` / “标已读”, the iPhone's “标记钉钉已读”, or “全部已读” — and comes back with its next message. Looking at a room in Dingclaw or reading it in DingTalk changes its badge, not its block. Use the Mac row menu, the pin icon in the Mac conversation header, iPhone's online right swipe or the iPhone conversation menu to choose “钉住 / 取消钉住”; latches are shared, survive restart and clear when the room is unpinned. The self-chat and other lenses are unchanged. TUI follows the grouping and displays “钉住” without a new key. See the source package's `docs/原生客户端.md`, section “Pinned-group latches (Mac and iPhone)”, for native client behavior.

## 安装

dingclaw 依赖钉钉官方的 DWS CLI。安装入口以
[钉钉开放平台](https://open.dingtalk.com/)为准；国内环境可以直接使用官网给出的 npm
镜像安装方式：

```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
dws --version
```

DWS 是独立项目，源码和更多安装方式见
[DingTalk-Real-AI/dingtalk-workspace-cli](https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli)。
官网也提供 macOS / Linux 安装脚本；采用脚本方式时应先下载并审阅内容，再在本机执行。
它访问企业数据前仍需企业管理员授权；dingclaw 不附带、转存或代发 DWS 凭据。

**企业环境注意**：有些组织只信任自家客户端附带的 DWS 构建。开源版装好后 `auth status` 全绿，
但每一次真实调用都返回 `ENTERPRISE_NOT_AUTHORIZED`。这种情况下把 `dws_binary` 指向客户端自带的
shim（例如 `~/.qoderwork/bin/dws`）：`init` 会逐个试本机找到的每一份 dws；`whoami` 只问一份，
优先你用 `--dws-binary` 指定的，其次配置里已有的，最后才是 PATH 上的。两者遇到这个错误码时都会
直接告诉你该换哪一份。

公开版通过 PyPI 安装。需要 Python 3.10 以上（macOS 自带的 3.9 不够）；`uv` 会自己下载合适的解释器：

```bash
uv tool install 'dingclaw==0.9.0'          # 或 pipx install 'dingclaw==0.9.0'
dingclawd --version
```

也可以用独立虚拟环境，避免改动系统 Python：

```bash
python3 -m venv ~/.local/share/dingclaw/venv
~/.local/share/dingclaw/venv/bin/pip install 'dingclaw==0.9.0'
~/.local/share/dingclaw/venv/bin/dingclawd --version
```

企业如需内部分发，可把同一组 wheel / sdist 同步到云效 Packages 等私有 PyPI；云效仓库仍要求
仓库成员凭据，不是互联网匿名下载入口。

安装后跑一次 `init`：

```bash
dingclawd whoami                                      # 只读，认领你的两个不可变 ID
dingclawd init --owner-name <你的花名> --agent qoder  # 配置 + 工作域 + launchd（不加载）
dingclawd doctor                                      # 列出你还欠的每一件事，和清掉它的命令
```

`init` 生成 `~/.config/dingclaw/config.json`（0600）、`~/.dingclaw/` 工作域骨架、
`~/Library/LaunchAgents/com.dingclaw.<你>.plist`，**所有门禁一律关闭**。
它不会、也无法打开任何门禁。新装默认 `avatar_mode=summon`：分身只回应召唤，不替你自动回普通消息；
要自动回复传 `--avatar-mode auto`。`init` 的输出里还有三样值得看：`dws_candidates`（本机每一份 dws
以及它能否认出你）、`agent_path`（守护进程的 launchd PATH 找不到 agent 或它的 node 时自动写进去的目录）、
`next_steps`（后面每一步的命令，按顺序）。

`whoami` 认不出你时（`auth status` 不返回 userId 的企业客户端、没登录、用错了 dws），输出里的
`next_steps` 写着怎么办；登录好之后不用重跑 `init`，`dingclawd whoami --write` 只把两个 ID 写进
已有配置（配置里已有不同的值会拒绝，状态库已经绑定身份也会拒绝并告诉你怎么处理）。

之后按 `doctor` 的 `next_steps` 走：`verify-contracts` 看证据再 `--attest --yes`，`verify-template`
看探针再 `--attest --yes`，summon 模式下 `enable-feature summon --yes` 打开召唤通道（不开的话
分身开了发送也不会回任何消息），最后 `enable-sending --yes`。想要「同事召唤 → 任务卡」这一层，
`dingclawd enable-feature summon-tasks` 先预览再 `--yes`，它会把持续任务和卡片的四个开关一起打开、
加一条 privileged 条目，并告诉你还差哪两道背书；分身已经在跑的实例第一次只准备，背书齐全后再跑一次
才翻开关，不会把运行中的分身弄停。

分身关掉的时候，用面板接管：

```bash
dingclawd tui                                       # 待回应列表 + 会话预览 + 分身模式切换
dingclawd tui --private-core                        # 网关故障时临时用：面板自带私有内核
```

面板默认经 unix socket 连常驻守护进程（`dingclawd run`）里的本地网关，钉钉读写都由守护进程代办，
和分身共用同一套出站预算；所以面板要求 `dingclawd run` 在跑（发送闸门关着也行）。连不上时
`dingclawd tui` 不开面板，直接在终端给出恢复步骤（`launchctl kickstart -k ...`、`dingclawd doctor`、
`dingclawd tui --private-core`）并以退出码 2 结束。面板开着时守护进程重启或停掉，面板保留列表、
打开的会话和输入框，最多 30 秒内自动重连，连上后重新读取当前视图；断开期间不发送。30 秒内连不上，
或重新连上的是另一个版本、另一个实例的网关时，面板把输入框里没发出的字存到
`~/.dingclaw/tui-unsent-<时间>.txt`（只有你能读），打印文件路径和同样的恢复步骤，以退出码 2 结束；
断开时还有发送没等到结果的，会提醒先去钉钉核对。`--private-core` 就是网关之前的面板：自己拉起
一个内核、自己开一套钉钉通道，只作兜底，保留一个版本。网关细节见下文「本地网关与镜像库」。

面板是 TypeScript + React + Ink 写的，随 wheel 分发成一个单文件 bundle，不需要 `npm install`。
它自己不做任何判断：门禁开没开、这行能不能触发、这条消息是不是分身发的，全部由 Python 内核回答，
面板只渲染答案。**把面板整个删掉，这个实例的安全属性一点不变。**

面板发送只在取得本次任务的成功确认后显示「已送达」；「已受理，待确认送达」不是成功。普通消息遇到空回执时仅用同一幂等 UUID 有限恢复，引用回复不自动重发，相同文字的历史消息不能证明这次送达。网关声明 `send-status` 能力时，面板继续只读查询该会话与 UUID 的发送台账（最多 16 分钟内启动 65 次查询，离开会话即停止），不会因查询而重发；无法确认时明确保留「未确认送达」，请在钉钉核对后再决定是否另发。旧网关或私有内核不支持后台查询时同样保留未确认状态。

Mouse navigation is available alongside the keyboard: single-click a conversation row to open that exact conversation, or a fold strip to expand/collapse it. The bottom controls page through loaded rows, refresh, open the people picker, and show help. Inside a conversation, `返回` returns directly to the list while retaining draft text; `上翻` / `下翻` scroll the loaded transcript, `最新` jumps to the newest message, and `更早` fetches older history. Disabled controls do nothing. Short windows use compact labels (`↑`, `↓`, `末`, `旧`) instead of duplicating keyboard hints. Click a full view chip to switch views; a compact view bar cycles to the next view. Its `3/5` counter identifies a view, not a data page. Opening honours the existing unread-hold setting and does not send a message; list paging cannot retrieve conversations outside the backend's capped window.

五个主视图均可按 `r`（或 `Ctrl+L`）主动拉取最新数据；可见会话的消息也一起刷新。刷新保留草稿、筛选和阅读位置，不触发回复或标记已读。输入框中的 `r` 仍是正文，退出输入后再刷新。

要默认打开时间线，在配置文件的 `panel_ui` 中设置 `"view": "timeline"`，重开面板生效。`single_lens` 保持 `false` 时仍可用数字键切换其他视图。

选中时间线视图时每 15 秒自动刷新最新消息，保留当前时间范围和选中消息；进入会话后仍继续刷新列表，切换到其他视图即停止。慢请求期间不叠加请求，失败保留旧列表并在后续周期重试。全屏面板逐行覆盖变化，不先擦空列表；最后成功刷新时间按当前视图记录，时间线更新不会继续显示分诊列表的旧时间。实时连接状态与读取结果分别判断，读取成功代表取得 DWS 当前可查询的数据，仍可能有上游索引延迟。

未读列表会合并独立未读来源与普通、置顶会话信息，每 15 秒自动刷新，收到实时消息后也会合并触发刷新；当前会话每 30 秒补读消息。后台更新保留当前选择和输入，不会清除未读。接口达到 100 条上限或读取失败时会持续提示覆盖限制；顶部连接标记与数据更新时间分别展示，连接正常不代表列表一定新鲜。

「待回应」（配置与协议标识仍为 `triage`）只收**最近 24 小时、本机已同步范围内**等你回应的事，不是账号全量待办。分三段：钉我（DING）、@我、单聊。DING 不以已读或已确认为排除条件；@我 只收群里点名你的消息，@所有人 不算；单聊收对方最后发言、你还没回的会话（读过没回也算，应用通知单聊除外）。一个会话只出一行，按最强信号归段，每行说明为什么仍待回应；其余信号和分身超时、生成失败、发送失败等原因只作附加标签，分身失败不再单独拉入会话。观察到你或分身在触发之后成功发言、手动处理完，或信号超出 24 小时窗口，这一行才离开；只读不回不会离开。对方在你上次发言之后只发了收尾话（好的、收到、谢谢、👍、表情之类），或者只发了你已在本机看过的短句（不超过 10 个字，没有问句、请求或报故障的字眼），这一行折进「可能无需回复」，不计入待回应数；判断由内核按规则做，带 DING 的行永不折叠，判断不了（镜像还没同步到、消息太多、带图片/文件/卡片）就照常显示。处理过的会话之后对方只发收尾话不会再冒出来，发了有内容的消息才回到待回应。免打扰、分身静默与可能无需回复三个折叠不计入顶部待回应数。处理一行：TUI 与 Mac 按 `e`，Mac 也可点行上悬停出现的 ✓ 或右键「处理完」，iPhone 左滑「处理完」；处理一整张列表：TUI 按 `E` 再按 `y`，Mac 点说明行右侧的「全部处理」（先确认）或在待回应里用触控板下拉保持 2 秒，iPhone 在待回应下拉保持 2 秒；全部处理包含折叠里的行，搜索时不可用。两者都只改本机待回应标记，没有撤回，之后新到的消息照常进入；不推进已看位置、不清红点、不确认 DING、不删除消息，也不停止分身任务。`R` 在每个视图都是清钉钉红点，待回应里也一样，清完行仍在。

「@我的」「特别关注」「稍后处理」是三个快捷视图（键 5 / 6 / 7；Mac 在视图分段控件右侧、iPhone 在导航栏右侧各有三个图标），再点一次回到原来的视图；要内核握手里带 `quick-lenses`，私有面板内核（`--private-core`）没有。「@我的」按消息列出最近 7 天群里点名你的消息（不含 @所有人、你自己的和撤回的），「特别关注」列出你在钉钉里特别关注的人发的消息（单聊群聊都算）；两者都分「未看 / 已看」两段（按本机已看位置，TUI 不推进它），「@我的」里应用机器人的消息收进「机器人」折叠，不计数；`u` 或列表底部「加载更早」往前翻。特别关注名单由内核每 6 小时读一次 `dws contact relation list-my-followings`，存在镜像里；待回应、时间线和会话列表里来自关注人的行标「关注」（TUI `[关注]`，Mac / iPhone 星标），待回应同一段内排在前面，但不会因为关注就进待回应。「稍后处理」只存在这台 Mac 的内核里，和钉钉 App 的稍后处理互不相通：在待回应里按 `w`（Mac 右键、iPhone 左滑「稍后」）这一行移到稍后处理并离开待回应；在其他视图或消息上（Mac 右键、iPhone 长按、TUI 会话里 `w`）只标那条消息。稍后处理列表不受 24 小时窗口限制，`e`「完成」移出并把那个会话在待回应里处理到这条消息，`w`「取消稍后处理」只移出，从待回应挂进来的会放回待回应。

可选 AI 辅助判断使用顶层布尔配置 `"triage_ai_enabled": false`，默认关闭。本人明确开启后，才把规则候选的有界消息摘录和必要上下文交给当前配置的隔离 Agent（禁用工具），后台缓存优先级、简短理由与「可能无需回复」提示。它不扫描额外会话、不隐藏或自动处理条目、不回复、不发 DING；超时、错误或不可用时保留规则结果。开启意味着这些摘录会交给所选模型处理并可能消耗额度，不是纯本地判断；详见 `docs/配置参考.md`。

支持内联图片的终端中，点击图片可交给系统默认查看器打开。键盘操作是在非输入状态选中消息后按 `v`（只有图片、音视频、PDF、文本、Office 文档和压缩包会直接打开；程序、脚本、网页、链接与配置描述文件只在访达中显示并说明原因，是否运行由你决定），光标在只有图片或文件、没有可展开内容的消息上时按 `Enter` 也一样；多个附件先选择，再按 `Enter` 或 `o`。kitty 协议只收 PNG，钉钉里的截图却常是 JPEG 或 GIF（文件名还是 `.png`）：macOS 上面板先用系统自带的 `sips` 转成 PNG 再画（太大的按长边 1600 像素缩小），转换副本在私有临时目录里读完即删；其他系统或转换失败时仍显示「只能内联 PNG」，`v` 打开原文件。占位行把 `v 打开` 放在原因前面，窄窗口截断时先丢原因。引用在窄窗口里被截成一行时，`Enter` 展开引用全文（折行显示），再按一次收起。退出会话、滚动和调整窗口时会清理旧图片位置。Clickable rows, controls, and images enable terminal mouse reporting; dragging does not activate them. Use the terminal's selection modifier to select native text (Shift in Ghostty).

在群聊输入框键入 `@` 会就地打开当前群成员选择器；继续输入可按群昵称、花名或姓名筛选，
`↑` / `↓` 选择、`Enter` 插入，`Esc` 则保留为普通文字。只有从选择器确认的青色 `@花名`
才携带钉钉原生通知，手打同名文本和 AI 草稿里的 `@名字` 都不会猜测身份或误通知；单聊中的
`@` 始终是普通文字。发送前身份行会持续显示本条将通知的人数。

撰写框里超过一行宽度的内容会自动折行并全部显示，不再截断；`Ctrl+J` 仍可手动换行。草稿高于编辑区上限（终端高度的三分之一）时只显示光标附近的行，框内首行会写明上面或下面还有几行。

撰写区按 `Ctrl+X Ctrl+E` 可把整段草稿交给外部 Vim/Neovim；保存退出只回填，不会自动发送。编辑器按 `DINGCLAW_EDITOR`、`VISUAL`、`EDITOR`、`nvim`、`vim`、`vi` 依次选择。保存空内容会丢弃整段草稿；先按 `Esc` 收起再按 `d` 丢的是 AI 的部分——框里是 AI 草稿就整段丢弃，框里是你自己打的字、旁边挂着参考建议或 AI 草稿登记时只丢建议和登记、保留你打的字，什么 AI 的部分都没有时清空输入框；正文里的普通 `d` 不受影响。AI 草稿经外部编辑后仍保留 AI 标识，只有完整丢弃后重新输入才转为本人文本。

经网关时，AI 起草的内容由内核登记为该会话的 AI 草稿，是否带 AI 标识由内核判定：登记还在，这个会话里发出的消息就带标识，哪怕是你在参考模式（`A`）下照着建议自己写的字——宁可多标，不漏标；身份行和参考框会写明「`d` 丢弃前发出都带标识」。登记跨面板重启保留，原生客户端起草的也算，面板每打开一个会话都会先读一次本会话的登记；发出后屏幕上是否标「分身代发」以内核实际的判定为准。`d`（或存空文件）会通知内核丢弃登记，确认后才提示不带 AI 标识，你自己打的字留在框里；草稿发送成功后由内核自动消耗。

需要 `node >= 20`；没有或版本不够时自动回落到旧的 curses 待办面板，并说明原因（`--legacy` 可强制回落）。

源码包和完整交付包中还附带 `docs/交付手册.md` 与 `docs/配置参考.md`；本文保留公开安装、
核心安全边界和日常使用所需的信息，避免 PyPI 页面依赖无法解析的仓库相对链接。

**也可以让 agent 代劳**：安装技能随包分发，`dingclawd skill path --skill dingclaw-install` 打印它的位置（交付包根目录也有一份 `SKILL.md`，仓库内是 `src/dingclaw/install_skill/SKILL.md`），交给 Claude Code / Qoder CLI / Codex 执行即可。它会跑完全部步骤，并在四个安全门禁处停下来要你本人确认——`verify-contracts --attest --yes`、`verify-template --attest --yes`、`enable-sending --yes`、以及 `launchctl bootstrap`。这四个命令没有 `--yes` 一律拒绝执行，agent 绕不过去。

手工配置也可以：`config.example.json` 是全量键 + 默认值，由代码生成、有测试钉住。

## 原生 macOS 客户端

`macos/` 提供 macOS 26+ 的 SwiftUI 客户端，与 TUI 共用 `dingclawd run` 的网关、出站预算、AI 来源登记和发送台账，不另开 DWS 通道。0.6.0 起随 GitHub Release 提供 Developer ID 签名并经过公证的 Apple Silicon 版（`Dingclaw-0.6.0-macos-arm64.zip`），要求网关协议 **2.3**；App 首次启动时用内置的 Python 和 wheel 安装同版本内核。

- 消息与多窗口之外，支持群与公告管理、通讯录部门树、DING、日/周日程、待办、审批处理、分身控制台、运维及白名单设置。
- 消息中的可识别 JSON 卡片显示标题、正文、状态与带名称的链接；历史消息、列表摘要和引用预览使用一致的可读内容，保留原始内容查看入口，不执行卡片回调。Mac 右键作用于指针下的消息，正文仍可选字和复制，图片/文件专属操作保留。
- 消息提醒：Mac 右键或 iPhone 长按消息选「提醒」（20 分钟后、1 小时后、今天 18:00、明天 9:00 或自定义并写备注），到点由内核给你本人发一条 DING，Mac App 在线时另弹系统通知、点开定位到消息；列表在 DING 页「提醒」分段。需要 `ding-send-personal` 契约，每日上限 `message_reminder_daily_cap`（默认 50）。
- Mac「待回应」按 `e`、行上悬停的 ✓ 或右键处理一行，点「全部处理」（先确认）或用触控板下拉保持 2 秒处理整张列表；没有多选与撤回。每行按它展示的最新信号处理，不把后来到达的新信号一起清掉。
- 打开会话只推进本机已看，不清钉钉红点。通知快捷回复保留 AI 来源披露；未知创建不会自动重试，刷新也不会解除防重复保护。危险操作须二次确认。
- `local_notify_yield_to_app` 默认 `true`：只有获系统授权、处理器就绪并订阅通知的 App 在线时，daemon 的 IM 本地横幅才让位；最后一个所有者消失 120 秒后恢复。主动 DING 提醒不受影响。
- `terminal_alerts_enabled` 默认 `false`。打开后，@我、真人单聊与 DING 写进事件目录 `terminal_alerts_dir`，供 herdr 里的 Claude Code 会话弹出提醒；DING 按 `terminal_alerts_ding_refresh_seconds`（60 秒）单独刷新。格式与规则见 `docs/配置参考.md`「终端会话提醒」。

构建、通知权限、写入契约、安装升级及功能边界见 源码包中的 `docs/原生客户端.md`。App 内置 wheel 与 uv；安装、停服、升级和真实业务验证都需要独立确认。Rollback never restores a database backup. Before any old-code reinstall, the stopped candidate must prove that the exact privately staged old wheel supports the current ordinary-output protocol and retained obligations. Unknown, incompatible, malformed or changed proof leaves maintenance active and the service stopped; it never authorizes a forced downgrade.

### Sticker and GIF viewing

Mac and iPhone render image-backed stickers and GIFs without changing the original downloaded bytes. Actual GIF detection uses ImageIO, including GIFs named `.png`. Automatic playback is limited to six visible animations on Mac and three on iPhone; offscreen/background animations pause, and Reduce Motion defaults to a poster with an explicit play action. Ordinary phone photos remain tap-to-load; explicit sticker/GIF metadata enables visible auto-loading.

Local attachment caches survive restarts (Mac 512 MiB, iPhone 256 MiB), expire after seven idle days, are private and excluded from backup. Clear them in Mac Settings > Core, or iPhone > My Mac. This does not clear the core's independent mirror cache or pending sends.

Original inline emoji artwork is **not bundled or automatically downloaded**. Import a folder you are authorized to use in the same settings section, independently on each device. It contains `emoji.json` such as `{"微笑":"smile.png"}` and local static PNG files. Missing artwork falls back to Unicode, then the exact `[name]`; copying keeps the original code. See the source package’s `docs/交付手册.md`, section ‘Sticker and GIF rendering’, for limits and unsupported formats.

## 手机分身（实验性、未发布）

手机分身默认关闭，仅规划受邀试用；当前接线开发中、尚未完成整体验收，不表示已经发布、可下载或通过真实手机验收。目标设备为 **Apple Silicon / macOS 26+ 的 Mac** 与 **iOS 18+ 的 iPhone**，使用原生 App。

- Mac 是唯一 DWS 出口。每人使用自己的 Mac、DWS/组织身份和模型账号；手机与公网中继不保管业务登录材料。
- 接收者通过完整 App 的 GUI 准备自有运行时并完成本人认证，不需敲安装命令或填写 manifest；完整资源校验未通过、开发包或未知分发标记保持不可用。扫码还需 Mac 本人核对设备和具体权限后批准，并可在 Mac 撤销。
- The iPhone source workbench includes conversations, timeline, triage and unread, message actions, contacts/departments/groups, DING, calendar, todos and approvals. Every method still requires its exact device grants; new scopes never expand an existing approval. Opening a message advances Dingclaw seen state, not the official DingTalk read marker.
- 分身只在钉钉会话里工作：Mac 与 iPhone 上没有独立的分身会话或任务页面；`@me <任务>` 召唤、`@me tasks` 看回执，召唤消息下方标出任务结果。AI 起草只在 Mac（`a`，`draft.compose`），手机不再请分身起草。
- Delegable Avatar settings and separately Mac-approved provider/model selections require an exact online action preview and confirmation. Credentials, device approval, workspace registration, runtime preparation and installation/upgrades remain Mac-only.
- 离线指令只保存待本人重确认；未知受理按原 UUID 核验，不换号自动补发。锁屏推送仅泛化文字，缓存按本人和 Mac 隔离保护，撤权不承诺即时擦除离线副本。

这些设置保存在独立 `companion.db`，不新增或猜写 `config.json` 键。Mac Developer ID/公证包与 iPhone TestFlight 是分发目标，**真实跨网、APNs、模型调用、净机首装及分发流程尚待验收**。完整说明与分发前门禁见源码包/仓库文档 `docs/手机分身.md`；既有 CLI 安装方式不代替手机试用的完整 GUI 交付。

## 轻量路由与持续任务

可选 `fast_routing` 默认关闭，默认 `shadow` 模式只记录判断。独立 `codex:fast` 无工具探针验证通过并开启持续任务后，可切到 `enforce`：纯问候使用可信模板，召唤帮助使用程序帮助原文，复杂问题、附件与信息不完整的任务继续使用原有强模型。轻量模型默认为 `gpt-5.6-luna`、`low`，单次超时 8 秒、并发 2、每 UTC 日独立额度 200 次；失败、超时、忙碌或额度耗尽自动回退。

路由使用独立空白 Codex app-server 上下文，不继承 AGENTS、MCP 或 Skills，不开放工具。关联输入先持久化再分类；纯催进度不取消或重跑任务，混合补充保留全部要求。快答沿用持久最终投递，文本选择与卡片创建原子互斥，不把已开始或未知的卡片再发成另一条文本。发送仍保留 AI 标识与原有权限校验。

路由连接常驻并预建下一轮 thread：上一轮退订后立刻开好下一轮要用的 thread，让 Codex 的会话初始化与本轮回复投递重叠；守护进程启动时预热连接，把进程拉起、`initialize` 与首个 thread 的冷启动移出首条消息的路径。任意时刻只保留一个活跃 thread，每个 thread 仍只承载一个请求，路由请求之间不共享上下文。本机实测单轮 4.3 秒降至 2.8 秒，其中会话初始化段 2.38 秒降至 0.90 秒。

本机装有托管 Agent Hook 时，守护进程只读跟随它的摘要日志，判断同步 hook 是否因本地引擎不应答而阻塞。只解析 `mode=sync` 记录的判定与耗时，命中 `allow_timeout` / `allow_circuit_open` / `allow_server_dead` 或单次 5 秒以上即置劣化，状态跃迁时记 `agent_hook_degraded` 与 `agent_hook_recovered`（带 `degraded_seconds`）。日志缺失即静默停用，读不了只报一次 `agent_hook_watch_failed`；日志中的 prompt 与命令原文既不解析也不回显。劣化期间，命中问候与召唤帮助白名单的新召唤直接用本地可信模板作答并标记 `engine_degraded_direct`——该路径下模型生成的文本本就会被同样的模板替换，等下去只是多付一次同步 hook 超时；引擎健康时行为不变，追问分类仍走模型。本机实测托管 hook 引擎不应答时单轮路由 19.6 秒，健康时 4.4 秒。

验证入口为 `dingclawd verify-fast-router --config ~/.config/dingclaw/config.json`；完整参数和灰度验收见源码包里 `docs/配置参考.md` 的「轻量路由」与 `docs/交付手册.md` 的「轻量路由交付验收」。不通过探针时仅停用优化，不降低强模型配置。

## 事务账本（实验性，默认关闭）

分身接住的事有时要等别人几天、要在另一个会话续上、要建单派活、要等修复上线后复验。事务账本（`cases.db`）把这些事记成一件件“事务”，六个场景模块（跨会话跟进、告警→缺陷→复验、钉钉→云效→工作台、会前/会后、群漏答提醒、周报草稿）各自 `off / observe / assist` 三档开关，默认全关。`assist` 档起草的每一条对外写入都先发到你自己的单聊（或 App）等你确认，确认后只执行一次并回读；没有自动写的档位。The six modules are now connected to the ledger and bounded workers. Mac provides list/detail, action review/editing, message-backed creation, wait controls and settings; iPhone provides online ledger review, one-use action confirmation and mode downgrades under the explicit `cases.manage` grant. External adapters remain fail-closed when their installed contract or configuration cannot be verified; mocked tests are not live acceptance. 配置见源码包中的 `docs/配置参考.md`「事务与场景模块」，只读查看用 `dingclawd cases list|show|stats|report`。第七个模块 `approval` 是召唤任务的查库确认通道：root 档任务只能经 `dingclawd db-query run` 查库，你本人任务的白名单库只读查询直接执行，其余发到你的单聊或 App 等你确认，确认后原任务接着给结论；`confirm_reads: false` 关掉只读查询的确认，`scope` 内任何会话的只读查询都直接执行，写操作照旧（见 `docs/配置参考.md`「召唤任务查库确认」）；`agent_mcporter_config` 让分身只看到 Executor 网关、看不到直连 DMS 的 MCP。Devflow completion requires explicit `cases.devflow.completed_status_ids` (default empty); terminal delivery is not business acceptance. Workbench declarations and traffic-probe commands are file-only configuration, never model-selected executables.

## 群告警统计与分级响应

显式配置 `alerts.groups` 数组后，可独立采集指定群中指定机器人发送的告警，按来源、程度和分类统计。
默认关闭，群名只用于显示，授权依据是群 `conversation_id` 和来源 `sender_open_ids`，不要把采集名单写进聊天的 `robot_user_ids` 排除项。

日报默认每天北京时间 21:00，统计前一天 21:00 至当天 21:00；周报默认周日 21:00，统计前一周同一时刻至本周同一时刻。
两种报告都由程序生成，不调用 AI。独立计时与工作线程不依赖聊天模型就绪，也不因群消息已读、未 @ 或 summon 模式跳过告警。
报告分别显示消息数、触发/恢复数、同类聚合、来源/级别/分类和采集缺口；同类聚合不能直接当作独立故障数。

卡片把级别仅放在图片里时，可通过 `alerts.card_templates` 配置人工核实过的图片 `media_id` 与级别、来源（含 ARMS）的精确映射。默认空列表，不调用 AI/OCR 识图，也不按颜色或未识别图片猜测级别。

严重程度先按当前告警字段和来源规则匹配：恢复不排障，显式 P0 优先；未命中显式业务规则的 P1 绕过默认降噪，其他普通 OOM、ACK Warn/资源修改、巡检及 CPU/带宽告警默认抑制自动分析，保留已知上游级别（未知级别才回退 P2）。CMS 聚合级别字段中的完整 `严重(critical)` 及带正整数次数的 `严重(critical)1次` 映射为 P1，不从链接或说明片段猜级别。`noise_categories` 可选择这些类别，设为空数组可禁用默认抑制；自定义规则支持上游级别、标题全部/任一关键词联合匹配，`severity=inherit` 配合 `response=record` 可保留 P1 统计而不触发模型。
常规告警仅统计；严重告警默认全实例每日最多调用 Codex 3 次，同类冷却 6 小时、并发 1。`investigation_max_concurrent`（1–4）可让多条调查各自独立执行，一条卡住不挡其他调查；实际并发不超过分析链的模型账号容量（`investigation_account_max_concurrent` × 账号数）。切到 `analysis_mode=rules` 后只生成规则排查建议。
Codex 默认使用 `analysis_permission=read-only`，只读取程序准备的脱敏证据目录，不开放其他目录、网络或 MCP。需要对严重告警查询真实日志和源码时，可显式设置 `analysis_permission=root`：沿用 privileged Codex 的 `danger-full-access` 及 `agent_config_home` 中的 MCP、Skills；这里的 root 是应用高权限，不是 sudo 或切换操作系统 UID。诊断职责仍限定只读，禁止自动修复、重启、发布和写业务数据；root 下这是提示约束，不是操作系统强制隔离。`analysis_evidence_paths` 是最多 8 个优先参考文件的绝对路径，read-only 只看脱敏快照，root 可继续查询其他已授权证据。

严重告警使用独立 Incident/Episode 和证据账本，调查结论以钉钉原生「引用回复」回到触发告警下：每次调查得出结论或转人工时发一条，排队、执行中和没有新结论的复查不发；告警恢复、模型返回和消息送达都不等于根因闭环。默认首次调用最多 30 分钟，首次结束后在 15/30/60 分钟检查新证据，自动窗口与模型累计执行时间均最多 2 小时；`analysis_timeout_seconds` 只可进一步缩短单次调用，`0` 不会让严重调查无限运行。证据不足会等待或转人工；自动确认必须通过程序的五类 observed 证据门槛，人工接受未知仍明确根因未确认。策略由有界的 `investigation_policy` 配置，日额度、冷却和并发限制继续生效。

```bash
dingclawd alerts status
dingclawd alerts report --group <群ID> --kind daily
dingclawd alerts report --group <群ID> --kind weekly --at 2026-09-13T21:00:00+08:00
dingclawd alerts investigations list --status NEEDS_HUMAN
dingclawd alerts investigations get <incident-id>
# 人工处置必须带最新 revision、理由、幂等 request key 与显式确认。
dingclawd alerts investigations act <incident-id> --action accept_unknown --expected-revision <revision> --reason <理由> --request-key <唯一键> --yes
# 明确发送预览所对应的报告；同窗口与定时任务共享幂等记录。
dingclawd alerts report --group <群ID> --kind daily --send
```

严重告警在 root 档默认按代码、日志、ACK 三方面交叉取证：对齐运行版本、调用链、业务终态、Pod 状态及指标，并验证候选原因和反证。某条查询失败后尝试现有且已授权的独立只读来源；不能把尚可执行的关键查询全部留作建议。结果必须列出三方面的核验或具体阻塞、因果链和调查收尾依据，并区分已确认根因、高可能原因、根因未明；业务成功与告警恢复另外判断。read-only 档只使用快照，证据缺口不扩大权限。结构完整性校验不代表工具证据或根因已获独立验证。

告警群、来源机器人、触发规则、日报周报和分析策略统一使用 `alerts` 配置，群 ID 和具体服务不写死在程序中。`analysis_checks` 可选择 `code`、`logs`、`ack` 的子集，默认三者全开；空数组只分析已有证据，不启动这些维度的查询。`analysis_context` 用于补充选中维度的诊断要求，不能取消只读、凭据和事实证据约束。`dingclawd alerts config schema/get/validate/set` 提供 JSON 配置入口和表单 schema；保存要求读取时返回的 revision，拒绝旧配置覆盖，供未来 GUI 共用同一配置服务。详见源码包里 `docs/配置参考.md` 的「告警配置接口」。

首次回填历史只参与统计，旧严重告警不会自动补做分析；错过发送时刻后仅补最新一期。
主动告警分析默认引用原消息（`alerts.reply_quote_enabled=true`），同时附上告警时间、名称和脱敏摘要；个人发送通道在已核验 `reply` 契约时使用钉钉原生引用，其他通道、契约未核验或缺少消息标识时保留正文摘要。设为 `false` 恢复普通回复，日报周报不引用某一条告警。

root 工具失败会区分瞬时故障、参数、认证权限和人工确认。必要工具取证仍未完成时保留已有分析并标明缺口；只读瞬时失败最多额外分析一次，间隔 60 秒并计入日额度，其他阻塞不自动重试。普通 root 任务也遵守有限重试、不重放结果未知的写操作和不绕过确认的规则；这不保证每次都能取得全部证据。
发送结果未知不自动重发，消息历史可见才是实际送达证据。
完整字段及规则示例见源码包里 `docs/配置参考.md` 的「群告警统计与分级响应」，部署验收见交付手册。

## 本机准备

个人身份模式必须分别填写本人的 `self_user_ids` 和 `self_open_dingtalk_ids`，再按需配置 `deny_user_ids`，设置 `sender_mode=personal`，并用脱敏真实响应验证 `list`、`list-all`、`list-mentions`、`list-unread-conversations`、`send`、`query-send-status` 六项契约后再打开门禁。`deny_user_ids` 可包含 userId 或 openDingTalkId；禁止使用姓名代替不可变 ID 作为生产拒绝规则。黑名单里的人只在显式召唤时需要回复的，把其 openDingTalkId 同时写入 `summon_exempt_user_ids`（见 `docs/配置参考.md`）。两类自身 ID 都会进入回复回路保护与状态库发送身份绑定。

启动前运行 `doctor`。它会验证 DWS 是否安装，并通过 `dws auth status` 检查指定组织的 OAuth、Access Token 和 Refresh Token；未授权时会输出可直接执行的设备流登录与复核命令。也可以手动执行：

```bash
dws auth login --device --profile <corpId> --format json
dws auth status --profile <corpId> --format json
```

Apple Silicon 上运行时会在 DWS 子进程出口使用 `/usr/bin/arch -arm64`，避免 Rosetta 的执行偏好让同一个分发脚本选中旧 Intel CLI。组织参数和身份校验仍然生效，无需修改 `dws_profile`。若手工执行提示 `unknown flag: --profile` 或找不到 `event consume`，先对比 `dws --version` 与 `arch -arm64 dws --version`，再用 `arch -arm64 dws auth status --profile <corpId> --format json` 核验；不要删除组织参数或反复重新授权来绕过版本差异。Intel Mac 与 Linux 不添加架构前缀。


若选择 `sender_mode=bot`，机器人必须属于同一个 `dws_profile`，并额外填写 `robot_code`、`robot_user_ids`，验证 `send-by-bot` 契约。机器人列表为空时才需要由该组织的开放平台开发者创建或复用企业内部应用并启用机器人能力；禁止借用其他组织或其他业务应用的机器人 Code。

### 给分身一个自己的 CLI 配置目录（`agent_config_home`）

不配这一项，分身用的就是**你本人**那份 CLI 配置：你的模型、你的思考档位、你装的每一个
skill 和 MCP server、以及你 cwd 之上的每一条 `CLAUDE.md`。这不只是慢——`claude:privileged`
带 `--permission-mode bypassPermissions`，等于每一轮代答都在你的全部工具面上跑。

`claude:privileged` 模板本来就声明了 `isolation="config_home"`，只是在此之前没有任何东西
去设那个环境变量。现在有了：

```bash
mkdir -p ~/.dingclaw/claude-home && chmod 700 ~/.dingclaw/claude-home
# 在里面放一份只服务于分身的 settings.json，然后一次性登录：
CLAUDE_CONFIG_DIR=~/.dingclaw/claude-home claude /login
```

再把绝对路径写进 `agent_config_home`。**只接受字面绝对路径，不支持 `~`**——与 `dws_binary`
同规则，猜不如拒。`agent_path` 同理，用来补齐 launchd 那份极窄的 `PATH`（没有它，
privileged 那一轮里 agent 想调 `node`、`uv` 会直接找不到，失败重试白烧时间）。
`init` 会在守护进程的 PATH 找不到所选 agent 或它的解释器（nvm 下的 `qodercli` 是
`#!/usr/bin/env node` 脚本）时自动写入 `agent_path`；已有实例跑 `doctor`，`warnings` 里会给出
应写入的完整值——`verify-template` 在你的 shell 里跑、守护进程在 launchd 下跑，两者 PATH 不同，
这是「探针绿、线上每轮失败」的来源。

**Qoder 的 privileged 档经 `--config-dir` 使用 `agent_config_home` 下的 `qoder` 子目录**：Qoder
没有配置目录环境变量，所以 `qoder:privileged` 模板在参数里带 `--config-dir <agent_config_home>/qoder`，
把这一轮的用户级配置根目录换成这个子目录。用子目录与 pi 的 `<agent_config_home>/pi` 同理：
`agent_config_home` 也是 Codex 的 `CODEX_HOME` 和 Claude 的 `CLAUDE_CONFIG_DIR`，Qoder 的 `.auth/`、
`logs/` 等文件不与它们混放。restricted 与 vision 档不带工具，不加这个参数。首次在该子目录登录一次：

```bash
qodercli --config-dir <agent_config_home 的绝对路径>/qoder   # 进入后执行 /login
```

`agent_config_home` 为空时这对参数整体省略，privileged 档照旧继承你本人的 Qoder 配置、skills 与
MCP。升级前已背书的 `qoder:privileged` 要重跑 `verify-template --agent qoder --tier privileged`：
模板变了，旧背书验证的是不带 `--config-dir` 的命令。仍按旧参数逐字写成 `command` 的条目照常运行、
没有配置目录隔离，`doctor` 的 `warnings` 会点名，改成 binary 形态（binary 填它现在的 command[0]，自动发现的填 "auto"）后重跑探针。

凭据按**目录**分账存在 Keychain 里，所以新目录必须自己登录一次；本仓库不提供任何
搬运凭据的代码路径。配好之后 `doctor` 会**常驻一条** login 引导 warning——config 层无法
判断登录态，这是设计如此，不是故障。判定这个目录确实可用的唯一凭据是探针通过：

```bash
dingclawd verify-template --agent claude --tier privileged --config ~/.config/dingclaw/config.json
```

探针与真实回复路径用的是**同一份**环境覆盖，所以探针过了就是运行时能跑。反过来，
不配 `agent_config_home` 时 `doctor` 会说出来自己正在继承（`agent_config_home is empty`），
升级到本版本的现存部署都会看到这条，配上才消失。

本机实测（同一条 prompt、真实 privileged 模板命令、各 6 次）：继承本人配置时墙钟中位
**23.0s**（20.0–29.5s），换成隔离目录（关 thinking、effort medium）后中位 **12.5s**
（10.4–14.2s），两个分布不重叠。收益来自模型档位，不是来自少读了几个 skill。

### 一个账号用尽时换下一个（`account_home`）

`agent_config_home` 让分身有自己的配置，但仍是**一个**账号。每条 `agents` 条目可以再填
`account_home`，把它钉在某个已登录的配置目录上；同一 tier 放两条，就是主备两个账号。

主账号报出额度耗尽或掉登录（`usage limit reached`、`429`、`Not logged in` 等）时，
这条消息立刻交给下一条，主账号进入冷却：有 CLI 打印的重置时间就用它，没有就用
`agent_account_cooldown_seconds`（默认 900 秒）。冷却期内不再先撞一次主账号；全部冷却时
仍试最早到期的那个，成功即恢复。识别不了的失败维持原样——换下一个，但不记冷却。
`doctor` 的 `agent_accounts` / `agent_account_cooldowns` 说明现在用的是哪个账号、谁在冷却。
细节见源码包里 `docs/配置参考.md` 的「多账号与额度感知切换」。

### 只经 zclaude 接入多个模型（`backend`）

推荐的接法是所有档位都用 `name: "zclaude"` 的条目，在条目上写 `backend`：`claude` 走 Claude 订阅直连，
`codex`、`qoder`、`qwenwork` 走 zclaude 托管的本机常驻 proxy（`zclaude proxy install/login/status`）。
同一档放多条就是按成本排好的兜底链：例如 restricted 先 `codex` 再 `claude`，privileged 先 `claude` 再 `codex`，
vision 先 `claude`（`model: "haiku"`）。每条都要有自己的 `account_home`（不能是 `~/.claude`、`~/.zclaude`），
背书按后端记为 `zclaude:<档位>:<后端>`，用 `verify-template --agent zclaude --tier <档位> --backend <后端>` 逐个背书。

restricted 档以 `--isolated --tools Read,Glob,Grep --permission-mode dontAsk` 运行，只能读 `restricted_working_directory`
（默认 `~/.dingclaw/knowledge`）；privileged 档 `bypassPermissions`，连续任务按 `--session-id`/`--resume` 续接。
分诊、DING、摘要、识图、告警分析和快路由（`fast_routing.agent: "zclaude"`）都按各自档位的条目链切换，
与回复共用冷却：一个 proxy 实例额度耗尽时，所有档位上该后端的条目一起跳过；proxy 不可达只算临时故障。
`doctor` 的 `zclaude_backends` 列出 zclaude 报告的每个后端，`data_packs` 列出词表来源，
仍在用非 zclaude 条目、直连 codex 的快路由或告警分析时给出警告。

团队可以用 `dingclawd init --preset presets/yunxiao-team.json …` 一次性导入共享默认（条目链与团队系统名路由词），
之后只改个人配置。分类与路由的词表在 `src/dingclaw/data/`，个人说法放进 `data_dir`（默认 `~/.dingclaw/data`）的同名文件，
整文件替换。细节见 `docs/配置参考.md` 的「zclaude 多后端」「数据包与团队预设」。

同一批新增的配置键：`owner_timezone`（提示词里的本人时钟，默认 `Asia/Shanghai`）、`reply_max_chars_by_tier`
（restricted/standard 回复上限，200–3000）、`prompt_budget`（提示词各段字节预算，合计不超过 `prompt_max_bytes`）、
`hook_health`（托管 agent hook 日志监视，默认关闭，启用时必须给出绝对路径的 `log_path`）。
不认识的配置键会出现在 `doctor` 的 `warnings` 里。

### root 会话与原生 CLI 的对齐

每条 `privileged` agent 可配置 `runtime`，与 provider、账号选择分开：

```json
"runtime": {
  "working_directory": "/absolute/path/to/project",
  "env_keys": ["SHELL", "LOGNAME", "SSH_AUTH_SOCK"],
  "session_mode": "auto",
  "max_reply_chars": 6000
}
```

`working_directory` 留空使用实例的 `agent-workspace`；指定项目目录后，CLI 可以按其原生规则发现该项目的指令。
`env_keys` 仅从守护进程继承列出的变量，空数组关闭额外继承；不会加载交互 shell、复制凭据，默认不继承其他终端的会话标识。
工具搜索路径仍由 `agent_path` 决定，应包含实际需要的 CLI 所在目录。模型、推理档位、Skills 与 MCP 继续由所选账号的原生配置控制。

`session_mode=auto` 默认让 Codex 的连续 root 任务持久化并按精确 ID 恢复 `exec` 会话；补充或纠正会取消当前尝试，下一次沿用原生工具历史。
`stateless` 恢复原有的逐次独立调用；`native` 要求 provider 支持续接，不支持会拒绝配置。目前只有 Codex 支持，Claude/Qoder/pi 的 `auto` 保留原行为。
状态库、任务、对话对象、账号、工作目录或执行配置不同不会复用历史；不使用 `--last`。状态库身份取规范化的 `state_path`，同目录下的独立实例也不共享历史。绑定保存在状态目录的私有 `agent-sessions/`，原生历史由 Codex 存在账号目录，清理该历史后原任务恢复会报错，不会偷偷重开并丢失证据。
绑定损坏、首轮会话身份未落盘或保存失败会终止任务，不自动换 provider 重做可能已执行的操作。

root 回复上限默认 6000 字符，可在 500–12000 之间配置；schema、提示与解析校验使用同一上限，restricted/vision 不采用这些配置。
`task_ignore_acknowledgements=true` 默认让正在运行的任务忽略无引用、无附件的“好 / OK / 收到”等纯确认；明确补充、纠正、停止、引用回复照常处理，可设为 `false` 恢复旧行为。
root 告警调查也使用所选 Codex 条目的账号、工作目录与环境，但仍是一次独立的只读调查，输出遵守告警专用 schema。
这些设置对齐执行条件和上下文，不保证不同输入、时间点和模型输出完全相同。

## 黑名单

黑名单使用不可变钉钉用户 ID，修改配置时采用进程锁、同目录原子替换并保持 `0600` 权限。添加、查看和移除命令如下：

```bash
dingclawd blacklist add --user-id <userId> --config ~/.config/dingclaw/config.json
dingclawd blacklist list --config ~/.config/dingclaw/config.json
dingclawd blacklist remove --user-id <userId> --config ~/.config/dingclaw/config.json
```

黑名单没有任何内置成员，加谁减谁完全由你决定。**入站消息的 `sender_id` 恒为空**，所以只写工号的条目永远匹配不到发送者——`doctor` 会把这类条目列进 `warnings`。发送阶段会持有黑名单读锁；`blacklist add` 返回后，不会再产生发给该用户的新发送调用。姓名黑名单仅为兼容字段，不建议作为生产拒绝依据。

### 时间线黑名单

另一份名单，**按会话隐藏时间线里的行**，走同一把锁、同样的原子替换和 `0600`：

```bash
dingclawd timeline-blacklist add --conversation-id <openConversationId> --config ~/.config/dingclaw/config.json
dingclawd timeline-blacklist list --config ~/.config/dingclaw/config.json
dingclawd timeline-blacklist remove --conversation-id <openConversationId> --config ~/.config/dingclaw/config.json
dingclawd timeline-blacklist mode --value exclude|allow-only --config ~/.config/dingclaw/config.json
```

它和上面那份不是一回事，**别混**：上面那份拒绝的是人，分身不再回复他；这份只是让刷屏的群不出现在「我错过了什么」这条混流里，**分身在那个群里的行为一点不变**，该回还是回，未读红点也照旧。

过滤分两层，取并集：

| | 配置层 `timeline_deny_conversation_ids` | 面板层（状态库里的表） |
| --- | --- | --- |
| 谁写 | `timeline-blacklist add/remove`、手改配置 | 面板里按 `b` |
| 何时生效 | 进程启动时读，**要重开面板/重启服务** | 每页现读，**立即生效** |
| 记不记群名 | 只有 id | 面板知道标题，会一并存下来 |
| 面板能不能取消 | **不能**，`b` 取消不掉配置层 | 能 |

`list` 会同时打印两层（`conversation_ids` 是配置层，`panel` 是面板层），所以「这个群为什么还是不显示」只用问一次。状态库不存在时 `panel.status` 是 `absent`，它不会为了打印而建库。

`mode` 改的是面板层：`exclude` 是黑名单（列出来的不显示），`allow-only` 是白名单（**只**显示放行的，名单空着时间线就整个是空的，面板页脚会直说）。配置层在两种模式下都隐藏。

按 `openConversationId` 而不是群名，因为群名随时被人改，认名字的过滤会在改名那天悄悄失效。id 从时间线、会话列表或收件箱的返回值里抄，别手打；它是 base64（形如 `cidAbCdEfGhIjKlMn+OpQrSt/w==`），**大小写敏感**——这点恰好和认人的黑名单相反。

只挡 `timeline`，**不挡 `search`**：搜索是你主动打了个词，隐藏它等于丢信息。过滤在整页取回之后做，所以一页可能只剩几条甚至空，而 `hasMore` 仍是 `true`——`hasMore` 和 `cursor` 描述的是钉钉那一页，改它们会让翻页开始漏消息。面板页脚会写「黑名单隐藏 N 条」，CLI 的返回值里是 `filtered` 和 `filterMode`，所以读时间线的 agent 不会把「被隐藏」说成「没消息」。

面板里在任意视图选中会话后按 `b`：黑名单模式下是加入/移出隐藏名单，白名单模式下管的是放行名单，状态行会写清楚这次到底改了什么——包括「面板名单去掉了但配置里还锁着」这种只改了一半的情况。切模式在命令面板（`:` 或 `^K`）里。

## 使用

```bash
dingclawd doctor --config ~/.config/dingclaw/config.json
dingclawd poll --config ~/.config/dingclaw/config.json
dingclawd pull-now --config ~/.config/dingclaw/config.json
dingclawd run --config ~/.config/dingclaw/config.json
```

`poll` 只执行一轮，适合 `launchd` 每 180 秒调度；`pull-now` 是语义明确的人工立即触发入口，仍复用相同租约、去重、黑名单、Agent 预算和发送状态机。修复水位后需要回放近期遗漏消息时可传 `--lookback-seconds 900`，其值不能超过 `max_message_age_seconds`。`run` 是内置循环模式，每轮都会重新加载配置、黑名单和 DWS OAuth 状态。`doctor` 不会发送消息或修改钉钉业务数据，但 `dws auth status` 可能刷新本地 OAuth token slot。门禁或 DWS OAuth 未满足时服务以禁用状态运行，不拉取消息、不调用 Agent、不发送，并输出授权引导。

`pull-now` 可能调用模型并真实回复消息，不应作为只读诊断命令使用。另一个轮询进程持有租约时，它会返回结构化 `busy`，不会并行执行第二条发送链。

已生成正文但尚未送达的最终回复，可通过手动补发入口检查和恢复，无需重新调用模型：

```bash
dingclawd delivery list
dingclawd delivery show --job-id <job_id>
dingclawd delivery retry --job-id <job_id>                 # 仅预览
dingclawd delivery retry --job-id <job_id> --yes           # 确认排队
dingclawd delivery retry --job-id <job_id> --yes --ack-unknown
```

各命令均支持 `--config`。先核对预览中的目标会话和正文；未知发送必须核对会话历史，并通过 `--ack-unknown` 明确接受可能重复的风险。已有卡片继续更新原卡片，无卡片身份的未知结果改用带 AI 角标的普通消息。成功、已受理待查询、正在发送、已停止、过期修订和已废弃的记录不能强制重发；没有已保存最终正文的消息不在此入口范围内。`PENDING` 已在自动重试队列，重复操作不创建新投递。补发操作保留审计记录。

`retry --yes` 只排入持久队列，需运行中的 `dingclawd run` 或既有定时轮次继续投递，且需保持 `card_delivery_enabled=true`。排队成功不代表已送达，使用 `delivery show` 回读状态；`ACCEPTED` 仍需查询确认，`DONE` 才表示最终结果投递成功。原有权限和发送就绪校验继续生效。

## 运行限制与巡检

### 独立健康巡检

每分钟检查运行状态，每小时（外部检查可疑时提前）往你自己的单聊发一次 `ping`，以分身回的 `pong` 判断它能不能收发；连续两次没回才弹本机通知，恢复后往单聊发一条异常经过汇总。不调用模型。你自己在单聊发 `ping` 也会立刻收到 `pong`（含回复耗时和待发数量），可随手自查。安装和边界见源码包里的 `docs/健康巡检.md`。

### Agent 输出限制

`agent_max_output_bytes` 默认 `1000000`；设置为 `0` 表示不限制回复 Agent 与告警分析的 stdout、stderr、流式事件累计量及最终结果字节数。正整数保留原有上限行为（流式事件最低上限为 16,000,000 字节）。修改后重启实例，确保预热进程使用同一配置。无限模式会增加内存与临时文件用量；模型上下文、执行超时及钉钉平台消息限制仍然有效。

### 不限运行时长

以下配置解除回复与告警分析的总时长、空闲和过程输出限制；修改已有配置时保留其余字段：

```json
{
  "agent_timeout_seconds": 0,
  "agent_timeout_seconds_by_tier": {"restricted": 0, "standard": 0, "privileged": 0},
  "long_task_enabled": true,
  "long_task_hard_timeout_seconds": 0,
  "long_task_idle_timeout_seconds": 0,
  "agent_max_output_bytes": 0,
  "alerts": {"analysis_timeout_seconds": 0}
}
```

`0` 对普通 Agent 的对应字段表示关闭限制；严重告警的 `analysis_timeout_seconds=0` 只表示不额外缩短，仍受有限 `investigation_policy` 约束。取消、服务关闭、授权校验、并发、租约心跳和发送去重继续有效；DWS/模型供应商及工具自身限制不受此配置控制。严重告警通过 fenced attempt 恢复中断状态，相同证据不自动重调；账本结果先持久化再更新唯一卡片，创建结果未知不重建，卡片送达不代表诊断成功。更新程序和配置后重启，并按交付手册核验。

## v2 六项能力（多数默认关闭）

v2 在同一条轮询链上叠加六项能力，全部有独立配置开关；未读门禁与轮次聚合默认开启，其余默认关闭，关闭开关即回到 v1 行为。

### 未读门禁（unread_gate_enabled）

开启后每轮 poll 先取一份 `list-unread-conversations` 快照，普通消息只自动回复"我还没读过"的消息；已读消息记 `skip_reason=already_read` 跳过。名次判定基于状态库中该会话全量非本人消息（含已处理消息），比快照更新的消息乐观放行。快照溢出（达到 `unread_snapshot_count`）时按 `unread_gate_on_overflow` 处理：默认 `skip_round` 暂停普通新消息，保留对账、发送恢复、已有任务的补充/停止及已识别同事明确召唤的处理。跨轮滞留的普通回复任务在发送前用当轮快照复核：未读清零若可归因于我方出站发送则照发，否则以 `read_after_generation` 取消。开启前必须运行契约断言并人工确认：

```bash
dingclawd verify-unread-contract --config ~/.config/dingclaw/config.json
```

`ranked` 模式零额外 DWS 调用；`exact` 模式对每会话再走一次历史分页取真实未读后缀。

### 召唤通道：@ 你自己（self_command_enabled）

召唤排障时，分身先沿已有线索查询业务证据。错误关键词未命中时继续检查日志原文、响应体和下游结果；必要时逐步扩大时间窗，并用真实责任仓库源码核对成功判定。只找到相似异常时会说明它是候选，缺少客户事件关联信息时仍会询问，不能保证每次都能唯一定位。简短回复不意味着只做一次查询。

只有携带本人不可变 ID 的消息才可能是**指令**；他人打出一模一样的字、甚至打出你的名字，永远不会变成你的指令。身份检查在文本之前，且只认不可变 ID——**名字从来不是授权信号**。有意放宽的通道只有下文两条：「同事在单聊里 @ 你」和「群里同事 @ 你 + 分身」。它们各是独立的解析方法、独立的开关，授权来自开关和黑名单，仍然不来自名字。

**一个动词，没有第二级关键词**，而且这个动词是钉钉里本来就有的手势：

| 你打的 | 效果 |
|---|---|
| `@你的真名(花名)` | 处理这个会话里等着的事 |
| `@你的真名(花名) 帮我回一下他` | 同上，并把这句话作为指令交给 privileged 模板 |
| `@me` | 同上；单聊里没有 @ 选择器，用这个 |

在群里直接从 @ 列表里选自己就行，钉钉会把它渲染成 `@真名(花名)` 进正文，两半哪一半匹配上都算。全角括号也认。`@me` 大小写不敏感（手机键盘会自动大写首字母）。

`@me <任务>` 的回复**发回你打召唤的那个会话**：自聊回给你自己，同事单聊回进那个单聊（对方看得到），群聊发进群。会话的对端从历史消息里解析；一个对方从没说过话的单聊解析不到人，回复退回你的自聊——答案宁可回错地方也不消失。回执类输出（指令无法识别、静音确认这些）**始终只发给你本人**：那是你自己的记账，不该出现在同事的窗口里。

`@me tasks`（也认 `@me 任务`）是分身任务的回执入口，**只在自聊里认**：回一条今日汇总（完成 · 无需回复 · 失败 · 进行中 · 其他）加最近 10 条，每条带时间、会话、发起人与结果；`@me tasks 7d` / `30d` / `all` 换范围，`@me tasks failed` 只看失败（可与范围连用）。在别的会话里发它不回复、不跑模型，消息就地结掉。Mac 与 iPhone 上没有单独的分身会话或任务页面：召唤过分身的消息下方会标出那次任务的结果。

召唤可以在**消息任意位置**——开头、中间、最后一行都认。你习惯先贴一段告警再在末尾补一句
`@自己 结合日志定位一下`，分身拿到的任务就是「去掉那个 @ 之后的整条消息」，贴进来的正文原样保留；
只有 @ 而没有别的内容才是裸召唤。边界规则不变：`@me` 后面要么空白要么结束（`@members`、`@me的想法`、
`@me.example` 不触发），`@名字` 必须整个对上（`@张三版` 不触发）；`@` 前面**不要求**空白，因为钉钉把选出来的
@ 渲染成紧贴前一个词的 `请@真名(花名) 看下`。引用别人一条 @ 了你的消息不会触发——引用走的是单独字段，
解析器不读它。代价是：转发或复述同事一句 @ 了你的话、在群里点名一串人时把自己也点进去，都会被当成召唤。

单聊、群聊、和自己的会话三种形态都生效，且**群聊里被召唤的会话会豁免「只处理 @我 的消息」这条策略**——群里真正需要处理的那条，通常恰恰是没有 @ 你的那条。豁免严格限定在这一条规则上：黑名单、自己发的、机器人发的照旧拦截。

**裸 `@me` 不发任何回执**。它通常打在别人的聊天窗口里，一句写给你自己看的「已登记 reprocess，窗口 900 秒」对同事毫无意义。分身产出的那条回复就是回执；没产出时去 `dingclawd tui` 里看原因。

#### 同事在单聊里 @ 你（peer_summon_enabled，默认开）

同事在**和你的单聊**里写 `@你的名字 <任务>`（`@张三 帮我查一下今天的构建`、`@李四(张三) 这个告警看下`），分身把它当成一次召唤：去掉那个 @ 之后的整条消息作为任务交给 **privileged 模板**（和你自己的 `@me <任务>` 走同一条链），回复发回这个单聊、发给这位同事。`summon` 模式下同样生效——这是它存在的理由：在 summon 模式里同事的消息本来一概不答，而一句点名的 `@你` 就是明确的呼叫。

边界（有意为之）：

- **只认单聊**。群里 @ 你走原有的 mention 路径和普通档，不因为这条变化；群里要召唤分身，得在 @ 后面跟上「分身」二字并打开下一节的开关。
- **只认你的名字**（`owner_display_name` / `self_mention_names`），同事打的 `@me` 指的是同事自己，不触发。名字没配则永远不触发。
- **@ 后面必须有内容**。裸 `@张三` 是叫人，不是给分身的任务，按普通消息处理；「先发一条 `@张三`、再发一条问题」这种两条消息的形态也不支持，和本人的 `@me` 一致。
- **不要求召唤词，但会替你吃掉**。单聊里一句 `@你` 就够了，同事若把群里学来的「分身」也打上（`@张三 分身 帮我查下`），召唤词和多打的那遍名字都不会进任务，交给模板的仍是 `帮我查下`——两条通道给模型的任务文本一致。单独一句 `@张三 分身` 仍然没有任务，按普通消息处理。
- **门禁照旧**：黑名单、机器人、会话静音仍对同事生效；已识别的明确召唤不受已读状态影响，未读快照失败或溢出也照常处理；`privileged` 模板未验证或未启用时同事的消息退回普通档，**不会**收到「模板未验证」那条本该给你自己看的回执；思考摘要按作者判定，同事看不到。
- 依赖 `self_command_enabled=true`（召唤通道总开关），通道关着时这个开关没有作用。

要说清楚的代价：这是**默认开着**的放权开关（召唤总开关 `self_command_enabled` 打开后才生效），把「谁能启动一个 privileged 任务」从只有你，放宽到了任何不在黑名单里、能和你单聊的人；任务文本来自同事。不想要就 `"peer_summon_enabled": false`，守护进程每轮重读配置，不用重启。转述一句同事 @ 了你的话同样会触发，代价与本人召唤相同。

#### 群里同事 @ 你 + 「分身」（group_avatar_summon_enabled，默认关）

群里被 @ 是日常手势，多数时候找的是你这个人，所以群里不能像单聊那样一句 `@你` 就算召唤。打开这个开关后，同事在群里**从 @ 列表里选中你**、紧跟着写「分身」二字并给出任务（`@李四(张三) 分身 帮我查一下今天的构建`、`@李四(张三)分身看下这个`），分身把它当成一次召唤：去掉「@你 分身」之后的整条消息交给 **privileged 模板**（与 `@me <任务>`、同事单聊召唤同一条链），回复发进这个群。`summon` 模式下同样生效——在 summon 模式里群 @ 本来一概留着不答，而点名「分身」就是明确的呼叫。

边界（有意为之）：

- **必须是真实 @**（从列表里选人，钉钉记为 @ 了你）。手打的 `@张三分身` 只是文字，不触发——它连普通群 @ 都不算，在 summon 模式下照旧留着等你自己的 `@me`。
- **召唤词必须紧跟在 @ 后面**，中间可以有空格（钉钉选人后自带一个），后面不要求空格（`分身帮我查下` 也认）；`@张三 帮我查下` 没有召唤词，仍是普通群 @，走普通档。认哪些词由 `avatar_summon_words` 决定，默认只有「分身」，见下。
- **唯一的例外是再打一遍你的名字**：选完 @ 又手打一遍名字是输入习惯不是另一句话，所以 `@张三 张三 分身 帮我查下` 照样算召唤。只有你的名字享受这个待遇，`@张三 老王 分身在吗` 是在问老王，不触发；打两遍也不行。
- **打了召唤词但没打对位置，会收到一句提示**。真实 @ 了你、正文里有召唤词、手势又没匹配上时（`@张三 老王 分身 …`、`@张三` 换行再写「分身」、`@张三 分身` 没给任务），分身在群里回一句怎么叫才对，然后把这条消息就地结掉，不跑模型。没有召唤词的普通群 @ 不会收到任何提示——那是找你这个人；只打了「分身」却没真 @ 的也不会，那是在聊分身而不是叫分身。
- **召唤词后面必须有内容**。裸 `@张三 分身` 按普通群 @ 处理。
- 名字规则与门禁和上一节相同：只认你的名字（同事的 `@me` 不算）；黑名单、机器人、会话静音照旧；已识别的明确召唤不受已读状态及未读快照失败、溢出影响；privileged 模板未验证时退回普通群 @，不发回执；思考摘要同事看不到；依赖 `self_command_enabled=true`。
- 回复发进群，**不会**额外 @ 提问的人（普通群 @ 的回复也不会，卡片路径也不支持）。

换一个召唤词：`avatar_summon_words` 是词表，默认 `["分身"]`——产品对自己的称呼，也是所有标识文案用的词。想教同事一个更短、或者不用切输入法的口令，把它加进去即可，例如 `["分身", "替身", "claw"]`：列表里**任何一个**词紧跟在真实 @ 后面都算召唤，其余门槛一个不减。英文词大小写不敏感（`claw`、`Claw`、`CLAW` 都认）；两个词互为前缀时长的优先（`["分身", "分身版"]` 下 `@你 分身版 x` 的任务是 `x` 而不是 `版 x`）。选词的硬约束来自「词后不要求边界」这一条：`帮我` 这种日常开头一旦进了词表，`@你 帮我看下` 天天都会被当成召唤，所以挑一个没人会对着同事说的词。写成 `[]` 等于把这个手势关掉，开关还开着时 `doctor` 会提示；含空白、`@` 或括号的词配置校验直接拒绝——那种词永远匹配不上。分身的自称文案不跟着变，仍然是「分身」：召唤词是给同事的口令，自称是对外的身份说明，两者本就不必一致。

代价：开着时，任何和你同群、不在黑名单里的人都能启动一个 privileged 任务，而且回复公开在群里。所以它**默认关**，只在你想要的实例上打开；守护进程每轮重读配置，即时回执引擎每分钟重读，关掉不用重启。会误触发的形态：正文恰好以「分身」开头（`@你 分身乏术…`）；同事重新从列表里选中你来转述一句「@你 分身 …」；群里有人花名与你相同、被选中后紧跟「分身」（名字只是触发条件，`@真名(花名)` 两半哪一半对上都算）。代价都是一次模型调用和一条群回复。

#### 你在群里 @ 机器人（robot_summon_names，默认空）

群里从 @ 列表里选自己，钉钉会给你自己推一条通知，同事也看见你在「@自己」。把群里某个机器人的名字写进 `robot_summon_names`，**你自己**发出的消息里 `@机器人名` 就是 `@me` 的别名：`@云监控告警 结合日志定位一下` 等于 `@me 结合日志定位一下`——去掉那个 @ 的整条消息交给 **privileged 模板**，回复发进本群；裸 `@云监控告警` 等于裸 `@me`（打开本会话的召唤窗、回看积压）。这不是放权：能触发的仍然只有你。

- **只认你自己发的消息**。dws 的消息回包里没有「@ 了谁」这个字段，被 @ 的机器人只能靠正文里的名字认——和 `@me` 一样是一段文本，授权仍然只来自你的不可变 ID。同事发 `@机器人名 <任务>` 什么也不会发生，上面两条同事通道也不读这些名字。
- **名字必须整个对上**：`@` 后紧跟名字，名字到下一个空白、括号或 `@` 为止（从列表里选出来的机器人钉钉自带一个空格，`@SLS看下` 这种黏连不认）；`@真名(花名)` 形态两半哪一半对上都算；大小写不敏感。配置时名字里不能有空白、`@` 和括号，不能与 `owner_display_name` / `self_mention_names` 重复。
- **名字和 openDingTalkId 去哪找**：`dws chat group bots --group <群 openConversationId>` 列出群里的机器人（`name`、`botOpenDingTalkId`、`robotCode`）。会自己回话的机器人（小钉这类 AI 助理）把 `botOpenDingTalkId` 写进 `robot_user_ids`，免得分身去答它的回复；只发告警的 webhook 机器人**不要**写——`robot_user_ids` 里的发送者不入上下文，告警正文就进不了分身的记忆。
- **两个开关**：`robot_summon_bare_reprocess`（默认 `true`）决定裸 `@机器人名` 是否等同裸 `@me`，`false` 时裸 @机器人 只是一条普通消息；`robot_summon_group_only`（默认 `false`）决定是否只在群里认，默认与 `@me` 一样在单聊、自聊里也认——轮次、即时回执、自聊探针三处口径一致。
- **时延**：@机器人 不会给你推 `at` 事件，只靠 `list-all` 轮询发现（空闲 `poll_interval_seconds`，活跃窗内 `active_poll_interval_seconds`）。想要秒级唤醒，给那个群加一条 `event_subscriptions` 项 `{"event_key": "user_im_message_receive_group", "target": "<群 openConversationId>"}`，代价是该群每条闲聊都会唤醒一轮。
- 出站护栏一并认识这些名字：分身回复、卡片思考摘要、回执里出现的 `@机器人名` 会换成全角 `＠`，不会自己召唤自己。

代价：你真心想问那个机器人本身（比如 `@小钉 帮我查下`）时分身也会答，一群两条回复；只填专门为分身准备的、不会回话的机器人最省心。名单每轮重读，改了不用重启；即时回执引擎里的名字在下一次启动守护进程时更新。

静音、状态、重置上下文都搬到了 `dingclawd tui`。旧的 `self_command_prefix` 通道仍然解析（`#前缀 mute 30m` / `unmute` / `status` / `reset` / `reprocess [15m]` / `do <任务>`），**只为一个场景保留**：人不在电脑前、TUI 没开，要能立刻让某个会话闭嘴。它不再是文档路径；把 `self_command_prefix` 设成 `@me` 即可彻底关掉它。

指令解析是确定性的。分身自己发出去的每一条消息都带着你的身份，下一轮读回来就是「你的消息」，所以出站全部过护栏：
模型输出**任意位置**出现召唤形态（`@me`、`@你的名字`、`@机器人名`）时，`@` 会被换成全角 `＠`（读起来一样、不通知任何人、
解析不到）；以配置前缀开头的回复仍整条替换为安全话术；卡片上的思考摘要、回执文案同样处理。这道护栏在指令通道关闭时也保持武装。

### 分身模式（avatar_mode）

| 值 | 行为 |
|---|---|
| `auto`（默认） | 按策略回全部命中的消息 |
| `summon` | **只回你 `@me` 过的会话**，其余消息一律不处理 |

`summon` 不是更弱的 `auto`，是另一个产品：分身从「你不在时替你说话的进程」变成「你拿起来用的工具」。

默认是 `auto` 而不是更窄的那个，理由和门禁相反：门禁回答「这个实例能不能发」，一律默认关；`avatar_mode` 回答「能发的那些里你想发哪些」，改它的默认值会让一个正在运行的实例在升级后**静默改变行为**，而这正是本配置在别处明确拒绝的事。

没有 `off` 值。彻底关掉分身是 `send_enabled`——一道需要人工背书的门禁；再加一个入口会让「分身到底开没开」有两个真值来源。

`summon` 模式下未被召唤的消息**不会**被标成终态跳过，而是留在待处理状态：`@me` 的意思是「处理这里的事」，其中当然包括召唤**之前**就到的那些。

### 聚合与忙时合并（round_merge_enabled / burst_quiet_seconds / max_supersede_per_job)

一轮 poll 内同一（会话, 发送者）至多一条回复：跨间隔的多段消息合并成一个 Round，从第二段起加 `[+Δs]` 相对时间标注（单段原文透传，保留问候拦截）。对方最后一条消息距今不足 `burst_quiet_seconds`（默认 30，0=关闭）时先按兵不动等下一轮，首条等待不超过 `burst_hold_max_seconds` 防饿死。上一轮未发出的旧回复遇到同会话更新消息时废弃并合并重生成（`superseded_by_newer_messages`），防饿死计数落在消息上，达到 `max_supersede_per_job` 后照发旧回复。

### 会话上下文与记忆（context_enabled）

每会话持久 transcript（SQLite，与消息状态同库）；提示词注入结构化 `context`（长期记忆、滚动摘要、近期轮次、未回复层），全部按 UTF-8 字节预算裁剪，当前批文本永不截断。未压缩尺寸超过 `compress_trigger_bytes` 后由维护任务用同构无工具模板压缩成滚动摘要并顺带沉淀长期事实；压缩失败只降级不阻塞回复。空闲超过 `session_reset_idle_hours` 或在 TUI 里重置时先冲刷记忆再换代。摘要与记忆始终位于不可信数据段，不具备指令权限。

#### 入账范围（context_ingest_scope）

transcript 记什么由 `context_ingest_scope` 决定：

| 取值 | 语义 |
|---|---|
| `replied`（默认） | 只有真正生成了回复的消息入账 |
| `participating` | 单聊全量；群聊仅记录分身已参与的会话（回过话、召唤过，或本条消息 @ 了自己） |
| `all` | 抓到的一切都入账，仅供压测排查 |

四种角色：`peer`（对方）、`self_human`（本人在钉钉手打）、`self_agent`（分身生成并发出）、`control`（自指令事件）。`self_human` 与 `self_agent` 必须分开——否则分身会把自己上轮的输出当成主人的指示。

入账复用发送门禁的同一套身份判定：黑名单发送者和机器人**一律不入账**，因为上下文会被拿去回复会话里的其他人，还会被压进滚动摘要和长期记忆。分身自己发出的回复会在下一轮被 `list-all` 抓回来，按内容在 `context_echo_claim_window_seconds` 窗口内认领已有的 `self_agent` 轮，不会重复入账；打字机分段会认领同一条。

turn 的时间取消息自身的产生时间（而非入账时刻），所以 `--lookback-seconds`、reprocess 复活、补偿回放带进来的旧消息在上下文里按真实时间线排序；会话的 `last_activity_on` 仍取入账时刻，旧消息不会把活跃时间往回拨。

#### 未回复层（context_since_last_reply_turns）

大于 0 时，transcript 在"分身自己最后一次发言"处切成两段：之前的进 `recentTurns`，之后的进 `sinceLastReply`——即"我上次说完到现在，会话里发生了什么、我还没回”。两段互斥，拼起来是完整时间线。提示词会显式声明这一层尚未被回复，避免模型以为已经答过。正在回答的这一批消息永远不会出现在自己的上下文里。

预算独立（默认 10KB，从近期轮次匀出），总量超限时的降级顺序是：skill 尾部 → recentTurns → sinceLastReply → memory → summary。

#### 引用透传

钉钉的引用回复（`quotedMessage`）会随消息一起解析、持久化并注入提示词与 transcript，只取一层、上限 1500 字、解析失败降级为无引用。一句「是的」带上它引用的原文，往往就足以消除歧义。引用图片保留自己的 `mediaId` 与源消息锚点；DWS 对引用文件不返回 `resources/fileId`，服务会按引用的源消息 ID 做一次有界回查后再解析，回查失败仍保留引用文字并降级为附件元数据。

### 权限分层与多 Agent（tier_standard_user_ids / tier_skill_packs / agent_templates_verified）

三档 tier 决定普通用户和自己各自能用到的 skills 与模板：

| tier | 触发者 | 内联 skill 包 | 模板 |
|---|---|---|---|
| `restricted`（默认） | 任何非白名单发送者 | `tier_skill_packs.restricted`（默认仅 证据与权限门禁、能力画像） | 现有无工具安全模板 |
| `standard` | `tier_standard_user_ids` 白名单同事 | 默认 `*` 全部包 | 同 restricted 模板 |
| `privileged` | 本人经 `@me <任务>` 或 `@机器人名 <任务>`（`robot_summon_names`，默认空）；单聊里同事经 `@你的名字 <任务>`（`peer_summon_enabled`，默认开）；群里同事经真实 @ 你 + `分身 <任务>`（`group_avatar_summon_enabled`，默认关；召唤词见 `avatar_summon_words`） | `*` | 高权限模板（独立验证位） |

"给普通用户少数 skills"通过内联技能包白名单实现；restricted 的外部工具能力由各 provider 模板限制，Codex 另使用 read-only 沙箱。模板注册表把逐字白名单从单模板推广为 (agent, tier) 模板表：codex restricted 使用 `read-only` 沙箱并关闭 shell、MCP、浏览器等外部工具，可通过 `agent_config_home` 指定 `CODEX_HOME`，qoder 逐字固定 `--model auto`，并以 `--tools "" --strict-mcp-config` 注册 restricted 条目，claude privileged 使用 `--permission-mode bypassPermissions --add-dir {agent_workspace}`；pi 两档逐字固定 `--provider openai-codex --model {pi_model}`（由 `pi_model` 展开，默认 `gpt-5.6-sol`），restricted 另以 `--no-tools --no-extensions --no-skills --no-prompt-templates --no-context-files` 关闭工具与继承资源，配置目录使用 `<agent_config_home>/pi`，与 qoder 一样没有 schema 参数、回复结构由解析器校验；opencode 仍为 blocked：它的工具关闭、沙箱与会话持久化只能经环境变量携带的配置设置，逐字比对无法背书。**qoder 没有 `--safe-mode` 也没有 `--json-schema`**，所以它的 restricted 档买到的是「无工具 + 无 MCP」的隔离，回复结构没有 CLI 层强制，只能由输出解析器事后拒绝——这是它与 claude 的真实能力差。启用任何模板都要求 `agent_templates_verified` 含对应 `agent:tier` 键，新模板先跑探针：

```bash
dingclawd verify-template --agent claude --tier privileged --config ~/.config/dingclaw/config.json
```

探针通过后由人工把键写入配置；命令本身不会修改配置。各 tier 有独立每日额度（`daily_agent_call_limit_by_tier`）。

### 打字机输出（typewriter_mode）

个人身份消息不可原地编辑，打字机以语义分段渐进发送实现：段落>句号>逗号切段，每段 ≤`chunk_max_chars`，段间 `chunk_interval_ms`；逐段确认，任一段不确定或失败即中止并抑制后续段（宁可少发不重发），job 状态由段状态推导。每段幂等 uuid 含段序号，同文本两段不会被 DWS 幂等吞并。默认 `off`——对同事场景多段即多次推送打扰，建议仅按 tier 对自聊开启（`typewriter_mode_by_tier.privileged=chunked`）。机器人 AI 卡片流式为预留项，契约未验证不实现。

### 常驻模式（run）：事件为主，轮询为兜底

`run` 的轮次有两个来源：DWS 长连接推来的**事件**把睡眠掐短（`event_listener_enabled` +
`event_subscriptions`），**间隔轮询**在没有事件时按点跑。事件覆盖到的会话，消息在
一两秒内就会催出一轮；覆盖不到的，等下一次轮询。这就是"事件为主、轮询兜底"的全部
含义——**事件永远只是加速器**：它不解析任何消息内容，门禁全部跑在轮次从 DWS 确认
读回的数据上；监听器死掉的唯一后果是回到轮询节奏，不会丢消息（游标安全由
`overlap_seconds` 保证，与轮询频率无关）。

**覆盖面的真相**（dws 只有三把事件钥匙，没有"全量消息"的全局键）：

| 流量 | 事件覆盖 | 没覆盖时 |
|---|---|---|
| 群里 @ 你 | `at` 键，一条订阅全局生效 | — |
| 自聊（`@me` 自测在内） | `o2o` 键订**你自己的 userId**，实测约 1.3 秒推达 | — |
| 白名单同事的单聊 | `event_subscribe_standard_tier=true` 自动为 `tier_standard_user_ids` 里每个 userId 派生一条 `o2o` 订阅（工号形如 `100001` 或 `WB00000001` 都算；openDingTalkId 是 `--user` 唯一拒收的形状，会被跳过并在 doctor 里说明） | 兜底轮询 |
| 名单外的人、新联系人的第一条消息 | **订不了** | 兜底轮询 |

每条订阅是一个常驻 consumer 进程（实测约 21MB），上限 32 条，超出时先丢派生、
永不丢显式配置。派生只在 listener 开启时发生；`event_listener_enabled requires
event_subscriptions` 这条门禁只认**显式**列表——名册的一次编辑不该能让实例起不来。

兜底间隔的取值取决于你对"承诺"的定义：只开事件唤醒时 300s 是合理姿态（事件负责快、
兜底负责不漏）；**开了下面的即时回执后建议 60s**——⌛ 是贴在对方消息上的显性承诺，
兜底轮是这个承诺在一切退化路径（事件未覆盖会话、事件通道故障、秒贴误判后的真实
回复）下的兑现上界，300s 的上界配不上秒级的承诺。代价同样要知情：名单外单聊的
最坏发现延迟等于这个间隔；未读快照持续溢出且全网静默超过 15 分钟时复判间隔同它。
活跃窗不受影响——近 `active_window_minutes` 内有新消息时仍以
`active_poll_interval_seconds`（默认 30s）快跑，quiet-hold 的"等对方打完字"靠的
正是它。兜底轮**救不回**中断到超龄的生成任务（遗弃判定窗大于消息年龄上限，被弃
即过期），别指望它做恢复。

**持续任务（`continuous_tasks_enabled`，默认关闭）**：开启后，一次明确召唤建立一项任务和一张进度卡。
同一会话、同一发起人的补充和纠正先保存，再取消旧执行并用合并后的输入继续；不会为每句话另发一份答案。
直接发送“停止”可停止当前任务。普通补充默认在最后一次输入后 120 秒内归入任务；你自己的单聊（会话里从没有别人说过话），以及同事与你的分身的单聊里同事发起的任务，在执行期间（排队、执行或更新中，且距最后一次输入不超过 30 分钟）一直接收发起人的普通补充，不受这 120 秒限制；新的 `@本人` 召唤不会并进正在跑的任务。召唤前 120 秒内发起人自己发的图片和文件随召唤一起进入任务，例如先发截图、再发「@本人 看下这个报错」。跨会话、跨发起人、卡片回声、巡检通知和 `ping` / `pong` 不合并。
一旦最终回复开始发送，新输入建立下一项任务。停止只能拦截尚未开始的操作，已经发生的外部操作不会自动撤销。
开启要求 `self_command_enabled`、`card_delivery_enabled`、`card_open_before_generation` 同时为真。
持续任务卡片只展示公开进度和工具状态，最终答案确认投递后才标记“已回复”。卡片创建明确返回 `RATE_LIMIT_ERROR` 时，自动改用带 AI 角标的普通消息发送最终正文；降级选择和正文持久保存，续作或重启后不再反复建卡，投递重试不重新调用模型。
普通消息明确失败会退避重试；发送结果未知时仅在首次可能已发送的尝试起的 23 小时安全窗口内使用同一 UUID 重试，超过窗口保留记录待核对，不换 UUID 重发。取得发送回执后查询状态。卡片创建结果未知且无句柄时降级文本，已有卡片的更新失败只重试同一卡片。
明确召唤后，即使模型判断无需回复，或任务准备、执行失败、模型重试耗尽，也会保存简短结束或失败反馈并通过同一最终投递流程发送。普通非任务消息仍可静默跳过。持续重试受权限、发送就绪、停止和最新任务修订约束；外部服务持续不可用时保留待处理状态，不能据此认定已经送达。
配置参数见源码包里 `docs/配置参考.md` 的「持续任务与出站调度」。

**任务上下文（`task_context_messages`，默认 5）**：分身执行召唤的任务时，会拿到召唤前同一会话里最近的几条消息（两小时内），并被要求动手前先读一遍——「看下这个报错」「上面那张图」指的东西通常就在召唤前一两条里。上下文只用来确定任务指的是什么，要做什么仍以召唤正文为准，其中出现的指令或批准一律不执行；每行标明是本人账号（含分身代发）、同事还是机器人。root 档会拿到图片的 mediaId 和下载命令。0 关闭；不依赖 `context_enabled`。

**面板 `@`：让分身直接回复（`panel_reply_tier`，默认 `privileged`）**：在 `dingclawd tui` 里选中对方的一条消息（列表里则是该会话对方的最新一条）按 `@`，确认框写明档位和回复对象，按 `y` 后分身不起草，直接以你的名义回复（带 AI 标识）。默认 root 档，与 `@me 任务` 同一条任务通道，能执行命令、查内部系统后再回；命令面板可切到 `standard` / `restricted`。这类任务只接受你本人引用续接，对方后续消息不会接着驱动它；root 档回复里的凭据值会被打码。依赖 `continuous_tasks_enabled=true`。

**即时回执（`presence_instant_ack`，缺省关）**：事件到达的那几秒内就把
⌛「回复中」贴到对方消息上，不等轮次跑到生成阶段——消息→⌛ 实测 3-7 秒（下限是
钉钉推送延迟，毫秒级不存在）。开启要求 `presence_markers_enabled` 与
`event_listener_enabled` 同时为真（缺一 ConfigError）。它是 presence 层的受控
白名单，不是第二条摄取路径：事件载荷只决定"要不要贴表情"，门禁、游标、生成、
投递全部照旧。判定门全部 fail-closed：无文本（表情包/语音）不贴、陈旧事件不贴、
身份不明不贴、deny/robot/mute 不贴（deny 名单、机器人召唤名和即时回执开关在事件处理前按配置文件变化热读）；SUMMON 模式下只有开着召唤窗的会话才贴，例外是 o2o 键上同事的
`@你 <任务>`（`peer_summon_enabled`）和 `at` 键上同事的 `@你 分身 <任务>`
（`group_avatar_summon_enabled`），轮次会把它们当 root 档任务跑；自己发的消息只有
`@me <任务>` 形态贴（回复恰好锚在召唤行上），裸 `@me` 不贴（它的回复锚在积压
消息上）。

Stream 是最快路径但不是可靠队列。若把
`presence_owner_summon_probe_interval_seconds` 设为 2–60，守护进程会只对主人自聊
增加一条只读重叠探针：每个间隔读取一次最近窗口，把主人自己发出的单聊
`@me <任务>` 去重后交给**同一个**即时回执队列。活动持续任务中的普通补充和停止也会
请求优先读取，由权威轮次重新读取并通过全部业务门禁；这些提示不会直接贴回执或接纳输入。
探针本身不创建任务、不移动游标、不发送正文。0 为关闭，也是升级
默认值。开启还要求 `self_command_enabled=true`、恰好一个 `self_user_ids`、至少一个
`self_open_dingtalk_ids`，并为该 userId 显式配置 `o2o` 订阅。这样正常时仍由 Stream
抢先；健康 DWS 下，单条 owner-o2o 漏推会多出一个探针间隔以及读取、贴表情耗时。探针
把每次 CLI 读取限制在 20 秒，给高负载下的本机启动留出余量；失败时不做通用的
`--verbose` 原地重试，等待时间按配置间隔逐次翻倍至 30 秒（配置间隔更长时保持原间隔），
读取成功后恢复配置间隔。读取窗口至少覆盖最近 120 秒，避免短暂退避漏掉召唤；更长故障
仍依靠正常轮询及其时效门禁。停止服务可中断等待和在途读取。轮次若确实发现 owner 自聊任务
只能自己首贴，会立即只重建这一条 o2o consumer，不再被其他订阅的流量掩盖或连带重启
全部 consumer。

每次秒贴都写前登记进 `presence_acks` 表（行上带 `landed` 位：引擎的
dws 调用落地前为 0，轮次遇到 0 不收养而是自己贴——同一描述符重复 add 实测幂等，两枚
合一——并**接管**该行，计入 `presence_acks_taken_over`；引擎调用失败时只有服务端明确
拒绝（`declined…`）才释放行，超时/进程死亡说不清贴纸有没有先落地，行保留为 0 交给
轮次接管，若此时行已被撤回/清扫（`row=gone`）则顺手撤一次以防贴纸其实落了地；
成功后发现行已被撤回/清扫则把刚贴的贴纸再撤掉，记一行
`presence_ack_taken_down`，`reason=row_gone`，带 `removed`；撤不掉时把行按已落地
态写回，交给 TTL 清扫兜底），轮次要么**收养**这张贴纸
（任务锚点命中：不重复贴，交给既有 ⌛→✅ 生命周期）、要么**撤回**（锚点右移、
或 TTL=`max_message_age_seconds` 到期清扫；引擎登记过却没落地的行不再同步调 dws
撤回——那多半是根本没贴上的贴纸，负载下每枚都要再吃一个超时，还正好堵在 ⌛ 的
关键路径上——而是删行后交给修复队列异步撤，每轮限量，同步撤回失败也入同一队列）
——崩溃在任何一步留下的都是登记行而不是无主贴纸，关掉开关后上一代残留行也照样
被清扫。日志：每次秒贴一行
`presence_ack`（带 `source=event|owner_probe`、`event_id`、`conversation_id`、
`message_id`；`latency_ms` 是消息时间→贴纸落地的总耗时，另带三段拆分：`push_ms`
消息时间→事件行离开 consumer 管道，即守护进程之外的部分；`queue_ms` 到达→引擎开始
处理；`add_ms` add-text-emotion 子进程本身。`push_ms` 大说明问题在事件链路上游或
进程调度档，不在引擎。每行还带 `attempts`（dws 调用次数，引擎固定为 1）；dws 调用
失败的 `presence_ack_failed` 行带同样三段外加 `reason`——`timeout` / `unavailable` /
`cancelled` / `exit:<退出码>:<输出摘要>` / `error:…` / `declined[:服务端错误码]`——
`row`（登记行的下场：`released` 已释放 / `retained` 保留给轮次 / `taken_over`
轮次已接管 / `gone` 已被撤回或清扫）和 `taken_over`（`row` 是否为 `taken_over` 的
布尔缩写）；引擎自身异常（sqlite、解析边角）是另一种形状：`reason=engine_error`，
只带 `error` 文本，没有三段与 `row`，每分钟至多一行并带累计 `total`（已写下的
登记行留在未落地态，由轮次接管或 TTL 清扫收敛）；成功行的 `taken_over=true`
表示轮次在调用期间先贴了，两枚合一。子进程预算由 `presence_ack_timeout_seconds`
决定，缺省 30s，引擎不重试——单工人线程上再试一次等于让后面所有事件多等一整个
超时；轮次收养/接管/撤回进 `presence_acks_adopted` / `presence_acks_taken_over` /
`presence_acks_withdrawn` 计数；引擎闸门拦下的事件按原因计数并限频上报
`presence_ack_skipped`（同一 reason 每分钟至多一行，带累计 total）——"引擎根本
没收到事件"和"收到了但被闸门拦下"从此在日志里可区分。轮次对引擎从未登记过的
消息自己首贴的 ⌛ 进 `presence_marks_fresh` 计数，它同时是下文投递活性规则的证据
来源；接管引擎未落地行的那次贴不算（事件明明到了，只是秒贴输了竞速）。
已知食言率来源（贴上后静默撤回）：ranked
unread 已读跳过、coalesce 锚点右移、SUMMON 消费轮后到达的尾消息、以及事件带出了
文本但轮次判定内容形态不支持的消息（超长文本引擎已同口径拒贴）——这是"先秒回
表情再干活"形态的固有属性。另外与开关无关：轮次侧的 ⌛ 现在在**请求构建之前**
就贴上（带图消息不再等图片理解付费），任务确定要跑的那一刻就是贴上的那一刻。

每一轮的日志都带 `wake` 字段说明自己为什么醒（`event` / `interval` / `immediate` /
`startup`）和 `events_seen` 累计数——事件通道死没死，从此是日志里的事实而不是推断。
一次性 `poll` 命令的输出没有这两个键，键的有无就是两种模式的区分。launchd 定时
`poll` 模式不受本节影响。

**订阅武装（arming）与自动重弹**：实测（2026-08-24）钉钉个人事件的服务端只把
"**长连接已建立时**发出的订阅 create"绑定到当前连接上推送；贴着 socket 出生前后
那几秒发出的 create 会返回成功、sublist 里 `status=1`，却永远不投递。daemon 冷启动
恰好就是这个形状——第一个 consumer 还在拉起 bus，所有订阅已经创建完了。而 dws 的
consumer 无论是否带 `--ephemeral`，退出时都会按"归属清理"注销自己创建的订阅（只有
`--subscribe-id` 复用的订阅才保留），所以每次重启都是一轮"注销 + 贴着新 socket 重建"，
事件通道时好时坏的全部来源就在这里，与订阅条数无关（14 条订阅在温 socket 上照常
投递）。对策是常驻的 rearm monitor：探测 bus 的出生时间，发现某个 consumer 的当前
代际是贴着 socket 出生 spawn 的，就在 socket 温热（约 25s）后把它弹跳一次——退出
注销、重生重建，这一次 create 落在温 socket 上，稳定武装。bus 的 `bus.log` 出现
`personal source reconnecting`（原地换连接、本地无进程死亡）时同样重弹。第三条
触发是**投递活性**：前两条规则在 2026-08-24 的一次线上失聪里双双失手——温 socket
上重建的订阅登记在案、consumer 健在，却 18 分钟一条不投；这种形状只有结果可观测。
于是轮询本身充当探针：某一轮不得不为一条引擎连登记行都没写过的消息自己首贴 ⌛
（`presence_marks_fresh`，说明秒回引擎没等到事件；接管引擎未落地行的贴不算）而
listener 的累计投递数又纹丝不动，即记一次证据；证据跨静默轮
保持、任何一条投递即清零，攒满两次就把全部 consumer 弹跳一次（冷却 15 分钟，冷却
内攒出的证据作废——那多半是重建订阅还在服务端传播时攒的）。该规则只在秒回引擎
武装时生效（引擎不在场时轮次首贴是常态而非证据），重试复活的补贴纸也不计证据。
三条规则共享一份状态：daemon 启动、socket 换代、任何一次重弹之后的温热+传播期
（约 45 秒）内活性证据一律作废，纪元重弹同时清证据并计入冷却——活性规则不会抢在
cold_socket 前面开火把 consumer 弹出纪元规则的覆盖范围，也不会在传播期里重复弹跳。
每次重弹在日志里是一行 `event_listener_rearm`（带
`reason=cold_socket|source_reconnect|delivery_liveness`）；监视器任何一环失效的
后果只是"没有这次助推"，轮询兜底不受影响。冷启动后事件通道达到可投递的时间约为
一分钟，这一分钟内照常由轮询覆盖。

从带 `--ephemeral` 订阅的旧版本（0.3.0 及更早）升级时不再需要手工 `dws event stop`
清墓碑：所谓"墓碑"其实就是上面的武装缺失，rearm monitor 起来后第一次重弹就会修好。

## 本地网关与镜像库

`dingclawd run` 内置一个本地网关，是这个实例唯一的钉钉数据通道：面板与原生客户端都经
`~/.dingclaw/run/gateway.sock`（目录 0700、socket 0600，连接方 UID 必须是你本人）连它，协议是
NDJSON v2，可以多个客户端同时连。同一台机器上用别的配置文件跑的实例各用自己的
`~/.dingclaw/run/<实例名>.sock`；`dingclawd tui`、`status`、`doctor` 会核对 socket 上的网关是不是本实例的，
不是就拒绝并说明怎么改，绝不经别的实例的网关读取或发送。网关与分身共用一套出站预算，读请求按 分身 > 界面 > 回填 放行，
后台回填同时最多一个、有别的请求在等就让路。网关起不来只记告警，分身照常工作。

网关把读到的会话与消息写进 `state_path` 同目录的 `mirror.db`（附件缓存在 `mirror-media/`）。它是
可重建的缓存，收录**可发现的会话**——最近 30 天消息扫描发现的、会话窗口里的、置顶的、实时事件
带来的、经通讯录或目录搜索打开过的——及其最近 30 天消息，不是账号全量；更早又没被发现的会话
用搜索打开。正文明文存放，保护靠 0600、FileVault（`doctor` 检查，关闭只是 warning）和 90 天
保留期；附件缓存超过 5GB 按最近使用淘汰。AI 草稿登记、发送结果台账、本机已看位置存在同目录的
`gateway_state.db`，它**不是缓存**，不随镜像重建删除。

分身默认仍按原来的轮询读取候选消息（`gateway_avatar_source: "poll"`）；每轮会把它与同步引擎的
结果比一次，`dingclawd status` 的 `gateway` 段给出最近 24 小时的差异汇总。观察一到两周确认一致后，
由你自己改成 `sync`。`dingclawd doctor` 的 `gateway` 段报告 socket 是否连得上、镜像库路径/权限/
schema 版本/大小和 FileVault 状态。

守护进程启动时 DWS 登录身份还核实不了（没登录、网络还没起来）时，两个库先只读打开，网关在后台
重试核实（间隔从 15 秒逐步拉长到 5 分钟），成功后不用重启就能写；在那之前发送、起草、已看等写
操作被拒并说明原因，`dingclawd status` / `doctor` 的 `gateway.identity` 显示进度。改了只能重启生效的
键（身份、`state_path`、`gateway_enabled`、`gateway_socket_path`）时，`restartRequired` 会列出来，
黑名单、AI 声明等其余设置照常即时生效。钉钉只受理、还没确认的发送，网关会在后台复查几次再定论，
确认送达才消耗 AI 草稿。关停时读请求立即取消、写请求最多等 10 秒，整个关停在 launchd 默认的 20 秒
之内。

新增配置键（`gateway_enabled`、`gateway_socket_path`、`gateway_idle_poll_seconds`、
`gateway_conversations_refresh_seconds`、`gateway_live_slots`、`gateway_avatar_source`、
`gateway_backfill_days`、`mirror_retention_days`、`mirror_media_cap_mb`）都有默认值，旧配置不用改，
逐项说明见 `docs/配置参考.md`「本地网关与镜像库」。

流式卡片发出后还会变长几分钟，DWS 只把它当纯文本返回、带 `messageAiSendFlag: "DWS"`。网关把这类
消息标成 `aiSent`，按 id 批量重读（`list-by-ids`）直到正文 `card_follow_settle_seconds`（默认 120 秒）
不再变化：会话在客户端里打开时每 `card_follow_open_seconds`（4 秒），否则每 `card_follow_idle_seconds`
（45 秒），每分钟不超过 `card_follow_calls_per_minute`（12 次）。变长期间消息带 `streaming`，客户端显示
「生成中」；会话刚打开时补读最近 `card_follow_open_recent`（10）条。`card_follow_enabled: false` 即时关掉，
说明见 `docs/配置参考.md`「流式卡片跟随」。

回滚从轻到重：`gateway_avatar_source` 改回 `poll`（下一轮生效）；停服务后删掉 `mirror.db*` 与
`mirror-media/` 再启动，镜像重建（**别删 `gateway_state.db`**）；`gateway_enabled: false` 后重启
守护进程，回到没有网关的形态，面板用 `dingclawd tui --private-core`；仍不行就按交付手册回装上一版。

## 未读历史消息一次性补偿

补偿采用不可变计划和显式执行两阶段，不能用扩大 `pull-now` 时间窗或清空状态库替代：

```bash
dingclawd compensate-unread plan --count 100 --config ~/.config/dingclaw/config.json
dingclawd compensate-unread execute --run-id <run_id> --yes --config ~/.config/dingclaw/config.json
dingclawd compensate-unread status --run-id <run_id> --config ~/.config/dingclaw/config.json
```

`plan` 会读取未读会话和历史消息并把不可变计划写入本地状态库，但不会调用 Agent 或发送消息。它按 `unreadPoint` 从每个会话最后一条消息向前恢复未读后缀；单聊保留全部符合身份策略的消息，群聊仍只保留未读后缀中明确 @ 当前用户的消息。如果 DWS 返回数量达到 `--count` 上限、单聊对方身份无法解析、历史分页无法完整恢复未读边界，计划状态为 `INCOMPLETE` 或直接失败，禁止执行。

首次 `execute` 会重新读取未读会话并严格比较快照；快照、个人 Skill 或发送身份有变化时要求重新生成计划。执行必须显式传 `--yes`，复用正常服务的租约、动态黑名单、每日 Agent 预算、发送幂等和状态对账。单次最多创建 `max_jobs_per_poll` 个回复任务；若 `remaining_messages` 非零，可对同一 `run_id` 再次执行，仍属于同一次幂等补偿。任务进入 `ACCEPTED` 不代表补偿成功，只有状态对账为 `SEND_SUCCEEDED` 才是成功投递；`SEND_UNKNOWN` 不自动重发。

## Agent Skill

一共三份 Skill，**分开的是授权范围，不是章节**：

| Skill | 在哪 | 能做什么 |
|---|---|---|
| `dingclaw-install` | 随 Python 包（仓库内 `src/dingclaw/install_skill/SKILL.md`），交付包根目录另放一份 `SKILL.md` 供装 `dingclawd` 之前阅读 | 装机：按交付手册走完全部步骤，在四道门禁处停下来等你本人 |
| `dingclawd` | 随 Python 包 | 运维：`doctor`、`status`、`pull-now`、`compensate-unread`、`blacklist`。**能触发调模型并真实发消息的轮次** |
| `dingclaw-inbox` | 随 Python 包 | 只读：`inbox`、`conversations`、`messages`、`timeline`、`search`。不发消息、不起草、不清红点、不静音、不切模式、不下附件 |

三份都可安装到 Codex、Claude Code、Qoder CLI：

```bash
dingclawd skill path                                    # 三份都打印
dingclawd skill install --agent all                     # 装两份运行时技能（dingclawd、dingclaw-inbox）
dingclawd skill install --agent claude --skill dingclaw-inbox     # 只给读的那一份
dingclawd skill install --agent claude --skill dingclaw-install   # 安装技能按名安装，不在 all 里
```

**只装 `dingclaw-inbox` 是一个有意义的选择**：想让 agent 回答「我错过了什么」的人，不必同时把「以我的身份说话」交出去。反过来，运维 skill 不包含读收件箱的命令。

已存在不同内容时默认拒绝覆盖；确认替换时显式增加 `--force`。三类 Agent 安装的内容来自同一份 package data，Skill 只编排本程序 CLI，不绕过程序直接操作 DWS 或 SQLite。

安装以 (Agent, Skill) 为单位原子且可回滚，不承诺跨目录的整体事务；中途失败时 CLI 输出 `atomicity=per_agent_skill`、已完成的 `results`、`failed_agent`、`failed_skill`、完全没动过的 `pending_agents` 和逐条列出的 `pending`，调用方可修复后幂等重试。

### 只读命令

面板的四个视图各有一条一次性 CLI，输出 JSON（管道里单行，终端里缩进），**都不会清红点**：

```bash
dingclawd inbox --hours 24            # 待回应：钉我 / @我 / 单聊，合并会话信号
dingclawd conversations --limit 50    # 会话列表
dingclawd messages --conversation-id <cid> --limit 20
dingclawd timeline --hours 24         # 跨会话按时间，最新在前
dingclawd search --query "发布" --days 14
```

面板这一侧的默认其实一样，**打开一个会话同样不清红点**——「未读保持」出厂就开着，只有显式按 `R` 清红点才调 `clear-red-point`（待回应视图里也一样；处理完待回应条目是 `e`，只改本地）；按 `^R` 关掉保持之后，打开会话才会顺手清掉，代价是分身的未读门禁跟着失效。`unread_gate_enabled` 读的是服务端未读状态，清红点会让分身以后不再处理那个会话——这个副作用该由看着屏幕的人决定，所以 CLI 这一侧连一条显式清的子命令都没有。写的那一半（发送、起草、清红点、静音、切模式、下载、触发）**不是加了 `--yes` 门禁，是压根没有子命令**。

## 安全边界

配置和运行日志不得提交到仓库。子进程调用不经过 shell，聊天正文通过标准输入交给 Agent。自动发送必须同时满足 `send_enabled=true`、真实契约验证、身份拒绝列表完整和 DWS OAuth 有效；机器人模式还要求机器人编码存在。服务会在每一次真实发送前重新加载门禁配置，并复核 DWS 组织和本人 ID，生成期间关闭门禁或切换授权都会阻止发送。DWS 接受任务不等于真实收件；服务会继续查询任务状态，也不会把未知发送结果当作成功。

自动发送使用逐字比对的 (agent, tier) 命令模板表和经安全校验的可执行路径，禁止追加工具、权限绕过或未知参数。四个 CLI Agent 各有条目（opencode 另列为封禁），但列在表里只是「允许尝试」，不是「允许发送」——每一条都还要在运行它的那台机器上跑过 `verify-template` 并由人工写入 `agent_templates_verified`。状态库首次初始化时同时绑定 `dws_profile` 和发送身份指纹（实际授权用户、发送模式、机器人 Code、DWS 路径等）；之后若切换组织或发送身份但复用同一 SQLite 文件，服务会在处理旧回复前直接拒绝启动。

从未包含 `self_open_dingtalk_ids` 的旧版本升级时，发送身份指纹会变化，禁止原地复用旧状态库。升级步骤必须按顺序执行：

1. 先卸载旧 launchd 任务，确认没有轮询进程。
2. 只读核验旧库不存在 `GENERATING`、`GENERATED`、`SENDING`、`ACCEPTED` 或 `RETRY_PENDING` 等非终态业务记录；存在时停止升级并人工处置，不能轮换。
3. 将同一状态库的 `state.db`、`state.db-wal`、`state.db-shm`（存在才处理）一起移动到同目录的同一时间戳备份名，保留恢复能力；不得只移动主文件。
4. 补齐并以 `0600` 保存配置中的 `self_open_dingtalk_ids`，运行 `doctor`，再用新程序创建全新的状态库。
5. 使用不超过 `max_message_age_seconds` 的 `pull-now --lookback-seconds` 做有界回放，核对结果后再 bootstrap launchd，并读回 launchd 状态和下一次 180 秒定时输出。

本仓库不自动迁移旧发送身份指纹：自动放宽或改写指纹可能让旧身份生成的回复被新身份发送。上述轮换只适用于已确认没有非终态业务记录的状态库。

launchd 任务由 `dingclawd init` 生成到 `~/Library/LaunchAgents/com.dingclaw.<你>.plist`，
但**不会自动加载**。确认 `doctor` 无错、`poll` 跑通一轮之后再手工加载：

```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.dingclaw.<你>.plist
```

label 按 owner 生成，所以同一台机器上的多个实例不会互相顶掉。plist 跑的是 `run`
（常驻循环）而不是 `poll`：两者是完全独立的代码路径，只有 `run` 里有自适应调度和事件监听。

## AI 标识

**每一条发出去的消息都必须让收件人知道不是人手打的**，这条不可关闭。

只有纯文本的 `chat message send` 与引用回复 `chat message reply` 有 DWS 原生 `--ai-tag` 角标，`send-card`（流式卡片）
和 `send-by-bot`（机器人）都没有。所以正文里的固定文案（`ai_disclosure_text`，
默认 `[AI 分身代发]`）才是覆盖全部三条路径的那道保障，**置空是就绪错误**。

`--ai-tag` 现在每次发送都显式声明（`ai_tag_enabled`，默认 true），不再继承 DWS CLI
的默认值——上游翻了默认值会让角标无声消失，且没有任何信号。

标识只加一次：注入点在投递收口且在打字机分段之前，所以一条回复标一次、标在第一段；实时流的中途帧各自整体覆写、各自带标识，读者仍只看到一次。

## 许可证

dingclaw 的后续新版本以 [GNU GPL v3.0 or later](https://spdx.org/licenses/GPL-3.0-or-later.html) 发布；已经发布的历史版本继续适用其随版本提供的许可证。TUI 单文件内嵌依赖的
许可文本与版权声明随 wheel 保存在 `dingclaw/tui_app/THIRD_PARTY_LICENSES.txt`。DWS CLI 是
独立的上游项目，使用其自己的许可证。

即时回执引擎在启动时创建；启动时关闭 `presence_instant_ack` 的实例首次开启需重启。已创建的引擎可以通过热配置关闭及恢复。
