Skip to content

feat(feishu-bridge): drive MiniMax Code from a Lark/Feishu conversation - #68

Open
antianqi wants to merge 3 commits into
MiniMax-AI:mainfrom
antianqi:feat/feishu-remote-bridge
Open

antianqi wants to merge 3 commits into
MiniMax-AI:mainfrom
antianqi:feat/feishu-remote-bridge

Conversation

@antianqi

@antianqi antianqi commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What this Plugin does

MiniMax Code is a terminal application. On the machine it is fine; away from it, the only options are a remote desktop session or a tunnel, and neither works well from a phone. This Plugin turns a Feishu conversation into a remote control for a local mcode install.

A message sent in Feishu becomes one mcode exec turn in a per-conversation workspace. The answer is written back by editing that same message, so a turn that makes twelve tool calls still produces one chat bubble instead of twelve.

Example prompt

create a file notes.md in the current directory with the content "shopping list", then read it back to confirm

Expected result

The bot's single reply, edited in place:

Created and read back successfully.

...workspaces/oc_xxxxxx/notes.md - 16 bytes, content:

shopping list

shopping list = 13 characters + newline = 14 bytes, consistent with the byte count and the content read back matches character for character.


⏱ 11.4s · 2 tool calls

Package shape

Skill-only. No mcp.json, no package.json, no node_modules, no native binaries, no symlinks, no install step.

The bridge drives two CLIs the user already has, discovered from PATH and from the install layout. No drive letter or absolute prefix is hardcoded anywhere, and $PLUGIN_DATA is honoured when the runtime provides it.

file purpose
plugin.json portable Agent Plugins 1.0 manifest, checked by scripts/validate.mjs
.claude-plugin/plugin.json v0.4.0+ manifest, skills pointing at both copies
skills/SKILL.md v0.4.0+ Skill
skills/mcode-feishu-bridge/SKILL.md v0.3.x Skill, byte-identical to the above
scripts/mcode-feishu-bridge.mjs the bridge
scripts/mcode-feishu-bridge.test.mjs the suite

Dependencies and platforms

  • lark-cli (@larksuite/cli), already configured for the user's account. The
    Plugin never signs in; if lark-cli is not authenticated it reports the failure
    and stops.
  • A local MiniMax Code installation.

Windows and macOS/Linux. Windows-specific paths (a process-tree kill, where) are
selected at runtime behind IS_WIN; everything else is portable.

Data flow and network

The Plugin contains no credentials of its own and never signs in. It shells out to
the user's own lark-cli; the user's credentials live in the user's own
lark-cli config directory.

Destinations, and only these:

destination why data
open.feishu.cn / open.larksuite.com read messages, send, edit, download attachments the conversation text being watched, and the text the bridge writes back
the Feishu Open Platform auth host token refresh, performed by lark-cli whatever lark-cli sends; this Plugin neither reads nor stores it

No telemetry, no analytics, no crash reporting, no update check, no registry, no
CDN, no other host.

Files written under the data directory ($PLUGIN_DATA, else
$MCODE_FEISHU_BRIDGE_DATA, else ~/.mcode-feishu-bridge): state.json (per chat:
workspace, mcode session id, last handled message id, turn count, at most one
pending delivery), config.json, bridge.log, bridge.lock, the per-chat
workspaces/ directory, and downloaded media/. state.json is written
atomically, so an interrupted write cannot leave a truncated file that would make
the bridge reprocess a whole conversation.

There is no default chat id, because a chat id is a private identifier. It comes
from --chat, $MCODE_FEISHU_CHAT, or config.json.

README.md carries four separate disclosure sections: no credentials of its own,
dependency on a lark-cli configuration the user already owns, no telemetry, and
Lark/Feishu as the third-party service.

Design decisions

shell: false everywhere. mcode answers can contain resource markup such as
<media type="file" src="..." />. The <, > and " in that are redirection and
quote operators to cmd, so forwarding arguments through cmd /c shreds the
command: exit 1, empty stdout, empty stderr, no diagnosable cause. Measured with one
payload against one message, cmd /c failed and a direct spawn delivered. Every
child process here is spawned directly and the shell is never involved.

