# @ycodium-ai/plugin-im

Talk to your agents from Telegram, WeCom, or Lark. Message the bot (or mention
it in a group) and it starts a turn on this machine; the conclusion comes back
to the chat, and anything the agent needs approved lands there too.

In group chats, each message sent to the agent starts with a line saying who
spoke, including the group name when available. Telegram supplies the sender's
first and last name (or username) and the group title. Lark looks up the sender's
name and the group name; displaying real names requires 通讯录（用户信息）读取权限
(contact/user information read permission) on the app. Without that permission,
the bot still works. WeCom smart bot callbacks supply neither name. When a name
is unavailable, the line uses a short code such as `member 3f2a`, stable for that
person in that bot and derived without exposing the platform user id. Private
chats have no source line. In a group, the source line comes before any quoted
reply and the user's words.

## What it does

Each settings-card row is one **bot card** (alias, platform, credentials, access
mode, allow list, queue vs steer, and channel options such as Lark panel mode and
WeCom numbered-reply hints). Row
actions check the connection, copy a diagnostic reference (`IM-CONN-…`), and for
Lark guide scope repair. Chats (private or group) are not configured on the card;
they pick their own project:

- **Connect a Lark bot** (Beta) is a button below the bot list in the settings section (web,
  desktop and mobile). One scan, then the plugin creates the Lark app through the developer
  console, publishes it visible to the scanner only, and adds a Lark row with the credentials
  saved as secrets and the scanner as the only allowed person: no draft, no Save. It relies on
  non-public console interfaces and may break; the inline panel then shows the step list with the
  failed step marked. See `src/lark/setup/`.
- **Access mode** (per bot): `allowlist` (default), `open`, `privateOpen` or `groupOpen`.
  `allowlist` admits only the people on the allow list; `open` admits any sender the
  platform can identify.
  `privateOpen` opens private chats and requires the allowlist in groups; `groupOpen`
  opens groups and requires the allowlist in private chats. Both use the same list.
  Either mixed mode requires the allowlist when the chat type is unknown.
  New bots start on `allowlist`.
- **Allowed people** is an allow-list of that platform's user ids (shown when the
  mode requires the allowlist for either chat type). Empty blocks everyone in chats
  that require the allowlist. Someone who is not admitted gets one reply with their
  own user id and where to put it, at most
  once every ten minutes per person (the table of who was told is bounded in memory).
  In `open` mode any identifiable sender is admitted; the deny hint still applies
  when the platform reports no sender id.
