opencode-feishu-plugin 0.1.4 → 0.2.1

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
@@ -242,7 +242,7 @@ Besides sessions created from Feishu, you can **load any past OpenCode session v
242
242
  [▶️ Enter topic] [▶️ New topic] [⬅️ Prev] [➡️ Next] [➕ New session]
243
243
  ```
244
244
 
245
- - Data source is `ctx.session.list()` (**all** OpenCode sessions, sorted by `time.updated` desc), not just the plugin's mapping table; if unavailable it falls back to the mapping list and logs a `warn`.
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
246
  - Each row shows: title (truncated), short id, relative time, `💬 topic-bound` (this session already has a topic mapping), `📍 <directory tail>`.
247
247
  - **Paging**: 8 per page by default (`sessionPageSize`, clamped 5–20); the bottom buttons flip pages (`{cmd:"list", page:N}`).
248
248
  - **"➕ New session"** opens the setup form card (same as `/new` `/form`) instead of creating a session directly.
@@ -358,9 +358,9 @@ The threshold is `staleExecutionMs` (default 5 minutes, clamped to 1–60 minute
358
358
 
359
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:
360
360
 
361
- - tap an option for single-choice fields; multi-field forms submit automatically once every field is filled;
362
- - for free-text fields, tap "✍️ reply directly" and send the answer as a message in the **same topic**;
363
- - the card resolves after submit/cancel.
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
+ - **the card is recalled once answered/cancelled**; if it is past Feishu's recall window, it degrades to a "submitted/cancelled" result card instead.
364
364
 
365
365
  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".
366
366
 
@@ -400,6 +400,28 @@ Without this relay, any clarifying question would stall the Feishu session forev
400
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
401
  | `topicStatusThrottleMs` | number | `1000` | Min root-card status refresh interval (clamped 500–10000); patched only when the kind changes |
402
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` |
403
+ | `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) |
404
+ | `keepaliveIntervalMs` | number | `1200000` | Keep-alive interval (default 20 min, clamped 5–45); must stay well below OpenCode's hardcoded 60-minute TTL |
405
+
406
+ ### Location keep-alive (on by default)
407
+
408
+ OpenCode **evicts idle Locations**, which unloads plugins and closes the Feishu long connection:
409
+
410
+ | Mechanism | Where | Trigger | Effect |
411
+ |---|---|---|---|
412
+ | LayerMap `idleTimeToLive` | `packages/core/src/location-services.ts` (hardcoded `60 minutes`) | no **session-scoped request** for 60 min | Location services destroyed (silently) |
413
+ | `@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` |
414
+
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
+
417
+ The plugin ships two built-in layers (both on by default, **no external script required**):
418
+
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.
423
+
424
+ > Still recommended (optional, not required): a cron / systemd timer doing one session-scoped GET every 15–30 minutes, covering the extreme case where the opencode process is down and nothing is used for a long time. See `scripts/keepalive-feishu.sh`. Set `keepalive: false` to disable all keep-alive.
403
425
 
404
426
  ---
405
427
 
@@ -451,6 +473,7 @@ otherwise (per session preset) → ask ─────────────
451
473
  | No plugin logs | Plugin stderr is discarded in service mode; set `logFile: true` and read `<configDir>/plugins/feishu.log` |
452
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 |
453
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` |
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 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" |
454
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 |
455
478
 
456
479
  ---
package/README.md CHANGED
@@ -161,6 +161,7 @@ opencode mcp list # 顺带确认服务健康
161
161
 
162
162
  ![/sessions 会话列表卡:翻页、进入/再开、新建会话](image/sessions.png)
163
163
 
164
+ - **数据源**:优先插件原生 `ctx.session.list()`(V2 运行时通常未暴露)→ **本机 HTTP `GET /api/session`**(与 opencode 同机,列出**全量**会话,含 TUI / Web 里开的)→ `SessionMap` 回退(仅机器人自己的会话)。进入外部会话时会补一条映射,审批 / 失败通知照常。
164
165
  - 每条显示标题 / 短 id / 相对时间 / 是否已绑话题 / 目录,当前会话标「← 当前」;已绑话题的按钮显示「▶️ 再开」,其余为「▶️ 进入」;底部可翻页 + 「➕ 新建会话」。
165
166
  - **「▶️ 进入话题」**:在主聊天流发一张恢复卡(含会话摘要),**直接回复这张卡**即续聊该历史会话。
166
167
  - `/resume [序号]` 跳过列表直达,同一套「发恢复卡 → 回复即续聊」流程。
@@ -210,7 +211,12 @@ opencode mcp list # 顺带确认服务健康
210
211
 