One edit queue per message. Tool-broadcast hints and the final answer used to be
two concurrent lark-cli processes, and Feishu is last-write-wins, so a slow hint
could overwrite the answer and leave the chat stuck on a tool call. A reproduction
lost 1 round in 6. All edits now go through a serial queue sealed before the final
is enqueued.

The watermark always advances. Stopping it on a delivery failure was meant to
enable a retry, but it made the whole poll restart: every cycle re-ran mcode and
re-sent a placeholder, so a persistent failure became a chat flood. Failures now
queue a delivery-only retry with linear backoff and a hard cap.

Fetch problems throw. Returning a status code and trusting a caller to check it
is not a safeguard: deleting that one line left the suite green. collectFresh
throws, so the only exit is a catch that already logs.

Newest page first, full pagination only when needed. --order asc --page-size 50 returns the oldest 50 messages. Once a conversation outgrows one page, new
messages are invisible and the bridge looks alive but deaf while logging nothing. It
now fetches the newest page and escalates to --page-all only when the watermark is
not on it.

State is written atomically. Staged, fsynced, renamed.

--permission full, stated plainly. A phone is a bad place to answer approval
prompts, so the prompts are removed. That means a Feishu message can cause arbitrary
local changes, and the README says so under "Security model" rather than burying it.

Validation

node scripts/validate.mjs
-> Validated 29 hosted Plugins and all examples.

node plugins/antianqi/mcode-feishu-bridge/scripts/mcode-feishu-bridge.test.mjs
-> 84 passed, 0 failed, 0 skipped

node --test        # whole repository
-> tests 346, pass 344, fail 1, skipped 1
   the single failure is pre-existing and unrelated:
   tests/plugins/octopus-meme-maker/smoke.test.mjs asserts that
   scripts/make_gif.py parses, and this host has no Python

The suite imports the real module under MCODE_FEISHU_BRIDGE_TEST=1 rather than a
copy, so a green run is evidence about the shipped code.

Test evidence

Two cases are regression tests for defects that shipped in an earlier draft and were
found in real use:

  • U reproduces the paging defect from a real conversation that had outgrown one
    page: 50 oldest messages, the watermark on the last of them, the new message on
    the next page. It asserts the old logic drops the message and the new logic
    recovers it.
  • W asserts that a fetch problem throws rather than returning an empty list.

Every other case guards a contract found by negative injection rather than by
inspection: the contract was broken on purpose, the suite was confirmed to go red,
and only then was the behaviour treated as covered. Nine such injections were run
against the delivery and locking logic and eight against the paging fix; all were
caught. Two earlier attempts were MISSED and produced the two extra cases.

Cases O and V read this Plugin's own source and fail if a child process is ever
routed through a shell, or if a message fetch is ever issued as --order asc
without --page-all.

CI behaviour

Case Q spawns a real mcode process to prove the hard timeout kills a process tree.
It skips when resolveMcodeCli() returns nothing, and the import-time executable
check does not exit under MCODE_FEISHU_BRIDGE_TEST=1, so the file does not fail on
a host without mcode. Verified with USERPROFILE and MCODE_HOME pointed at empty
directories:

78 passed, 0 failed, 1 skipped

Design compliance

  • One Plugin per folder, one contribution per pull request. No other contributor's
    Plugin is touched.
  • Zero npm dependencies; no package.json, no lockfile, no install step.
  • plugin.json declares only $schema, name, version, description, author,
    homepage, repository, license, keywords, with no unknown field.
  • README.md and an Apache-2.0 LICENSE are present; the manifest license matches.
  • Both runtime layouts ship in parallel, with byte-identical Skill copies.
  • No credentials, private endpoints, hidden telemetry, installers, native binaries,
    or symlinks.
  • No host-literal paths. Verified by scanning for drive-letter, /Users/ and
    /home/ literals; the only regex match in the source is a false positive inside a
    template literal.
  • No TODO, no UTF-8 BOM, LF endings enforced by a Plugin-local .gitattributes.