- A chat with no project starts nothing. The first message from an allowed
  person gets a project picker (a card on Lark, an inline keyboard on Telegram,
  a numbered text list on WeCom: reply with the number or
  `/repo <number>`). The pick is written as a plugin fact and survives restarts.
  `/repo` switches the project: the old thread is kept (still under that
  project's `/sessions`), the chat forgets it, and a running turn blocks the
  switch. The panel has a Pick project button. On the WeCom smart bot, `/repo`
  and this prompt stay the numbered list (`projectPicker` is false on
  `src/wecomPanel.ts`); the button on the command card is separate.
- On Telegram a forum topic is a chat of its own: its chat id is `chat:topic`
  (`chatIdOf`, only when Telegram marks the message `is_topic_message`; a reply
  chain in an ordinary supergroup also carries `message_thread_id` and stays the
  group), so each topic picks its own project and keeps its own thread. Sends to a
  topic carry `message_thread_id`; edits, reactions and the member count use the
  group id. General is the group itself. An adapter reports the chat a chat hangs
  under as `parentChatId` on the inbound message (a topic's group); a chat with no
  project yet copies the parent's project, model and plan mode on its first message
  (`inheritFromParent` in `inbound.ts`, one `chat-updated` fact) and is on its own
  from then on.
- On Lark every answer is a reply to the message that caused it. An adapter opts in
  with `quotesReplies`; only then does `inbound.ts` put a reply anchor
  (`ImReplyAnchor`: message id, in a topic or not) on the binding it handles a message
  with, so command replies and the panel card quote the command, and only threads that
  have an anchor are looked up at all. `bindingOf` drops the anchor, since a binding
  built for another chat (`/group`) must not reply into the old one. `turns.ts` records
  the anchor against the user message it dispatches (`replyAnchors.ts`, in memory,
  bounded), and the reply card, approval and question cards, the settled
  attachments and `im_send_file` look it up through the host's own record of which
  user message started the turn (`getThreadExecution`: the turn intent carrying that
  turn id), not through the order of the message list, because a message queued
  behind a running turn is already in that list. The queued-message card replies to
  the queued message itself. A turn continued from the app, or one that outlives a
  restart, has no anchor and is posted with the plain send. `larkApi.ts` calls the reply endpoint and falls back to the
  plain send when Lark rejects the reply (the quoted message was withdrawn, the chat
  does not take topic replies); a network error is not retried, since the message may
  have gone out. `reply_in_thread` is set when the inbound message already sits in a
  topic (`thread_id`) or mentions the bot while the per-bot **Open a topic when
  mentioned** column (`larkTopicOnMention`, on unless set to `off`, read only by the
  Lark adapter) is on.
- On Lark a topic in a group is a chat of its own, like a Telegram forum topic. Its
  chat id is `chat:rootMessage` (`topicChatIdOf` in `larkAdapter.ts`): the root message
  is known before the topic exists, the platform's own topic id only after the first
  reply. Later messages in the topic carry the topic id, and usually the root; the
  adapter reports the topic id as `chatAlias`, and the core keeps a persisted
  alias-to-chat table (`chat-aliased` facts, first one wins, `chatAliasOf` in
  `chatState.ts`) so a message that carries only the topic id still lands in the same
  chat. The adapter also learns the topic id when a topic reply goes out (the reply
  response, or a read-back of the root message when the response has none) and reports
  it through `noteChatAlias`; every send takes its target from one helper
  (`sendTarget`), so no send path can skip that. A send to a topic chat with nothing to
  quote replies to the topic's root, since Lark cannot address a topic directly. A
  card callback names only the group, so a panel click is matched to the chat that
  opened the card when that chat sits under the clicked group (`handlePanelClick`
  asks the adapter's `parentChatIdOf`; how a chat id is encoded stays in the adapter).
  Private chats are never split by topic. A main-window message that mentions the
  bot does not yet belong to a topic: the adapter reports it under the group with
  `branchChatId`, and `inbound.ts` moves it to that chat only at the point where a
  turn is about to start, after commands, the project picker, pending questions and
  team routing were handled for the group, so `/repo` typed in the group still changes
  the group.
- A finished turn or a failed thread is reported to the chat that is talking in that
  thread and to nobody else (`notificationTargets.ts`). Only a thread no chat is
  talking in (opened in the app) is reported by project: one conclusion per finished
  turn and thread failures go to the chats that picked that project; a chat with no
  project hears nothing.
- `/group <name>` (Lark only): the bot creates a group, adds the sender as owner
  and posts the project picker in it. Telegram and WeCom bots cannot create
  groups and answer so.
- **In groups, answer** (per bot): only when mentioned (default), every message,
  or every message except ones aimed at someone else. WeCom only delivers
  mentions, so it has no effect there. Under the default, a group that holds only the
  sender and the bot needs no mention (`soloGroup.ts`; Lark `im.v1.chat.get`, Telegram
  `getChatMemberCount`, answer cached five minutes; WeCom unsupported).
- Archived projects are read-only and are not offered by the picker. A message
  to a chat whose project was archived or deleted gets a one-line reply that
  says so and points at `/repo`, at most once every ten minutes per chat. Chat
  commands such as help and status still work. Restore the project to resume.
- Secrets (bot tokens, app credentials) stay in the environment secret store.
  The patch file keeps a reference, not the value.
- A row can pick its own proxy. On a given machine one platform may be
  unreachable while another is fine.

Platforms are adapters behind one plugin, not three plugins. Lark uses the
vendor SDK for its long connection; WeCom speaks JSON over a WebSocket; Telegram
is HTTP long-polling. **WeCom App** (`wecom-app`) is the self-built application
channel: HTTP callbacks with official encrypt/decrypt, URL verification, inbound
text and images (fetched through `media/get`), outbound text, images and files
(`media/upload` then `message/send`). Private chats only; group messages are ignored.
It listens on `127.0.0.1:30987` by default (path `/wecom-app-callback/<rowId>/<secret>`);
put a reverse proxy or tunnel in front and paste the public URL into WeCom “Receive
messages → API receive”. Each column is validated, and a row with problems does not
connect. Streaming replies are off by default (the app's passive stream reply has not
been verified on a real WeCom client). With it on, WeCom refreshes the reply as the agent
writes; if the client never refreshes the stream, the final reply is sent as text.
Team goals finish the initial placeholder when accepted; the lead's replies arrive as
new messages.

If a message cannot be saved before dispatch, the bot replies that there is no need to
send it again and that it will be handled automatically the next time the machine starts
(a WeCom App streaming placeholder is finished first).

The default is one message per finished turn. A single turn can produce dozens
of assistant messages, mostly commentary between tool calls.

The reply to a thread this plugin opened is written as the agent works
(`thread.turn-progress` ticks, reply text read with `getThreadMessage`) and
settled when the turn ends; threads opened elsewhere still get the per-turn or
per-message text. How depends on the platform:

- **Lark** patches one card.
- **Telegram** edits one message (`editMessageText`, plain text) and, when the
  reply outgrows a single message, freezes it and continues in the next one.
  A `message is not modified` answer counts as success; a 429 waits the
  `retry_after` it names, once.
- **WeCom** keeps sending frames of one `stream.id` (`finish: false`) on the
  inbound message's `req_id` and closes with `finish: true`. With no inbound
  message to reply to, or after the platform rejects the stream, the settled text
  goes out as ordinary messages instead.

An adapter opts in with the optional `turnCards` surface (`src/adapter.ts`).
Approval messages are posted for every pending request on platforms with
buttons and settled when the request is answered, here or
elsewhere (`thread.request-resolved`); WeCom, which answers by number, posts only
the first and just stops accepting its number. Each bot's **More settings** includes
an approval timeout in minutes: the default is 60, and 0 turns automatic denial off
(integers from 0 to 1440). Unanswered approvals are denied automatically at the
deadline. Lark and Telegram show a notice on the approval card or message
stating the number of minutes; platforms without editable approval cards, and
failed card updates, send the same notice as chat text. The timeout continues from
the approval's creation time after a restart, including immediately denying
overdue requests. Changing the setting recalculates pending deadlines immediately;
setting it to 0 cancels them. Questions have no timeout.

Once a request has been answered or has timed out, Lark cards and Telegram messages show the result first, followed by the original approval content; timeout notices sent as chat text include a short excerpt of that content.

On a platform whose `turnCards` set
`rotatesOnInteraction` (Lark: patching a card does not move it), an approval or
question that someone answered seals the open reply card as "continues below" and the
next tick opens a new one under the interaction card (`rotate` in `turnCards.ts`, on
the same per-thread queue as the ticks), so the settled reply is the last card in the
chat. Cards that were still open when the
plugin restarted are marked interrupted. The pieces are pure and separate:
`turnView.ts` (ticks to a view model), `larkCards.ts` (view model to card JSON),
`larkMarkdown.ts`, `larkCardStream.ts` (throttle, one update in flight, newest
snapshot wins), `turnCards.ts` (per-thread queue and lifecycle),
`plainTurnText.ts` (view model to plain text), `telegramTurn.ts` (message
rollover).

The agent's questions and the messages queued behind a running turn use the
optional `choiceCards` surface (`ImChoiceCard`, `src/adapter.ts`): Lark patches a
card, Telegram edits a message and its inline keyboard. A request is posted on
`thread.user-input-pending` (read with `listPendingUserInputs`), one card per
question, and answered through `ctx.threads.userInput.respond` once every question
has an answer; a free-form answer is the next ordinary message in the chat (its
attachments, and any waiting ones, go along in `attachmentsByQuestionId`; an
option-only question leaves them waiting for the next message), and
`thread.request-resolved` freezes the cards of a question answered elsewhere or
closed unanswered. WeCom writes the options as numbers and the
reply is parsed in `src/questionCards.ts`. A message that lands behind a running
turn gets a queued card (Steer, Withdraw, Resume sending) drawn from
`getThreadExecution` and refreshed when the turn intents or the queue change
(`src/queueCards.ts`, `src/queueFlow.ts`); the buttons call
`threads.turnIntents.run` (steer, with the active turn id), `threads.turnIntents.cancel`
and `threads.turnQueue.resume`, and the card is the user's own action, so no
`intentAction` is sent. WeCom only gets a one-line notice: that notice has no
steer or withdraw buttons (resuming is the `/continue` command). Editing a queued message is not offered:
the host does not let a plugin dispatch `thread.turn-intent.edit`.

A chat keeps its place across restarts. Which project a chat picked, which thread
it is talking in, the team it is pointed at, the model and plan mode set from the
chat, whether this chat's session list includes archived sessions, the threads this plugin has opened, and the approval, question and
queued-message buttons still waiting in the chat are
written as plugin facts (`ctx.events.append`) and folded back by a pure function
(`ctx.derivedState`, see `src/chatState.ts`). A saved thread that was deleted,
archived or no longer on the chat's project is dropped on the next message.
Listing archived sessions does not keep that thread. Unarchive it on the web or in another client
before the next message can continue in it.
The chat key is `<bot row id>:<chat id>`: two bots in the same person's private chat share
the chat id, so the key has to carry the bot.

Inbound attachments. An adapter reports a message's resources as `resources`
(`ImInboundResource`, `src/adapter.ts`) and implements `fetchResource`, which must stop reading
at `maxBytes` (`readBodyLimited`, `src/attachments.ts`) and throw `ImResourceTooLargeError`.
An adapter whose platform caps bot downloads tighter than the host sets `downloadLimitBytes`
(Telegram: 20 MB); a size the platform reports up front is checked before any download. The
core (`collectResources` in `src/inboundAttachments.ts`) cleans the name (`sanitizeAttachmentName`: the host
splices it into the `[Attached … "<name>" …]` line the agent reads, so newlines and double
quotes must not survive), takes the type from the bytes (`resolveMime`: platforms self-report,
Lark says PNG for every image), checks its own memory-protection limits (10 MiB image, 25 MiB other, 20 per
turn; the host itself has no attachment limits, but `ctx.attachments.store` takes whole bytes) and stores each one with
`ctx.attachments.store`; an image the host can inline reaches the agent as an image, anything
else as a path. A failed one is reported in the chat and left as a line in the turn text.
A message with attachments and no text starts no turn: the thread is created if the chat has
none (attachments are stored per thread), the attachments wait in memory for that thread, and
the next message with text carries them. Waiting attachments are dropped when the chat's
thread changes and are not persisted. Lark does not require a mention for a message that is only
a resource (`RESOURCE_MESSAGE_TYPES` in `src/larkInbound.ts`); the sender allow-list still
applies. Lark rich text is read by `src/larkPost.ts` (every image, `<image N>` placeholders in
order, the client's `md` field preferred). Known types we do not handle are reported with
`unsupportedType` and answered with one line, rate-limited per chat. A chat pointed at a team
cannot carry attachments. WeCom reports `image`, `file`, `video` and the images of `mixed` (a group "mention plus picture"; text items and `<image N>` placeholders keep their order, and a mixed with only images still has content, so it starts a turn) in `resources`, with the download URL and `aeskey` packed into the resource key. `fetchResource` (`src/wecomMedia.ts`) GETs the URL (valid five minutes) and decrypts: `aeskey` is base64 for a 32-byte key, the IV is its first 16 bytes, AES-256-CBC with auto padding off, then PKCS#7 removed with a **32-byte block** (pad value 1 to 32, every pad byte equal, else the media is rejected). Without an `aeskey` the bytes are used as they are. The ciphertext is capped at limit + 32, the plaintext at the limit, and the file name comes from `Content-Disposition` (`filename*=UTF-8''…` first, then `filename="…"`) through `ImFetchedResource`. A WeCom `voice` message is its transcript as text, not an attachment.

Lark, Telegram and the WeCom smart bot read quoted text and attachment summaries into the agent's message; quoted attachment bytes are not downloaded. Telegram uses the selected `quote.text` when present and ignores forum-topic service anchors. Lark reads an explicit `parent_id` with a five-second timeout, excluding a topic's automatic reference to its root. If the original cannot be read, a short reason accompanies the user's question and the turn still starts. WeCom App and personal WeChat do not read quotes.

Outbound files. The plugin contributes one MCP tool, `im_send_file` (`src/sendFile.ts`;
`ctx.toolkit` name `im`), with `path` and an optional `description` (the caption). It sends only
to the chat whose thread is the calling conversation (`chatOf(...).threadId`), never by
project, and fails with `not-in-chat` otherwise. The bytes come from `invocation.workspace`
(`workspace.read`: realpath-bounded to the thread's folders, regular files, 50 MiB cap) and must
be read before the handler returns, because every method rejects `invocation-ended` afterwards.
Order: `stat` (size against the adapter's `outboundLimits`, before reading), `readFile` with
`maxBytes` = the file limit, type from the bytes (`resolveMime`), then the caption line, then
`sendImage` (a picture within the image limit) or the optional adapter method `sendFile`. Audio
and video go as files. Lark uploads `file_type=stream` to `im/v1/files`; Telegram uses
`sendDocument` and falls back to it when `sendPhoto` answers 400. Failures return `isError` with
`errorCode` (`not-in-chat`, `no-workspace`, `outside-workspace`, `not-a-file`, `not-found`,
`empty`, `too-large`, `unsupported-platform`, `upload-failed`); the text for the agent is plain
English, only the caption goes through the catalog. The limits are Lark 10 MB image / 30 MB file
(Lark docs), WeCom 10 MB image (PNG, JPEG, GIF) / 20 MB file / at least 5 bytes (WeCom docs), and
Telegram 10 MB / 50 MB (from memory of the Bot API docs, not checked in this repository).

WeCom uploads over the existing long connection (`src/wecomUpload.ts`, `request` in
`src/wecomApi.ts`): `aibot_upload_media_init` (type, filename, total_size, total_chunks, md5) gives
an `upload_id`; `aibot_upload_media_chunk` sends each chunk of at most 512 KB as base64 (all chunks
in parallel up to 4, three at a time up to 10, else two; two retries per chunk after 500 ms and
1000 ms); `aibot_upload_media_finish` gives the `media_id`; then `aibot_send_msg` carries
`{ chatid, chat_type, msgtype: "image" | "file", <msgtype>: { media_id } }`. Every request waits for
its ack by `req_id` (15 s); an `errcode` other than 0 or a missing ack fails the upload, and nothing
is sent to the chat. `chat_type` (1 single, 2 group) comes from the last inbound message of that
conversation and is left out when none has arrived since the plugin started, as plain text does.
A picture goes as `image` only when it is PNG, JPEG or GIF within 10 MB (`imageMimeTypes`).

Chat commands: `/stop`, `/steer <instruction>`, `/batch`, `/send`, `/cancel`, `/continue`, `/compact`, `/status`, `/version`, `/new`, `/sessions`, `/archived`, `/resume`, `/history [count]`, `/model`,
`/reasoning`, `/permission`, `/plan`, `/repo`, `/group`, `/team`, `/thread`, `/help`, `/panel`. In a chat with no project
`/sessions`, `/resume` and the team commands answer with the project picker. `/version` and `/archived` still answer. `/sessions` and `/resume` only list and
switch threads this plugin opened, since the host lets a plugin send only into
its own threads. `/continue` calls `threads.turnQueue.resume` for the chat's thread
when its queue is paused (a `/stop` or a failure pauses it), which is how WeCom
resumes a paused queue from the chat; it is not `/resume`, which already switches threads.

`/steer <instruction>` adds text to the turn running in this chat, regardless of the bot's queue mode. It preserves multiple lines and rejects images and files. While the host waits for an answer or approval it is refused and not kept; the user handles that first or sends `/stop`, then sends it again. The waiting check reads the host execution view, not the plugin interaction records. With no bound session or running turn, it replies without starting work. If the turn ends just as the host accepts the instruction, it waits as the next message. Files sent on their own before it are not attached; they stay for the next ordinary message. Type it; panels and native command menus do not list it. There is no Chinese alias.

`/batch` collects up to 10 text messages in a private chat without replying or starting a turn. `/send` submits them together as one input; a failed submission keeps them for retry. `/cancel` discards them. Repeating `/batch` reports progress, and a full batch never submits automatically. Attachments are refused without counting them; other slash commands are blocked until `/send` or `/cancel`. Starting a batch is refused in a group, before a project is picked, while the chat targets a team, or while the host reports a running turn or pending answer or approval. If the project has been archived or deleted when `/send` is sent, nothing is submitted and the batch is kept. After unarchiving, `/send` works again; to switch projects (the only way on for a deleted one), `/cancel` (discarding it), then `/repo` and start again with `/batch` (other commands stay blocked while collecting). These commands take no arguments, have no Chinese aliases, and appear in `/help` but not panels or native menus. Batches live only in memory: restarting Ycodium clears a collecting batch, and its messages must be resent. See [Batch input](../../docs/user/im-bots.md#batch-input).

`/history [count]` previews the current session's recent user and assistant messages in private chats only (default 3, maximum 5), oldest first. It does not start a turn or change the session. Images and files appear as placeholders; long text is split for the platform, with a notice when the preview text is incomplete. Type the command in the chat; panels and native command menus do not list it. With the reply language set to Chinese, `会话历史 [数量]` works too.

`/version` answers with one line, the plugin name and its version. It takes no arguments; if anything follows it, the reply is a usage reminder. It works in private chats and groups, does not read the session, and does not start a turn. Type it; panels and native command menus do not list it. There is no Chinese alias.

`/permission` lists the four numbered permission modes and marks the current mode,
the bot ceiling, and modes above that ceiling as unavailable. `/permission <number or mode id>`
saves a choice for this chat; `/permission default` clears it. Only senders explicitly in
the bot's allowlist can change or clear the choice, including in open chats; admitted
senders outside the list can view it. Each turn uses the stricter of the chat choice
and the bot's current setting, so tightening the bot setting also limits saved choices.
Changes apply from the next turn, survive restarts, and do not change a running turn.
With Chinese replies, `权限` and `权限 默认` work too. This is a typed command in private
chats and groups, listed in `/help`; command panels, Telegram menus, Lark native menus
and WeCom panels do not add a permission picker. See [Chat permissions](../../docs/user/im-bots.md#chat-permissions).

`/compact` asks the current session to compact its context. It takes no arguments; if anything follows it, the reply is a usage reminder. With no current session, while a turn is still running, or when the current model's agent does not support compaction, it replies and does not start compaction. When the request is accepted, it replies that compaction has started, then sends one message to the chat that requested it when compaction completes or fails. If two chats request compaction for the same session, only the latest chat receives the result. Pending replies are kept in memory: after Ycodium or the plugin restarts, it no longer sends results for those requests. Type it; panels and native command menus do not list it, because support depends on the current model and the WeCom main menu already holds six buttons. With the reply language set to Chinese, `压缩` works too.

`/archived on` makes this chat's session list (`/sessions`, and the same list on the command panel) include archived sessions, each marked as archived. `/archived off` lists only sessions that are not archived. `/archived` with no argument replies with the current setting and how to use it; anything other than on or off gets the same reply and does not change the setting. on and off ignore case. The choice is remembered on this chat and kept across restarts. A chat that has never set it lists only sessions that are not archived. An archived session shows up only as a row in that list; open it on the web to read it. `/resume` by number or by the start of an id, and a tap on that row in the command panel, does not switch to it or unarchive it. The bot says the session is archived and cannot be continued, and the chat stays on its current session. Unarchive it on the web or in another client first, then switch to it, or send `/new` to start a new one. It works before a project is picked. Type it; panels and native command menus do not list it, because it is a low-frequency switch and the WeCom main menu already holds six buttons. There is no Chinese alias.

On Lark, Telegram, and the WeCom smart bot, `/panel` sends a command panel, and `/help` stays text and ends with a pointer to it. WeCom App does not implement the panel surface, so `/panel` answers with the text list and `/help` does not mention `/panel`. Lark cards and Telegram inline keyboards show all command buttons. The WeCom smart bot uses a template card from `src/wecomPanel.ts`; its main menu is limited to six buttons. Commands that do not fit are listed in `/help` and can be typed into the chat. Commands that take an argument (a thread, a project, a model) open a second view on the same message with a Back button. These lists let you reach every option by paging: Lark and Telegram show eight items per page, and WeCom shows three, leaving room for Back, Previous, and Next. A tap on the WeCom card updates that same card; when the update cannot be applied, `src/panelController.ts` posts a new card and the old one stops accepting taps. A busy placeholder is not drawn on that card: it stays as it is until the next interactive view. Telegram buttons carry only a short `pn:<version>:<index>` token (`callback_data` is capped at 64 bytes); the action stays in memory and a tap on an older keyboard version counts as expired. Each card keeps its own view stack in memory (`src/panelController.ts`), so after a restart an old card only answers that it has expired. A tap runs the same handler as the typed command and waits in the same per-chat queue as typed messages. Another platform opts in by implementing the optional `panel` surface on its adapter (`src/adapter.ts`).

`/reasoning` shows the current model's reasoning efforts; `/reasoning <value>` or `/reasoning <number>` switches it. Numbers follow the available values at the time you send the command, so send `/reasoning` first to check the choices. See [Model and Plan Mode](../../docs/user/im-bots.md#model-and-plan-mode) for details.

`/team` uses the chat's selected project. It reads the roster from the `team` plugin and
each text message invokes `startGoal` with a stable request ID derived from the bot,
platform, chat, and inbound message ID. A successful goal binds the chat to the lead's
conversation so the existing reply cards and turn notifications carry the lead's replies.
See [Pointing a Chat at a Team](../../docs/user/im-bots.md#pointing-a-chat-at-a-team).

The **Reply language** setting (Chinese by default, or English) picks the
language of everything the bot says itself. The settings section follows the
interface language instead. The copy lives in `locales/en.json` and
`locales/zh-CN.json`; Chinese shortcut commands only work in Chinese.

## Requirements

A Ycodium server. The plugin asks to read files in the project of the conversation that
calls its tool (`workspace.read`, used only by `im_send_file`). Its generated session ports
require the `commands.dispatch` declarations for `thread.create`,
`thread.meta.update`, `thread.turn.start`, `thread.turn.interrupt`,
`thread.approval.respond`, `thread.user-input.respond`, `thread.turn-intent.run`,
`thread.turn-intent.cancel`, `thread.turn-queue.resume` and `thread.context.compact`. The plugin calls the generated
`threads` and `approvals` ports; it does not send session commands through
`commands.dispatch`. These ports only write to threads this plugin started.

## Install

```
ycodium plugin add @ycodium-ai/plugin-im
```

The plugin ships enabled with Ycodium as `core.im`. Disabling it closes every
chat connection. Uninstalling it writes a removal onto the home patch, so a
restart or upgrade does not bring it back. Reinstalling the unversioned bundled
package restores the same entry without an npm fetch. Bot configuration and
secrets stay until you clear them.

## Surfaces

- **Web / Desktop / Mobile** — Settings → Plugins, the **Chat** section. The plugin draws it
  itself in the `settings.section` seat (`src/ui/`); on mobile the same bundle runs in a WebView.
  The settings card this package registers is declaration-only (`presentation: "hidden"`): the
  host draws no form for it, and reads it to know which cell holds a proxy and which hold passwords.
- Chat replies themselves go to Telegram, WeCom, or Lark, not into a Ycodium
  page.

## Source layout

`src/index.ts` only assembles: it builds the adapters, registers the settings card and the settings commands, builds the
shared core and wires the feature modules together. Everything else is one module per
responsibility, each receiving a small `core` object (a few host capabilities, the reply
language, the saved-state mirror) plus an explicit `deps` object, never the whole `ctx`:

- `settingsContract` (what the settings commands and the UI share: command names, wire shapes,
  which column applies to a row, the reply-language options; pure data and functions only, it is
  bundled into the browser UI), `settingsView` (the redacted read view and the write transforms
  for the bots table and the delivery targets table),
  `settingsCommands`, `settingsUpdater` (the one read-modify-write entry for the plugin's own
  config), `ui/` (the settings section)
- `botConfig` (settings card, reading bots, chat keys, allow-list), `core` (state mirror and
  persistence, delivery, bot and chat lookup), `connections` (one long connection per bot)
- `inbound` (per-bot message handlers), `chatQueue` (per-chat serial queue), `commands`,
  `chatSetup` (picker, `/repo`, `/group`), `projectRows`/`projectList` (the project list),
  `sessions`, `modelPlan`, `turns`, `inboundAttachments`, `teamGoals`
- `approvals`, `interactions` (question and queue cards, button routing), `replyCards`,
  `notifications`, `acknowledgements`, `panelWiring` (with `panelController`)
- `lark/setup/` (the scan flow): `setupFlow` (the engine; running shows one progress line, the
  step list is sent only on failure, success returns the credentials instead of emitting `done`),
  `setupCommands` (the `larkSetup.session` stream, each item a cumulative snapshot so the UI keeps
  the QR code while status lines follow; saves the bot, then emits `done`), `saveBot` (the pure `bots`-table upsert), `consoleClient`/`qrLogin`/`fakeConsole` and
  friends

## Developing

The package builds its own code and the Lark SDK into `dist/index.js`. The host
supplies the shared `@ycodium-ai/plugin-api` at runtime. The plugin requires
plugin API version 3.

## License

UNLICENSED.

## 主动投递（骨架）

按 botId+targetId 投递（提案 [0068](../../docs/internals/proposals/0068-im-proactive-delivery.md)）：

- 设置分区里可配投递目标（每个目标一块：选机器人、填目标 id 与平台聊天 id），并 **opt-in** 开启本机 HTTP（**只听 `127.0.0.1`**，可选 Bearer token）。
- `POST /v1/delivery/messages` 体为 `{ botId, targetId, text }`；`botId` 是机器人表行 id，设置里选机器人的下拉在名字后面的括号里显示它。
- **不要**用 `network.serve` 当这条入站口；同 Host 插件互调另走 typed RPC（尚未做）。