211
212
  ### 表单 / 提问(`question` 工具)
212
213
 
213
- agent 调 `question` 等 form 类交互时,插件把它转成飞书卡片:单选题直接点选项,多字段逐项点选,自由文本点「✍️ 直接回复答案」后在话题里发一条消息。**没有这层转发,agent 一反问飞书会话就会永久卡住**——这也是会话卡死的常见原因。
214
+ agent 调 `question` 等 form 类交互时,插件把它转成飞书卡片:
215
+
216
+ - **两种作答方式等价**:直接点选项按钮,或**直接在话题里发文字**(无需先点「✍️ 直接回复答案」)。文本会智能匹配——命中选项 label/value 用选项值,`boolean` 认「是/否、yes/no、1/0」,`number`/`integer` 转数值,多选按顿号/逗号拆分,其余视为**手动输入**。
217
+ - 多字段表单可以混合作答:点几个按钮 + 补一条文字,填满即自动提交。
218
+ - **作答 / 取消后卡片会被撤回**(不再残留待填卡);若超出飞书撤回时限,降级为「已提交 / 已取消」结果卡。
219
+ - **没有这层转发,agent 一反问飞书会话就会永久卡住**——这也是会话卡死的常见原因。
214
220
 
215
221
  ### 卡片内容守卫(表格超限降级)
216
222
 
@@ -255,6 +261,8 @@ agent 调 `question` 等 form 类交互时,插件把它转成飞书卡片:
255
261
  | `topicStatusInTitle` | boolean | `false` | 是否在根卡标题加状态 emoji 前缀 |
256
262
  | `topicStatusThrottleMs` | number | `1000` | 根卡状态刷新最小间隔(500–10000) |
257
263
  | `cardMaxTables` | number | `4` | 单卡最多保留的 markdown 表格数(1–5);超出按整卡累计降级为围栏代码块,避免飞书 400 `code=230099` |
264
+ | `keepalive` | boolean | `true` | **位置保活**:周期性向 opencode 发一次活动,阻止 60 分钟空闲回收 location(会关掉飞书长连接、机器人失联) |
265
+ | `keepaliveIntervalMs` | number | `1200000` | 保活间隔(默认 20 分钟,夹取 5–45);必须显著小于 opencode 硬编码的 60 分钟 TTL |
258
266
 
259
267
  ---
260
268
 
@@ -274,6 +282,27 @@ permission.evaluate (插件 hook) permission.asked (事件流)
274
282
  - **三重单人边界**:可用范围「仅本人」+ 不申请群权限 + 代码层 open_id 白名单。
275
283
  - **`always` 语义**:仅当请求带 `save[]` 时才持久化,否则等价于「允许一次」。
276
284
 
285
+ ### 位置保活(防空闲失联,默认开启)
286
+
287
+ opencode 会**回收空闲的 location**,这会连带卸载插件、关闭飞书长连接:
288
+
289
+ | 机制 | 位置 | 触发条件 | 表现 |
290
+ |---|---|---|---|
291
+ | LayerMap `idleTimeToLive` | `packages/core/src/location-services.ts`(硬编码 `60 minutes`) | 60 分钟内无**会话级请求** | location 服务被销毁(静默) |
292
+ | `@opencode/LocationActivity` | 同为硬编码 60 分钟 | 60 分钟内无**带 location 的 durable 事件** | 先 interrupt 活动会话,再 `invalidate(location)`,日志 `location services evicted` |
293
+
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
+
296
+ 插件内置两道防线(都是默认开启,**无需任何外部脚本**):
297
+
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 秒延迟,重启后恢复很快。
303
+
304
+ > 仍建议(可选,非必需):用 cron / systemd timer 每 15–30 分钟做一次「会话级 GET」,覆盖「opencode 进程整个挂掉且长时间无人使用」的极端场景。仓库提供 `scripts/keepalive-feishu.sh` 一键脚本;`keepalive: false` 可关闭全部保活。
305
+
277
306
  ---
278
307
 
279
308
  ## 六、故障排查
@@ -291,6 +320,7 @@ permission.evaluate (插件 hook) permission.asked (事件流)
291
320
  | 卡片内容被截断 | 飞书卡片上限约 30KB,超长会话丢弃卡片上最旧块(完整内容仍在会话里) |
292
321
  | 建会话后没看到话题 | 表单卡会改写为「✅ 已创建 · …」并附手动创建话题指引 |
293
322
  | 会话像卡死、发消息只排队 | 看门狗默认 5 分钟后自动中断;也可点「⏹ 强制停止」或发 `/stop` |