Known limits

Stated in the README rather than left to be discovered:

  • one turn at a time per conversation; a second message waits for the first
  • no card messages; plain text messages are edited in place
  • the bridge never recalls anything
  • long replies are not yet tested against the Feishu per-message length limit
  • only text, post, image and file message types are parsed
  • multi-conversation isolation is implemented but has only been exercised with one
    conversation

Manual test evidence

Driven for two hours against a real Feishu conversation on Windows 11, mcode 0.5.10:

check result
message → mcode → reply, single message edited in place 11 of 11 turns, 7.9–12.3s
session continuity across turns same mcode session id reused, context intact
tool broadcast visible during the turn ⚙️ Calling \write`…` then the answer
image attachment downloaded and passed to mcode
shell metacharacters in both prompt and answer a<b>c & d>e | f survived end to end, 0 replacement characters
mcode resource markup in an answer <media type="file" src="..." /> delivered intact
hard timeout kills the process tree, chat gets an explicit timeout notice
single-instance lock second watcher refused with exit 3; stale lock reclaimed
two watchers racing prevented by the lock

The three defects the comments record (shell double-parsing, edit ordering, and
paging) were each found in that real use, reproduced in isolation, and are now
covered by the suite.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

## Problem

MiniMax Code is a terminal application. On the machine it is fine; away from it,
the only options are a remote desktop session or a tunnel, and neither works well
from a phone. This Plugin turns a Feishu conversation into a remote control for a
local mcode install.

A message sent in Feishu becomes one `mcode exec` turn in a per-conversation
workspace. The answer is written back by editing that same message, so a turn that
makes twelve tool calls still produces one chat bubble instead of twelve.

## Example prompt

    create a file notes.md in the current directory with the content
    "shopping list", then read it back to confirm

## Expected result

The bot's single reply, edited in place:

    Created and read back successfully.

    `...workspaces/oc_xxxxxx/notes.md` - 16 bytes, content:

    ```
    shopping list
    ```

    ---
    11.4s - 2 tool calls

## Package shape

Skill-only. No `mcp.json`, no `package.json`, no `node_modules`, no native
binaries, no symlinks, no install step.

The bridge drives two CLIs the user already has, discovered from `PATH` and from
the install layout. No drive letter or absolute prefix is hardcoded anywhere, and
`$PLUGIN_DATA` is honoured when the runtime provides it.

- `plugin.json` - portable Agent Plugins 1.0 manifest, validated by
  `scripts/validate.mjs`
- `.claude-plugin/plugin.json` - v0.4.0+ manifest, `skills` pointing at both copies
- `skills/SKILL.md` and `skills/mcode-feishu-bridge/SKILL.md` - byte-identical,
  per the recommended cross-version layout
- `scripts/mcode-feishu-bridge.mjs` - the bridge
- `scripts/mcode-feishu-bridge.test.mjs` - the suite

## Design decisions

**`shell: false` everywhere.** mcode answers can contain resource markup such as
`<media type="file" src="..." />`. The `<`, `>` and `"` in that are redirection
and quote operators to `cmd`, so forwarding arguments through `cmd /c` shreds the
command: exit 1, empty stdout, empty stderr, no diagnosable cause. Measured with
one payload against one message, `cmd /c` failed and a direct spawn delivered.
Every child process here is spawned directly and the shell is never involved.

**One edit queue per message.** Tool-broadcast hints and the final answer used to
be two concurrent `lark-cli` processes, and Feishu is last-write-wins, so a slow
hint could overwrite the answer and leave the chat stuck on a tool call. A
reproduction lost 1 round in 6. All edits now go through a serial queue that is
sealed before the final is enqueued.

**The watermark always advances.** Stopping it on a delivery failure was meant to
enable a retry, but it made the whole poll restart: every cycle re-ran mcode and
re-sent a placeholder, so a persistent failure became a chat flood. Failures now
queue a delivery-only retry with linear backoff and a hard cap.

