opencode-feishu-plugin 0.2.11 → 0.2.12

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
@@ -173,7 +173,7 @@ When the agent asks via `question` or other form tools, the form becomes a Feish
173
173
  | `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | Log level |
174
174
  | `logFile` | string \| boolean | — | `true` = write `<configDir>/plugins/feishu.log`; recommended in server mode |
175
175
  | `approvalTtlMs` | number | `600000` | Approval token / card validity |
176
- | `staleExecutionMs` | number | `300000` | Watchdog threshold (1–60 min) |
176
+ | `staleExecutionMs` | number | `300000` | Watchdog threshold (0–60 min; **0 = disabled**; sessions waiting on a form / pending approval are never killed) |
177
177
  | `gatewayLocation` | string | — | Only start the gateway at this location (or its subdirectories); empty = any location |
178
178
 
179
179
  Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepalive*`, `gatewayMatchGraceMs`) → [docs/advanced.en.md](./docs/advanced.en.md#full-configuration).
@@ -188,7 +188,7 @@ Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepa
188
188
  | Plugin not loaded at all | npm: confirm the package name is in the `plugins` array; directory: confirm `plugins/<name>/index.js` exists |
189
189
  | No approval cards | That session wasn't started from Feishu (no mapping); by design the plugin doesn't take it over |
190
190
  | Button click says invalid credential | Token expired (10 min default) or clicker not in allowlist |
191
- | Session seems stuck, messages only queue | Watchdog auto-interrupts after 5 min; or tap "⏹ force stop" / send `/stop` |
191
+ | Session seems stuck, messages only queue | Watchdog auto-interrupts after 5 min (never while a form / approval is pending; `staleExecutionMs: 0` disables it); or tap "⏹ force stop" / send `/stop` |
192
192
  | 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) |
193
193
  | Can't see plugin logs | stderr is discarded in server mode; set `logFile: true` |
194
194
  | 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) |
