Metadata-Version: 2.4
Name: dingclaw
Version: 0.4.2
Summary: DingTalk digital-avatar runtime: reply as yourself, fail closed
Author: Zhuoyue
License-Expression: MIT
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; extra == "test"
Requires-Dist: pytest-cov<8,>=4; extra == "test"
Provides-Extra: release
Requires-Dist: twine<7,>=6; extra == "release"
Dynamic: license-file

# dingclaw

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

名字取自 `ding`（钉钉）+ `claw`，与 [openclaw](https://openclaw.ai) 同源不同路：
openclaw 是通用多渠道 agent 网关，dingclaw 是**单渠道、单组织、以本人身份、fail-closed** 的窄深实现。

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

默认策略：

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

## 安装

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 凭据。

公开版通过 PyPI 安装。使用独立虚拟环境可避免改动系统 Python：

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

也可以执行 `pipx install 'dingclaw==0.4.2'`。企业如需内部分发，可把同一组 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`，**所有门禁一律关闭**。
它不会、也无法打开任何门禁。

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

```bash
dingclawd tui                                       # 分诊列表 + 会话预览 + 分身模式切换
```

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

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

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

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

**也可以让 agent 代劳**：交付包里带一份 `SKILL.md`（仓库内是 `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` 是全量键 + 默认值，由代码生成、有测试钉住。

## 本机准备

个人身份模式必须分别填写本人的 `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 作为生产拒绝规则。两类自身 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
```

若选择 `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` 会直接找不到，失败重试白烧时间）。

凭据按**目录**分账存在 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。

## 黑名单

黑名单使用不可变钉钉用户 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` 返回后，不会再产生发给该用户的新发送调用。姓名黑名单仅为兼容字段，不建议作为生产拒绝依据。

## 使用

```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`，不会并行执行第二条发送链。

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

召唤必须在**消息开头**——`@members`、`@me的想法` 不会触发，引用别人一条 @ 了你的消息也不会。

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

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

静音、状态、重置上下文都搬到了 `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，只取一层、上限 500 字、解析失败降级为无引用。一句「是的」带上它引用的原文，往往就足以消除歧义。

### 权限分层与多 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 <任务>` | `*` | 高权限模板（独立验证位） |

"给普通用户少数 skills"通过内联技能包白名单实现，不给任何宿主机工具。模板注册表把逐字白名单从单模板推广为 (agent, tier) 模板表：codex 以 `--sandbox read-only` + 独立 `CODEX_HOME` 注册为 restricted 条目，qoder 以 `--tools "" --strict-mcp-config` 注册，claude privileged 使用 `--permission-mode bypassPermissions --add-dir {agent_workspace}`；opencode/pi 预留为 blocked，接入需满足稳定 JSON 协议、显式隔离、回放验证三件套。**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` 订阅（工号形如 `551400` 或 `WB02018153` 都算；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 的"等对方打完字"靠的
正是它。兜底轮**救不回**中断到超龄的生成任务（遗弃判定窗大于消息年龄上限，被弃
即过期），别指望它做恢复。

**即时回执（`presence_instant_ack`，缺省关）**：事件到达的那几秒内就把
⌛「回复中」贴到对方消息上，不等轮次跑到生成阶段——消息→⌛ 实测 3-7 秒（下限是
钉钉推送延迟，毫秒级不存在）。开启要求 `presence_markers_enabled` 与
`event_listener_enabled` 同时为真（缺一 ConfigError）。它是 presence 层的受控
白名单，不是第二条摄取路径：事件载荷只决定"要不要贴表情"，门禁、游标、生成、
投递全部照旧。判定门全部 fail-closed：无文本（表情包/语音）不贴、陈旧事件不贴、
身份不明不贴、deny/robot/mute 不贴（deny 名单每 60s 从配置热读，`blacklist add`
一分钟内生效）；SUMMON 模式下只有开着召唤窗的会话才贴；自己发的消息只有
`@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 读取限制在 5 秒，失败时不做通用的 `--verbose` 原地重试，而是在下一个间隔
创建全新读取，避免单个启动抖动把无回执窗口连续翻倍。轮次若确实发现 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 起来后第一次重弹就会修好。

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

补偿采用不可变计划和显式执行两阶段，不能用扩大 `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` | 交付包根目录（仓库内 `install_skill/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 skill install --agent claude --skill dingclaw-inbox   # 只给读的那一份
```

**只装 `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
```

注意这和面板本身不一样：面板里打开一个会话会调 `clear-red-point`（除非按了 `^R` 未读保持）。`unread_gate_enabled` 读的是服务端未读状态，清红点会让分身以后不再处理那个会话——这个副作用该由看着屏幕的人决定，所以 CLI 这一侧够不到。写的那一半（发送、起草、清红点、静音、切模式、下载、触发）**不是加了 `--yes` 门禁，是压根没有子命令**。

## 安全边界

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

自动发送使用逐字比对的 (agent, tier) 命令模板表和固定绝对路径，禁止追加工具、权限绕过或未知参数。三个 CLI Agent 各有条目，但列在表里只是「允许尝试」，不是「允许发送」——每一条都还要在运行它的那台机器上跑过 `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`（纯文本）有 DWS 原生 `--ai-tag` 角标，`send-card`（流式卡片）
和 `send-by-bot`（机器人）都没有。所以正文里的固定文案（`ai_disclosure_text`，
默认 `[AI 分身代发]`）才是覆盖全部三条路径的那道保障，**置空是就绪错误**。

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

标识只加一次：注入点在唯一的投递收口且在打字机分段之前，所以一条回复标一次、标在第一段。

## 许可证

dingclaw 以 [MIT License](https://spdx.org/licenses/MIT.html) 发布。TUI 单文件内嵌依赖的
许可文本与版权声明随 wheel 保存在 `dingclaw/tui_app/THIRD_PARTY_LICENSES.txt`。DWS CLI 是
独立的上游项目，使用其自己的许可证。