**Fetch problems throw.** Returning a status code and trusting a caller to check
it is not a safeguard: deleting that one line left the suite green. `collectFresh`
throws, so the only exit is a catch that already logs.

**Newest page first, full pagination only when needed.** `--order asc
--page-size 50` returns the oldest 50 messages. Once a conversation outgrows one
page, new messages are invisible and the bridge looks alive but deaf while logging
nothing. It now fetches the newest page and escalates to `--page-all` only when the
watermark is not on it.

**State is written atomically.** Staged, fsynced, renamed. An interrupted write
would otherwise leave a truncated `state.json`, and `loadState` falls back to an
empty state, which would reprocess the whole conversation.

**`--permission full`, stated plainly.** A phone is a bad place to answer approval
prompts, so the prompts are removed. That means a Feishu message can cause
arbitrary local changes, and the README says so under "Security model" rather than
burying it.

## Data and network

The Plugin contains no credentials and never signs in. It shells out to the user's
own `lark-cli`. The only destinations are the Feishu/Lark Open Platform, for
message read, send, edit, and attachment download. No telemetry, no analytics, no
update check, no other host. `README.md` carries four separate disclosure
sections: no credentials of its own, dependency on a `lark-cli` configuration the
user already owns, no telemetry, and Lark/Feishu as the third-party service.

There is no default chat id, because a chat id is a private identifier. It comes
from `--chat`, `$MCODE_FEISHU_CHAT`, or `config.json`.

## Validation

    node scripts/validate.mjs
    -> Validated 29 hosted Plugins and all examples.

    node scripts/mcode-feishu-bridge.test.mjs
    -> 84 passed, 0 failed, 0 skipped

    node --test        # whole repository
    -> tests 346, pass 344, fail 1, skipped 1
       the single failure is pre-existing and unrelated:
       tests/plugins/octopus-meme-maker/smoke.test.mjs asserts that
       scripts/make_gif.py parses, and this host has no Python

The suite imports the real module under `MCODE_FEISHU_BRIDGE_TEST=1` rather than
a copy, so a green run is evidence about the shipped code.

### Test evidence

Two cases are regression tests for defects that shipped in an earlier draft and
were found in real use. Their comments record the measured numbers.

- `U` reproduces the paging defect from a real conversation that had outgrown one
  page: 50 oldest messages, the watermark on the last of them, and the new message
  on the next page. It asserts the old logic drops the message and the new logic
  recovers it.
- `W` asserts that a fetch problem throws rather than returning an empty list.

Every other case guards a contract that was found by negative injection rather than
by inspection: each contract was first broken on purpose, the suite was confirmed
to go red, and only then was the behaviour treated as covered. Nine such
injections were run against the delivery and locking logic, and eight against the
paging fix; all were caught.

Cases `O` and `V` read this Plugin's own source and fail if a child process is ever
routed through a shell, or if a message fetch is ever issued as
`--order asc` without `--page-all`.

### CI behaviour

Case `Q` spawns a real mcode process to prove the hard timeout kills a process
tree. It skips when `resolveMcodeCli()` returns nothing, and the import-time
executable check does not exit under `MCODE_FEISHU_BRIDGE_TEST=1`, so the file does
not fail on a host without mcode.

Verified on a host with no mcode installed (`USERPROFILE` and `MCODE_HOME` pointed
at empty directories):

    78 passed, 0 failed, 1 skipped

## Design compliance

- One Plugin per folder, one contribution per pull request. No other contributor's
  Plugin is touched.
- Zero npm dependencies; no `package.json`, no lockfile, no install step.
- `plugin.json` declares `$schema`, `name`, `version`, `description`, `author`,
  `homepage`, `repository`, `license`, `keywords`, and no unknown field.
- `README.md` and Apache-2.0 `LICENSE` are present; the manifest license matches.
- Both runtime layouts are shipped in parallel, with byte-identical Skill copies.
- No credentials, private endpoints, hidden telemetry, installers, native
  binaries, or symlinks.