package/README.md CHANGED
@@ -173,7 +173,7 @@ agent 反问时表单会变成飞书卡片,**点按钮或在话题里直接发
173
173
  | `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | 日志级别 |
174
174
  | `logFile` | string \| boolean | — | `true` = 写 `<configDir>/plugins/feishu.log`,服务模式建议开启 |
175
175
  | `approvalTtlMs` | number | `600000` | 审批 token / 卡片有效期 |
176
- | `staleExecutionMs` | number | `300000` | 看门狗阈值(1–60 分钟) |
176
+ | `staleExecutionMs` | number | `300000` | 看门狗阈值(0–60 分钟;**0 = 关闭看门狗**;待答表单 / 未决审批期间不判卡死) |
177
177
  | `gatewayLocation` | string | — | 只在该 location(及其子目录)启动网关;留空 = 任意 location 生效 |
178
178
 
179
179
  完整配置(含 `cardMaxTables`、`topicStatus*`、`resumeSummary*`、`keepalive*`、`gatewayMatchGraceMs` 等进阶项)见 [docs/advanced.md](./docs/advanced.md#完整配置项)。
@@ -188,7 +188,7 @@ agent 反问时表单会变成飞书卡片,**点按钮或在话题里直接发
188
188
  | 插件完全没被加载 | npm 方式确认包名在 `plugins` 数组;目录方式确认 `plugins/<名>/index.js` 存在 |
189
189
  | 审批卡收不到 | 该会话不是从飞书发起的(无映射),插件按设计不接管 |
190
190
  | 点按钮提示凭证无效 | token 过期(默认 10 分钟)或点击者不在白名单 |
191
- | 会话像卡死、只排队 | 看门狗默认 5 分钟后自动中断;也可点「⏹ 强制停止」或发 `/stop` |
191
+ | 会话像卡死、只排队 | 看门狗默认 5 分钟后自动中断(**待答表单 / 未决审批期间不中断**;`staleExecutionMs: 0` 可关闭);也可点「⏹ 强制停止」或发 `/stop` |
192
192
  | 空闲约 1 小时后失联 | opencode 会回收空闲 location;内置保活默认开启会自动恢复,见 [docs/advanced.md](./docs/advanced.md#位置保活) |
193
193
  | 看不到插件日志 | 服务模式下 stderr 被丢弃,设 `logFile: true` |
194
194
  | 多个长连接 / 重复回复 | 设置 `gatewayLocation` 为常用工作目录,见 [docs/advanced.md](./docs/advanced.md#多实例与网关选举) |
package/dist/index.js CHANGED
@@ -144008,7 +144008,7 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144008
144008
  const topicGuidance = asBoolean(merged.topicGuidance, true);
144009
144009
  const throttle2 = clamp4(asNumber(merged.streamThrottleMs, 400), 400, 6e4);
144010
144010
  const approvalTtlMs = clamp4(asNumber(merged.approvalTtlMs, 10 * 60 * 1e3), 3e4, 24 * 60 * 60 * 1e3);
144011
- const staleExecutionMs = clamp4(asNumber(merged.staleExecutionMs, 5 * 60 * 1e3), 6e4, 60 * 60 * 1e3);
144011
+ const staleExecutionMs = clamp4(asNumber(merged.staleExecutionMs, 5 * 60 * 1e3), 0, 60 * 60 * 1e3);
144012
144012
  const maxResourcesShown = clamp4(asNumber(merged.maxResourcesShown, 8), 1, 50);
144013
144013
  const allowedRootsRaw = asStringArray(merged.allowedRoots);
144014
144014
  const allowedRootsCandidates = (allowedRootsRaw.length > 0 ? allowedRootsRaw : [...DEFAULT_ALLOWED_ROOTS]).filter(isAbsolute);
@@ -144507,6 +144507,18 @@ var TtlMap = class {
144507
144507
  }
144508
144508
  this.data.delete(key);
144509
144509
  }
144510
+ /** 遍历未过期条目(惰性清理已过期项;用于按会话反查 pending 状态)。 */
144511
+ entries() {
144512
+ const out = [];
144513
+ for (const [key, hit] of this.data) {
144514
+ if (hit.expiresAt <= this.now()) {
144515
+ this.delete(key);
144516
+ continue;
144517
+ }
144518
+ out.push([key, hit.value]);
144519
+ }
144520
+ return out;
144521
+ }
144510
144522
  clear() {
144511
144523
  for (const timer of this.timers.values()) clearTimeout(timer);
144512
144524
  this.timers.clear();
@@ -145940,14 +145952,17 @@ var ExecutionTracker = class {
145940
145952
  /**
145941
145953
  * 返回「正在跑但超过 maxIdleMs 无任何活动」的 sessionID,并将其从运行态移除。
145942
145954
  * 用于插件层看门狗:事件丢失或交互工具(question/permission)永久挂起时兜底放开排队。
145955
+ *
145956
+ * `shouldSkip` 返回 true 的会话视为**合法等待**(如待答表单 / 未决审批):
145957
+ * 既不返回、也不移出运行态(避免在等待期被误杀,也避免丢追踪导致后续排队判定失真)。
145943
145958
  */
145944
- stale(maxIdleMs, now3 = Date.now()) {
145959
+ stale(maxIdleMs, now3 = Date.now(), shouldSkip) {
145945
145960
  const out = [];
145946
145961
  for (const [sessionID, at] of this.running) {
145947
- if (now3 - at >= maxIdleMs) {
145948
- this.running.delete(sessionID);
145949
- out.push(sessionID);
145950
- }
145962
+ if (now3 - at < maxIdleMs) continue;
145963
+ if (shouldSkip?.(sessionID)) continue;
145964
+ this.running.delete(sessionID);
145965
+ out.push(sessionID);
145951
145966
  }
145952
145967
  return out;
145953
145968
  }
@@ -145955,6 +145970,41 @@ var ExecutionTracker = class {
145955
145970
  this.running.clear();
145956
145971
  }
145957
145972
  };
145973
+ var SessionParentLinks = class {
145974
+ constructor(maxEntries = 2e3) {
145975
+ this.maxEntries = maxEntries;
145976
+ }
145977
+ maxEntries;
145978
+ parents = /* @__PURE__ */ new Map();
145979
+ /** 登记/清理某会话的父链接(无 parentID = 顶级会话,清掉旧链接)。 */
145980
+ remember(sessionID, parentID) {
145981
+ if (!sessionID) return;
145982
+ if (!parentID) {
145983
+ this.parents.delete(sessionID);
145984
+ return;
145985
+ }
145986
+ this.parents.delete(sessionID);
145987
+ this.parents.set(sessionID, parentID);
145988
+ while (this.parents.size > this.maxEntries) {
145989
+ const oldest = this.parents.keys().next().value;
145990
+ if (oldest === void 0) break;
145991
+ this.parents.delete(oldest);
145992
+ }
145993
+ }
145994
+ parentOf(sessionID) {
145995
+ return this.parents.get(sessionID);
145996
+ }
145997
+ /** 沿父链逐级回调(含自身;防环 + 深度上限)。 */
145998
+ walk(sessionID, visit) {
145999
+ let current = sessionID;
146000
+ const seen = /* @__PURE__ */ new Set();
146001
+ for (let depth = 0; current && depth < 16 && !seen.has(current); depth += 1) {
146002
+ seen.add(current);
146003
+ visit(current);
146004
+ current = this.parents.get(current);
146005
+ }
146006
+ }
146007
+ };
145958
146008
 
145959
146009
  // src/utils/throttle.ts
145960
146010
  function createThrottler(options2) {
@@ -146735,12 +146785,13 @@ function createRunController(deps) {
146735
146785
  hasActive(sessionID) {
146736
146786
  return sessions.get(sessionID)?.active !== void 0;
146737
146787
  },
146738
- staleQueued(maxIdleMs, now3 = Date.now()) {
146788
+ staleQueued(maxIdleMs, now3 = Date.now(), shouldSkip) {
146739
146789
  const out = [];
146740
146790
  for (const [sessionID, runs] of sessions) {
146741
146791
  if (runs.active) continue;
146742
146792
  const first = runs.queued[0];
146743
146793
  if (!first || now3 - first.queuedAt < maxIdleMs) continue;
146794
+ if (shouldSkip?.(sessionID)) continue;
146744
146795
  if (notifiedQueued.has(sessionID)) continue;
146745
146796
  notifiedQueued.add(sessionID);
146746
146797
  out.push(sessionID);
@@ -147251,6 +147302,13 @@ var FormRelay = class {
147251
147302
  this.forms.delete(id2);
147252
147303
  void this.finish(pending, "cancelled");
147253
147304
  }
147305
+ /** 是否有尚未提交/取消的待答表单(看门狗判活:等表单属合法等待,不应判 stale)。 */
147306
+ hasPendingFor(sessionID) {
147307
+ for (const [, pending] of this.forms.entries()) {
147308
+ if (pending.sessionID === sessionID && !pending.settled) return true;
147309
+ }
147310
+ return false;
147311
+ }
147254
147312
  dispose() {
147255
147313
  this.disposed = true;
147256
147314
  this.forms.clear();
@@ -149014,6 +149072,13 @@ var ApprovalManager = class {
149014
149072
  parsed.decision === "reject" ? "\u5DF2\u62D2\u7EDD" : "\u5DF2\u5141\u8BB8"
149015
149073
  );
149016
149074
  }
149075
+ /** 该会话是否有未决审批(看门狗判活:等审批属合法等待,不应判 stale)。 */
149076
+ hasPendingFor(sessionID) {
149077
+ for (const [, card] of this.cards.entries()) {
149078
+ if (!card.resolved && card.input.sessionID === sessionID) return true;
149079
+ }
149080
+ return false;
149081
+ }
149017
149082
  dispose() {
149018
149083
  this.seenRequests.clear();
149019
149084
  this.cards.clear();
@@ -150767,6 +150832,14 @@ async function routeEvent(event, deps) {
150767
150832
  const touched = event.data?.sessionID;
150768
150833
  if (typeof touched === "string") deps.touch(touched);
150769
150834
  switch (event.type) {
150835
+ case "session.created": {
150836
+ const data = event.data;
150837
+ const childId = typeof data.sessionID === "string" && data.sessionID ? data.sessionID : typeof data.id === "string" && data.id ? data.id : "";
150838
+ if (!childId) break;
150839
+ const parent = typeof data.parentID === "string" && data.parentID ? data.parentID : typeof data.parentId === "string" && data.parentId ? data.parentId : void 0;
150840
+ deps.onSessionCreated?.(childId, parent);
150841
+ break;
150842
+ }
150770
150843
  case "permission.asked":
150771
150844
  void deps.onPermissionAsked(event.data).catch((err2) => deps.log.warn("\u5904\u7406 permission.asked \u5931\u8D25", { error: errorMessage(err2) }));
150772
150845
  break;
@@ -151556,6 +151629,7 @@ async function start(ctx, config, log2, logSink) {
151556
151629
  await owner.load().catch((err2) => log2.warn("owner \u8BFB\u53D6\u5931\u8D25", { error: errorMessage(err2) }));
151557
151630
  const dedup = new MessageDedup(storage, log2);
151558
151631
  const executions = new ExecutionTracker();
151632
+ const sessionLinks = new SessionParentLinks();
151559
151633
  const runs = createRunController({
151560
151634
  sender,
151561
151635
  log: log2,
@@ -152161,9 +152235,13 @@ ${downloadedAttachmentPrompt(message.attachment, outcome)}`;
152161
152235
  })();
