opencode-feishu-plugin 0.1.0 โ 0.1.2
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.
- package/README.en.md +75 -6
- package/README.md +135 -241
- package/dist/index.js +3257 -1762
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -21,6 +21,7 @@ Bring [OpenCode](https://opencode.ai) into Feishu/Lark: **one Feishu topic = one
|
|
|
21
21
|
| โ
**In-card approvals** | Permission requests become Feishu cards (allow once / always / reject) with signed, replay-proof buttons |
|
|
22
22
|
| ๐ช **Permission presets** | Read-only / Editable / Ask-on-risky / Trust โ pick once per session instead of approving every call |
|
|
23
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 |
|
|
24
25
|
| ๐งต **Native queueing** | Busy session โ messages queue via OpenCode's native `delivery:"queue"` |
|
|
25
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 |
|
|
26
27
|
| ๐ซ **No ports** | Everything over a long connection; nothing to expose |
|
|
@@ -135,7 +136,7 @@ The main chat is management-only; plain text never enters a session.
|
|
|
135
136
|
| `/form [title]` | Same as `/new` โ an equivalent entry point |
|
|
136
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) |
|
|
137
138
|
| `/use <n\|id-prefix>` | Switch current session (legacy, kept for compatibility) |
|
|
138
|
-
| `/resume [n]` | **Resume a past session**:
|
|
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 |
|
|
139
140
|
| `/current` | Show current session |
|
|
140
141
|
| `/stop` | Interrupt the running task in the current session (every run card also has a "โน force stop" button) |
|
|
141
142
|
| `/steer <text>` | Send a message that **cuts in immediately** (steers into the running step instead of queuing) |
|
|
@@ -246,15 +247,22 @@ Besides sessions created from Feishu, you can **load any past OpenCode session v
|
|
|
246
247
|
- **Paging**: 8 per page by default (`sessionPageSize`, clamped 5โ20); the bottom buttons flip pages (`{cmd:"list", page:N}`).
|
|
247
248
|
- **"โ New session"** opens the setup form card (same as `/new` `/form`) instead of creating a session directly.
|
|
248
249
|
|
|
249
|
-
**"โถ๏ธ Enter topic" โ
|
|
250
|
+
**"โถ๏ธ Enter topic" โ post a resume card in the main chat**
|
|
250
251
|
|
|
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.
|
|
252
|
-
- If it exists โ
|
|
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.
|
|
253
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).
|
|
254
262
|
|
|
255
263
|
**`/resume [n]` โ skip the list**
|
|
256
264
|
|
|
257
|
-
- `/resume` runs the same "
|
|
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.
|
|
258
266
|
- It uses the same ordering as `/sessions` (`time.updated` desc).
|
|
259
267
|
|
|
260
268
|
**Limitations**
|
|
@@ -263,6 +271,43 @@ Besides sessions created from Feishu, you can **load any past OpenCode session v
|
|
|
263
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.
|
|
264
272
|
- With `threadRouting=false` (fallback mode), entering topics and `/resume` are unsupported.
|
|
265
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:
|
|
277
|
+
|
|
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
|
+
|
|
266
311
|
### Permission presets
|
|
267
312
|
|
|
268
313
|
| Preset | Meaning | Session ruleset |
|
|
@@ -274,6 +319,20 @@ Besides sessions created from Feishu, you can **load any past OpenCode session v
|
|
|
274
319
|
|
|
275
320
|
The preset is written to a **session-scoped** ruleset and can be changed any time with `/perm`, without affecting other sessions.
|
|
276
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
|
+
|
|
277
336
|
### Queue and cut-in (`/steer` `/now`)
|
|
278
337
|
|
|
279
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:
|
|
@@ -333,6 +392,14 @@ Without this relay, any clarifying question would stall the Feishu session forev
|
|
|
333
392
|
| `approvalTtlMs` | number | `600000` | Approval token / card TTL |
|
|
334
393
|
| `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 |
|
|
335
394
|
| `maxResourcesShown` | number | `8` | Max resource lines shown on an approval card |
|
|
395
|
+
| `sessionAllowButton` | boolean | `true` | Show the "โ
Allow this tool in this session" button on approval cards; disable to go back to three buttons |
|
|
396
|
+
| `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) |
|
|
397
|
+
| `resumeSummaryTimeoutMs` | number | `15000` | Resume-card **fast summary** timeout (clamped 3000โ60000); a timeout is treated as failure and degrades gracefully |
|
|
398
|
+
| `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 |
|
|
399
|
+
| `topicStatus` | boolean | `true` | Topic root card status master switch (colour + footer). Disable to stop refreshing entirely |
|
|
400
|
+
| `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 |
|
|
401
|
+
| `topicStatusThrottleMs` | number | `1000` | Min root-card status refresh interval (clamped 500โ10000); patched only when the kind changes |
|
|
402
|
+
| `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` |
|
|
336
403
|
|
|
337
404
|
---
|
|
338
405
|
|
|
@@ -343,6 +410,7 @@ permission.evaluate (plugin hook) permission.asked (event stream)
|
|
|
343
410
|
โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
|
|
344
411
|
allow-listed tool โ allow event carries {id, sessionID, action, resources, save}
|
|
345
412
|
deny list โ deny โ
|
|
413
|
+
session allowActions โ allow (already granted) โ
|
|
346
414
|
otherwise (per session preset) โ ask โโโโโโโโโโโโโโโโโโโโโโ
|
|
347
415
|
โผ
|
|
348
416
|
Feishu approval card (button value = signed token)
|
|
@@ -357,6 +425,7 @@ otherwise (per session preset) โ ask โโโโโโโโโโโโโ
|
|
|
357
425
|
|
|
358
426
|
- **Signed tokens**: HMAC-SHA256 binding `requestID + sessionID + operator openId + expiry + nonce`; forgery, forwarding and replay are rejected.
|
|
359
427
|
- **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).
|
|
428
|
+
- **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**.
|
|
360
429
|
- **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.
|
|
361
430
|
- **Three layers of single-user isolation**: platform availability (only you) + no group scopes + code-level open_id allowlist with silent ignore.
|
|
362
431
|
- **`always` semantics**: persisted only when the request carries `save[]`; otherwise it behaves like "once" (the card says so).
|
|
@@ -395,7 +464,7 @@ npm test # vitest (pure logic, no live Feishu)
|
|
|
395
464
|
npm run dev # tsup --watch
|
|
396
465
|
```
|
|
397
466
|
|
|
398
|
-
**Architecture**: `src/index.ts`
|
|
467
|
+
**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.
|
|
399
468
|
|
|
400
469
|
**Implementation notes**
|
|
401
470
|
- 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"`.
|
|
@@ -417,7 +486,7 @@ This plugin targets **OpenCode V2 only** (`@opencode/plugin`, `Plugin.define`).
|
|
|
417
486
|
- 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).
|
|
418
487
|
- 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.
|
|
419
488
|
|
|
420
|
-
- **A topic's first message may omit `thread_id`**: Feishu sometimes delivers the event without `thread_id` (it is assigned afterwards).
|
|
489
|
+
- **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.
|
|
421
490
|
|
|
422
491
|
|
|
423
492
|
> 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.
|