opencode-feishu-plugin 0.2.0 → 0.2.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 CHANGED
@@ -414,12 +414,14 @@ OpenCode **evicts idle Locations**, which unloads plugins and closes the Feishu
414
414
 
415
415
  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).
416
416
 
417
- The plugin ships a two-channel keep-alive (default every 20 min):
417
+ The plugin ships two built-in layers (both on by default, **no external script required**):
418
418
 
419
- 1. **session-scoped `GET /api/session/{id}`** → `locations.get()` renews the LayerMap entry; if it was already evicted, this request **re-creates the Location** (plugin reloaded, WS reconnected);
420
- 2. **create + immediately delete a probe session** → the `session.created` event renews `LocationActivity`.
419
+ 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).
420
+ 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:
421
+ - 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);
422
+ - 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.
421
423
 
422
- > **External safety net**: a plugin cannot revive itself once evicted (its timer dies with it), and it is not loaded after a **service restart** either. Add a cron / systemd timer that performs one session-scoped GET every 15–30 minutes (see `scripts/keepalive-feishu.sh`) to also cover restarts and long sleeps.
424
+ > **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.
423
425
 
424
426
  ---
425
427
 
@@ -471,7 +473,7 @@ otherwise (per session preset) → ask ─────────────
471
473
  | No plugin logs | Plugin stderr is discarded in service mode; set `logFile: true` and read `<configDir>/plugins/feishu.log` |
472
474
  | 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 |
473
475
  | 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` |
474
- | **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 is on by default; if it still happens (service restart / long sleep / keep-alive disabled), one session-scoped request re-creates it: `opencode api get /api/session/{id}`, or set up the external cron described under "Location keep-alive" |
476
+ | **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` |
475
477
  | 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 |
476
478
 
477
479
  ---
package/README.md CHANGED
@@ -293,12 +293,17 @@ opencode 会**回收空闲的 location**,这会连带卸载插件、关闭飞
293
293
 
294
294
  两者都会让插件被 dispose(飞书长连接关闭)。**此后若该 location 再无请求,插件不会自行恢复 → 机器人永久沉默**(官方 issue:[#51343](https://github.com/anomalyco/opencode/issues/51343)、[#51891→#48691](https://github.com/anomalyco/opencode/issues/48691)、[#51828](https://github.com/anomalyco/opencode/issues/51828);TTL 无配置项)。
295
295
 
296
- 插件内置双通道保活(默认每 20 分钟):
296
+ 插件内置两道防线(都是默认开启,**无需任何外部脚本**):
297
297
 
298
- 1. **会话级 `GET /api/session/{id}`** → `locations.get()` 续期 LayerMap;若已被回收,**该请求会重建 location**(插件重新加载、长连接重连);
299
- 2. **创建 + 立即删除探针会话** → `session.created` 事件续期 `LocationActivity`。
298
+ 1. **网关保活**(每 20 分钟,`keepaliveIntervalMs`):① 会话级 `GET /api/session/{id}` → `locations.get()` 续期 LayerMap,若已被回收则**重建 location**;② 创建 + GET + 删除一个探针会话 → 续期 `LocationActivity`(`session.created` 事件)。
299
+ 2. **进程级网关看门狗**(同样每 20 分钟,每进程仅一个定时器):**任意** location 的插件实例都会登记,周期性对网关 location 做会话级 GET。效果:
300
+ - 网关实例即使已被回收,只要进程里还有**别的** location 存活(例如你在别的项目里开了 TUI/Web),网关会被自动救活;
301
+ - **服务重启后**,你第一次使用任意 location 时看门狗即启动(并在约 3 秒后立即探测一次),网关随之上线;
302
+ - 首次探测带 3 秒延迟,重启后恢复很快。
300
303
 
301
- > **外部兜底建议**:插件自身被回收后无法自救(定时器随插件销毁),且**服务重启后**也不会自动加载。可再加一条 cron / systemd timer 每 15–30 分钟做一次「会话级 GET」作为唤起器(`keepalive-feishu.sh` 示例见仓库 `scripts/`),覆盖重启 / 长时间休眠场景。
304
+ > **不需要任何外部脚本 / cron / systemd 配置**:以上两道防线都在插件进程内完成。
305
+ > 唯一无法覆盖的是「opencode 进程整个挂掉且长时间无人使用」——此时任何插件都无从执行;
306
+ > 重新使用 opencode 时会由看门狗自动恢复。`keepalive: false` 可关闭全部保活。
302
307
 
303
308
  ---
304
309
 
@@ -317,7 +322,7 @@ opencode 会**回收空闲的 location**,这会连带卸载插件、关闭飞
317
322
  | 卡片内容被截断 | 飞书卡片上限约 30KB,超长会话丢弃卡片上最旧块(完整内容仍在会话里) |
318
323
  | 建会话后没看到话题 | 表单卡会改写为「✅ 已创建 · …」并附手动创建话题指引 |
319
324
  | 会话像卡死、发消息只排队 | 看门狗默认 5 分钟后自动中断;也可点「⏹ 强制停止」或发 `/stop` |
320
- | **空闲约 1 小时后机器人完全失联**(日志无「长连接已启动」) | opencode 回收了空闲 location(60 分钟硬编码 TTL)。内置保活默认开启;若仍发生(服务重启 / 长休眠 / 保活被关),用 `opencode api get /api/session/{id}` 任一本地会话即可唤起,或按上方「位置保活」配置外部 cron |
325
+ | **空闲约 1 小时后机器人完全失联**(日志无「长连接已启动」) | opencode 回收了空闲 location(60 分钟硬编码 TTL)。内置保活 + 进程级看门狗默认开启,会自动恢复;也可手动用 `opencode api get /api/session/{id}`(任一本地会话)立即唤起。若长期不恢复,确认 `keepalive` 未被设为 `false` |
321
326
  | 看不到插件日志 | 服务模式下 stderr 被丢弃,设 `logFile: true` |
322
327
  | 切了 `/model` 但历史还是旧模型 | 预期行为:切换只影响后续回复,历史消息保留各自当时的模型 |
323
328
 
package/dist/index.js CHANGED
@@ -147137,6 +147137,15 @@ async function touchLocationOverHttp(directory, deps) {
147137
147137
  deps.log.debug("\u4FDD\u6D3B\u63A2\u9488\u521B\u5EFA\u5F02\u5E38", { directory, error: errorMessage(err2) });
147138
147138
  }
147139
147139
  if (sessionID) {
147140
+ try {
147141
+ const getRes = await doFetch(`${service3.url}/api/session/${encodeURIComponent(sessionID)}`, {
147142
+ method: "GET",
147143
+ headers,
147144
+ signal: signal()
147145
+ });
147146
+ touched = touched || getRes.ok;
147147
+ } catch {
147148
+ }
147140
147149
  try {
147141
147150
  await doFetch(`${service3.url}/api/session/${encodeURIComponent(sessionID)}`, {
147142
147151
  method: "DELETE",
@@ -147149,6 +147158,44 @@ async function touchLocationOverHttp(directory, deps) {
147149
147158
  if (touched) deps.log.debug("\u4FDD\u6D3B\u5FC3\u8DF3\u5DF2\u53D1\u9001", { directory, probe: sessionID ?? "(none)" });
147150
147159
  return touched;
147151
147160
  }
147161
+ var WATCHDOG_SLOT = /* @__PURE__ */ Symbol.for("opencode-feishu-v2/gateway-watchdog");
147162
+ function ensureGatewayWatchdog(input) {
147163
+ const g = globalThis;
147164
+ const existing = g[WATCHDOG_SLOT];
147165
+ if (existing) {
147166
+ if (input.authoritative && existing.target !== input.directory) {
147167
+ existing.target = input.directory;
147168
+ input.log.debug("\u7F51\u5173\u770B\u95E8\u72D7\u76EE\u6807\u66F4\u65B0\u4E3A\u7F51\u5173 location", { directory: input.directory });
147169
+ }
147170
+ return false;
147171
+ }
147172
+ const setIntervalImpl = input.setIntervalImpl ?? setInterval;
147173
+ input.clearIntervalImpl ?? clearInterval;
147174
+ const setTimeoutImpl = input.setTimeoutImpl ?? setTimeout;
147175
+ input.clearTimeoutImpl ?? clearTimeout;
147176
+ const probe = input.probe ?? ((dir) => touchLocationOverHttp(dir, { log: input.log }));
147177
+ const slot = {
147178
+ target: input.directory,
147179
+ timer: void 0,
147180
+ initial: void 0
147181
+ };
147182
+ const run2 = () => {
147183
+ void probe(slot.target).catch((err2) => {
147184
+ input.log.debug("\u7F51\u5173\u770B\u95E8\u72D7\u63A2\u6D4B\u5F02\u5E38", { error: errorMessage(err2) });
147185
+ });
147186
+ };
147187
+ const delay3 = input.immediateDelayMs ?? 3e3;
147188
+ if (delay3 > 0) slot.initial = setTimeoutImpl(run2, delay3);
147189
+ slot.timer = setIntervalImpl(run2, input.intervalMs);
147190
+ slot.timer.unref?.();
147191
+ slot.initial?.unref?.();
147192
+ g[WATCHDOG_SLOT] = slot;
147193
+ input.log.info("\u7F51\u5173\u770B\u95E8\u72D7\u5DF2\u542F\u52A8\uFF08\u8FDB\u7A0B\u7EA7\uFF09", {
147194
+ directory: input.directory,
147195
+ intervalMs: input.intervalMs
147196
+ });
147197
+ return true;
147198
+ }
147152
147199
  function startKeepalive(deps) {
147153
147200
  const setIntervalImpl = deps.setIntervalImpl ?? setInterval;
147154
147201
  const clearIntervalImpl = deps.clearIntervalImpl ?? clearInterval;
@@ -150917,24 +150964,37 @@ var index_default = plugin_exports.define({
150917
150964
  hasAppSecret: hasSecret(config.appSecret)
150918
150965
  });
150919
150966
  const here = ctx.location?.directory;
150967
+ let startedWatchdog = false;
150968
+ if (config.keepalive) {
150969
+ const watchdogTarget = config.gatewayLocation ?? here;
150970
+ if (watchdogTarget) {
150971
+ startedWatchdog = ensureGatewayWatchdog({
150972
+ log: log2,
150973
+ directory: watchdogTarget,
150974
+ intervalMs: config.keepaliveIntervalMs
150975
+ });
150976
+ }
150977
+ }
150920
150978
  if (config.gatewayLocation) {
150921
150979
  const matched = Boolean(here) && isUnder(here, config.gatewayLocation);
150980
+ const keepSink = matched || startedWatchdog;
150922
150981
  trackGatewayLocationSeen({
150923
150982
  here: here ?? "(unknown)",
150924
150983
  expected: config.gatewayLocation,
150925
150984
  matched,
150926
150985
  warn: (message) => log2.warn(message),
150927
- ...matched ? {} : { onSettled: () => logSink?.close() }
150986
+ ...keepSink ? {} : { onSettled: () => logSink?.close() }
150928
150987
  });
150929
150988
  if (!matched) {
150930
150989
  log2.debug("\u8DF3\u8FC7\u975E\u7F51\u5173 location", { here, expected: config.gatewayLocation });
150990
+ if (!keepSink) logSink?.close();
150931
150991
  return async () => {
150932
150992
  };
150933
150993
  }
150934
150994
  }
150935
150995
  if (!acquireProcessGuard()) {
150936
150996
  log2.debug("\u68C0\u6D4B\u5230\u540C\u8FDB\u7A0B\u91CD\u590D setup\uFF0C\u8DF3\u8FC7\u542F\u52A8\uFF08\u4EC5\u9996\u4E2A\u5B9E\u4F8B\u751F\u6548\uFF09");
150937
- logSink?.close();
150997
+ if (!startedWatchdog) logSink?.close();
150938
150998
  return async () => {
150939
150999
  };
150940
151000
  }
@@ -151487,6 +151547,15 @@ async function start(ctx, config, log2, logSink) {
151487
151547
  }
151488
151548
  const here = ctx.location?.directory;
151489
151549
  const keepaliveDirectory = here ?? config.gatewayLocation;
151550
+ if (config.keepalive && keepaliveDirectory) {
151551
+ ensureGatewayWatchdog({
151552
+ log: log2,
151553
+ directory: keepaliveDirectory,
151554
+ intervalMs: config.keepaliveIntervalMs,
151555
+ authoritative: true,
151556
+ immediateDelayMs: 0
151557
+ });
151558
+ }
151490
151559
  const stopKeepalive = config.keepalive && keepaliveDirectory ? startKeepalive({
151491
151560
  log: log2,
151492
151561
  directory: keepaliveDirectory,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Feishu/Lark plugin for OpenCode V2 — single-user topics as sessions, in-card permission approvals, streaming cards. Minimal permissions, no public endpoint.",
5
5
  "type": "module",
6
6
  "main": "index.js",