152162
152236
  const eventDeps = {
152163
152237
  log: log2,
152164
- touch: (sessionID) => executions.touch(sessionID),
152238
+ touch: (sessionID) => sessionLinks.walk(sessionID, (id2) => executions.touch(id2)),
152165
152239
  markStarted: (sessionID) => executions.markStarted(sessionID),
152166
152240
  markEnded: (sessionID) => executions.markEnded(sessionID),
152241
+ onSessionCreated: (sessionID, parentID) => {
152242
+ sessionLinks.remember(sessionID, parentID);
152243
+ log2.debug("session.created \u7236\u5B50\u767B\u8BB0", { sessionID, parentID: parentID ?? null });
152244
+ },
152167
152245
  applyRun: (sessionID, event) => runs.apply(sessionID, event),
152168
152246
  onPermissionAsked: (data) => approvals.onAsked(data),
152169
152247
  onPermissionReplied: (data) => approvals.onReplied(data),
@@ -152186,13 +152264,16 @@ ${downloadedAttachmentPrompt(message.attachment, outcome)}`;
152186
152264
  }
152187
152265
  await sender.sendText(link2.chatId, text);
152188
152266
  }
152189
- const stopWatchdog = startWatchdog({
152267
+ const hasPendingInteraction = (sessionID) => approvals.hasPendingFor(sessionID) || formRelay.hasPendingFor(sessionID);
152268
+ const stopWatchdog = config.staleExecutionMs > 0 ? startWatchdog({
152190
152269
  log: log2,
152191
152270
  staleExecutionMs: config.staleExecutionMs,
152192
- staleExecutions: () => executions.stale(config.staleExecutionMs),
152193
- staleQueued: () => runs.staleQueued(config.staleExecutionMs),
152271
+ staleExecutions: () => executions.stale(config.staleExecutionMs, Date.now(), hasPendingInteraction),
152272
+ staleQueued: () => runs.staleQueued(config.staleExecutionMs, Date.now(), hasPendingInteraction),
152194
152273
  recover: (sessionID, reason) => recovery.recover(sessionID, reason)
152195
- });
152274
+ }) : () => {
152275
+ log2.info("\u770B\u95E8\u72D7\u5DF2\u5173\u95ED\uFF08staleExecutionMs=0\uFF09");
152276
+ };
152196
152277
  async function notifyStuck(sessionID, reason, ok) {
152197
152278
  const link2 = await sessionMap.resolveBySession(sessionID);
152198
152279
  if (!link2) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
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",