323
+ | **空闲约 1 小时后机器人完全失联**(日志无「长连接已启动」) | opencode 回收了空闲 location(60 分钟硬编码 TTL)。内置保活默认开启;若仍发生(服务重启 / 长休眠 / 保活被关),用 `opencode api get /api/session/{id}` 任一本地会话即可唤起,或按上方「位置保活」配置外部 cron |
294
324
  | 看不到插件日志 | 服务模式下 stderr 被丢弃,设 `logFile: true` |
295
325
  | 切了 `/model` 但历史还是旧模型 | 预期行为:切换只影响后续回复,历史消息保留各自当时的模型 |
296
326
 
package/dist/index.js CHANGED
@@ -144024,6 +144024,12 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144024
144024
  const topicStatusInTitle = asBoolean(merged.topicStatusInTitle, false);
144025
144025
  const topicStatusThrottleMs = clamp4(asNumber(merged.topicStatusThrottleMs, 1e3), 500, 1e4);
144026
144026
  const cardMaxTables = clamp4(asNumber(merged.cardMaxTables, 4), 1, 5);
144027
+ const keepalive = asBoolean(merged.keepalive, true);
144028
+ const keepaliveIntervalMs = clamp4(
144029
+ asNumber(merged.keepaliveIntervalMs, 20 * 60 * 1e3),
144030
+ 5 * 60 * 1e3,
144031
+ 45 * 60 * 1e3
144032
+ );
144027
144033
  const domain = merged.domain === "lark" ? "lark" : "feishu";
144028
144034
  const logFile = resolveLogFile(merged.logFile, env, deps);
144029
144035
  const gatewayLocation = normalizeGatewayLocation(asString(merged.gatewayLocation));
@@ -144071,7 +144077,9 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144071
144077
  topicStatus,
144072
144078
  topicStatusInTitle,
144073
144079
  topicStatusThrottleMs,
144074
- cardMaxTables
144080
+ cardMaxTables,
144081
+ keepalive,
144082
+ keepaliveIntervalMs
144075
144083
  };
144076
144084
  }
144077
144085
  function loadConfigFile(env, deps, warnings) {
@@ -145125,10 +145133,18 @@ function createFeishuSender(client, log2, options2 = {}) {
145125
145133
  }
145126
145134
  },
145127
145135
  async deleteMessage(messageId) {
145128
- if (!messageId) return;
145136
+ if (!messageId) return { ok: false, error: "missing messageId" };
145129
145137
  try {
145130
- await client.im.message.delete({ path: { message_id: messageId } });
145131
- } catch {
145138
+ const res = await client.im.message.delete({ path: { message_id: messageId } });
145139
+ if (res?.code && res.code !== 0) {
145140
+ log2.debug("\u64A4\u56DE\u6D88\u606F\u5931\u8D25", { messageId, code: res.code, msg: res.msg });
145141
+ return { ok: false, error: `code=${res.code} msg=${res.msg ?? ""}` };
145142
+ }
145143
+ return { ok: true };
145144
+ } catch (err2) {
145145
+ const error = describeLarkError(err2);
145146
+ log2.debug("\u64A4\u56DE\u6D88\u606F\u5F02\u5E38", { messageId, error });
145147
+ return { ok: false, error };
145132
145148
  }
145133
145149
  }
145134
145150
  };
@@ -145304,6 +145320,25 @@ var SessionMap = class {
145304
145320
  await this.persist(chatId, record2);
145305
145321
  await this.safeSet(`${SESSION_KEY_PREFIX}${sessionID}`, serializeSession(link2));
145306
145322
  }
145323
+ /**
145324
+ * 为**外部来源**的会话(TUI/Web,无飞书映射)补一条会话索引(P7.5)。
145325
+ *
145326
+ * `/ls` 列出 opencode 全量会话后,用户「进入话题」的会话需要审批投递、
145327
+ * 跨 location 路由(`dir`)与失败通知的目标。已存在则只补缺失字段,
145328
+ * **不覆盖** perm/gateMode/model 等既有元数据。
145329
+ */
145330
+ async ensureSessionLink(sessionID, input) {
145331
+ if (!sessionID || !input.chatId) return;
145332
+ const existing = await this.resolveBySession(sessionID);
145333
+ const link2 = {
145334
+ ...existing,
145335
+ chatId: input.chatId,
145336
+ openId: input.openId,
145337
+ ...input.directory ? { dir: input.directory } : {}
145338
+ };
145339
+ this.remember(sessionID, link2);
145340
+ await this.safeSet(`${SESSION_KEY_PREFIX}${sessionID}`, serializeSession(link2));
145341
+ }
145307
145342
  /** 切换当前会话;会话不属于该 chat 时返回 false。 */