- No host-literal paths. Verified by scanning for drive-letter, `/Users/`, and
  `/home/` literals; the only regex match in the source is a false positive inside
  a template literal.
- No `TODO`, no UTF-8 BOM, LF endings enforced by a Plugin-local `.gitattributes`.
- `state.json` writes are atomic.

## Known limits

Stated in the README rather than left to be discovered:

- one turn at a time per conversation; a second message waits
- no card messages; plain text messages are edited in place
- the bridge never recalls anything
- long replies are not yet tested against the Feishu per-message length limit
- only `text`, `post`, `image` and `file` message types are parsed
…d the failure modes

The Setup section said "a lark-cli you have already configured" and stopped
there. That is not enough, because the bridge reads and writes through different
identities and a half-configured app fails in a way the log does not make obvious.

The bridge calls `--as user` for +chat-messages-list and
+messages-resources-download, and `--as bot` for +messages-send, +messages-reply
and +messages-edit. A bot cannot see a p2p conversation history, and only a bot
may edit a message it sent, so both identities are genuinely required and they
fail independently. Documented:

- `lark-cli auth status` must show `identities.bot.status` and
  `identities.user.status` both `ready`.
- The minimum scope set, split by identity: `im:message:readonly` and
  `im:resource` on the user side; `im:message` and `im:message:update` on the bot
  side. Called out `offline_access` separately, because without it the user token
  stops working after a couple of hours and the bridge goes quiet at a moment when
  nothing reports an error.
- That the user identity is the user"s own account, so the bridge acts as them.
- A p2p chat with the app as the simplest arrangement, which is what this Plugin
  is built and tested for.
- First-run behaviour: the workspace is created on start and nothing historical
  is picked up, so the first message has to come after the bridge starts.
- Obtaining a chat id, including the `--page-all` that the listing needs.

Also added, because an operator hits them immediately and none were documented:

- Running it in the background, with the reason the Plugin ships no autostart and
  the specific trap that a supervisor inheriting the child"s std handles blocks
  forever. This is the defect that cost the most time during development.
- Uninstall: `--stop`, then delete the data directory. Removing the Plugin leaves
  that directory alone on purpose; it is user data.
- Network: outbound HTTPS to the Feishu/Lark Open Platform only, no inbound
  listener, and `lark-cli` owns proxy configuration.
- A symptom-to-cause table for the identity, scope, token-expiry, attachment,
  paging and lock failures, noting that every one of them writes a log line, so an
  empty log plus a silent chat means the watcher is not running.

skills/SKILL.md carries the same identity and scope requirements in its
pre-flight and troubleshooting sections, so the agent checks them before starting
rather than after a user reports silence.

Validation: `node scripts/validate.mjs` green, 84 passed / 0 failed.
…reference to the README

Audited the shipped docs against the three questions a reader actually arrives
with: what does it do, how do I set it up, how do I use it. Setup was complete
after the previous commit. The other two were not, and both gaps were of the same
kind: the information existed, but somewhere other than where a reader would look
for it.

- There was no positive feature list. The README had one worked example and a
  "what it deliberately does not do" section, so a reader had to infer the
  capabilities. Added "What you get": one message per task, live tool progress,
  real tool access, continuous and isolated per-conversation context, attachments,
  a hung turn being killed rather than waited on, the footer, delivery that does
  not lose the result, single-instance enforcement, and a log readable while
  running.
- There was no package tree. Added "What you in the package", including the note
  that the two Skill copies are byte-identical because the two runtimes discover
  Skills differently, and that there is no mcp.json, package.json or binary.
- There was no command or flag reference. The flags table existed only in
  SKILL.md, so answering "how do I run it" from the README alone was not possible.
  Added a Usage section with the three commands, the full flag table, and the
  two ways to configure the conversation once instead of on every start.

Also cross-linked the failure table from README to the SKILL troubleshooting
order, so an agent and a human are looking at the same list.

Validation: validator green, 84 passed / 0 failed, both Skill copies still
byte-identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant