dsh-feishu
English | 简体中文
Drive an existing dsh (DeepSeek Harness) session from Feishu/Lark on your phone — dispatch work, watch progress live, answer questions, get the results. Outbound-only WebSocket: no open ports, no tunnels.
Requires dsh >= 0.1.5-rc.2 — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the latest/next dist-tags at runtime). The alpha line is no longer supported.
✨ Highlights
- One-command onboarding:
/feishu-onboard on the desktop — scan-to-create the Feishu app, auto-writes credentials + operators, hot-activates without a restart
- Self-serve pairing: empty allowlist → the first DM claims admin via a one-tap card
- Live round cards: one card per LLM round-trip — current state (🤔 thinking / 🔧 tool / ⏳ subagent), tool calls, and a growing tail of the in-flight message, refreshed every 5 seconds (pseudo-streaming)
- Replies land in the round card: when a round settles, its answer embeds into the very card you were watching — a
💬 Round 回复 section right above the stats footer — instead of arriving as a bare message wedged between status cards (answers longer than one body segment still ship as their own cards)
- Quick actions on the round card: ⛔ 停止 while a turn runs, ▶️ 继续 once it ends — one tap instead of typing
- Approval cards: when the host's approval waterfall asks for a sandbox escalation, the phone gets a ✅ 允许一次 / ❌ 拒绝 card — an unattended run no longer stalls at the desk (the session's approval policy must be
ask; expiry fails closed)
- Interactive ask-user cards: when the agent calls
ask_user_question, your phone gets an interactive card (dropdown / multi-select / text input + submit); the answer flows straight back. Pair it with ask-router for both desktop and phone prompting — first answer wins
- Group chats: pull the bot into a Feishu group — @-mention it to dispatch work or run commands (allowlisted members only, silent to everyone else); in the group the bot is actively driving, images flow without a mention
- Image dispatch: send a picture in DM — it is downloaded, media-type sniffed, committed as a durable attachment and injected into the session as an image block (the session's model route must accept image input)
- Background push (
backgroundPush): completion cards for sessions the phone is not bound to — cron deliveries and subagent settlements (mode cron), or every finished turn (mode all); off by default, the bot never messages unprompted unless told to
- Interactive /model: pick a model on the phone, grouped by provider —
bot-created sessions switch live
- Interactive pickers for the desktop selectors:
/think (reasoning
effort), /permission (permission preset), /select-skill (skill
activation) and /profile-switch (model profiles) render as one-tap
selection cards on the phone
- Interactive /resume: pick a session from the card (dropdown + enter), or just reply
/resume N
- One-tap corrupt-log repair: if /resume hits a damaged session log (historical double-writer writes), the bot offers a repair card — it rebuilds the log in place (the original is kept as a backup) and re-enters the session
/new starts a fresh session that inherits the previous one's working directory, model and reasoning effort
- Phone dispatch: messages sent mid-turn default to steer (they join the running turn — course corrections land immediately)
- Remote stop:
/stop aborts anytime; non-allowlisted users are completely invisible to the bot
- By-the-way questions:
/btw fires a side question alongside the running task and streams the answer into its own card — the main line never notices (parity with dsh-tui-pi's /btw, duplicated not shared)
🎬 Demo
/new starts a fresh session; dispatch work right from the phone:
https://github.com/user-attachments/assets/177e8839-523b-487e-b3d1-6d725cd8aba5
/resume interactive session picker + answering an ask-user card:
https://github.com/user-attachments/assets/c0d7092f-deda-4443-b75a-2bc93bd30d86
🚀 Install & Configure
Step 1: Install the plugin into your profile (≈2 min)
From npm (recommended):
dsh plugin --profile <your-profile> add @aiwayds/dsh-feishu
Or from a git checkout (when hacking on the plugin itself):
git clone git@github.com:fan56/dsh-feishu.git ~/github/dsh-feishu
cd ~/github/dsh-feishu && npm install && npm run link-closure
Edit ~/.dsh/profiles/<your-profile>/package.json:
{
"dsh": { "profile": { "bundles": [
// …existing bundles…
"@aiwayds/dsh-feishu" // ← add
]}},
"dependencies": {
// …existing deps…
"@aiwayds/dsh-feishu": "link:/path/to/dsh-feishu" // ← add
}
}
cd ~/.dsh/profiles/<your-profile> && pnpm install
Step 2: Configure the bot — pick one
/feishu-onboard is a desktop command — run it in the dsh TUI on your
computer (from the phone it replies with a pointer back to the desktop). The
three options below mirror the three paths the command offers.
Option A — /feishu-onboard (recommended, ≈2 min, no Feishu console needed)
Run /feishu-onboard — in the TUI or the web UI. It asks a handful of
questions one by one through dsh's native ask prompt (web: the question card
pops right in the browser, scoped to your open session; no ask provider or no
live session? it degrades to a plain printed guide) and picks the path with
you:
- Scan to create the app — zero Feishu console work
- Bind an app you already have — see Option B
- Manual guide — see Option C
On the scan path the launcher link arrives as a question card — web: right
in the browser (TTY also renders a QR code) → open it on your phone or
desktop, confirm in Feishu, then tap 「我已完成确认」 → the plugin creates an
enterprise custom app for you via Feishu's
official scan-to-create flow (OAuth device flow, official SDK registerApp),
pre-provisioned with everything this plugin needs:
- Bot capability
- Long-connection events:
im.message.receive_v1, card.action.trigger
- Permissions:
im:message:send_as_bot, im:message.p2p_msg:readonly,
im:message.group_at_msg:readonly, im:message.resources:readonly,
im:message.reactions:write, im:chat:readonly
Then it finishes the job: app_id/app_secret are written into the dsh
credentials service (refs DSH_FEISHU_APP_ID / DSH_FEISHU_APP_SECRET), the
scanning user is added as an operator, and the plugin hot-activates in the
same process — no dsh restart. Scan, then DM the bot; that's the whole
setup.
Fine print: the preset permissions ride a platform gray release. Where the
gray hasn't landed, the command verifies them automatically and guides the
top-up with a permission-preselection deep link. And for colleagues to
use the bot you still publish it once under Version Management & Release
(not needed for your own use).
Option B — You already have a Feishu app
Two ways to hand the credentials to the plugin:
- Run
/feishu-onboard and pick "existing app": enter App ID / App Secret
(written only to the local credentials file — never into session logs) →
the command verifies them against the API on the spot; wrong credentials
are re-asked; if the app lacks the bot capability (error code 11205) the
credentials are still saved and a console fix checklist is printed. You can
also add your own open_id as an operator there.
- Or write the two files yourself:
# ~/.dsh/.credentials.yaml (chmod 600; restart dsh after changing)
DSH_FEISHU_APP_ID: cli_xxxxxxxxxx
DSH_FEISHU_APP_SECRET: xxxxxxxxxxxxxxxx
Only allowlisted Feishu users can use the bot — everyone else is invisible:
# ~/.dsh/cordis.patch.yml
- id: dsh-feishu
config:
operators:
- ou_xxxxxxxxxxxxxx # your open_id (admin console → member details)
The effective allowlist is a union: operators here ∪
dsh-feishu.pairedOperators in ~/.dsh/settings.yaml (written by pairing
mode and /feishu-onboard) ∪ the DSH_FEISHU_OPERATORS env var
(comma-separated open_ids — handy for quick local tests without editing the
patch).
Option C — Manual console setup
Prefer driving the open.feishu.cn console yourself?
Six steps (≈10 min) — once they're done, return to Option B to hand the
credentials to the plugin:
- Create the app: sign in at open.feishu.cn → create a Custom App
(企业自建应用); note the
App ID (starts with cli_) and App Secret
- Add the bot: "Add app capability" → Bot
- Permissions ("Permissions & management"):
im:message:send_as_bot,
im:message.p2p_msg:readonly, im:message.group_at_msg:readonly (group
@-mention dispatch), im:message.resources:readonly (image download),
im:message.reactions:write, im:chat:readonly. Shortcut: the
permission-preselection deep link
https://open.feishu.cn/app/{AppID}/auth?q=...&op_from=openapi pre-ticks
the scopes — the same link /feishu-onboard hands you when a preset scope
isn't gray-released for your tenant
- Events & callbacks: subscription mode Long connection; add the
events
im.message.receive_v1 (messages) and card.action.trigger (card
interactions — required by the ask cards and the /resume picker)
- Availability → version & publish: add yourself under Availability,
then create a version and publish — events don't flow until you
publish (the most common stumbling block)
- Credentials: they belong in
~/.dsh/.credentials.yaml (chmod 600;
restart dsh after changing) — paste the Option B yaml by hand, or run
/feishu-onboard → "existing app" and let it store and verify them
Step 3: Start & verify
dsh --profile <your-profile>
# the log line dsh-feishu: armed (1 operator(s), feishu) means success
DM the bot /help → you get the command list; /resume lists sessions; send
text to dispatch work.
Operators list still empty? The bot no longer sits fully dormant: with
valid credentials and no operators it stays connected in pairing mode —
anyone who DMs it receives an admin pairing confirmation card, and one tap
claims admin (first come, first served; persisted to
dsh-feishu.pairedOperators in ~/.dsh/settings.yaml, effective immediately,
no restart). Group chats never trigger it, and once the list has an admin,
everyone outside it is invisible again. On a shared tenant that means the
first colleague to DM the bot becomes its admin — if that's not what you
want, DM it yourself first, or pre-configure operators per Option B.
🔀 Recommended: add ask-router (multi-surface prompting)
npm install -g @aiwayds/dsh-ask-router
Add @aiwayds/dsh-ask-router to bundles, after dsh-base and before any UI
bundle. With it: phone cards and the desktop TUI panel prompt
simultaneously — first answer wins. Without it things still work — the
phone owns prompting when no other UI is present, otherwise the desktop UI
takes it.
🗑️ Uninstall
Remove the plugin from a profile:
dsh plugin --profile <name> remove @aiwayds/dsh-feishu
The host reconciles the profile automatically: the dsh.profile.bundles entry is spliced and the patch layer (the dsh-feishu insert with its config) is dropped.
What stays on disk (kept on purpose — deleting data is destructive; a reinstall reuses it):
~/.dsh/settings.yaml dsh-feishu: section — bound session id, picker style, phone-model preference, and pairedOperators (the paired-admin list written by pairing mode / /feishu-onboard). Delete the section to reset the pairing — admins included.
- Repair artifacts inside session dirs:
*.corrupt-bak* is the only pre-repair copy of a damaged session log — keep it; *.repaired.* is the rewritten log the repair produced.
/tmp/dsh-feishu-bot.lock can linger after a SIGKILL; the stale-pid check steals it on the next start, so no manual step is needed.
Plugin unload (reload, disable, process exit) settles pending phone-side flows: unanswered ask/approval/selection cards are patched to a terminal state and their host-side callers fail fast instead of hanging.
📱 Usage
| Command | What it does |
|---|
/resume | Interactive session picker card (dropdown + enter; or reply /resume N), sorted by last update |
/new | Start a brand-new session and bind to it (inherits cwd, model and reasoning effort) |
/stop | Abort the running turn (queued messages survive) |
/btw <question> | By-the-way side question while the main task runs: one tool-less model call over a recent-conversation snapshot, streamed into its own card — the main line never notices. Not kept in the session; idle main line refuses; --model provider/model overrides the route; bare /btw re-sends the last exchange (btwContextMessages config sizes the snapshot) |
/status | Binding and run status |
/sub N | Inspect the Nth subagent |
/model | Interactive model picker (two steps: pick a provider → pick one of its models); live-switches bot-created sessions, otherwise saved as the phone default (applies to /new) |
/think | Interactive reasoning-effort picker for the current model (adapter-provided efforts + provider default); live-switches bot-created sessions, otherwise saved as the phone default |
/permission | Interactive permission-preset picker; the pick is replayed as /permission <name> through the dsh command registry |
/select-skill | Interactive skill picker (user-invocable skills of the bound workspace); activation rides dsh's native /name skill gesture |
/profile-switch | Interactive model-profile switcher over $DSH_HOME/model-profiles.json; applies the profile's provider/model/effort (agent frontmatter updates remain desktop-only) |
/feishu-plugin think on|off | Toggle the reasoning tail in the activity section (default on) |
/settings /preset /theme /reload /hotkeys /model-sync |
Group usage: add the bot to a Feishu group, then @-mention it — @dsh 帮我跑一下测试 dispatches exactly like a DM; commands (/resume, /stop, …) work the same way after a mention. Only allowlisted members ever trigger the bot; everyone else is invisible. Cards land in the group while the dispatches keep coming from there; the binding itself stays the bot's single global one (one session at a time, whichever chat drove it last).
Typical flow:
Session running on your desktop → open Feishu on the train → /resume and pick it
→ keep going from the phone (auto-steer) → answer ask cards with a tap → /stop anytime
⚙️ Configuration (config: block)
| key | default | description |
|---|
operators | [] | open_id allowlist — the effective list is the union of this, dsh-feishu.pairedOperators (settings.yaml) and DSH_FEISHU_OPERATORS (comma-separated open_ids); an empty list boots the bot into pairing mode |
mode | "on" | "off" disables the plugin entirely |
domain | "feishu" | "feishu" (CN) or "lark" (international) |
statusIntervalMs | 5000 | round-card refresh beat (pseudo-streaming), range [5000, 600000] |
bodySegmentChars | 3500 | long-body segmentation threshold — also the embed ceiling: a settled round's body at or under it rides the round card itself (💬 Round 回复 section) instead of shipping as separate message(s); values outside [500, 30000] error the plugin off |
resumeListStyle | "auto" | /resume list: auto/table/list |
backgroundPush | "off" | Completion push for sessions the phone is not bound to, into the last active chat: off / cron (turns carrying a cron delivery or a subagent-settled notice) / all (every finished turn). Env override: DSH_FEISHU_BACKGROUND_PUSH |
roundButtons | "off" | Quick-action buttons on round cards: on renders ⛔ 停止 under the live card and ▶️ 继续 under the ended card; off keeps both out — the /stop command (with its own confirmation) is the stop path either way. Env override: DSH_FEISHU_ROUND_BUTTONS |
appIdRef / appSecretRef | DSH_FEISHU_APP_ID/SECRET | credentials ref names |
Credential resolution order: plaintext in patch > DSH_FEISHU_APP_ID/SECRET env
vars > the credentials service.
🧩 Bundled skill
The plugin ships a bundled skill (dsh-feishu-config): ask the agent to set up or
configure the Feishu bot and the guide loads automatically — it checks the
prerequisites (Feishu app, credentials), collects the operators allowlist and
the backgroundPush mode via ask_user_question, writes the config: block
above for you, and walks the phone-side pairing. It also documents the full
config key table, the DSH_FEISHU_* env vars, and the runtime-state
(settings.yaml dsh-feishu: section) vs config distinction.
🧯 Troubleshooting
| Symptom | Fix |
|---|
Log shows pairing mode | Expected with credentials set but an empty allowlist: the bot runs in pairing mode — the first person to DM it gets the admin-pairing card and can claim admin with one tap. Pre-configure operators (Option B) to skip pairing |
| Others can't use the bot | No published version covers them: create a version under Version Management & Release and publish, and keep them inside the availability scope |
Log: no Lark credentials | Credentials missing (Option B); restart after changing |
Log: startup failed | Wrong App ID/Secret, network blocked, or the app version isn't published |
| Bot ignores DMs | Your open_id isn't in the allowlist (non-allowlisted users are silently ignored) |
| Ask card taps do nothing | card.action.trigger isn't subscribed (Option C, step 4) |
/resume N says expired | The list lives 5 minutes — send /resume again |
Development
npm run check # tsc --noEmit
npm test # build + node --test (230+ pure-logic unit tests)
Boundaries
- Single-writer guarantee (host-native since dsh 0.1.5): cold-resuming a
session another process is driving is refused by the host's kernel write
lease (
SessionAlreadyOwnedError) instead of silently forking the log into
interleaved seq numbers; same-process attach (shared agent instance) never
opens a second write handle and behaves as before; a refused /resume
degrades into a READ-ONLY watch over the persisted log — the phone still
receives every turn's final reply (poll-delayed, no streaming detail), and
queued follow-ups take over automatically once the other process lets the
session go
- Group chats are mention-gated and share the bot's single global binding:
one bound session at a time, whichever chat dispatched last receives the
cards. A group image is accepted only from the chat that is currently the
active dispatch surface
- Approval cards ride the host's
approval/request waterfall with the
standard selector TTL (10 min); an expired or undeliverable approval fails
closed as unavailable — never an implicit allow
- After attaching, session history is not replayed; counters start from attach
time when a turn is already running
- Never install ask-router into a web profile (the upstream apiproxy does
not tolerate duplicate provider registrations)
License: MIT. Author fan56.