145308
145343
  async setActive(chatId, sessionID) {
145309
145344
  const record2 = await this.loadChat(chatId);
@@ -146813,7 +146848,7 @@ var FormRelay = class {
146813
146848
  return false;
146814
146849
  }
146815
146850
  const field = pending.form.fields.find((f) => f.key === fieldKey);
146816
- pending.answers[fieldKey] = coerceText(field?.type, text);
146851
+ pending.answers[fieldKey] = coerceAnswer(field, text);
146817
146852
  pending.awaitingField = void 0;
146818
146853
  this.awaiting.delete(sessionID);
146819
146854
  if (isComplete(pending.form, pending.answers)) {
@@ -146837,7 +146872,7 @@ var FormRelay = class {
146837
146872
  }
146838
146873
  this.awaiting.delete(sessionID ?? pending.sessionID);
146839
146874
  this.forms.delete(id2);
146840
- void this.patch(pending, buildFormResolvedCard(pending.form, pending.answers, "answered"));
146875
+ void this.finish(pending, "answered");
146841
146876
  }
146842
146877
  /** 处理 `form.cancelled`。 */
146843
146878
  onCancelled(data) {
@@ -146847,7 +146882,7 @@ var FormRelay = class {
146847
146882
  if (!pending) return;
146848
146883
  this.awaiting.delete(sessionID ?? pending.sessionID);
146849
146884
  this.forms.delete(id2);
146850
- void this.patch(pending, buildFormResolvedCard(pending.form, pending.answers, "cancelled"));
146885
+ void this.finish(pending, "cancelled");
146851
146886
  }
146852
146887
  dispose() {
146853
146888
  this.disposed = true;
@@ -146868,6 +146903,13 @@ var FormRelay = class {
146868
146903
  error: errorMessage(err2)
146869
146904
  });
146870
146905
  pending.settled = false;
146906
+ const missing2 = pending.form.fields.find(
146907
+ (f) => f.hidden !== true && pending.answers[f.key] === void 0
146908
+ );
146909
+ if (missing2) {
146910
+ pending.awaitingField = missing2.key;
146911
+ this.awaiting.set(pending.sessionID, pending.form.id);
146912
+ }
146871
146913
  this.forms.set(pending.form.id, pending);
146872
146914
  await this.patch(
146873
146915
  pending,
@@ -146876,7 +146918,20 @@ var FormRelay = class {
146876
146918
  return;
146877
146919
  }
146878
146920
  this.forms.delete(pending.form.id);
146879
- await this.patch(pending, buildFormResolvedCard(pending.form, pending.answers, "answered"));
146921
+ await this.finish(pending, "answered");
146922
+ }
146923
+ /**
146924
+ * 表单收敛:**优先撤回卡片**(作答/取消后不再残留待填卡),撤回失败(超时限/无权限)
146925
+ * 才降级 patch 成结果卡,避免卡片永久停在待填态。
146926
+ */
146927
+ async finish(pending, outcome) {
146928
+ if (this.disposed) return;
146929
+ const res = await this.deps.sender.deleteMessage(pending.messageId);
146930
+ if (res.ok) {
146931
+ this.deps.log.debug("\u8868\u5355\u5361\u5DF2\u64A4\u56DE", { formID: pending.form.id, outcome });
146932
+ return;
146933
+ }
146934
+ await this.patch(pending, buildFormResolvedCard(pending.form, pending.answers, outcome));
146880
146935
  }
146881
146936
  async patch(pending, card) {
146882
146937
  if (this.disposed) return;
@@ -146890,17 +146945,47 @@ function isFormValue(value3) {
146890
146945
  if (typeof value3 === "string" || typeof value3 === "number" || typeof value3 === "boolean") return true;
146891
146946
  return Array.isArray(value3) && value3.every((x) => typeof x === "string");
146892
146947
  }
146893
- function coerceText(type, text) {
146948
+ function coerceAnswer(field, text) {
146894
146949
  const trimmed = text.trim();
146895
- if (type === "number") {
146896
- const n = Number(trimmed);
146897
- return Number.isFinite(n) ? n : text;
146898
- }
146899
- if (type === "integer") {
146900
- const n = Number.parseInt(trimmed, 10);
146901
- return Number.isFinite(n) && String(n) === trimmed ? n : text;
146950
+ if (!field) return trimmed;
146951
+ switch (field.type) {
146952
+ case "number": {
146953
+ const n = Number(trimmed);
146954
+ return trimmed !== "" && Number.isFinite(n) ? n : trimmed;
146955
+ }
146956
+ case "integer": {
146957
+ const n = Number.parseInt(trimmed, 10);
146958
+ return Number.isFinite(n) && String(n) === trimmed ? n : trimmed;
146959
+ }
146960
+ case "boolean": {
146961
+ const parsed = parseBooleanText(trimmed);
146962
+ return parsed === void 0 ? trimmed : parsed;
146963
+ }
146964
+ case "multiselect": {
146965
+ const parts = trimmed.split(/[、,,;;\n]+/).map((s) => s.trim()).filter(Boolean);
146966
+ if (parts.length === 0) return trimmed;
146967
+ return parts.map((p) => matchOption(field, p) ?? p);
146968
+ }
146969
+ default:
146970
+ return matchOption(field, trimmed) ?? trimmed;
146902
146971
  }
146903
- return text;
146972
+ }
146973
+ function matchOption(field, text) {
146974
+ if (!field.options?.length) return void 0;
146975
+ const trimmed = text.trim();
146976
+ const lower = trimmed.toLowerCase();
146977
+ const hit = field.options.find(
146978
+ (o) => o.value === trimmed || o.label.trim() === trimmed || o.label.trim().toLowerCase() === lower
146979
+ );
146980
+ return hit?.value;
146981
+ }
146982
+ var TRUE_WORDS = /* @__PURE__ */ new Set(["\u662F", "\u5BF9", "\u597D", "\u53EF\u4EE5", "\u5141\u8BB8", "\u786E\u8BA4", "\u540C\u610F", "yes", "y", "true", "1"]);
146983
+ var FALSE_WORDS = /* @__PURE__ */ new Set(["\u5426", "\u4E0D", "\u4E0D\u884C", "\u62D2\u7EDD", "\u53D6\u6D88", "\u4E0D\u8981", "no", "n", "false", "0"]);
146984
+ function parseBooleanText(text) {
146985
+ const lower = text.trim().toLowerCase();
146986
+ if (TRUE_WORDS.has(lower)) return true;
146987
+ if (FALSE_WORDS.has(lower)) return false;
146988
+ return void 0;
146904
146989
  }
146905
146990
  function serviceStatePath(env = process.env) {
146906
146991
  const base2 = env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state");
@@ -146960,6 +147045,173 @@ async function replyFormOverHttp(input, deps) {
146960
147045
  }
146961
147046
  }
146962
147047
 
147048
+ // src/session/session-list-http.ts
147049
+ async function listSessionsOverHttp(input, deps) {
147050
+ const discover = deps.discover ?? (() => discoverLocalService());
147051
+ const doFetch = deps.fetchImpl ?? fetch;
147052
+ const service3 = await discover();
147053
+ if (!service3) {
147054
+ deps.log.debug("\u672A\u53D1\u73B0\u672C\u673A opencode \u670D\u52A1\uFF0CHTTP \u4F1A\u8BDD\u5217\u8868\u8DF3\u8FC7");
147055
+ return void 0;
147056
+ }
147057
+ const params = new URLSearchParams();
147058
+ params.set("limit", String(input.limit ?? 200));
147059
+ params.set("order", input.order ?? "desc");
147060
+ if (input.rootsOnly ?? true) params.set("parentID", "null");
147061
+ try {
147062
+ const response2 = await doFetch(`${service3.url}/api/session?${params.toString()}`, {
147063
+ method: "GET",
147064
+ headers: { ...authHeaders(service3) },
147065
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 8e3)
147066
+ });
147067
+ if (!response2.ok) {
147068
+ deps.log.debug("HTTP \u4F1A\u8BDD\u5217\u8868\u5931\u8D25", { status: response2.status });
147069
+ return void 0;
147070
+ }
147071
+ return await response2.json();
147072
+ } catch (err2) {
147073
+ deps.log.debug("HTTP \u4F1A\u8BDD\u5217\u8868\u5F02\u5E38", { error: errorMessage(err2) });
147074
+ return void 0;
147075
+ }
147076
+ }
147077
+
147078
+ // src/session/keepalive.ts
147079
+ var KEEPALIVE_SESSION_TITLE = "__opencode_feishu_keepalive__";
147080
+ async function touchLocationOverHttp(directory, deps) {
147081
+ const discover = deps.discover ?? (() => discoverLocalService());
147082
+ const doFetch = deps.fetchImpl ?? fetch;
147083
+ const service3 = await discover();
147084
+ if (!service3) {
147085
+ deps.log.debug("\u4FDD\u6D3B\u8DF3\u8FC7\uFF1A\u672A\u53D1\u73B0\u672C\u673A opencode \u670D\u52A1");
147086
+ return false;
147087
+ }
147088
+ const headers = {
147089
+ "content-type": "application/json",
147090
+ ...authHeaders(service3)
147091
+ };
147092
+ const timeout3 = deps.timeoutMs ?? 8e3;
147093
+ const signal = () => AbortSignal.timeout(timeout3);
147094
+ let touched = false;
147095
+ try {
147096
+ const params = new URLSearchParams({ limit: "1", parentID: "null" });
147097
+ if (directory) params.set("directory", directory);
147098
+ const listRes = await doFetch(`${service3.url}/api/session?${params.toString()}`, {
147099
+ method: "GET",
147100
+ headers,
147101
+ signal: signal()
147102
+ });
147103
+ if (listRes.ok) {
147104
+ const list = await listRes.json().catch(() => void 0);
147105
+ const target = list?.data?.[0]?.id;
147106
+ if (target) {
147107
+ const getRes = await doFetch(`${service3.url}/api/session/${encodeURIComponent(target)}`, {
147108
+ method: "GET",
147109
+ headers,
147110
+ signal: signal()
147111
+ });
147112
+ touched = getRes.ok;
147113
+ }
147114
+ }
147115
+ } catch (err2) {
147116
+ deps.log.debug("\u4FDD\u6D3B\u4F1A\u8BDD\u7EA7\u7EED\u671F\u5931\u8D25", { directory, error: errorMessage(err2) });
147117
+ }
147118
+ let sessionID;
147119
+ try {
147120
+ const response2 = await doFetch(`${service3.url}/api/session`, {
147121
+ method: "POST",
147122
+ headers,
147123
+ body: JSON.stringify({
147124
+ title: KEEPALIVE_SESSION_TITLE,
147125
+ ...directory ? { location: { directory } } : {}
147126
+ }),
147127
+ signal: signal()
147128
+ });
147129
+ if (response2.ok) {
147130
+ const body = await response2.json().catch(() => void 0);
147131
+ sessionID = body?.data?.id;
147132
+ touched = true;
147133
+ } else {
147134
+ deps.log.debug("\u4FDD\u6D3B\u63A2\u9488\u521B\u5EFA\u5931\u8D25", { status: response2.status, directory });
147135
+ }
147136
+ } catch (err2) {
147137
+ deps.log.debug("\u4FDD\u6D3B\u63A2\u9488\u521B\u5EFA\u5F02\u5E38", { directory, error: errorMessage(err2) });
147138
+ }
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
+ }
147149
+ try {
147150
+ await doFetch(`${service3.url}/api/session/${encodeURIComponent(sessionID)}`, {
147151
+ method: "DELETE",
147152
+ headers,
147153
+ signal: signal()
147154
+ });
147155
+ } catch {
147156
+ }
147157
+ }
147158
+ if (touched) deps.log.debug("\u4FDD\u6D3B\u5FC3\u8DF3\u5DF2\u53D1\u9001", { directory, probe: sessionID ?? "(none)" });
147159
+ return touched;
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
+ }
147199
+ function startKeepalive(deps) {
147200
+ const setIntervalImpl = deps.setIntervalImpl ?? setInterval;
147201
+ const clearIntervalImpl = deps.clearIntervalImpl ?? clearInterval;
147202
+ const touch = deps.touch ?? ((dir) => touchLocationOverHttp(dir, { log: deps.log }));
147203
+ const timer = setIntervalImpl(() => {
147204
+ void touch(deps.directory).catch((err2) => {
147205
+ deps.log.debug("\u4FDD\u6D3B\u5FC3\u8DF3\u5F02\u5E38", { error: errorMessage(err2) });
147206
+ });
147207
+ }, deps.intervalMs);
147208
+ timer.unref?.();
147209
+ deps.log.info("\u4F4D\u7F6E\u4FDD\u6D3B\u5DF2\u542F\u52A8", { directory: deps.directory, intervalMs: deps.intervalMs });
147210
+ return () => {
147211
+ clearIntervalImpl(timer);
147212
+ };
147213
+ }
147214
+
146963
147215
  // src/feishu/commands.ts
146964
147216
  var ALIASES = {
146965
147217
  new: "new",
@@ -149452,28 +149704,53 @@ async function cmdSessions(ctx, message, page = 0) {
149452
149704
  }
149453
149705
  }
149454
149706
  async function loadSessionEntries(ctx, chatId) {
149455
- if (ctx.deps.listAllSessions) {
149456
- try {
149457
- const raw = await ctx.deps.listAllSessions();
149458
- const normalized = normalizeSessionList(raw);
149459
- if (normalized) {
149460
- ctx.deps.log.info("\u5217\u51FA\u5168\u90E8\u4F1A\u8BDD", { count: normalized.length, source: "session.list" });
149461
- return normalized;
149462
- }
149463
- ctx.deps.log.warn("session.list \u8FD4\u56DE\u5F62\u72B6\u65E0\u6CD5\u8BC6\u522B\uFF0C\u56DE\u9000 SessionMap \u5217\u8868", { chatId });
149464
- } catch (err2) {
149465
- ctx.deps.log.warn("session.list \u8C03\u7528\u5931\u8D25\uFF0C\u56DE\u9000 SessionMap \u5217\u8868", {
149466
- chatId,
149467
- error: errorMessage(err2)
149468
- });
149469
- }
149470
- } else {
149471
- ctx.deps.log.warn("\u8FD0\u884C\u65F6\u672A\u66B4\u9732 session.list\uFF0C\u56DE\u9000 SessionMap \u5217\u8868", { chatId });
149472
- }
149707
+ const viaApi = await loadViaSessionListApi(ctx, chatId);
149708
+ if (viaApi) return viaApi;
149709
+ const viaHttp = await loadViaHttp(ctx, chatId);
149710
+ if (viaHttp) return viaHttp;
149473
149711
  const fallback = fallbackEntries(await ctx.deps.sessionMap.listSessions(chatId));
149474
149712
  ctx.deps.log.warn("\u4F1A\u8BDD\u5217\u8868\u56DE\u9000\u5230 SessionMap", { chatId, count: fallback.length });
149475
149713
  return fallback;
149476
149714
  }
149715
+ async function loadViaSessionListApi(ctx, chatId) {
149716
+ if (!ctx.deps.listAllSessions) {
149717
+ ctx.deps.log.warn("\u8FD0\u884C\u65F6\u672A\u66B4\u9732 session.list\uFF0C\u5C1D\u8BD5 HTTP \u515C\u5E95", { chatId });
149718
+ return void 0;
149719
+ }
149720
+ try {
149721
+ const raw = await ctx.deps.listAllSessions();
149722
+ const normalized = normalizeSessionList(raw);
149723
+ if (normalized) {
149724
+ ctx.deps.log.info("\u5217\u51FA\u5168\u90E8\u4F1A\u8BDD", { count: normalized.length, source: "session.list" });
149725
+ return normalized;
149726
+ }
149727
+ ctx.deps.log.warn("session.list \u8FD4\u56DE\u5F62\u72B6\u65E0\u6CD5\u8BC6\u522B\uFF0C\u5C1D\u8BD5 HTTP \u515C\u5E95", { chatId });
149728
+ } catch (err2) {
149729
+ ctx.deps.log.warn("session.list \u8C03\u7528\u5931\u8D25\uFF0C\u5C1D\u8BD5 HTTP \u515C\u5E95", {
149730
+ chatId,
149731
+ error: errorMessage(err2)
149732
+ });
149733
+ }
149734
+ return void 0;
149735
+ }
149736
+ async function loadViaHttp(ctx, chatId) {
149737
+ if (!ctx.deps.listAllSessionsHttp) return void 0;
149738
+ try {
149739
+ const raw = await ctx.deps.listAllSessionsHttp();
149740
+ const normalized = normalizeSessionList(raw);
149741
+ if (normalized) {
149742
+ ctx.deps.log.info("\u5217\u51FA\u5168\u90E8\u4F1A\u8BDD", { count: normalized.length, source: "http" });
149743
+ return normalized;
149744
+ }
149745
+ ctx.deps.log.warn("HTTP \u4F1A\u8BDD\u5217\u8868\u5F62\u72B6\u65E0\u6CD5\u8BC6\u522B\uFF0C\u56DE\u9000 SessionMap \u5217\u8868", { chatId });
149746
+ } catch (err2) {
149747
+ ctx.deps.log.warn("HTTP \u4F1A\u8BDD\u5217\u8868\u8C03\u7528\u5931\u8D25\uFF0C\u56DE\u9000 SessionMap \u5217\u8868", {
149748
+ chatId,
149749
+ error: errorMessage(err2)
149750
+ });
149751
+ }
149752
+ return void 0;
149753
+ }
149477
149754
  async function buildListCard(ctx, chatId, entries3, page, activeID) {
149478
149755
  const pageSize = ctx.sessionPageSize;
149479
149756
  const pageCount = Math.max(1, Math.ceil(entries3.length / pageSize));
@@ -149510,6 +149787,13 @@ async function enterSessionThread(ctx, input) {
149510
149787
  const info = input.info;
149511
149788
  const link2 = await ctx.deps.sessionMap.resolveBySession(sessionID);
149512
149789
  const dir = info?.directory ?? link2?.dir;
149790
+ if (!link2) {
149791
+ await ctx.deps.sessionMap.ensureSessionLink(sessionID, {
149792
+ chatId,
149793
+ openId: operatorOpenId,
149794
+ ...dir ? { directory: dir } : {}
149795
+ });
149796
+ }
149513
149797
  const showSummary = (ctx.deps.resumeSummary ?? true) && Boolean(ctx.deps.summarizeSession);
149514
149798
  const onLimit = (report) => {
149515
149799
  ctx.deps.log.warn("\u4F1A\u8BDD\u6062\u590D\u5361\u5185\u5BB9\u8D85\u9650\uFF0C\u5DF2\u964D\u7EA7", { sessionID, ...report });
@@ -150680,24 +150964,37 @@ var index_default = plugin_exports.define({
150680
150964
  hasAppSecret: hasSecret(config.appSecret)
150681
150965
  });
150682
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
+ }
150683
150978
  if (config.gatewayLocation) {
150684
150979
  const matched = Boolean(here) && isUnder(here, config.gatewayLocation);
150980
+ const keepSink = matched || startedWatchdog;
150685
150981
  trackGatewayLocationSeen({
150686
150982
  here: here ?? "(unknown)",
150687
150983
  expected: config.gatewayLocation,
150688
150984
  matched,
150689
150985
  warn: (message) => log2.warn(message),
150690
- ...matched ? {} : { onSettled: () => logSink?.close() }
150986
+ ...keepSink ? {} : { onSettled: () => logSink?.close() }
150691
150987
  });
150692
150988
  if (!matched) {
150693
150989
  log2.debug("\u8DF3\u8FC7\u975E\u7F51\u5173 location", { here, expected: config.gatewayLocation });
150990
+ if (!keepSink) logSink?.close();
150694
150991
  return async () => {
150695
150992
  };
150696
150993
  }
150697
150994
  }
150698
150995
  if (!acquireProcessGuard()) {
150699
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");
150700
- logSink?.close();
150997
+ if (!startedWatchdog) logSink?.close();
150701
150998
  return async () => {
150702
150999
  };
150703
151000
  }
@@ -151009,6 +151306,9 @@ async function start(ctx, config, log2, logSink) {
151009
151306
  sessionPageSize: config.sessionPageSize,
151010
151307
  threadRouting: config.threadRouting,
151011
151308
  listAllSessions: listAllSessionsRaw,
151309
+ // `ctx.session.list` 在 V2 运行时未暴露 → 本机 HTTP `GET /api/session`
151310
+ // 兜底列出全量会话(含 TUI/Web 来源)。
151311
+ listAllSessionsHttp: () => listSessionsOverHttp({}, { log: log2 }),
151012
151312
  getSessionInfo: getSessionInfoRaw,
151013
151313
  resumeSummary: config.resumeSummary,
151014
151314
  resumeSummaryTimeoutMs: config.resumeSummaryTimeoutMs,
@@ -151245,6 +151545,22 @@ async function start(ctx, config, log2, logSink) {
151245
151545
  const res = link2.replyMessageId ? await sender.replyCard(link2.replyMessageId, card) : await sender.sendCard(link2.chatId, card);
151246
151546
  if (!res.ok) log2.warn("\u5361\u6B7B\u63D0\u793A\u5361\u53D1\u9001\u5931\u8D25", { sessionID, error: res.error ?? "unknown" });
151247
151547
  }
151548
+ const here = ctx.location?.directory;
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
+ }
151559
+ const stopKeepalive = config.keepalive && keepaliveDirectory ? startKeepalive({
151560
+ log: log2,
151561
+ directory: keepaliveDirectory,
151562
+ intervalMs: config.keepaliveIntervalMs
151563
+ }) : void 0;
151248
151564
  log2.info("\u98DE\u4E66\u63D2\u4EF6\u5DF2\u5C31\u7EEA");
151249
151565
  let cleanedUp = false;
151250
151566
  return async () => {
@@ -151253,6 +151569,7 @@ async function start(ctx, config, log2, logSink) {
151253
151569
  log2.info("\u98DE\u4E66\u63D2\u4EF6\u5378\u8F7D\u4E2D");
151254
151570
  abort.abort();
151255
151571
  stopWatchdog();
151572
+ stopKeepalive?.();
151256
151573
  await subscription.catch(() => void 0);
151257
151574
  runs.dispose();
151258
151575
  executions.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
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",