<div align="center">

<h1>dsh-telegram</h1>

<p><strong>在 Telegram 上与你的 Agent 对话——并且在它提问时，真的能够回答。</strong></p>

<p>
  <a href="https://github.com/ashafizullah/dsh-telegram/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/ashafizullah/dsh-telegram/actions/workflows/ci.yml/badge.svg"></a>
  <a href="https://www.npmjs.com/package/@ashafizullah/dsh-telegram"><img alt="npm" src="https://img.shields.io/npm/v/%40ashafizullah/dsh-telegram?logo=npm&logoColor=white&color=cb3837"></a>
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/npm/l/%40ashafizullah/dsh-telegram?color=3da639"></a>
  <a href="package.json"><img alt="node" src="https://img.shields.io/node/v/%40ashafizullah/dsh-telegram?logo=node.js&logoColor=white&color=5fa04e"></a>
  <a href="https://core.telegram.org/bots/api"><img alt="Bot API" src="https://img.shields.io/badge/Bot%20API-10.1%2B-2ca5e0?logo=telegram&logoColor=white"></a>
  <a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek-Harness-4d6bfe"></a>
</p>

<p><a href="README.md">English</a> · <a href="README.id.md">Bahasa Indonesia</a> · <strong>中文</strong></p>

</div>

