opencode-feishu-plugin 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.en.md +114 -457
  2. package/README.md +82 -268
  3. package/dist/index.js +296 -41
  4. package/package.json +1 -1
package/README.en.md CHANGED
@@ -2,55 +2,48 @@
2
2
 
3
3
  **English** | [简体中文](./README.md)
4
4
 
5
- Bring [OpenCode](https://opencode.ai) into Feishu/Lark: **one Feishu topic = one OpenCode session**. Manage multiple sessions from chat, drive the agent from inside topics, and **approve permission requests with a button on a Feishu card**.
5
+ Connect [OpenCode](https://opencode.ai) to Feishu/Lark: **one Feishu topic = one OpenCode session**, and permission approvals happen right on Feishu cards.
6
6
 
7
- > Built **only on the OpenCode V2 plugin API** (`Plugin.define({ id, setup(ctx) })`) — no V1 packages.
8
- > **Pure long connection** (WebSocket) for events and card callbacks: **no listening port, no public URL required**.
7
+ - OpenCode **V2 only** (`@opencode/plugin`, `Plugin.define`); no V1 packages.
8
+ - **Pure long connection** (WebSocket) for events and card callbacks: no port listening, no public endpoint.
9
9
 
10
- ---
10
+ ## Preview
11
+
12
+ ![Full session inside a Feishu topic: tool calls, permission approval card, force stop](image/image.png)
11
13
 
12
14
  ## Highlights
13
15
 
14
- | | |
16
+ | | Description |
15
17
  |---|---|
16
- | 🔐 **Minimal permissions** | Only 2 scopes (read p2p messages + send as bot). **No group scopes at all** — the bot physically cannot receive group messages |
17
- | 💬 **Topics as sessions** | Each Feishu topic maps to one OpenCode session. The main chat is a management console; work happens inside topics |
18
- | 🚀 **One-tap entry** | `/new` opens the setup form directly; on submit the bot creates a topic under your message automatically |
19
- | 📝 **One-shot form** | `/new` and `/form` are **fully equivalent**: fill directory + model + permissions once and submit. The directory can be typed or picked from a dropdown of the allowed root's first-level subdirectories. **Zero new permissions** |
20
- | 🗂 **Directory tolerance** | Empty directory = the allowed root; a non-existent one is created automatically (still constrained by the `allowedRoots` allowlist) |
21
- | ✅ **In-card approvals** | Permission requests become Feishu cards (allow once / always / reject) with signed, replay-proof buttons |
22
- | 🪜 **Permission presets** | Read-only / Editable / Ask-on-risky / Trust — pick once per session instead of approving every call |
23
- | 📊 **Live visibility** | Instant ack card, live tool calls (auto-collapsed when ≥3), streaming text, current model in the footer |
24
- | 🚦 **Status at a glance** | The topic root card changes colour by session state (running/review/pending/done…) with a body footer; the title stays stable and the summary is preserved |
25
- | 🧵 **Native queueing** | Busy session → messages queue via OpenCode's native `delivery:"queue"` |
26
- | ⏹ **One-tap force stop** | **Every AI reply card carries a "force stop" button** — one tap interrupts (whitelist + HMAC signed). The watchdog auto-interrupts stuck sessions instead of queueing forever |
27
- | 🚫 **No ports** | Everything over a long connection; nothing to expose |
28
-
29
- ---
18
+ | 🔐 **Minimal permission** | Only 2 scopes; no group permission is requested, so the bot physically cannot receive group messages |
19
+ | 💬 **Topic = session** | Each Feishu topic maps to one OpenCode session; the main chat is console-only |
20
+ | 🚀 **One-shot session** | `/new` opens a single form (directory + model + permission preset); submit to create a session and auto-open a topic |
21
+ | ✅ **Card approvals** | Permission requests become cards: allow once / always / this session only / deny — signed tokens prevent forgery and replay |
22
+ | 📊 **Real-time visible** | "Thinking" receipt → live tool-call cards → streaming text updates; footer shows the current model |
23
+ | ⏹ **Controllable** | Every reply card has a "force stop" button; a watchdog auto-interrupts stuck sessions; native queue with `/steer` `/now` to cut in |
24
+ | 📎 **Images / files** | Images and files sent in Feishu are downloaded and attached to the session, so vision/file-capable models can see and read them |
25
+ | 🚫 **No ports** | Full long-connection; no inbound port needed on the server |
30
26
 
31
27
  ## 1. Feishu app setup (~3 minutes)
32
28
 
33
- 1. Go to the [Feishu Open Platform](https://open.feishu.cn/app) → **Create a custom app**.
34
- 2. **Add capability → Bot**.
35
- 3. **Permissions** — enable only these two:
36
- - `im:message.p2p_msg:readonly` — read direct messages sent to the bot
37
- - `im:message:send_as_bot` — send messages *as the app* (also used to update cards)
38
- 4. **Events & Callbacks → Event subscription**: choose **"Receive events via long connection"** (do **not** pick Webhook), add event `im.message.receive_v1`.
39
- 5. **Events & Callbacks → Callback subscription**: also choose **long connection**, add callback `card.action.trigger` (**zero permission required**).
40
- 6. **Version management & release**: set **availability = only yourself**, create a version and publish it.
41
- 7. Note the **App ID** (`cli_…`) and **App Secret**.
29
+ 1. Open the [Feishu Open Platform](https://open.feishu.cn/app) → **Create an enterprise self-built app**.
30
+ 2. **Add app capability → Bot**.
31
+ 3. **Permission management** — enable these two minimal scopes:
32
+ - `im:message.p2p_msg:readonly` — read messages users send to the bot in p2p chat
33
+ - `im:message:send_as_bot` — send messages as the app (also used to update cards)
42
34
 
43
- > **Why no group scopes?** This plugin is a *personal console*. With no group scopes the bot **physically cannot**
44
- > receive group messages, so the single-user boundary is enforced by the platform, not just by code.
35
+ To **receive images / files** (downloaded and attached to sessions), add one more:
36
+ - `im:message:readonly` — fetch message resources (required to download images / files)
37
+ 4. **Events & Callbacks → Event configuration**: subscription method **"Use long connection to receive events"** (do **not** pick Webhook), add event `im.message.receive_v1`.
38
+ 5. **Events & Callbacks → Callback configuration**: same long-connection method, add callback `card.action.trigger` (zero permission requirement).
39
+ 6. **Version management & release**: availability = **only yourself**, create a version and **publish**. ⚠️ Without publishing the app stays in "development" state and the long connection cannot connect — the bot will never respond.
40
+ 7. Note down the **App ID** (`cli_…`) and **App Secret**.
45
41
 
46
- ---
42
+ > **Why no group permission?** This plugin is a "single-user remote control". Without group permission the bot **physically cannot receive group messages** — the single-user boundary is guaranteed by the platform scope layer, not only by code.
47
43
 
48
44
  ## 2. Installation
49
45
 
50
- ### 2.1 Install the plugin (V2: loaded from npm, recommended)
51
-
52
- OpenCode V2 declares packages to load in the `plugins` array of its config; on startup it installs them with Bun
53
- (cached under `~/.cache/opencode/node_modules/`). Two equivalent ways:
46
+ ### 2.1 Install the plugin
54
47
 
55
48
  ```bash
56
49
  # Option A — CLI (recommended)
@@ -58,480 +51,144 @@ opencode plugin add opencode-feishu-plugin
58
51
  ```
59
52
 
60
53
  ```jsonc
61
- // Option B — write ~/.config/opencode/opencode.jsonc yourself
62
- {
63
- "$schema": "https://opencode.ai/config.json",
64
- "plugins": ["opencode-feishu-plugin"]
65
- }
54
+ // Option B — write config manually (APPEND to the existing plugins array, don't overwrite the file)
55
+ { "plugins": ["opencode-feishu-plugin"] }
66
56
  ```
67
57
 
68
- The plugin entrypoint is the **self-contained** `dist/index.js` (Feishu SDK included), referenced by
69
- `package.json#exports`. You do **not** run `npm install` by hand and need no extra `node_modules`.
58
+ The entrypoint is a **self-contained** `dist/index.js` (Feishu SDK bundled); no manual `npm install` needed at runtime.
70
59
 
71
- **Local development (without npm):** clone, `npm install && npm run build`, then point `plugins` at the local directory:
72
-
73
- ```jsonc
74
- { "plugins": ["./path/to/opencode-feishu-plugin"] }
75
- ```
60
+ ### 2.2 Configuration
76
61
 
77
- ### 2.2 Alternative: global plugin directory (offline / fixed path)
78
-
79
- You can also drop the build output into `<configDir>/plugins/<any-name>/` (`configDir` = `OPENCODE_CONFIG_DIR` or
80
- `~/.config/opencode`). OpenCode auto-discovers `index.js` there (this package ships such a root entry that re-exports `dist/`):
81
-
82
- ```bash
83
- cd /path/to/opencode-feishu-plugin && npm install && npm run build
84
- mkdir -p ~/.config/opencode/plugins/feishu
85
- cp -r dist index.js package.json ~/.config/opencode/plugins/feishu/
86
- ```
87
-
88
- > Whichever loading path you use, **restart the service after upgrading** so the module is re-imported:
89
- > ```bash
90
- > opencode service restart
91
- > ```
92
-
93
- ### 2.3 Configure
94
-
95
- `<configDir>/plugins/feishu.json` (`configDir` = `OPENCODE_CONFIG_DIR` or `~/.config/opencode`):
62
+ Create `~/.config/opencode/plugins/feishu.json` (`configDir` = `OPENCODE_CONFIG_DIR` or `~/.config/opencode`):
96
63
 
97
64
  ```bash
98
65
  install -m 600 /dev/null ~/.config/opencode/plugins/feishu.json
99
66
  cat > ~/.config/opencode/plugins/feishu.json <<'JSON'
100
67
  {
101
- "appId": "{env:FEISHU_APP_ID}",
102
- "appSecret": "{env:FEISHU_APP_SECRET}"
68
+ "appId": "cli_xxxxxxxx",
69
+ "appSecret": "xxxxxxxx",
70
+ "logFile": true
103
71
  }
104
72
  JSON
105
- chmod 600 ~/.config/opencode/plugins/feishu.json
106
- ```
107
-
108
- Put the credentials into the **OpenCode service process** environment (not your interactive shell):
109
-
110
- ```bash
111
- opencode service set env FEISHU_APP_ID cli_xxxxxxxx
112
- opencode service set env FEISHU_APP_SECRET xxxxxxxx
113
73
  ```
114
74
 
115
- Plaintext values inside `feishu.json` work too (keep it `chmod 600`). **Precedence**: `options` > `feishu.json` > environment.
75
+ - Credential **priority**: `plugins[].options` > `feishu.json` > environment variables. You may also use `{env:NAME}` / `${NAME}` placeholders in values to pull from env.
76
+ - **We recommend `logFile: true`**: stderr is discarded in server mode; the log file is your only window into plugin behavior.
116
77
 
117
- ### 2.4 Activate & verify
78
+ ### 2.3 Activate & verify
118
79
 
119
80
  ```bash
120
81
  opencode reload
82
+ tail -f ~/.config/opencode/plugins/feishu.log # you should see "飞书长连接已启动(WSClient)"
121
83
  ```
122
84
 
123
- Send the bot a direct message. **The first sender is bound as the owner**; everyone else is silently ignored.
85
+ Then send a message to the bot in Feishu. **The first person to message is auto-bound as owner** — a card reply means installation succeeded; everyone else is silently ignored.
124
86
 
125
- ---
87
+ > After upgrading the plugin or switching to a global plugin directory, run `opencode service restart` so it re-imports.
126
88
 
127
- ## 3. Usage
89
+ ## 3. Quick start
128
90
 
129
91
  ### Main chat (console)
130
92
 
131
- The main chat is management-only; plain text never enters a session.
93
+ The main chat **only manages**; plain text never enters any session.
132
94
 
133
- | Command | Purpose |
95
+ | Command | Effect |
134
96
  |---|---|
135
- | `/new [title]` | **Open the setup form directly**; submit to create the session and auto-open a topic (equivalent to `/form`; the title becomes the session title) |
136
- | `/form [title]` | Same as `/new` — an equivalent entry point |
137
- | `/sessions` (`/ls`) | **All** sessions card: each row shows title / short id / relative time / `💬 topic-bound` / `📍 directory`, with a "▶️ Enter topic" button; 8 per page (`sessionPageSize`, 5–20) |
138
- | `/use <n\|id-prefix>` | Switch current session (legacy, kept for compatibility) |
139
- | `/resume [n]` | **Resume a past session**: post a "🔄 resume card" in the main chat for the most-recently-updated (or the N-th) session; **reply to that card** to continue |
140
- | `/current` | Show current session |
141
- | `/stop` | Interrupt the running task in the current session (every run card also has a "⏹ force stop" button) |
142
- | `/steer <text>` | Send a message that **cuts in immediately** (steers into the running step instead of queuing) |
143
- | `/now` | Promote this session's already-queued, not-yet-delivered messages to run immediately |
144
- | `/dir <path>` | **Pre-fill** the form's working directory (empty = allowed root; a missing path is auto-created) |
145
- | `/model [query]` | **Pre-fill** the form's model (also switches the current session's model inside a topic) |
146
- | `/perm [preset]` | **Pre-fill** the form's permission preset (also changes the current session inside a topic) |
147
- | `/cancel` | Discard an un-submitted form |
148
- | `/help` | Command list |
97
+ | `/new [title]` | Send a session-create form; submit to create a session and auto-open a topic (same as `/form`) |
98
+ | `/sessions` (`/ls`) | **All** sessions list (including every local opencode session); paginate, enter, create |
99
+ | `/resume [index]` | Send a resume card for the most recent (or Nth) session; **reply to the card** to continue |
100
+ | `/current`, `/stop` | Show current session / interrupt current run |
101
+ | `/steer <text>`, `/now` | Cut in immediately / run all queued messages now |
102
+ | `/dir`, `/model`, `/perm` | **Pre-fill** the create-session form (directory / model / permission tier) |
103
+ | `/cancel`, `/help` | Discard pending form / list commands |
104
+
105
+ ![Create-session form card: directory, model, permission](image/new.png) ![Session list card: paginate, enter/reopen, create](image/sessions.png)
149
106
 
150
107
  ### Inside a topic (work)
151
108
 
152
- One topic = one session. **Plain text inside a topic is a prompt to the agent**; replies stay in the same topic.
109
+ A topic = a session; sending plain text is giving the AI a command.
153
110
 
154
- | Command | Purpose |
111
+ | Command | Effect |
155
112
  |---|---|
156
- | `/model` | Switch the model for this session |
157
- | `/perm` | Change the permission preset for this session |
158
- | `/cd <path>` | Move this session's working directory (empty = allowed root; a missing path is auto-created) |
159
- | `/steer <text>` | Steer a message into the running step immediately |
160
- | `/now` | Promote this session's queued messages to run immediately |
161
- | `/current` `/stop` `/help` | Same as main chat, scoped to this topic's session |
162
-
163
- ### What `/model` really does
164
-
165
- A `/model` switch only affects **subsequent** model calls; it does **not** rewrite history:
166
-
167
- - opencode's `switchModel` means "switch the model used by subsequent provider turns" and appends a `model-switched` marker to the session. Earlier assistant messages keep the model they **actually ran on** at the time.
168
- - So seeing "`Session.Info.model` is already the new model, but an earlier batch of messages is still the old model" is **expected**, not a failed switch.
169
- - To be safe, the plugin **reads back** `ctx.session.get` after switching: it shows "✅ model switched" only when the read-back matches; a mismatch is reported as "⚠️ model may not have taken effect"; a failed read-back degrades to the requested value with a note. **The run-card footer and `/current` also display the read-back truth**.
170
- - A failed switch (no permission / session not found) reports the error instead of pretending success.
171
-
172
- ### Topic soft guidance (topics never hard-block off-topic messages)
173
-
174
- When you create a session from Feishu via `/new <title>` or the form, the title becomes the topic's "theme". The plugin does **not** block off-topic messages; it only injects a short system note so the model can **briefly remind** the user to open a new session with `/new` when they clearly drift away — without refusing to answer or lecturing:
175
-
176
- - Injected only for **Feishu-originated sessions**; local TUI sessions are **never** touched (no pollution of your own sessions).
177
- - Skipped when the session title is unavailable; injection failures only `log.warn` and never affect execution.
178
- - Disable it entirely with `topicGuidance: false`.
113
+ | `/model` | Switch this session's model (affects follow-up replies only) |
114
+ | `/perm` | Change this session's permission tier |
115
+ | `/cd <path>` | Migrate this session's working directory |
116
+ | `/steer <text>`, `/now` | Cut in / run queued messages now |
117
+ | `/current`, `/stop`, `/help` | Same as main chat, scoped to this topic's session |
179
118
 
180
- ### Creating a session (`/new` and `/form` are fully equivalent)
119
+ ### Permission tiers
181
120
 
182
- ```
183
- /new fix the login bug (or /form fix the login bug)
184
- ↓
185
- 📝 setup form card
186
- directory: type it, or pick from the dropdown (first-level subdirectories of the allowed root); empty = allowed root, auto-created if missing
187
- model: dropdown (defaults to the current/most recent)
188
- permissions: pick one of four presets
189
- ↓ tap "Create"
190
- The form message itself becomes the topic root: the bot replies to it with
191
- `reply_in_thread` to post the "session ready" card inside the topic
192
- ↓
193
- The form card is rewritten in place into a success card titled
194
- `✅ Created · <session title>` (this title becomes the topic name)
195
- ↓
196
- Jump into the topic and just send a message
197
- ```
198
-
199
- - `/new` and `/form` share **one entry point** and post the setup form directly; the old directory → model → permissions → confirm step cards are **gone**.
200
- - `/dir` `/model` `/perm` still work, but only as **form pre-fill** (no longer required steps): each replies with a new pre-filled form card.
201
- - Submission consumes the wizard state first (prevents double-click duplicates); an invalid directory **never creates a session** and returns the form with an error while keeping your input.
202
- - Send `/cancel` to discard an un-submitted form.
203
-
204
- ### Directory tolerance rules
205
-
206
- | Input | Behaviour |
121
+ | Tier | Meaning |
207
122
  |---|---|
208
- | Empty | Uses the **allowed root** `allowedRoots[0]` (the user's home by default); not an error |
209
- | Non-existent absolute path | Auto-created with `mkdir -p`, but **must still be under `allowedRoots`** |
210
- | Outside the roots / system dir / `/` | Rejected, nothing is created |
211
- | Symlinks | Re-checked with `realpath` after creation; escaping `allowedRoots` or landing in a system dir → rejected |
212
-
213
- `/cd` follows **exactly the same** rules.
214
-
215
- **Directory precedence in the form** (dropdown and text input coexist): dropdown pick (other than "✍️ Manually enter a path") > text input > both empty falls back to `allowedRoots[0]`.
216
- The dropdown defaults to "✍️ Manually enter a path" so typing stays authoritative and you never accidentally pick an unexpected directory; `/dir <path>` writes to the input and selects it in the dropdown if it is one of the listed options, otherwise it falls back to "Manually enter a path" (any path can still be typed).
217
-
218
- ### One-shot form (`/form`)
219
-
220
- - Send `/form` (or `/new` — they are equivalent) to open the form card.
221
- - Fill in one go: **directory** (type it, or pick from a dropdown of the allowed root's first-level subdirectories; may be empty), **model** (dropdown of recent + popular, defaulting to the current/most recent model) and **permission preset** (dropdown, four presets with descriptions). Tap **Create** to submit.
222
- - Directory dropdown options: `✍️ Manually enter a path (use the input above)` + `🏠 <root> (use this root)` + the **first-level subdirectories** of that root (hidden dirs and `node_modules` filtered out, sorted by name, at most 15; subdirectories containing `.git` are prefixed with `📦 `).
223
- - The dropdown uses **only `allowedRoots[0]`** (the first allowed root). A scan failure (missing / no permission) silently degrades to just "manual input + root" without affecting the form or the plugin; the scan runs while rendering the form (low-frequency, not cached). `/dir` can still type any (in-scope) path.
224
- - On submit: `session.create` → `reply_in_thread` on the **form card message** posts the ready card (the form message becomes the topic root) → bind, and you can start working in the new topic.
225
- - With `/new <title>`, the title is stored in the wizard state and becomes the session title on submit.
226
- - **Zero new permissions**: form submission reuses the `card.action.trigger` callback (permission requirement: None) — **no new scope, no app re-release**.
227
- - An invalid directory **never creates a session**: the bot returns the form with an error and keeps your filled-in directory/model/permissions so you can fix and resubmit.
228
-
229
- ### Resume a past session (`/sessions` + `/resume`)
230
-
231
- Besides sessions created from Feishu, you can **load any past OpenCode session visible to this machine** and keep working on it.
232
-
233
- **`/sessions` (`/ls`) — all sessions**
234
-
235
- ```
236
- /sessions
237
- ↓
238
- 🧩 OpenCode sessions (all)
239
- 1. Fix the login bug (`ses_ab12cd34…`) · 3 hours ago · 💬 topic-bound · 📍 my-app
240
- 2. Refactor the API (`ses_ef56gh78…`) · 2 days ago · 📍 api-server
241
- …
242
- [▶️ Enter topic] [▶️ New topic] [⬅️ Prev] [➡️ Next] [➕ New session]
243
- ```
244
-
245
- - Data source, in order: `ctx.session.list()` (usually **not exposed** in the V2 plugin runtime) → **local HTTP `GET /api/session`** (same machine, returns **all** sessions including ones created in the TUI/Web) → the plugin's mapping table. Entering an external session also binds a mapping for it so approvals/notifications keep working.
246
- - Each row shows: title (truncated), short id, relative time, `💬 topic-bound` (this session already has a topic mapping), `📍 <directory tail>`.
247
- - **Paging**: 8 per page by default (`sessionPageSize`, clamped 5–20); the bottom buttons flip pages (`{cmd:"list", page:N}`).
248
- - **"➕ New session"** opens the setup form card (same as `/new` `/form`) instead of creating a session directly.
249
-
250
- **"▶️ Enter topic" — post a resume card in the main chat**
251
-
252
- - Tap the button (value `{cmd:"open", s, c}`): first the session is checked for existence (`ctx.session.get`); if missing → toast "session not found" and the list card is patched into a notice.
253
- - If it exists → a plain resume card is posted in the **main chat**: **title `🔄 <session title>`**, body contains session id / directory / model / last activity / summary, and **that card message** is recorded as the session's root (`root → session`). **No topic is opened and no `thread_id` is bound at this stage.**
254
- - **How to continue**: simply **reply to the resume card** (Feishu forms a topic under it) to continue that past session. The user's first reply event **may carry only a `root_id` and no `thread_id`**; the plugin falls back to the `root → session` mapping to route to the session, and once a `thread_id` is available it writes the `thread → session` mapping. Later messages in that topic follow normal topic routing. (OpenCode session context is persistent, so this is effectively a resume.)
255
- - **Summary block (task B, three paths)**:
256
- 1. **Reuse (zero model calls)**: read the session's **full messages** (`session.message.list`, i.e. `/api/session/{id}/message`; note `/context` is a reduced shape without `summary`) and take the latest `status:"completed"` compaction `summary`, labelled "会话摘要";
257
- 2. **Fast summary (default path)**: when no native summary exists, it **never feeds the whole session** — it takes the most recent messages, builds a **compact transcript** (per-message clipping, ≤6K chars total) and passes it to a one-shot generation labelled "摘要(快摘要)". That request **must carry `x-opencode-session`**, otherwise the opencode-go endpoint rejects it (`Request is missing x-opencode-session`). It is implemented **A first, B fallback**: A calls `ctx.generate.text(input, { headers: { "x-opencode-session": sessionID } })`; B calls the local HTTP `POST /api/experimental/generate` (Basic auth from `service.json` + URL-encoded `x-opencode-directory`) and sets the header explicitly. It **never** falls back to `ctx.session.generate` (that feeds the whole session and always times out on large sessions). Timeout (`resumeSummaryTimeoutMs`, default **15s**, clamped 3–60s) degrades to "(summary generation failed; just send a message to continue)";
258
- 3. **Native compaction (user-initiated only)**: the card carries a **"🗜 压缩并总结"** button (value `{cmd:"compact", s, t}`, self-signed token + allowlist + replay guard). Tapping it returns a toast within 3s and **asynchronously** calls `POST /api/session/{id}/compact`; the card shows "🗜 正在压缩会话…" and polls the session messages every 2s until a **new** completed summary appears, then patches to "已压缩 · 会话摘要"; failure/timeout (`resumeCompactTimeoutMs`, default **120s**, clamped 30–300s) only patches an explanation, so you can keep working. **Compaction rewrites session history, so the plugin never triggers it implicitly when entering a session.**
259
- Set `resumeSummary: false` to disable the whole summary block and the compact button.
260
- - Only the clicked session is affected: the card binds just that session's root; other sessions' mappings are untouched.
261
- - For an already topic-bound session the button becomes "▶️ New topic" — **one session can be routed from several topics** (each topic has its own conversation context; replies land in the triggering topic).
262
-
263
- **`/resume [n]` — skip the list**
264
-
265
- - `/resume` runs the same "post a resume card" flow for the **most recently updated** session; `/resume 3` picks the 3rd row. An out-of-range index reports the valid range. It is the same resume card — **reply to it** to continue.
266
- - It uses the same ordering as `/sessions` (`time.updated` desc).
267
-
268
- **Limitations**
269
-
270
- - Only sessions **visible on this machine** can be resumed; deleted / foreign / invisible-to-`session.list` sessions cannot be entered.
271
- - `/sessions` and `/resume` are main-chat commands and are **disabled inside topics** (they tell you to go back); once inside a topic just send plain text.
272
- - With `threadRouting=false` (fallback mode), entering topics and `/resume` are unsupported.
273
-
274
- ### Topic root card status (colour + footer)
275
-
276
- The topic root card (the `/new` created card / the `/resume` resume card) reflects what the session is doing, so you can tell at a glance which sessions need you:
123
+ | 🔒 **Read-only** | Look only (denies `edit` / `shell`) |
124
+ | ✏️ **Editable** | File edits free, shell commands ask |
125
+ | ⚠️ **High-risk approval** | File edits, shell commands and out-of-root access all ask |
126
+ | 🔓 **Full trust** | Never ask |
277
127
 
278
- | Kind | Header colour | Footer | Trigger |
279
- |---|---|---|---|
280
- | 🟡 Review | `orange` | `🟡 待审核:<tool>` | an **unanswered** permission request (`permission.asked`; cleared on reply) |
281
- | 🧠 Running | `blue` | `🧠 运行中 · 12:03` | `execution.started` / `session.status(busy\|retry)`, until a terminal event |
282
- | ⏳ Pending | `grey` | `⏳ 待回复(排队 2)` | the inbox has **queued, not-yet-delivered** messages (`inbox.enqueued` / `delivered`) |
283
- | 🔴 Failed | `red` | `🔴 失败` | the most recent terminal state was failure (`execution.failed` / run-card failure) |
284
- | ⏹ Interrupted | `grey` | `⏹ 已中断` | the most recent terminal state was interruption (`execution.interrupted` / `/stop` / watchdog) |
285
- | ✅ Done | `green` | `✅ 完成` | idle / `execution.succeeded` / `session.status(idle)` |
286
-
287
- **Priority (high → low): Review > Running > Pending > Failed/Interrupted > Done.** "Review" is deliberately ranked above "Running": when the session is blocked on an approval, that is exactly when you need to tap the button.
288
-
289
- - **The title carries no status by default**: the topic name shows up in the sidebar, and changing it on every status flip is noisy. Status is expressed only via the **header colour + body footer**; the title stays `🔄 <session topic>` (or `✅ 已创建 · <topic>` for a freshly created session). If you really want the status emoji in the title, set `topicStatusInTitle: true` (e.g. `🟡 已完成 · topic`).
290
- - **The summary/metadata is never lost**: a root card may carry a session summary plus directory/model metadata, and a status refresh is a **whole-card patch**. The plugin first persists the card's "base content" in the session record (`rootCard`) and, on refresh, re-renders from that base with the shared builder before layering the status on top — so a status change **never wipes the summary**.
291
- - **Only the session's latest root card is updated**: the target message id is the session's `replyMessageId`. A session without it (non-Feishu) or without base content (old session) is **skipped**; the plugin never fabricates a card.
292
- - **Throttling and fault tolerance**: the card is patched only when the **kind changes**, at most once per `topicStatusThrottleMs` (default 1s). A failed patch only logs a `warn` (the user may have deleted the card) — it never throws or retries in a storm; after repeated consecutive failures for a session the plugin stops refreshing it and logs why.
293
- - This is fully independent from the per-message **run card**: a status refresh only touches the topic root card and does not change the run card's streaming behaviour.
294
-
295
- Config: `topicStatus` (default `true`; disable to stop refreshing entirely), `topicStatusInTitle` (default `false`), `topicStatusThrottleMs` (default `1000`, clamped 500–10000).
296
-
297
- ### Card content guard (table over-limit degradation)
298
-
299
- A Feishu card supports **at most 5 table components**; beyond that `im.message.patch` returns 400 `code=230099 card table number over limit`. The real-world trap: when a single assistant reply contains **many markdown comparison tables** (5+ in one go), **every card patch fails**, the card is stuck on old content, and the user thinks the bot has "frozen".
300
-
301
- The plugin guards the **whole card** (not each element separately):
302
-
303
- - **Tables are counted cumulatively per card**: multiple markdown elements **share** one budget (default `cardMaxTables=4`, leaving one slot of headroom; clamped 1–5, so even 5 equals the Feishu hard limit).
304
- - **Tables beyond the budget are degraded into fenced code blocks** (`` ``` `` / `~~~`): **no content is lost**, they are simply no longer rendered as tables, so the 400 is avoided.
305
- - **A `|` inside a code block is never misdetected as a table**: a per-line fence mask (``` / ~~~, up to 3 leading spaces) is computed first and fenced lines are skipped. Degrading is therefore **idempotent** and never loops.
306
- - **Element-count backstop**: a single card's component count is clamped to ≤200 (oldest elements are dropped first, keeping the newest content), avoiding another class of 400.
307
- - Applies to run-card text blocks, topic root / resume cards and their summaries, plus a **final backstop in the send layer** (`sendCard` / `replyCard` / `patchCard`) — no path can emit an over-limit card. A degradation logs a `warn` keyed by `sessionID` (with detected/degraded counts) for observability.
308
-
309
- Config: `cardMaxTables` (default `4`, clamped `1–5`).
310
-
311
- ### Permission presets
312
-
313
- | Preset | Meaning | Session ruleset |
314
- |---|---|---|
315
- | 🔒 Read-only | Look, don't touch | deny `edit` / `shell` |
316
- | ✏️ Editable | Edits free, **commands need approval** | allow `edit`, `shell` → ask |
317
- | ⚠️ Ask-on-risky | Edits, commands and outside-directory access all ask | risky actions ask each time |
318
- | 🔓 Trust | Never ask | allow all |
319
-
320
- The preset is written to a **session-scoped** ruleset and can be changed any time with `/perm`, without affecting other sessions.
321
-
322
- ### Approval card: per-session "allow this tool in this session"
323
-
324
- The approval card has **4 buttons** by default: `✅ Allow once` / `🔓 Always allow` / `✅ Allow this tool in this session` / `❌ Reject`.
325
-
326
- "Always allow" only persists the **command prefix** OpenCode provides (e.g. `ls *`), so a different command asks again; "Trust" is too broad (it also opens up edit / outside-directory). "**Allow this tool in this session**" is the middle ground:
327
-
328
- - It only affects the **current session**: the tool action is recorded in the session's `allowActions` and **appended** to the session ruleset (`{action, resource:"*", effect:"allow"}`); once matched, the `permission.evaluate` gate **no longer downgrades it to ask**, so later calls of the same tool in this session stop bothering you.
329
- - **Other sessions and the global config are untouched** — switch to another session and it still asks.
330
- - `shell` and `bash` are allowed together (the real tool id is `bash`, the design name is `shell`; both are covered).
331
- - Tapping also replies "once" to the **currently pending request** (otherwise this run would still hang), then the card collapses to "✅ Allowed bash in this session" with no buttons.
332
- - Changing the preset with `/perm` is an explicit permission change: it **clears the session's "allow in this session" grants** so old grants cannot override the new preset.
333
- - Same security boundary as force-stop: the button value is `{cmd:"allow_session", a:"<action>", t:"<token>"}`; the token reuses the HMAC mechanism and binds `sessionID + action + TTL + nonce` (plus requestID to locate the card). Click validation order is **allow-list → signature → sessionID match → replay guard**; forged / cross-session / replayed taps are rejected, and repeat taps only show a toast.
334
- - Set `sessionAllowButton: false` to hide this button (the card goes back to three buttons).
335
-
336
- ### Queue and cut-in (`/steer` `/now`)
337
-
338
- While a session is busy, new messages use OpenCode's native queue (`delivery:"queue"`, the card footer shows "queued") and run only after the current task finishes. Two ways to cut in:
339
-
340
- - `/steer <text>` — send this message with `delivery:"steer"` to insert it immediately (interrupts the current step, like steering in the TUI).
341
- - `/now` — promote this session's already-queued, not-yet-delivered messages to `steer` (via OpenCode's `session.inbox.update`; content is neither lost nor re-sent).
342
-
343
- ### Force-stop button and the watchdog
344
-
345
- **Every AI reply card has a "⏹ force stop" button at the bottom** (ack card, streaming run card, terminal card and stuck-notice card):
346
-
347
- - running / queued: a **red danger** "⏹ Force stop" button; tapping it interrupts the session's current execution and cancels not-yet-delivered queued messages;
348
- - done / failed / interrupted: still rendered, but as a `default` "⏹ Stop" button; tapping only shows the toast "this task has ended" (so it never looks like you can still stop it).
349
-
350
- The button is a **signed action** `{ cmd:"stop", sid:<sessionID>, t:<token> }`. The token reuses the approval-card HMAC mechanism and binds `sessionID + purpose + expiry + nonce`; the card **re-signs on every patch**, so long tasks never become un-stoppable due to an expired token.
351
- Validation order: **allowlist (allowUsers/owner) → signature → sessionID binding → replay guard**; forged, cross-session and replayed clicks are rejected.
352
-
353
- **Watchdog (5-minute threshold, configurable)**: when an execution has produced no event for longer than the threshold it is treated as stuck; the plugin **actually interrupts the server-side session** (`session.interrupt`) + **cancels queued inbox messages** + finalizes the run card + sends a notice card with a force-stop button. If a session stays queued past the same threshold without an `execution.started`, the same recovery runs and a notice is sent — no more "session stuck once, every later message queues forever".
354
-
355
- The threshold is `staleExecutionMs` (default 5 minutes, clamped to 1–60 minutes).
128
+ Permission requests become approval cards: `✅ Allow once` / `🔓 Always allow` / `✅ Allow this tool in this session` / `❌ Deny`. Changing tier (`/perm`) clears this session's "allow in this session" grants.
356
129
 
357
- ### Forms and questions (`question` tool)
130
+ ### Forms & questions (`question` tool)
358
131
 
359
- When the agent calls the `question` tool (or any form interaction), OpenCode creates a pending form that blocks execution. The plugin relays it as a Feishu card:
132
+ When the agent asks via `question` or other form tools, the form becomes a Feishu card. **Click buttons or just reply in the topic with text** — both work, and the card is withdrawn after answering. For pure option questions you can reply with the option number/letter directly; any other content is treated as a normal message to the AI.
360
133
 
361
- - **Two equivalent ways to answer**: tap an option button, or just **send text in the topic** (no need to tap "✍️ reply directly" first). Text is matched intelligently — option label/value are matched to their value, booleans accept 是/否 & yes/no & 1/0, numbers are parsed, multiselect splits on commas, anything else counts as a **manual answer**;
362
- - multi-field forms can be answered with a mix of taps and a text reply; they submit automatically once every field is filled;
363
- - **Option-only questions** (options present, custom input not allowed): you may reply with the **option number/letter** (e.g. `1`, `B`) or the option label. Any **other** text is treated as "you meant something else": the plugin **skips that form** and passes your message to the AI as a **normal message** instead of a bogus answer.
364
- - **the card is recalled once answered/cancelled**; if it is past Feishu's recall window, it degrades to a "submitted/cancelled" result card instead.
134
+ ## 4. Configuration (common)
365
135
 
366
- Without this relay, any clarifying question would stall the Feishu session forever and every later message would queue behind it — a common cause of "stuck sessions".
367
-
368
- ---
369
-
370
- ## 4. Configuration
371
-
372
- `<configDir>/plugins/feishu.json` (or `plugins[].options` in OpenCode). `{env:NAME}` / `${NAME}` expansion supported.
136
+ `<configDir>/plugins/feishu.json` (or `plugins[].options` in opencode config); `{env:NAME}` / `${NAME}` expansion supported.
373
137
 
374
138
  | Field | Type | Default | Description |
375
139
  |---|---|---|---|
376
- | `appId` | string | — | Feishu App ID (**required**; missing ⇒ plugin disabled, never throws) |
377
- | `appSecret` | string | — | Feishu App Secret (**required**; never logged) |
378
- | `domain` | `feishu`\|`lark` | `feishu` | Feishu or Lark international |
379
- | `allowUsers` | string[] | `[]` | open_id allowlist. **Empty = app owner only** (first sender is bound and persisted) |
380
- | `permissionGate` | `off`\|`notify`\|`gate`\|`lockdown` | `gate` | Global approval gate |
381
- | `allowTools` | string[] | `["read","glob","grep","webfetch"]` | Auto-allow list; supports `prefix*` |
382
- | `denyTools` | string[] | `[]` | Hard deny (takes precedence) |
383
- | `allowedRoots` | string[] | `[homedir]` | Roots allowed as session working directories (the default directory is `allowedRoots[0]`); `/`, the filesystem root and system dirs are always rejected; an empty directory falls back to the first root and a non-existent one is auto-created. **The directory dropdown only scans the first root's first-level subdirectories** |
384
- | `stream` | boolean | `true` | Stream replies into the card |
385
- | `streamThrottleMs` | number | `400` | Min card update interval (floor 400ms; Feishu limit is 5 QPS) |
386
- | `threadRouting` | boolean | `true` | Topic routing master switch; `false` restores the legacy behaviour |
387
- | `topicGuidance` | boolean | `true` | Topic soft guidance: inject a short "use `/new` for a new topic" system note into Feishu sessions (never blocks messages); local TUI sessions are never touched |
388
- | `recentDirsLimit` | number | `5` | Number of recent directories (1–20) |
389
- | `recentModelsLimit` | number | `5` | Number of recent models (1–20) |
390
- | `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | Log level (secrets are never logged, only their presence) |
391
- | `logFile` | string \| boolean | — | `true` writes `<configDir>/plugins/feishu.log`. **Plugin stderr is discarded in service mode — enable this when debugging** |
392
- | `gatewayLocation` | string | — | Only start the gateway in this location **or any of its subdirectories**. `~` is expanded and relative paths / trailing slashes are normalized. **Set this to your usual working directory** to avoid multiple long connections; leave empty to run in every location |
393
- | `gatewayMatchGraceMs` | number | `3000` | Grace window for **exact-match priority**: a subdirectory candidate waits this long and yields if a location equal to `gatewayLocation` shows up (`0` = no wait, subdirectory takes over immediately) |
394
- | `approvalTtlMs` | number | `600000` | Approval token / card TTL |
395
- | `staleExecutionMs` | number | `300000` | Watchdog threshold: an execution with no event for this long is treated as stuck and auto-interrupted; a queue stuck this long without `execution.started` also triggers a notice. Clamped to 1–60 minutes |
396
- | `maxResourcesShown` | number | `8` | Max resource lines shown on an approval card |
397
- | `sessionAllowButton` | boolean | `true` | Show the "✅ Allow this tool in this session" button on approval cards; disable to go back to three buttons |
398
- | `resumeSummary` | boolean | `true` | Show a summary on the resume card (reuse a native compaction summary first, else the fast summary; disabling also removes the compact button) |
399
- | `resumeSummaryTimeoutMs` | number | `15000` | Resume-card **fast summary** timeout (clamped 3000–60000); a timeout is treated as failure and degrades gracefully |
400
- | `resumeCompactTimeoutMs` | number | `120000` | Poll timeout after a **user-initiated** compaction (`session.compact`), clamped 30000–300000; a timeout only patches an explanation. Compaction is explicit and rewrites session history |
401
- | `topicStatus` | boolean | `true` | Topic root card status master switch (colour + footer). Disable to stop refreshing entirely |
402
- | `topicStatusInTitle` | boolean | `false` | Add a status emoji prefix to the root card title (e.g. `🟡 session name`). Off by default: the topic name shows in the sidebar, and flipping it would be noisy |
403
- | `topicStatusThrottleMs` | number | `1000` | Min root-card status refresh interval (clamped 500–10000); patched only when the kind changes |
404
- | `cardMaxTables` | number | `4` | Max markdown tables kept per card (clamped 1–5); tables beyond it are degraded **cumulatively per card** into fenced code blocks (no content lost) to avoid Feishu 400 `code=230099` |
405
- | `runnerCardMaxTools` | number | `12` | Max tool blocks kept on the run card (1–50); older ones collapse into "…omitted N tool calls" |
406
- | `runnerCardTextMax` | number | `2048` | Per-text-block character cap on the run card (512–8192) |
407
- | `finalAnswerMinChars` | number | `600` | Final answers at least this long are sent **as their own card/file** (`0` disables splitting) |
408
- | `finalAnswerFileMinBytes` | number | `20480` | Final answers at least this many bytes are delivered as a `.md` file (8192–102400) |
409
- | `keepalive` | boolean | `true` | **Location keep-alive**: periodically emits activity so OpenCode does not evict the idle Location after 60 minutes (which unloads the plugin and closes the Feishu long connection) |
410
- | `keepaliveIntervalMs` | number | `1200000` | Keep-alive interval (default 20 min, clamped 5–45); must stay well below OpenCode's hardcoded 60-minute TTL |
411
-
412
- ### Long answers (run-card slimming + separate final answer)
413
-
414
- A long turn (dozens of tool calls) can push a single run card to its limits (28KB / 200 elements), triggering degradation and **dropping the oldest blocks** — the card looks "full" and earlier content disappears. Default strategy:
415
-
416
- 1. **The run card is progress-only**: keep the last `runnerCardMaxTools` (default 12) tool blocks, collapsing older ones into "…omitted N tool calls"; each text block is capped at `runnerCardTextMax` (default 2KB).
417
- 2. **The final answer is sent separately**: when a turn ends and the trailing text is at least `finalAnswerMinChars` (default 600 chars), it is sent as its own "✅ 完整回答" card; the run card keeps only a short notice.
418
- 3. **Very long answers become files**: at least `finalAnswerFileMinBytes` (default 20KB) → delivered as a `.md` file (preview/download), never truncated.
419
-
420
- ### Location keep-alive (on by default)
421
-
422
- OpenCode **evicts idle Locations**, which unloads plugins and closes the Feishu long connection:
423
-
424
- | Mechanism | Where | Trigger | Effect |
425
- |---|---|---|---|
426
- | LayerMap `idleTimeToLive` | `packages/core/src/location-services.ts` (hardcoded `60 minutes`) | no **session-scoped request** for 60 min | Location services destroyed (silently) |
427
- | `@opencode/LocationActivity` | hardcoded 60 min as well | no **durable event carrying the location** for 60 min | interrupts active sessions, then `invalidate(location)`; logs `location services evicted` |
428
-
429
- Both dispose the plugin (closing the Feishu WS). **After that, no request means no recovery — the bot stays silent permanently** (see issues [#51343](https://github.com/anomalyco/opencode/issues/51343), [#48691](https://github.com/anomalyco/opencode/issues/48691), [#51828](https://github.com/anomalyco/opencode/issues/51828); the TTL has no config knob).
430
-
431
- The plugin ships two built-in layers (both on by default, **no external script required**):
432
-
433
- 1. **Gateway keep-alive** (every 20 min, `keepaliveIntervalMs`): ① a session-scoped `GET /api/session/{id}` → `locations.get()` renews the LayerMap entry, and **re-creates the Location** if it was evicted; ② create + GET + delete a probe session → renews `LocationActivity` (via the `session.created` event).
434
- 2. **Process-wide gateway watchdog** (same interval, exactly one timer per process): every Location's plugin instance registers it, and it performs a session-scoped GET against the gateway Location. Result:
435
- - if the gateway instance was evicted, it is revived automatically as long as **any other Location** still has a loaded instance (e.g. you have a TUI/Web open in another project);
436
- - after a **service restart**, the first use of any Location starts the watchdog, which fires an immediate probe after ~3s and brings the gateway back up.
437
-
438
- > **No external script / cron / systemd setup is required**: both layers run inside the plugin process. The only case neither can cover is "the opencode process is fully down and nothing is used for a long time" — no plugin can run then; the watchdog restores the gateway as soon as opencode is used again. Set `keepalive: false` to disable all keep-alive.
439
-
440
- ---
441
-
442
- ## 5. Security model
443
-
444
- ```
445
- permission.evaluate (plugin hook) permission.asked (event stream)
446
- ────────────────────────── ──────────────────────
447
- allow-listed tool → allow event carries {id, sessionID, action, resources, save}
448
- deny list → deny │
449
- session allowActions → allow (already granted) │
450
- otherwise (per session preset) → ask ─────────────────────┘
451
- ▼
452
- Feishu approval card (button value = signed token)
453
- │ user taps
454
- ▼
455
- card.action.trigger over the long connection (<3s response)
456
- │
457
- verify: operator allow-listed → signature → bound fields → replay guard
458
- ▼
459
- ctx.permission.reply({sessionID, requestID, reply})
460
- ```
461
-
462
- - **Signed tokens**: HMAC-SHA256 binding `requestID + sessionID + operator openId + expiry + nonce`; forgery, forwarding and replay are rejected.
463
- - **Force-stop uses the same signature scheme**: its token binds `sessionID + purpose + expiry + nonce`, the click passes the open_id allowlist before verification, and it is purpose-isolated from approval tokens (neither works for the other).
464
- - **Per-session allow uses the same signature scheme**: the approval card's "allow this tool in this session" token binds `sessionID + action + expiry + nonce` (plus requestID to locate the card) and is purpose-isolated. When matched it records `allowActions`, appends a session ruleset, and the `evaluate` gate **no longer downgrades that action to ask** (the `denyTools` red line still wins) — and **only for that session**.
465
- - **Only Feishu-originated sessions**: sessions without a chat↔session mapping (e.g. your local TUI) are **never downgraded to `ask`**, otherwise they would hang forever with no approval channel.
466
- - **Three layers of single-user isolation**: platform availability (only you) + no group scopes + code-level open_id allowlist with silent ignore.
467
- - **`always` semantics**: persisted only when the request carries `save[]`; otherwise it behaves like "once" (the card says so).
468
-
469
- ---
470
-
471
- ## 6. Troubleshooting
140
+ | `appId` | string | — | Feishu App ID (**required**; plugin disabled when missing) |
141
+ | `appSecret` | string | — | Feishu App Secret (**required**; never written to logs) |
142
+ | `domain` | `feishu`\|`lark` | `feishu` | Feishu / Lark global |
143
+ | `allowUsers` | string[] | `[]` | open_id allowlist; empty = owner only |
144
+ | `permissionGate` | `off`\|`notify`\|`gate`\|`lockdown` | `gate` | Global permission gate tier |
145
+ | `allowTools` | string[] | `["read","glob","grep","webfetch"]` | No-approval allowlist, supports `prefix*` |
146
+ | `denyTools` | string[] | `[]` | Forced deny (takes precedence over allowlist) |
147
+ | `allowedRoots` | string[] | `[user home]` | Allowed working-directory roots; out-of-root / system dirs denied |
148
+ | `stream` | boolean | `true` | Stream reply updates |
149
+ | `threadRouting` | boolean | `true` | Topic routing master switch |
150
+ | `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | Log level |
151
+ | `logFile` | string \| boolean | — | `true` = write `<configDir>/plugins/feishu.log`; recommended in server mode |
152
+ | `approvalTtlMs` | number | `600000` | Approval token / card validity |
153
+ | `staleExecutionMs` | number | `300000` | Watchdog threshold (1–60 min) |
154
+ | `gatewayLocation` | string | — | Only start the gateway at this location (or its subdirectories); empty = any location |
155
+
156
+ Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepalive*`, `gatewayMatchGraceMs`) → [docs/advanced.en.md](./docs/advanced.en.md#full-configuration).
157
+
158
+ ## 5. Troubleshooting
472
159
 
473
160
  | Symptom | Fix |
474
161
  |---|---|
475
- | Bot does not respond | ① App **published** and availability includes you? ② Event/callback subscription set to **long connection** (not Webhook)? ③ `im:message.p2p_msg:readonly` granted? |
476
- | `feishu.json` changes ignored | Confirm the path is `<configDir>/plugins/feishu.json`, then `opencode reload` |
477
- | Plugin never loads (no logs, no error) | npm path: make sure the package name is in the config `plugins` array (`opencode plugin list` shows it). Directory path: make sure `plugins/<name>/index.js` exists (OpenCode ignores `package.json#main`) |
478
- | Plugin code changes ignored | `opencode reload` only re-runs `setup`; it does **not** re-import the module from the same path. Upgrade with `opencode plugin update opencode-feishu-plugin`, or restart the service |
479
- | Multiple long connections / duplicate replies | Set `gatewayLocation` to your usual working directory (subdirectories also match) |
480
- | **No response at all**, and no "long connection started" / "plugin ready" in the log | Almost always `gatewayLocation` does not match the directory where you actually opened opencode. The plugin emits a `warn` about "no loaded location matched" after ~2s; you can also set `logLevel: "debug"` to see `skipping non-gateway location`. If still stuck, leave `gatewayLocation` empty to rule it out |
481
- | No approval cards | The session did not originate from Feishu (no mapping); by design the plugin does not take it over |
482
- | "Invalid credentials" on button tap | Token expired (default 10 min) or the tapper is not allow-listed |
483
- | Card content truncated | Feishu card limit is ~30KB; the plugin truncates and marks it. Very long sessions drop the oldest blocks from the card (full content stays in the session) |
484
- | Form submit does nothing / errors | Client too old (`select_static` needs ≥ V3.7.0), or the card is stale (form consumed/cancelled) — send `/form` or `/new` again for a fresh form |
485
- | Form submitted but no session | A directory outside the allowlist or in a system dir returns an error card and **does not create a session**; fix it and resubmit. Empty / non-existent in-scope dirs are auto-created and never fail |
486
- | No topic after creating a session | If auto-opening the topic fails, the form card is rewritten to "✅ Created · …" with manual-topic guidance; you can also create a topic manually from the `/sessions` card |
487
- | No plugin logs | Plugin stderr is discarded in service mode; set `logFile: true` and read `<configDir>/plugins/feishu.log` |
488
- | Main chat replies with a hint card | Expected: the main chat is management-only. Use `/new` and work inside a topic; set `threadRouting: false` to revert |
489
- | Session looks stuck and messages only queue | The watchdog auto-interrupts it after `staleExecutionMs` (default 5 min) and cancels the queue, then sends a notice card; you can also tap the card's "⏹ force stop" or send `/stop` |
490
- | **Bot goes completely silent after ~1 hour idle** (no "long connection started" in the log) | OpenCode evicted the idle Location (hardcoded 60-min TTL). The built-in keep-alive + process-wide watchdog are on by default and restore it automatically; you can also force it manually with one session-scoped request: `opencode api get /api/session/{id}`. If it never recovers, check that `keepalive` is not set to `false` |
491
- | Switched `/model` but older messages still show the old model | Expected: a switch only affects **subsequent** replies; history keeps each message's model. The receipt / run-card footer / `/current` all show the read-back truth |
492
-
493
- ---
494
-
495
- ## 7. Development
496
-
497
- ```bash
498
- npm install
499
- npm run typecheck # tsc --noEmit
500
- npm run build # tsup → dist/ (self-contained bundle)
501
- npm test # vitest (pure logic, no live Feishu)
502
- npm run dev # tsup --watch
503
- ```
504
-
505
- **Architecture**: `src/index.ts` is assembly only (config, gateway, watchdog, hook registration and cleanup); `src/runtime/` holds the unit-testable event dispatch (`event-router.ts`) and card-callback routing (`card-action-router.ts`); the session command orchestration is split under `src/session/` (`session-commands.ts` is a thin facade; implementations live in `session-list.ts` / `setup-wizard.ts` / `session-ops.ts` / `model-perm.ts` / `context.ts`); the Feishu interaction layer lives in `src/feishu/` (event parsing, card builders, topic routing, wizard state machine, streaming-card reducer — mostly **pure functions** for testability); `src/security/` holds token signing and the allowlist.
506
-
507
- **Implementation notes**
508
- - Cards are **JSON 2.0** (buttons directly in `body.elements`, callbacks via `behaviors`; the 1.0 `tag:"action"` container returns HTTP 400 on 2.0). Form cards add: `form` must sit at the root of `body.elements`, interactive `name`s must be globally unique, and at least one button must carry `form_action_type:"submit"`.
509
- - Card updates are throttled to ≥400ms; ≥3 consecutive tool calls collapse into one summary panel (names only) to stay under the 30KB limit.
510
- - Run-card state is maintained by a **pure reducer** (text blocks / tool blocks / footer / terminal state), keyed per `assistantMessageID`.
511
-
512
- ---
513
-
514
- ## 8. Relationship to other projects
515
-
516
- This plugin targets **OpenCode V2 only** (`@opencode/plugin`, `Plugin.define`). The separately maintained `opencode-feishu` package is a **V1** plugin (`@opencode-ai/plugin`) — the two are incompatible and share no code. Pick according to your OpenCode version.
162
+ | No response to messages | ① Is the app **published** and does availability include you? ② Is the subscription **long connection** (not Webhook)? ③ Is `im:message.p2p_msg:readonly` enabled? |
163
+ | `feishu.json` changes don't apply | Check the path, then `opencode reload` |
164
+ | Plugin not loaded at all | npm: confirm the package name is in the `plugins` array; directory: confirm `plugins/<name>/index.js` exists |
165
+ | No approval cards | That session wasn't started from Feishu (no mapping); by design the plugin doesn't take it over |
166
+ | Button click says invalid credential | Token expired (10 min default) or clicker not in allowlist |
167
+ | Session seems stuck, messages only queue | Watchdog auto-interrupts after 5 min; or tap "⏹ force stop" / send `/stop` |
168
+ | Bot goes silent after ~1h idle | opencode recycles idle locations; built-in keepalive restores automatically, see [docs/advanced.en.md](./docs/advanced.en.md#location-keep-alive) |
169
+ | Can't see plugin logs | stderr is discarded in server mode; set `logFile: true` |
170
+ | Multiple long connections / duplicate replies | Set `gatewayLocation` to a common working directory, see [docs/advanced.en.md](./docs/advanced.en.md#multiple-instances-and-gateway-election) |
517
171
 
518
- ## Known limitations
172
+ ## 6. Known limitations
519
173
 
520
- - Text-only inbound (including rich text); images/files/audio get a textual placeholder and are not downloaded.
521
- - Only approvals for **Feishu-originated** sessions are handled. Local TUI sessions are untouched by design.
522
- - Message dedup is `get-then-set` (not atomic): under extreme concurrency a duplicate is theoretically possible.
523
- - Deleted topics leave stale mappings (lazily ignored).
524
- - There is a single main path for creating sessions: the **`/new` / `/form` setup form card**; `/dir` `/model` `/perm` only pre-fill the form. The old directory/model/permissions/confirm step cards are retired from `/new` (their builders and compatibility callbacks remain, marked deprecated).
525
- - The form is JSON 2.0 (`form` at the root of `body.elements`, globally unique interactive `name`s, a submit button with `form_action_type:"submit"`); some older clients require `select_static` ≥ V3.7.0.
174
+ - Image / file messages are **downloaded and attached to the session** (requires `im:message:readonly`); audio / video / stickers still get a text placeholder.
175
+ - Only takes over approvals for **Feishu-originated sessions**; local TUI sessions are unaffected.
176
+ - One main path to create a session: the `/new` / `/form` form card.
177
+ - Forms are JSON 2.0; old clients need ≥ V3.7.0 for `select_static`.
178
+ - A topic's first message may lack `thread_id`: the plugin falls back to `root_id` routing; if a command lands in the main chat from a new topic, just send it inside the topic.
179
+ - There is another `opencode-feishu` (V1 plugin); it is incompatible and shares no code with this one.
526
180
 
527
- - **A topic's first message may omit `thread_id`**: Feishu sometimes delivers the event without `thread_id` (it is assigned afterwards). When you **reply to a card that has a root mapping** (e.g. a resume card), the plugin falls back to the `root_id` to route to the corresponding session and writes the topic mapping once a `thread_id` is available. However, for a **brand-new topic** whose event omits `thread_id`, a main-chat-only command such as `/new` sent at that moment runs as a main-chat command (e.g. the form card lands in the main chat). Just continue inside the topic with a normal message.
181
+ ## 7. Roadmap
528
182
 
183
+ Iterating from real usage feedback; current plan:
529
184
 
530
- > Publishing tip: `npm publish` triggers `prepublishOnly` (typecheck + build + test). If `node_modules` is missing it **runs `npm ci` first**, so a fresh clone can be published directly without a manual install.
185
+ - [x] **Accept images / files**: done — downloaded automatically into `<configDir>/plugins/feishu-files/` and attached to the session (requires `im:message:readonly`; default max 20MB per attachment).
186
+ - [ ] **New messages cut in by default when busy**: currently new messages queue natively while a session is busy (manual cut-in via `/steer`, `/now`). Planned: new messages default to **cutting in immediately**, interrupting the current step to run first.
531
187
 
188
+ ## Advanced topics & development
532
189
 
533
- > Publishing note: provenance can only be generated in CI (GitHub Actions), so `package.json` deliberately does **not** set `publishConfig.provenance`; our workflows pass `npm publish --provenance` explicitly. Publishing locally is just `npm publish --access public`.
190
+ Security model, location keep-alive, card guard, session resume, multi-instance gateway election, full config reference, and development architecture → [docs/advanced.en.md](./docs/advanced.en.md).
534
191
 
535
192
  ## License
536
193
 
537
- MIT
194
+ MIT