[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 Telegram 前端。

在手机上与你的 Agent 对话——并且在它提问时，真的能够*回答*。

## 为什么需要它

用聊天软件驱动 Agent，会在两个具体的地方卡住，这个插件就是为了解决它们。

**Agent 写的是 markdown，而 Telegram 收到的是原文。** 模型的回答里有
`**粗体**`、标题、表格、任务清单和代码块。以纯文本发送时，这些全都变成了字面
上的星号和竖线。

从 Bot API 10.1 起，Telegram 自己会解析 markdown，因此本插件通过
`sendRichMessage` 几乎原样转发 Agent 的回复——表格显示为表格，清单显示为清单
——同时消息上限也从 4096 提升到 32768 个字符。

**Agent 会提问，却没有地方回答。** 当 Agent 调用 `ask_user_question`，或某个工
具需要你的许可时，harness 会阻塞并等待某个 UI 作答。而这件事以前只有浏览器能
做。完全发生在 Telegram 里的对话，会在第一个提问处停住，且无从解开。本插件把自
己注册为那个 UI，于是提问与授权都以按钮的形式出现在聊天里。

## 前置要求

- 一个可以添加插件的 DeepSeek Harness profile
- **Bot API 10.1 或更高版本**，用于 `sendRichMessage` 与 `sendRichMessageDraft`
- Node 22 或更高版本

没有 HTML 回退路径。Telegram 的 rich markdown 解析器是宽容的——未闭合的代码围栏
或一行散乱的标记都会被接受而非拒绝——所以流式过程中的中间帧并不需要回退。

## 安装

```bash
npx @deepseek-ai/dsh plugin --profile web add -w @ashafizullah/dsh-telegram
```

或者从源码检出，以便在其之上开发：

```bash
git clone https://github.com/ashafizullah/dsh-telegram.git
cd dsh-telegram
pnpm install && pnpm build

npx @deepseek-ai/dsh plugin --profile web add -w "$(pwd)"
```

然后给它一个机器人 Token。用 [@BotFather](https://t.me/BotFather) 创建机器人，
并把 Token 存放在凭据引用之下——永远不要写进配置文件：

```bash
npx @deepseek-ai/dsh credentials set TELEGRAM_BOT_TOKEN
```

启动 profile，控制台会打印一个认领码：

```
[dsh-telegram] this bot has no owner yet. Message @your_bot with:

    /claim 3f9a2b1c
```

把它发给你的机器人，它就归你了。在此之前，它不回应任何人。

该认领码同时会以仅属主可读的权限写入
`$DSH_HOME/dsh-telegram/claim-code.txt`，因为有些 profile 根本没有组合任何控制
台输出，而一个没人能读到的认领码会让机器人永远无法使用。

## 访问控制

任何知道句柄的人都能找到一个 Telegram 机器人，而它背后的 Agent 能在你的机器上
执行 shell 命令。因此默认是关闭的。

- **认领流程**（默认）：第一个发送控制台认领码的人成为属主。所有权是持久且一次
  性的——即使拿着正确的码，后来的认领也会被拒绝，所以事后泄露的码毫无用处。
- **允许名单**：把 `allowFrom` 设为一组 Telegram 用户 ID，即可完全跳过认领。用
  `/whoami` 查看自己的 ID。

认领码每次重启都会更换，且从不经由 Telegram 发送。

访问检查先于其它一切进行，因此未经授权的文本不会到达 Agent——连命令也不会。

## 命令

| 命令 | 作用 |
| --- | --- |
| `/start` | 这个机器人是什么，以及你是否可以使用它 |
| `/help` | 列出所有命令 |
| `/claim <码>` | 认领一个尚未被认领的机器人 |
| `/new` | 开始新对话，忘掉当前这一段 |
| `/cd [路径]` | 查看或切换工作目录 |
| `/model [名称]` | 查看模型、`/model list`，或切换到某一个 |
| `/effort [强度]` | 查看或调整模型思考的深度 |
| `/vision [名称]` | 查看、更换或关闭负责读图的模型 |
| `/permission [名称]` | 查看或调整 Agent 在这里被允许做什么 |
| `/diag` | 插件对自身的观察，以及最近的失败 |
| `/screenshot` | 发送 harness 所在机器的屏幕截图 |
| `/sessions` | 继续这个聊天里较早的一段对话 |
| `/status` | 会话 ID、工作目录，以及是否已加载 |
| `/stop` | 取消 Agent 当前正在做的事 |
| `/whoami` | 你的 Telegram 用户 ID |

### 在群里

一个每句话都要接的机器人，没人愿意留在群里。所以在群里它只在被 @提及或被回复时
才作答——这也正是大家已经在用的惯例。它自己的 @提及会在进入提示词前被去掉，因为
那是称呼而不是内容；而回复它说过的话可以继续这段交流，无需每行都 @一次。私聊不
受影响。想要旧行为，把 `requireMentionInGroups` 设为 `false`。

@提及是跟 Telegram 自己解析出的区间比对的，而不是在文本里搜索：
`@mybot_staging` 里含有 `@mybot`，用子串匹配会让这个机器人抢答另一个机器人的
@提及。

### Agent 被允许做什么

部署会为它运行的一切选定一个权限默认值，而这个选择通常是对着网页界面做的：
仅回环访问，有人在旁边看着。Telegram 机器人不是这样——它从任何地方都能被联系到，
只靠一份用户 ID 名单把关。所以同样的 `danger-full-access`，在那里意味着完全不同
的东西。`permissionPreset` 从部署自己的表里挑一个，只作用于 Telegram 对话。

它同时决定审批按钮能否工作：在审批策略为 `never` 的 preset 下，永远不会有人来
请求许可，按钮也就永远不会出现。选一个会询问的 preset，才是把它们打开。

### 屏幕截图

`/screenshot` 会把 harness 所在机器正在显示的画面发过来。这正是这个机器人存在的
理由，只不过用在了屏幕本身上：机器在桌上，而你不在——否则想看看那个跑了很久的构建
现在显示到哪了，就得走回键盘前。

它**默认关闭**，而且这个开关刻意放在部署设置里，而不是做成聊天命令。屏幕上有什么
就会拍到什么——打开着的密码管理器、别人的消息、毫不相干的客户数据——而这是这里唯一
一件不经过 Agent 就把本机内容发往外部的事。打开它，理应需要与配置这个机器人相同的
权限。

macOS 还需要给运行 harness 的进程「屏幕录制」权限。没有它，`screencapture` **仍会
成功**，只是返回一张没有任何窗口的桌面图——看起来像功能坏了，其实只是缺权限。所以
这种情况会被明确说出来，而不是含糊带过。请在 系统设置 → 隐私与安全性 → 屏幕录制
中授权，然后重启 harness。

超过 Telegram 10 MB 照片上限的截图会改以文件形式发送，那条通道可到 50 MB——大尺寸
显示器的 PNG 经常需要。

### 思考强度，以及被允许做什么

`/effort` 显示模型思考得多深，并列出**这个模型**提供的档位——档位是从模型自己读来
的，因为 `low`/`medium`/`high` 只是某一家供应方的说法，而不是所有人的；提供一个
模型没有的档位，失败的会是这个回合，而不只是这条命令。`/effort default` 可以还原。

`/permission` 显示 Agent 在这里被允许做什么，并可切换：`read-only`、
`workspace-write`、`danger-full-access`，或者你的部署定义的任何其他名字——名字读
自它自己的表，而不是写死在这里。拼写很宽松，`full access`、`full-access` 和
`readonly` 都能命中；而同时匹配两个 preset 的简写会被拒绝，而不是靠猜。改动对正在
进行的对话同样生效，因为人们收紧权限的理由，通常正是马上要跑的那个回合。

这几项都按对话生效，并且叠在设置页面所配置的东西**之上**。这意味着有两个界面在展示
相关状态，所以命令会说明是哪一层在回答：一旦某个对话自己做了选择，它的回复就会一并
说出底下的部署默认值。没有这句，设置页面读起来就像在撒谎——它显示一个值，而聊天里
在按另一个值行事，两者之间毫无关联。`/… default` 会把对话交还给部署默认值。

`/status` 用一条消息回答全部——会话、目录、模型、思考强度、权限——因为为了搞清楚
自己在跟什么说话而要敲四条命令，是四条太多了。

### 用哪个模型，以及哪一段对话

`/model` 告诉你当前对话用的是哪个模型，`/model list` 列出已配置的，
`/model provider/model` 则切换。当只有一个供应方提供某个模型 id 时，直接写它就
够了；有多个时，它会反问是哪一个。与 `/cd` 不同，这不会重启任何东西——harness 在
组装每一步时都会读取一份可变的选择，所以改动会落在下一条消息上，历史完好无损。
`/model default` 把对话交还给部署默认值。

`/sessions` 把这个聊天里较早的对话做成按钮供你挑选。在此之前 `/new` 是一扇单向
门：harness 保留了每一份日志，但指向当前对话的绑定被替换掉了，从手机上再没有别的
路回去。这份列表属于本插件自己，因此装的是这个聊天里的对话——而不是网页界面开过
的每一个会话。

### Agent 有哪些工具

工具由 preset 提供。注册表本身属于 host 平面，但几乎每一个面向模型的行——bash、
编辑器、grep、skills、子代理、todo、计划模式——都注册在某个 **preset** 的 scope
层里。因此没有加入任何 preset 的 agent，到达模型时只带着 host 组合中全局注册的那
些。Telegram 会话按部署的默认 preset 组合，或在指定了 `agentPreset` 时按它组合，
并把该选择记入会话头，好让之后的读取者解析到同一套组合。

### Agent 在哪里工作

`/cd` 不带参数会告诉你当前对话在哪；`/cd ~/projects/app` 则把它移过去。绝对路径、
`~`、以及相对当前位置的路径都可以用，粘贴进来的路径会自动去掉引号。

移动目录会开启一段新对话，机器人也会明说。这不是偷懒：沙箱的可写根目录来自会话的
工作目录，而这个根在会话打开时就已固定——所以切换目录在构造上就等于换一个会话。
你的选择按聊天记住，`/new` 和重启都不会丢。这正是它与 `/new` 会丢弃的会话绑定分开
存放的原因。

目录不存在、目标其实是个文件、以及读不到，是三种不同的错误，会得到三种不同的说明。
三种情况都让对话原地不动。

每次连接时这份列表都会注册到 Telegram，所以在聊天里输入 `/` 就会看到命令提示和
各自的说明。机器人一旦有了主人，`/claim` 就会从列表里消失——它是唯一一个成功之后
便不再有用的命令。

除此之外你输入的任何内容，都会作为提示词交给 Agent。

## 你可以发送什么

| 你发送 | Agent 收到 |
| --- | --- |
| 文本 | 提示词本身 |
| 照片，或以文件形式发送的图片 | 视觉模型读出的内容，以及你的说明文字 |
| 一次发多张照片 | 全部合成一条消息，附在你的说明文字下 |
| 文本文件——日志、堆栈、源码 | 其内容进入提示词，过长时会被截断 |
| 语音、音频或视频 | 一句说明：无法读取 |

图片经由 harness 的附件接缝，它接受 PNG、JPEG、WebP 和 GIF。其余类型被官方明确
搁置，因此本插件会直言相告，而不是收下消息再悄悄丢掉其中的内容。

该接缝还会拒绝最长边超过 `maxImageDimension`（默认 2000 像素）的图片——而**每一张**
满屏的手机截图都超过它：iPhone 是 1179×2556，多数 Android 是 1080×2400。Telegram
会为一张照片渲染多个尺寸，因此这里选的是**放得下**的最大尺寸，而不是现有的最大
尺寸；限制值直接从 store 本身读取，不再另存一份会走样的数字。若接缝仍然拒绝，就
退到下一个更小的尺寸。至于以文件形式发送的图片——只有一个尺寸，无处可退——拒绝
信息会说明限制是多少，并提示改用照片方式发送，让 Telegram 提供较小的副本。

文件过大或下载失败时，会变成提示词里的一句说明——无论如何，你的说明文字仍会到达
Agent。

### 一次发好几张

Telegram 没有「一条消息里放多张照片」这回事。相册会作为 N 条独立更新到达，彼此之间
只靠一个共享 id 连着，而说明文字只挂在其中**一条**上——所以三张截图以前会变成三个
回合，其中两个是 Agent 无从下手的裸图片。

现在属于相册的消息会先被暂存而不是立即作答，等相册不再增长，整组作为一条提示词送
出去：你的说明文字，然后是全部图片。这点等待只由相册承担，且每个相册只付一次——比
把同一个问题回答三遍划算得多。

### 模型必须看得见

不声明图片输入的模型会拒绝**整个**请求，因此图片在发送前会对照
`inputModalities` 做检查。**没有任何 DeepSeek 模型接受图片**——
`deepseek-v4-flash` 与 `deepseek-v4-pro` 都是纯文本——所以开箱即用的情况下，截
图会被婉拒，并附上一句说明什么才可行，而你的说明文字仍会到达 Agent。

**设置 → Telegram → 附件** 提供一个下拉框，列出你已在 设置 → Models 中配置好的
模型。选一个，图片就能被读取了。

`/vision` 用来选择由哪个模型在这里读图，或者用 `/vision off` 把读图整个关掉。
「关掉」是一个真正的答案，而不是答案的缺席：对话本身能看见的时候，它不需要任何读图
者，而这个表态必须压过部署层面的任何配置。和其他几项一样，它按对话生效，并且能挺过
`/new`。

**如果对话本身用的模型就能读图，下面这一切都不会发生。** 图片会直接送进去，由模型
自己去看。自 DeepSeek 发布 `deepseek-v4-flash-vision-exp` 起，这已是一个现实的选项
——而且当截图不只是文字时，它是更好的那个：转写会丢掉图表、曲线、错位的布局，也就是
你真正在问的东西。

下面这层间接之所以存在，是因为供应方会检查整个请求历史，图片会把对话绑定到一个看得
见的模型上。而当那个模型**正是**你选的那个，就没有什么需要挣脱，也没有什么需要绕开
——于是读取、谢绝、以及粘住的路由会一起退场。

图片本身从不进入你的对话。它会被发到该模型上的一个一次性会话，被要求转写其中的
每一处文字，并简要描述这是什么；回答以普通文本返回，**那才是**你的对话所收到的
内容，就放在你自己的说明文字下面。那个会话随后即被销毁——它只活一个回合。

这一层间接正是关键。供应方会检查整个请求历史中的图片，所以留在对话里的一张图片
会把这段对话终身绑定到一个看得见图片的模型上：一张截图之后，后续每一个回合——
无论其文字多么普通——都得跟着跑到那里，远离你选定的模型和围绕它配置的工具。把
图片放到别处去读，历史中就始终没有图片，对话因而留在原处、保有工具，也永远不会
卡住。

如果根本没有配置视觉模型，这条路径压根不会被走到：图片在下载之前就被谢绝，并附上
一句说明哪些模型本可胜任，而你的说明文字仍会到达 Agent。

如果读取已经尝试但失败了——模型无法连接，或该回合在两分钟后超时——图片就按原样
发出，改为让对话迁移到视觉模型上，并持久生效，直到 `/new`。那是退路而非设计，
提示词里会说明发生了哪一种情况。

浏览器能读到的模型目录不携带模态信息，所以下拉框无法标出哪些模型接受图片。这项
检查交由 host 在图片真正发送时进行，那是唯一能给出确定答案的地方。视觉模型通过
承载它们的供应方进入 harness，例如在 设置 → Models 中添加的
OpenAI-compatible 路由，其模型条目声明了 `input: [text, image]`。

### 当没有模型能看时

在没有配置视觉模型时，图片过去会被直接谢绝，你得到的是一句关于模型配置的说明，而
不是关于这张图的任何信息。现在，只要装了 `tesseract`，就改为读取其中的文字。

它是退路，而且它自己会这么说。OCR 读的是**文字**，它并不「看见」。报错、日志或收据
的截图会读得很干净——文字清晰、对比度高、没有透视，正是它最擅长的情形；而白板、
架构图或图表则只会变成一堆散落的词，没有任何东西能说明这张图是什么。因此读出的内容
无论去到哪里都会被标注为 OCR：把未加标注的 OCR 交给 Agent，它会把读错的数字当成
事实，而收据上的金额恰恰是最容易读错的。

tesseract 从不被假定存在。没有任何一个运行本插件的操作系统自带它，因此它的缺席才是
常态：只探测一次，缺失时旧的谢绝依然生效——只是现在会同时说明**两条**出路。
`/diag` 会告诉你这台机器有哪一条。

同一条退路也覆盖「配置了视觉模型但连不上」的情形，理由相同：读出文字总好过什么都不
返回。

拉丁字母仅用 `eng` 就读得不错——印尼语、数字、日期和金额都能穿过——所以
`media.ocr.languages` 只有在换一种书写系统时才需要改。`tesseract --list-langs` 会
列出已安装的语言。

### 当对话卡住时

有一类失败重试永远无法解决——最常见的正是上面那种：早先的某条消息携带了当前模型
不接受的内容，而你接下来输入什么都无济于事。机器人会识别这类失败，说明失败原
因，并给出一个开启新对话的按钮。让用户去记住 `/new`，等于让他们替插件做诊断。

可能自行恢复的失败则不带按钮上报，因为对那些失败来说，重试确实是正确的做法。

## 配置

在 harness 的网页界面中打开 **设置 → Telegram**。该页面直接写入设置文档——没有
保存按钮，因为 host 通过重新连接来应用已提交的更改，而一个暂存改动的表单会让页
面和正在运行的机器人对"当前配置是什么"产生分歧。

机器人 Token 是例外。它是机密，因此从不经由设置通道来回传输：页面只知道是否已
存有 Token，通过 credentials 域写入它，并且对于环境变量已经提供的引用拒绝提供编
辑——在那里写入会看似成功，而解析仍旧返回环境变量中的值。

页面上的每一项，同样可以在 profile patch 中设置，供以文件方式配置的部署使用。

## 配置项

每个字段都有可用的默认值；配置为空也能运行。

| 键 | 默认值 | 含义 |
| --- | --- | --- |
| `enabled` | `true` | 连接是否随 harness 一同启动 |
| `tokenRef` | `TELEGRAM_BOT_TOKEN` | 存放 Token 的凭据引用名 |
| `baseUrl` | `https://api.telegram.org` | Bot API 源站；仅在使用代理时修改 |
| `allowFrom` | `[]` | 允许的用户 ID；留空则启用认领流程 |
| `cwd` | harness 的 cwd | 对话的起始目录，直到用 `/cd` 切换 |
| `agentPreset` | `""` | Telegram 对话所用的 preset；留空则取部署默认值。工具正是由 preset 提供 |
| `permissionPreset` | `""` | Telegram 使用的权限 preset，取自部署自己的表；留空则跟随部署默认值 |
| `requireMentionInGroups` | `true` | 在群里，只有被 @提及或被回复时才作答 |
| `screenshot.enabled` | `false` | 允许 `/screenshot`。默认关闭；macOS 还需要「屏幕录制」权限 |
| `streaming.enabled` | `true` | 边生成边显示回答 |
| `streaming.throttleMs` | `1200` | 两帧之间的最小间隔 |
| `timeoutMs` | `30000` | 单次 Bot API 请求的超时时间 |
| `longPollSeconds` | `25` | Telegram 保持空轮询打开的时长 |
| `media.enabled` | `true` | 读取用户发送的图片和文本文件 |
| `media.maxBytes` | `20 MB` | 超过则拒绝；Telegram 的机器人下载上限即在此 |
| `media.maxTextChars` | `60000` | 内联文本文件截断到此字符数 |
| `media.ocr.enabled` | `true` | 没有视觉模型时，用 tesseract 读取图片中的文字。未安装 tesseract 则不起作用 |
| `media.ocr.languages` | `eng` | tesseract 读取的语言；多个用 `+` 连接。只有已安装的才可用 |
| `media.visionModel` | `""` | 在独立会话中读取图片的 `provider/model`；留空则把图片直接发给对话本身 |
| `reconnect.baseDelayMs` | `1000` | 第一次重连前的延迟 |
| `reconnect.maxDelayMs` | `30000` | 重连之间的最长延迟 |

## 诊断

`/diag` 报告插件对自身的观察：连接状况、这个部署究竟组合了哪些 harness 接缝，以及
最近二十件出错的事。

它还会报告正在运行的版本，以及 npm 上是否有更新——只读，并缓存一小时，所以问第二遍
不花任何代价。这里刻意**没有**配套的 `/update`：更新 harness 只有重启后才生效，而从
运行在其中的插件里重启，等于杀掉正在回答你的那个进程——在没有守护进程的机器上，没有
任何东西会把它拉起来。知道自己落后了是有用的那一半；动手则该在你能盯着的地方做。

接缝列表是其中最有用的部分。缺席的接缝能一眼解释一整类「它为什么不会做那个」，
不需要任何人去猜——缺少 `agentPresets` 正是 Telegram agent 曾经到达模型时几乎没有
工具的原因，而当时没有任何地方说出这件事。

`ctx.logger` 写往部署所组合的任何输出端，而有些 profile 一个都没有组合——因此一
个只把失败写进日志的插件，实际上是沉默的。本插件还会在每次状态变化时把自身状态
写入 `$DSH_HOME/dsh-telegram/status.json`：

```json
{ "state": "connected", "bot": "your_bot", "updatedAt": "..." }
```

`connecting`、`connected`、带原因的 `idle`、带原因的 `failed`。机器人 Token 绝不
会出现在其中。

## 与网页界面共存

harness 只允许**一个** user-questions provider，而在同时运行网页应用的 profile
中，浏览器已经占用了它。本插件接管该位置，并把浏览器的 provider 保留为回退：属
于浏览器会话的提问会被原样转交回去，属于 Telegram 对话的提问则变成聊天里的按
钮。卸载本插件会把先前的安排原样恢复。

授权本身是可组合的——harness 以 waterfall 方式运行它们——因此本插件只为自己的会
话作答，其余一律向后传递。

## 各部分如何衔接

```
Telegram Bot API
      │  长轮询：message + callback_query
      ▼
UpdatePoller ──► UpdateRouter ──┬──► SessionRunner ──► ctx.agents
                                │           │
                                │           └──► VisionExtractor ──► 一次性会话
                                ├──► MediaCollector ──► ctx.attachments
                                ├──► TelegramQuestionProvider ──► ctx.userQuestions
                                └──► TelegramApprovalAnswerer ──► approval/request

ctx.on('session/event') ──┬──► VisionExtractor   （它自己的读取会话）
                          └──► TurnBridge ──► RichReplyStream ──► sendRichMessage

TypingIndicator          （路由器与桥接持有，直到回复出现）
```

### 回复是如何流式呈现的

Telegram 提供了两种机制，二者不可互换：

- **私聊**使用 `sendRichMessageDraft`——一个临时预览，共享同一 draft id 的各帧之
  间会有动画过渡。它在最后一帧之后 30 秒过期，因此在漫长的工具调用期间，会有一
  个心跳重发当前文本；否则预览会消失，机器人看上去就像死了。草稿从不持久化，所
  以一个回合以真正的 `sendRichMessage` 结束。
- **群组没有草稿 API。** 那里就在回复写完时直接发送。

两者最终都归于一条持久的 rich message。

### 在有话可说之前，什么都不发

等待期交给 Telegram 自己的 “正在输入…” 指示，回复只在真正有内容时才出现——第一批
文字，或者 Agent 调用的工具名。回合一开就发出去的省略号，只是在告诉用户他们已经
知道的事；在群里，它还是一条永久留存的消息。

指示是**被持有**的，而不是发一次。`sendChatAction` 五秒即失效，比这里几乎所有值得
等待的事都短——下载文件、在视觉模型上读取图片、排在上一个回合后面、或在某个工具
调用里待上一分钟——所以单次调用读起来就像机器人启动后立刻死了。持有按会话计数，
并在其自身有效期内重发，因此路由器读取附件时的持有与桥接随后那个回合的持有能干净
地重叠，只有最后一个释放时才停止输入。另有十分钟的兜底，以防某次释放永远不来。

任何一次重绘都不会比上一帧显示得更少。当一个工具跑完而正文还没出现时，那行工具名会
一直留着，直到有真正的文字来替换它——Telegram 拒绝空草稿，所以另一种做法等于用一帧
什么也没说的内容，换掉刚刚发生过的事。

在 Agent 工作期间，正在运行的工具会以 `<tg-thinking>` 块显示在回复上方：

```
▸ bash: npm test

目前我发现的是……
```

Telegram 只在草稿中接受该块，别处一概不接受，这与它的生命周期恰好吻合——回合被
持久化时它就消失，于是最终回复承载的是答案，而不是产生答案的脚手架。它只有被截
断的一行：一次工具调用的参数可能长达整个文件，而这里的目的是知道 Agent 还活着，
不是阅读一份记录。

## 开发

```bash
pnpm install
pnpm test          # 598 个测试
pnpm test -- --coverage
pnpm typecheck     # host 与 browser 两半
pnpm build         # host 用 tsc，浏览器包用 esbuild
```

发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时完成，走 npm 的可信发布：
GitHub 通过 OIDC 证明该工作流的身份，npm 据此换发一份只在这一次发布期间有效的凭据。
没有任何地方存放 token，也没有需要抢时间输入的一次性密码——当账号的第二因素是通行密钥
而不是验证码时，这一点尤其重要。工作流会拒绝与 `package.json` 不一致的标签，因为那是
它唯一可能悄无声息发布出去的错误。

每个模块都能脱离 harness 运行，这正是测试套件跑得快的原因：插件入口是对着 Bot
API 的真实 HTTP 桩来执行的，而浏览器包的物化方式与 shell 完全一致。

### 浏览器那一半

`build.client.mjs` 把 esbuild 产出的 CJS 包裹进 shell 的惰性 CJS 工厂信封
(`window.__ModuleLoader__.load({ id, factory })`)。该信封是复现出来的而非引入
的：harness 的 `clientBundle` 预设并未发布，其自身文档也把这一点列为对仓库之外
插件的已知限制。

因此这里是本插件唯一与内部格式耦合的地方，而 `test/client-bundle.test.ts` 将其
钉住——该测试会执行构建、用桩 `require` 物化工厂，并检查 `apply` 是否占据了它的
设置席位。若某个 harness 版本改变了该格式，失败会在那里以明确的名字出现，而不是
表现为一个空白的设置页。

React 与 shell 自身的包被标记为 external；打包第二份 React 会在页面挂载的一瞬间
破坏所有 hook。

## 已知限制

- **每个对话一个目录。** `/cd` 可以移动对话，但会话本身无法移动——切换目录会
  开启一段新会话。
- **暂不支持语音、音频或视频。** harness 的附件接缝只接受图片。

## 许可证

MIT
