@modelzen/feishu-codex-bridge 0.6.8 → 0.6.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 (3) hide show
  1. package/README.md +14 -0
  2. package/dist/cli.js +736 -96
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -155,6 +155,35 @@ function getMaxConcurrentRuns(cfg) {
155
155
  function getPendingPolicy(cfg) {
156
156
  return cfg.preferences?.pendingPolicy === "queue" ? "queue" : "steer";
157
157
  }
158
+ function getCompletionReminderConfig(cfg) {
159
+ const raw = cfg.preferences?.completionReminder;
160
+ const mode = raw?.mode === "manual" || raw?.mode === "long" || raw?.mode === "always" ? raw.mode : "failures";
161
+ const minutes = raw?.longTaskMinutes;
162
+ const longTaskMinutes = typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0 ? COMPLETION_REMINDER_LONG_TASK_DEFAULT_MINUTES : Math.min(
163
+ COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES,
164
+ Math.max(COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES, Math.floor(minutes))
165
+ );
166
+ return { mode, longTaskMinutes };
167
+ }
168
+ function shouldShowCompletionReminderButton(cfg) {
169
+ return getCompletionReminderConfig(cfg).mode === "manual";
170
+ }
171
+ function shouldSendCompletionReminder(cfg, decision) {
172
+ if (decision.outcome === "interrupted" || decision.outcome === "cancelled") return false;
173
+ const reminder = getCompletionReminderConfig(cfg);
174
+ switch (reminder.mode) {
175
+ case "manual":
176
+ return decision.manuallyRequested === true;
177
+ case "long": {
178
+ const elapsedMs = Number.isFinite(decision.elapsedMs) ? Math.max(0, decision.elapsedMs) : 0;
179
+ return elapsedMs >= reminder.longTaskMinutes * 6e4;
180
+ }
181
+ case "always":
182
+ return true;
183
+ case "failures":
184
+ return decision.outcome === "error" || decision.outcome === "idle_timeout";
185
+ }
186
+ }
158
187
  function getRunIdleTimeoutMs(cfg) {
159
188
  const raw = cfg.preferences?.runIdleTimeoutSeconds;
160
189
  if (raw === 0) return void 0;
@@ -233,10 +262,13 @@ function canEnableCliBridge(cfg) {
233
262
  function getCommentsConfig(cfg) {
234
263
  return cfg.preferences?.comments ?? {};
235
264
  }
236
- var RUN_IDLE_TIMEOUT_MIN_SEC, RUN_IDLE_TIMEOUT_MAX_SEC;
265
+ var COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES, COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES, COMPLETION_REMINDER_LONG_TASK_DEFAULT_MINUTES, RUN_IDLE_TIMEOUT_MIN_SEC, RUN_IDLE_TIMEOUT_MAX_SEC;
237
266
  var init_schema = __esm({
238
267
  "src/config/schema.ts"() {
239
268
  "use strict";
269
+ COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES = 1;
270
+ COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES = 1440;
271
+ COMPLETION_REMINDER_LONG_TASK_DEFAULT_MINUTES = 3;
240
272
  RUN_IDLE_TIMEOUT_MIN_SEC = 10;
241
273
  RUN_IDLE_TIMEOUT_MAX_SEC = 3600;
242
274
  }
@@ -2550,6 +2582,7 @@ async function pollEventSubscription(appId, appSecret, tenant, opts = {}) {
2550
2582
 
2551
2583
  // src/agent/types.ts
2552
2584
  var DEFAULT_BACKEND_ID = "codex-appserver";
2585
+ var REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
2553
2586
  function isGoalTerminal(status) {
2554
2587
  return status === "complete" || status === "budgetLimited" || status === "usageLimited" || status === "blocked";
2555
2588
  }
@@ -4159,6 +4192,7 @@ var PushablePrompt = class {
4159
4192
  function toSdkEffort(e) {
4160
4193
  if (!e) return void 0;
4161
4194
  if (e === "none" || e === "minimal") return "low";
4195
+ if (e === "ultra") return "max";
4162
4196
  return e;
4163
4197
  }
4164
4198
  var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
@@ -5532,8 +5566,13 @@ var EFFORT_LABEL = {
5532
5566
  low: "\u4F4E",
5533
5567
  medium: "\u4E2D",
5534
5568
  high: "\u9AD8",
5535
- xhigh: "\u6781\u9AD8"
5569
+ xhigh: "\u6781\u9AD8",
5570
+ max: "\u6700\u9AD8",
5571
+ ultra: "\u8D85\u5F3A"
5536
5572
  };
5573
+ function reasoningEffortLabel(effort) {
5574
+ return EFFORT_LABEL[effort] ?? (effort || "\u672A\u77E5");
5575
+ }
5537
5576
  function buildModelCard(state) {
5538
5577
  const visible = state.models.filter((m) => !m.hidden);
5539
5578
  const cur = state.models.find((m) => m.id === state.model);
@@ -5561,7 +5600,7 @@ function buildModelCard(state) {
5561
5600
  actionId: MC.effort,
5562
5601
  placeholder: "effort",
5563
5602
  initial: state.effort,
5564
- options: efforts.map((e) => ({ label: `effort\uFF1A${EFFORT_LABEL[e]}`, value: e }))
5603
+ options: efforts.map((e) => ({ label: `effort\uFF1A${reasoningEffortLabel(e)}`, value: e }))
5565
5604
  })
5566
5605
  );
5567
5606
  }
@@ -5779,6 +5818,10 @@ var DM = {
5779
5818
  // 假死超时「自定义…」:watchdogCustom 打开输入卡,watchdogCustomSubmit 保存任意秒数
5780
5819
  watchdogCustom: "dm.set.watchdog.custom",
5781
5820
  watchdogCustomSubmit: "dm.set.watchdog.customSubmit",
5821
+ // 普通群任务结束提醒(与 ☕ CLI Stop 通知无关):四档策略 + 长任务阈值子卡
5822
+ setCompletionReminder: "dm.set.completionReminder",
5823
+ completionReminderCustom: "dm.set.completionReminder.custom",
5824
+ completionReminderCustomSubmit: "dm.set.completionReminder.customSubmit",
5782
5825
  setPending: "dm.set.pending",
5783
5826
  setConcurrency: "dm.set.concurrency",
5784
5827
  // 权限管理:全局 admins(settings 卡进入)+ 项目响应白名单(项目列表 / 建项目完成卡进入)
@@ -6314,6 +6357,7 @@ function settingItem(name, desc, actionId, current, opts) {
6314
6357
  }
6315
6358
  function buildSettingsCard(cfg) {
6316
6359
  const watchdogSec = cfg.preferences?.runIdleTimeoutSeconds ?? 120;
6360
+ const completionReminder = getCompletionReminderConfig(cfg);
6317
6361
  return card(
6318
6362
  [
6319
6363
  settingSection("\u{1F4E4} \u8F93\u51FA\u5C55\u793A"),
@@ -6348,6 +6392,23 @@ function buildSettingsCard(cfg) {
6348
6392
  ),
6349
6393
  button("\u81EA\u5B9A\u4E49\u2026", { a: DM.watchdogCustom })
6350
6394
  ]),
6395
+ ...settingItem(
6396
+ "\u{1F514} \u4EFB\u52A1\u7ED3\u675F\u63D0\u9192",
6397
+ "\u7ED3\u675F\u540E\u989D\u5916\u56DE\u590D\u4E00\u6761\u6D88\u606F\u5E76 @ \u53D1\u8D77\u4EBA\u3002\u300C\u4EC5\u624B\u52A8\u300D\u4F1A\u5728\u8FD0\u884C\u5361\u663E\u793A\u63D0\u9192\u6309\u94AE\uFF1B\u5176\u4ED6\u6863\u4F4D\u81EA\u52A8\u5224\u65AD\uFF0C\u4E0D\u663E\u793A\u6309\u94AE\u3002",
6398
+ DM.setCompletionReminder,
6399
+ completionReminder.mode,
6400
+ [
6401
+ { label: "\u4EC5\u624B\u52A8", value: "manual" },
6402
+ { label: "\u957F\u4EFB\u52A1", value: "long" },
6403
+ { label: "\u5931\u8D25\u6216\u8D85\u65F6", value: "failures" },
6404
+ { label: "\u6BCF\u6B21\u7ED3\u675F", value: "always" }
6405
+ ]
6406
+ ),
6407
+ ...completionReminder.mode === "long" ? [
6408
+ md(`**\u23F3 \u957F\u4EFB\u52A1\u9608\u503C** \xB7 \u5F53\u524D **${completionReminder.longTaskMinutes} \u5206\u949F**`),
6409
+ note("\u4EFB\u52A1\u8017\u65F6\u8FBE\u5230\u8FD9\u4E2A\u9608\u503C\u540E\uFF0C\u7ED3\u675F\u65F6\u624D\u4F1A\u63D0\u9192\u3002"),
6410
+ actions([button("\u4FEE\u6539\u9608\u503C\u2026", { a: DM.completionReminderCustom })])
6411
+ ] : [],
6351
6412
  ...settingItem(
6352
6413
  "\u{1F4E5} \u8FD0\u884C\u4E2D\u6765\u65B0\u6D88\u606F",
6353
6414
  "\u6B63\u5728\u8DD1\u65F6\u4F60\u53C8\u53D1\u6D88\u606F\uFF1A\u5F15\u5BFC\uFF1D\u63D2\u8FDB\u5F53\u524D\u8F6E\u7EA0\u504F\uFF1B\u6392\u961F\uFF1D\u7B49\u8FD9\u8F6E\u8DD1\u5B8C\u518D\u5904\u7406\u3002",
@@ -6445,7 +6506,7 @@ function buildCommentSettingsCard(cfg, backendOptions, models, notice) {
6445
6506
  selectMenu({
6446
6507
  name: "effort",
6447
6508
  placeholder: "\u9009\u62E9\u63A8\u7406\u5F3A\u5EA6",
6448
- options: unionEfforts.map((e) => ({ label: EFFORT_LABEL[e], value: e })),
6509
+ options: unionEfforts.map((e) => ({ label: reasoningEffortLabel(e), value: e })),
6449
6510
  initial: curEffort
6450
6511
  })
6451
6512
  );
@@ -6550,6 +6611,25 @@ function buildWatchdogCustomCard(cfg) {
6550
6611
  { header: { title: "\u23F1 \u81EA\u5B9A\u4E49\u8D85\u65F6", template: "blue" } }
6551
6612
  );
6552
6613
  }
6614
+ function buildCompletionReminderCustomCard(cfg) {
6615
+ const cur = getCompletionReminderConfig(cfg).longTaskMinutes;
6616
+ return card(
6617
+ [
6618
+ md("**\u81EA\u5B9A\u4E49\u957F\u4EFB\u52A1\u9608\u503C**"),
6619
+ note(
6620
+ `\u4EFB\u52A1\u8017\u65F6\u8FBE\u5230\u8FD9\u4E2A\u65F6\u957F\uFF0C\u7ED3\u675F\u65F6\u989D\u5916\u56DE\u590D\u6D88\u606F\u5E76 @ \u53D1\u8D77\u4EBA\u3002\u8303\u56F4 ${COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES}\u2013${COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES} \u5206\u949F\u3002`
6621
+ ),
6622
+ form("completion_reminder_custom", [
6623
+ input({ name: "minutes", label: "\u65F6\u957F\uFF08\u5206\u949F\uFF09", placeholder: "\u4F8B\u5982 10", value: String(cur), required: true }),
6624
+ actions([
6625
+ submitButton("\u2705 \u4FDD\u5B58", { a: DM.completionReminderCustomSubmit }, "primary", "submit_completion_reminder")
6626
+ ])
6627
+ ]),
6628
+ actions([button("\u2B05\uFE0F \u8FD4\u56DE\u8BBE\u7F6E", { a: DM.settings })])
6629
+ ],
6630
+ { header: { title: "\u{1F514} \u957F\u4EFB\u52A1\u9608\u503C", template: "blue" } }
6631
+ );
6632
+ }
6553
6633
  function buildGroupSettingsCard(project) {
6554
6634
  const kind = project.kind ?? "multi";
6555
6635
  const noMention = project.noMention ?? defaultNoMention(project);
@@ -6688,10 +6768,10 @@ function buildPermissionCard(p) {
6688
6768
  { header: { title: "\u{1F510} \u6743\u9650", template: "blue" } }
6689
6769
  );
6690
6770
  }
6691
- var EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh"];
6771
+ var EFFORT_ORDER = REASONING_EFFORTS;
6692
6772
  function modelDefaultSummary(p) {
6693
6773
  if (!p.defaultModel) return "\u540E\u7AEF\u9ED8\u8BA4\uFF08\u672A\u8BBE\uFF09";
6694
- const eff = p.defaultEffort ? ` \xB7 \u5F3A\u5EA6 ${EFFORT_LABEL[p.defaultEffort]}` : "";
6774
+ const eff = p.defaultEffort ? ` \xB7 \u5F3A\u5EA6 ${reasoningEffortLabel(p.defaultEffort)}` : "";
6695
6775
  return `${p.defaultModel}${eff}`;
6696
6776
  }
6697
6777
  function buildModelDefaultCard(p, models, ctx, notice) {
@@ -6742,7 +6822,7 @@ function buildModelDefaultCard(p, models, ctx, notice) {
6742
6822
  selectMenu({
6743
6823
  name: "effort",
6744
6824
  placeholder: "\u9009\u62E9\u9ED8\u8BA4\u63A8\u7406\u5F3A\u5EA6",
6745
- options: unionEfforts.map((e) => ({ label: `\u5F3A\u5EA6\uFF1A${EFFORT_LABEL[e]}`, value: e })),
6825
+ options: unionEfforts.map((e) => ({ label: `\u5F3A\u5EA6\uFF1A${reasoningEffortLabel(e)}`, value: e })),
6746
6826
  initial: curEffort
6747
6827
  })
6748
6828
  );
@@ -6866,6 +6946,8 @@ function buildAddAllowedCard(projectName, members) {
6866
6946
  }
6867
6947
 
6868
6948
  // src/admin/ops.ts
6949
+ init_schema();
6950
+ init_store();
6869
6951
  var AdminWriteError = class extends Error {
6870
6952
  code = "ADMIN_WRITE_REJECTED";
6871
6953
  constructor(reason) {
@@ -6873,7 +6955,26 @@ var AdminWriteError = class extends Error {
6873
6955
  this.name = "AdminWriteError";
6874
6956
  }
6875
6957
  };
6958
+ function createAppPreferencesWriter(opts) {
6959
+ let chain = Promise.resolve();
6960
+ const persist = opts.persistConfig ?? saveConfig;
6961
+ return (mutate) => {
6962
+ const run = chain.then(async () => {
6963
+ const preferences = { ...opts.cfg.preferences ?? {} };
6964
+ mutate(preferences);
6965
+ await persist({ ...opts.cfg, preferences });
6966
+ opts.cfg.preferences = preferences;
6967
+ return preferences;
6968
+ });
6969
+ chain = run.then(
6970
+ () => void 0,
6971
+ () => void 0
6972
+ );
6973
+ return run;
6974
+ };
6975
+ }
6876
6976
  var TIERS = ["qa", "write", "full"];
6977
+ var COMPLETION_REMINDER_MODES = ["manual", "long", "failures", "always"];
6877
6978
  function validateBackendSwitch(opts) {
6878
6979
  if (!opts.registered.includes(opts.target)) {
6879
6980
  return `\u672A\u77E5\u540E\u7AEF\u300C${opts.target}\u300D\uFF08\u53EF\u7528\uFF1A${opts.registered.join("\u3001")}\uFF09`;
@@ -6972,9 +7073,30 @@ async function performSetModelDefault(opts) {
6972
7073
  await updateProject(opts.projectName, { defaultModel: opts.model, defaultEffort: opts.effort });
6973
7074
  return { ok: true, project: await freshOr(opts.projectName, { ...p, defaultModel: opts.model, defaultEffort: opts.effort }) };
6974
7075
  }
7076
+ async function performSetCompletionReminder(opts) {
7077
+ if (!COMPLETION_REMINDER_MODES.includes(opts.mode)) {
7078
+ return { ok: false, reason: `\u672A\u77E5\u5B8C\u6210\u63D0\u9192\u7B56\u7565\u300C${String(opts.mode)}\u300D` };
7079
+ }
7080
+ if (opts.longTaskMinutes !== void 0 && (!Number.isInteger(opts.longTaskMinutes) || opts.longTaskMinutes < COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES || opts.longTaskMinutes > COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES)) {
7081
+ return {
7082
+ ok: false,
7083
+ reason: `\u957F\u4EFB\u52A1\u9608\u503C\u5FC5\u987B\u662F ${COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES}\u2013${COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES} \u4E4B\u95F4\u7684\u6574\u6570\u5206\u949F`
7084
+ };
7085
+ }
7086
+ const writePreferences = opts.writePreferences ?? createAppPreferencesWriter({ cfg: opts.cfg, persistConfig: opts.persistConfig });
7087
+ await writePreferences((preferences) => {
7088
+ const current = getCompletionReminderConfig({ ...opts.cfg, preferences });
7089
+ preferences.completionReminder = {
7090
+ mode: opts.mode,
7091
+ longTaskMinutes: opts.longTaskMinutes ?? current.longTaskMinutes
7092
+ };
7093
+ });
7094
+ return { ok: true, completionReminder: getCompletionReminderConfig(opts.cfg) };
7095
+ }
6975
7096
  function createAdminWriteExecutor(deps) {
7097
+ const writePreferences = deps.writePreferences ?? (deps.cfg ? createAppPreferencesWriter({ cfg: deps.cfg, persistConfig: deps.persistConfig }) : void 0);
6976
7098
  return async (op) => {
6977
- const outcome = await runAdminWriteOp(op, deps);
7099
+ const outcome = await runAdminWriteOp(op, { ...deps, writePreferences });
6978
7100
  if (!outcome.ok) throw new AdminWriteError(outcome.reason);
6979
7101
  };
6980
7102
  }
@@ -6998,12 +7120,20 @@ async function runAdminWriteOp(op, deps) {
6998
7120
  on: op.on,
6999
7121
  evictLiveSessionsForChat: deps.evictLiveSessionsForChat
7000
7122
  });
7123
+ case "setCompletionReminder":
7124
+ if (!deps.cfg) return { ok: false, reason: "bot \u8FD0\u884C\u914D\u7F6E\u4E0D\u53EF\u7528\uFF0C\u65E0\u6CD5\u5373\u65F6\u66F4\u65B0\u5B8C\u6210\u63D0\u9192" };
7125
+ return performSetCompletionReminder({
7126
+ cfg: deps.cfg,
7127
+ mode: op.mode,
7128
+ longTaskMinutes: op.longTaskMinutes,
7129
+ persistConfig: deps.persistConfig,
7130
+ writePreferences: deps.writePreferences
7131
+ });
7001
7132
  }
7002
7133
  }
7003
7134
 
7004
7135
  // src/bot/handle-message.ts
7005
7136
  init_schema();
7006
- init_store();
7007
7137
 
7008
7138
  // src/card/dispatcher.ts
7009
7139
  init_logger();
@@ -7618,6 +7748,8 @@ function gaugeEl(state) {
7618
7748
  }
7619
7749
  var RC = {
7620
7750
  stop: "run.stop",
7751
+ /** manual completion-reminder mode: notify this turn's requester when it ends. */
7752
+ remind: "run.remind",
7621
7753
  /** goal-only: clear the goal but let the in-flight turn finish (no auto-continue). */
7622
7754
  endGoal: "goal.end"
7623
7755
  };
@@ -7672,8 +7804,16 @@ function renderRunning(state, rc) {
7672
7804
  )
7673
7805
  );
7674
7806
  }
7675
- } else if (rc.cardKey && !rc.hideStop) {
7676
- elements.push(actions([button("\u23F9 \u7EC8\u6B62", { a: RC.stop, m: rc.cardKey }, "danger")], CONTROLS_EID));
7807
+ } else if (rc.cardKey) {
7808
+ if (rc.completionReminder === "requested") {
7809
+ elements.push(noteMd("_\u{1F514} \u672C\u8F6E\u7ED3\u675F\u540E\u4F1A\u63D0\u9192\u53D1\u8D77\u4EBA_"));
7810
+ }
7811
+ const controls = [];
7812
+ if (!rc.hideStop) controls.push(button("\u23F9 \u7EC8\u6B62", { a: RC.stop, m: rc.cardKey }, "danger"));
7813
+ if (rc.completionReminder === "available") {
7814
+ controls.push(button("\u{1F514} \u5B8C\u6210\u540E\u63D0\u9192\u6211", { a: RC.remind, m: rc.cardKey }, "default"));
7815
+ }
7816
+ if (controls.length > 0) elements.push(actions(controls, CONTROLS_EID));
7677
7817
  }
7678
7818
  return elements;
7679
7819
  }
@@ -7787,7 +7927,14 @@ function buildQueuedCard(qc) {
7787
7927
  md(`\u23F3 \u6392\u961F\u4E2D\uFF08\u7B2C **${qc.position ?? 1}** \u4F4D\uFF09`),
7788
7928
  noteMd("\u5168\u5C40\u5E76\u53D1\u6C60\u5DF2\u6EE1\uFF08\u6240\u6709\u7FA4/\u8BDD\u9898\u5171\u4EAB\uFF09\uFF0C\u8F6E\u5230\u540E\u81EA\u52A8\u5F00\u59CB\u3002")
7789
7929
  ];
7790
- if (qc.cardKey) els.push(actions([button("\u23F9 \u53D6\u6D88", { a: RC.stop, m: qc.cardKey }, "danger")], CONTROLS_EID));
7930
+ if (qc.completionReminder === "requested") els.push(noteMd("_\u{1F514} \u672C\u8F6E\u7ED3\u675F\u540E\u4F1A\u63D0\u9192\u53D1\u8D77\u4EBA_"));
7931
+ if (qc.cardKey) {
7932
+ const controls = [button("\u23F9 \u53D6\u6D88", { a: RC.stop, m: qc.cardKey }, "danger")];
7933
+ if (qc.completionReminder === "available") {
7934
+ controls.push(button("\u{1F514} \u5B8C\u6210\u540E\u63D0\u9192\u6211", { a: RC.remind, m: qc.cardKey }, "default"));
7935
+ }
7936
+ els.push(actions(controls, CONTROLS_EID));
7937
+ }
7791
7938
  return card(els, { summary: "\u6392\u961F\u4E2D" });
7792
7939
  }
7793
7940
  function* groupBlocks(blocks) {
@@ -7869,7 +8016,9 @@ var EFFORT_TIER = {
7869
8016
  low: { label: "\u4F4E", color: "yellow" },
7870
8017
  medium: { label: "\u4E2D", color: "green" },
7871
8018
  high: { label: "\u9AD8", color: "violet" },
7872
- xhigh: { label: "\u6781\u9AD8", color: "purple" }
8019
+ xhigh: { label: "\u6781\u9AD8", color: "purple" },
8020
+ max: { label: "\u6700\u9AD8", color: "purple" },
8021
+ ultra: { label: "\u8D85\u5F3A", color: "purple" }
7873
8022
  };
7874
8023
  function modelEffortMd(model, effort) {
7875
8024
  if (!effort) return model;
@@ -8014,6 +8163,16 @@ var RunCardStream = class {
8014
8163
  lastAnswerText = "";
8015
8164
  // Per-chat pacer shared with the chat's other streams (set in create()).
8016
8165
  pacer = null;
8166
+ // Forced whole-card writes (button/settings repaint, queue → run flip,
8167
+ // terminal frame) must never race one another. In particular, an async
8168
+ // completion-reminder repaint that started just before turn completion must
8169
+ // land BEFORE the terminal frame, not finish late and put the card back into
8170
+ // its running layout. This tail is deliberately separate from the coalesced
8171
+ // pump: event consumption remains non-blocking, and the caller drains that
8172
+ // pump before finalizing.
8173
+ forcedUpdateTail = Promise.resolve();
8174
+ /** Once terminal finalization starts, reminder/settings repaint is stale. */
8175
+ liveUpdatesFrozen = false;
8017
8176
  get messageId() {
8018
8177
  return this._messageId;
8019
8178
  }
@@ -8205,8 +8364,10 @@ var RunCardStream = class {
8205
8364
  return false;
8206
8365
  }
8207
8366
  }
8208
- /** Forced whole-card replace for structural/terminal updates. A terminal
8209
- * card built with streaming off clears the typewriter cursor.
8367
+ /** Forced whole-card replace for structural updates. Calls are serialized in
8368
+ * invocation order so concurrent callback repaints cannot complete out of
8369
+ * order. A terminal card built with streaming off clears the typewriter
8370
+ * cursor.
8210
8371
  *
8211
8372
  * The terminal frame MUST land — losing it leaves the card "streaming"
8212
8373
  * forever (cursor + dead ⏹) while the run is over and `runsByCard` already
@@ -8214,10 +8375,43 @@ var RunCardStream = class {
8214
8375
  * with exponential backoff (1s/2s/4s, {@link TERMINAL_RL_RETRIES} retries);
8215
8376
  * anything else — typically 200810 "card in ongoing interaction" when the
8216
8377
  * update fires inside a ⏹ click's 3s window — waits out the window and
8217
- * retries once. */
8218
- async updateCard(channel, fullCard) {
8219
- if (!this.cardId) return;
8378
+ * retries once. Returns whether the requested frame is observably on the
8379
+ * entity, so terminal callers can emit a truthful fallback notification. */
8380
+ updateCard(channel, fullCard) {
8381
+ return this.enqueueForcedUpdate(channel, fullCard);
8382
+ }
8383
+ /**
8384
+ * Repaint a still-live card (currently used by the completion-reminder
8385
+ * control). Once {@link finalizeCard} has synchronously frozen live repaints,
8386
+ * late callbacks become a no-op. Repaints accepted before the freeze share
8387
+ * the forced-update tail and therefore finish before the terminal frame.
8388
+ */
8389
+ updateLiveCard(channel, fullCard) {
8390
+ if (this.liveUpdatesFrozen) return Promise.resolve(false);
8391
+ return this.enqueueForcedUpdate(channel, fullCard);
8392
+ }
8393
+ /**
8394
+ * Freeze live repaints synchronously, then enqueue the terminal whole-card
8395
+ * frame after every already-accepted forced update. Future intentional
8396
+ * non-live updates (for example demoting an old terminal card's settings
8397
+ * control) may still use {@link updateCard}.
8398
+ */
8399
+ finalizeCard(channel, fullCard) {
8400
+ this.liveUpdatesFrozen = true;
8401
+ return this.enqueueForcedUpdate(channel, fullCard);
8402
+ }
8403
+ enqueueForcedUpdate(channel, fullCard) {
8404
+ if (!this.cardId) return Promise.resolve(false);
8220
8405
  const data = JSON.stringify(fullCard);
8406
+ const task = this.forcedUpdateTail.then(() => this.pushForcedUpdate(channel, data));
8407
+ this.forcedUpdateTail = task.then(
8408
+ () => void 0,
8409
+ () => void 0
8410
+ );
8411
+ return task;
8412
+ }
8413
+ async pushForcedUpdate(channel, data) {
8414
+ if (!this.cardId) return false;
8221
8415
  const push = async () => {
8222
8416
  await channel.rawClient.cardkit.v1.card.update({
8223
8417
  path: { card_id: this.cardId },
@@ -8229,13 +8423,13 @@ var RunCardStream = class {
8229
8423
  await this.pacer?.wait();
8230
8424
  try {
8231
8425
  await push();
8232
- return;
8426
+ return true;
8233
8427
  } catch (err) {
8234
8428
  const rl = isRateLimited(err);
8235
8429
  if (rl) this.pacer?.penalize();
8236
8430
  if (i >= (rl ? TERMINAL_RL_RETRIES : 1)) {
8237
8431
  log.fail("card", err, { phase: "run-update-retry", cardId: this.cardId, seq: this.seq });
8238
- return;
8432
+ return false;
8239
8433
  }
8240
8434
  log.fail("card", err, { phase: "run-update", cardId: this.cardId, seq: this.seq, retry: true, rateLimited: rl });
8241
8435
  await new Promise((r) => setTimeout(r, rl ? RL_BACKOFF_BASE_MS * 2 ** i : 3200));
@@ -8583,6 +8777,81 @@ function withCodexHooksFeature(text) {
8583
8777
  return [...lines, ""].join("\n");
8584
8778
  }
8585
8779
 
8780
+ // src/card/completion-reminder.ts
8781
+ function formatCompletionElapsed(elapsedMs) {
8782
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1e3));
8783
+ const hours = Math.floor(totalSeconds / 3600);
8784
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
8785
+ const seconds = totalSeconds % 60;
8786
+ const parts = [];
8787
+ if (hours > 0) parts.push(`${hours} \u5C0F\u65F6`);
8788
+ if (minutes > 0) parts.push(`${minutes} \u5206`);
8789
+ if (seconds > 0 || parts.length === 0) parts.push(`${seconds} \u79D2`);
8790
+ return parts.join(" ");
8791
+ }
8792
+ function buildCompletionReminderContent(input2) {
8793
+ const task = compactSummary(input2.summary);
8794
+ const elapsed = formatCompletionElapsed(input2.elapsedMs);
8795
+ const headline = input2.outcome === "done" ? ` \u2705\u300C${task}\u300D\u5DF2\u5B8C\u6210 \xB7 \u7528\u65F6 ${elapsed}` : input2.outcome === "idle_timeout" ? ` \u23F1\u300C${task}\u300D\u54CD\u5E94\u8D85\u65F6 \xB7 \u7B49\u5F85 ${elapsed}` : ` \u26A0\uFE0F\u300C${task}\u300D\u6267\u884C\u5931\u8D25 \xB7 \u7528\u65F6 ${elapsed}`;
8796
+ const detail = input2.cardUpdated ? input2.outcome === "done" ? "\u7ED3\u679C\u5728\u4E0A\u65B9\u5361\u7247\u3002" : "\u8BE6\u60C5\u5728\u4E0A\u65B9\u5361\u7247\u3002" : "\u6700\u7EC8\u5361\u7247\u66F4\u65B0\u5931\u8D25\uFF0C\u8BF7\u67E5\u770B\u4E0A\u65B9\u6D41\u5F0F\u5185\u5BB9\u6216\u91CD\u65B0\u53D1\u8D77\u4EFB\u52A1\u3002";
8797
+ return JSON.stringify({
8798
+ zh_cn: {
8799
+ title: "",
8800
+ content: [
8801
+ [
8802
+ { tag: "at", user_id: input2.requesterOpenId },
8803
+ { tag: "text", text: headline }
8804
+ ],
8805
+ [{ tag: "text", text: detail }]
8806
+ ]
8807
+ }
8808
+ });
8809
+ }
8810
+ function compactSummary(summary) {
8811
+ const clean = (summary ?? "").replace(/\s+/g, " ").trim();
8812
+ if (!clean) return "\u672C\u8F6E\u4EFB\u52A1";
8813
+ return clean.length > 32 ? `${clean.slice(0, 31)}\u2026` : clean;
8814
+ }
8815
+
8816
+ // src/bot/completion-reminder.ts
8817
+ init_schema();
8818
+ init_logger();
8819
+ async function sendCompletionReminderReply(deps, input2) {
8820
+ if (!input2.requesterOpenId || input2.outcome !== "done" && input2.outcome !== "error" && input2.outcome !== "idle_timeout") {
8821
+ return "skipped";
8822
+ }
8823
+ const elapsedMs = Math.max(0, (deps.now?.() ?? Date.now()) - input2.requestedAt);
8824
+ const policyMatch = shouldSendCompletionReminder(deps.cfg, {
8825
+ outcome: input2.outcome,
8826
+ elapsedMs,
8827
+ manuallyRequested: input2.manuallyRequested
8828
+ });
8829
+ if (input2.cardUpdated && !policyMatch) return "skipped";
8830
+ if (deps.dedupe.seen(input2.cardMsgId)) return "skipped";
8831
+ const content = buildCompletionReminderContent({
8832
+ requesterOpenId: input2.requesterOpenId,
8833
+ outcome: input2.outcome,
8834
+ elapsedMs,
8835
+ summary: input2.summary,
8836
+ cardUpdated: input2.cardUpdated
8837
+ });
8838
+ try {
8839
+ await deps.channel.rawClient.im.v1.message.reply({
8840
+ path: { message_id: input2.cardMsgId },
8841
+ data: { msg_type: "post", content, reply_in_thread: input2.replyInThread }
8842
+ });
8843
+ log.info("card", "completion-reminder", {
8844
+ terminal: input2.outcome,
8845
+ elapsedMs,
8846
+ cardUpdated: input2.cardUpdated
8847
+ });
8848
+ return "sent";
8849
+ } catch (err) {
8850
+ log.fail("card", err, { phase: "completion-reminder", terminal: input2.outcome });
8851
+ return "failed";
8852
+ }
8853
+ }
8854
+
8586
8855
  // src/service/update.ts
8587
8856
  init_paths();
8588
8857
  import { closeSync, existsSync as existsSync9, openSync as openSync2, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync2, writeSync } from "fs";
@@ -9889,7 +10158,15 @@ function heatmapElements(p, today) {
9889
10158
  return [md("\u{1F4C8} **\u6BCF\u65E5 Token \u7528\u91CF**"), heatmapChartEl(p.dailyBuckets, today)];
9890
10159
  }
9891
10160
  function effortLabel(effort) {
9892
- const m = { minimal: "\u6781\u4F4E", low: "\u4F4E", medium: "\u4E2D", high: "\u9AD8", xhigh: "\u8D85\u9AD8" };
10161
+ const m = {
10162
+ minimal: "\u6781\u4F4E",
10163
+ low: "\u4F4E",
10164
+ medium: "\u4E2D",
10165
+ high: "\u9AD8",
10166
+ xhigh: "\u8D85\u9AD8",
10167
+ max: "\u6700\u9AD8",
10168
+ ultra: "\u8D85\u5F3A"
10169
+ };
9893
10170
  return m[effort] ?? effort;
9894
10171
  }
9895
10172
  function insightsElements(p) {
@@ -10130,6 +10407,7 @@ init_paths();
10130
10407
  init_logger();
10131
10408
  import { mkdir as mkdir11 } from "fs/promises";
10132
10409
  import { existsSync as existsSync10 } from "fs";
10410
+ import { homedir as homedir7 } from "os";
10133
10411
  import { isAbsolute as isAbsolute2, join as join17, resolve as resolve7 } from "path";
10134
10412
 
10135
10413
  // src/project/announcement.ts
@@ -10319,22 +10597,38 @@ function assertBackendUsable(backend, mode) {
10319
10597
  }
10320
10598
  throw new Error(`\u6240\u9009\u540E\u7AEF\u300C${backend}\u300D\u5F53\u524D\u4E0D\u53EF\u7528\uFF08\u672A\u4E0B\u8F7D\u6216\u4E0D\u652F\u6301\u8BE5\u6743\u9650\u6863\uFF09\uFF0C\u8BF7\u56DE\u5361\u7247\u91CD\u65B0\u9009\u62E9`);
10321
10599
  }
10322
- async function resolveCwd(name, existingPath) {
10600
+ async function resolveCwd(name, existingPath, configuredProjectsRootDir) {
10323
10601
  if (existingPath) {
10324
10602
  const cwd2 = isAbsolute2(existingPath) ? existingPath : resolve7(existingPath);
10325
10603
  if (!existsSync10(cwd2)) throw new Error(`\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\uFF1A${cwd2}`);
10326
10604
  return { cwd: cwd2, blank: false };
10327
10605
  }
10328
- const cwd = join17(paths.projectsRootDir, name);
10606
+ const cwd = join17(resolveProjectsRootDir(configuredProjectsRootDir), name);
10329
10607
  await mkdir11(cwd, { recursive: true });
10330
10608
  return { cwd, blank: true };
10331
10609
  }
10610
+ function resolveProjectsRootDir(configured) {
10611
+ if (configured === void 0 || configured === null) return paths.projectsRootDir;
10612
+ if (typeof configured !== "string") {
10613
+ throw new Error("\u914D\u7F6E preferences.projectsRootDir \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
10614
+ }
10615
+ const value = configured.trim();
10616
+ if (!value) return paths.projectsRootDir;
10617
+ if (value === "~") return homedir7();
10618
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
10619
+ return resolve7(homedir7(), value.slice(2));
10620
+ }
10621
+ if (!isAbsolute2(value)) {
10622
+ throw new Error("\u914D\u7F6E preferences.projectsRootDir \u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\u6216\u4EE5 ~ \u5F00\u5934");
10623
+ }
10624
+ return resolve7(value);
10625
+ }
10332
10626
  async function createProject(channel, input2) {
10333
10627
  const name = input2.name.trim();
10334
10628
  if (!name) throw new Error("\u9879\u76EE\u540D\u4E0D\u80FD\u4E3A\u7A7A");
10335
10629
  if (await getProjectByName(name)) throw new Error(`\u9879\u76EE\u540D\u300C${name}\u300D\u5DF2\u5B58\u5728\uFF0C\u6362\u4E2A\u540D\u6216\u7528 /projects \u770B\u5DF2\u6709\u7684`);
10336
10630
  assertBackendUsable(input2.backend, input2.mode ?? "full");
10337
- const { cwd, blank } = await resolveCwd(name, input2.existingPath);
10631
+ const { cwd, blank } = await resolveCwd(name, input2.existingPath, input2.projectsRootDir);
10338
10632
  const res = await channel.rawClient.im.v1.chat.create({
10339
10633
  params: { user_id_type: "open_id" },
10340
10634
  data: { name, user_id_list: [input2.ownerOpenId] }
@@ -10371,7 +10665,7 @@ async function joinExistingGroup(channel, input2) {
10371
10665
  const bound = await getProjectByChatId(input2.chatId);
10372
10666
  if (bound) throw new Error(`\u8BE5\u7FA4\u5DF2\u7ED1\u5B9A\u4E3A\u9879\u76EE\u300C${bound.name}\u300D`);
10373
10667
  assertBackendUsable(input2.backend, input2.mode ?? "qa");
10374
- const { cwd, blank } = await resolveCwd(name, input2.existingPath);
10668
+ const { cwd, blank } = await resolveCwd(name, input2.existingPath, input2.projectsRootDir);
10375
10669
  const project = {
10376
10670
  name,
10377
10671
  chatId: input2.chatId,
@@ -11505,7 +11799,6 @@ function selectValue(formValue, name) {
11505
11799
  function asTier(v) {
11506
11800
  return v === "qa" || v === "write" || v === "full" ? v : void 0;
11507
11801
  }
11508
- var REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
11509
11802
  function asEffort(v) {
11510
11803
  return v !== void 0 && REASONING_EFFORTS.includes(v) ? v : void 0;
11511
11804
  }
@@ -11532,6 +11825,22 @@ function safeBackendId(formValue) {
11532
11825
  const v = selectValue(formValue, "backend");
11533
11826
  return v && backendIds().includes(v) ? v : void 0;
11534
11827
  }
11828
+ function activateQueuedTurn(state, turn) {
11829
+ state.requesterOpenId = turn.requesterOpenId;
11830
+ state.completionReminderRequested = false;
11831
+ }
11832
+ function isCompletionReminderRequester(operatorOpenId, requesterOpenId) {
11833
+ return Boolean(operatorOpenId && requesterOpenId && operatorOpenId === requesterOpenId);
11834
+ }
11835
+ function settleOrdinaryTurnRender(render, input2) {
11836
+ if (input2.interrupted) render.interrupt();
11837
+ else if (input2.timedOut) render.timeout(input2.idleTimeoutSeconds);
11838
+ else if (input2.procDead && render.terminal() === "running") {
11839
+ render.apply({ type: "error", message: "agent \u8FDB\u7A0B\u5F02\u5E38\u9000\u51FA\uFF0C\u8BF7\u91CD\u53D1\u672C\u6761\u6D88\u606F", willRetry: false });
11840
+ } else {
11841
+ render.finalize();
11842
+ }
11843
+ }
11535
11844
  function parseGoalTrigger(text) {
11536
11845
  if (!/(^|\s)\/goal(?=\s|$)/i.test(text)) return null;
11537
11846
  const objective = text.replace(/(^|\s)\/goal(?=\s|$)/gi, " ").replace(/\s+/g, " ").trim();
@@ -11628,14 +11937,21 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11628
11937
  const commentInstrUsed = /* @__PURE__ */ new Map();
11629
11938
  const sema = new Semaphore(getMaxConcurrentRuns(cfg));
11630
11939
  const currentIdleMs = () => getRunIdleTimeoutMs(cfg) ?? 0;
11940
+ const writePreferences = createAppPreferencesWriter({ cfg });
11631
11941
  const resumePending = /* @__PURE__ */ new Map();
11632
11942
  const modelPending = /* @__PURE__ */ new Map();
11633
11943
  const runsByCard = /* @__PURE__ */ new Map();
11634
11944
  const runCards = /* @__PURE__ */ new Map();
11635
11945
  const runStreams = /* @__PURE__ */ new Map();
11946
+ const completionReminderRefreshers = /* @__PURE__ */ new Map();
11947
+ const refreshCompletionReminderCards = () => {
11948
+ for (const refresh of completionReminderRefreshers.values()) refresh();
11949
+ };
11950
+ const completionReminderSent = new RecentIdCache(4096, 24 * 60 * 6e4);
11636
11951
  const lastRunCard = /* @__PURE__ */ new Map();
11637
11952
  const lastUsage = /* @__PURE__ */ new Map();
11638
11953
  const seenInbound = new RecentIdCache();
11954
+ const completionReminderView = (state) => shouldShowCompletionReminderButton(cfg) ? state.completionReminderRequested ? "requested" : "available" : void 0;
11639
11955
  const listModels = (be = backend) => be.listModels();
11640
11956
  async function addReaction(messageId, emoji) {
11641
11957
  try {
@@ -11943,7 +12259,12 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11943
12259
  }
11944
12260
  }
11945
12261
  }
11946
- cur.queue.push({ text: woven, images });
12262
+ cur.queue.push({
12263
+ input: { text: woven, images },
12264
+ requesterOpenId: msg.senderId,
12265
+ requestedAt: msg.createTime || Date.now(),
12266
+ summary: stripFileTokens(text).slice(0, 80) || void 0
12267
+ });
11947
12268
  log.info("intake", "queued", { depth: cur.queue.length });
11948
12269
  return;
11949
12270
  }
@@ -11968,7 +12289,12 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11968
12289
  void replyGoalBusy(msg, flat);
11969
12290
  return;
11970
12291
  }
11971
- existing.queue.push({ text, images: preloadedImages });
12292
+ existing.queue.push({
12293
+ input: { text, images: preloadedImages },
12294
+ requesterOpenId: msg.senderId,
12295
+ requestedAt: msg.createTime || Date.now(),
12296
+ summary: stripFileTokens(summaryText2 ?? text).slice(0, 80) || void 0
12297
+ });
11972
12298
  log.info("intake", "queued", { depth: existing.queue.length });
11973
12299
  return;
11974
12300
  }
@@ -12044,7 +12370,9 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12044
12370
  firstText,
12045
12371
  images,
12046
12372
  knownThreadId: sessionKey,
12373
+ summary: stripFileTokens(summaryText2 ?? text).slice(0, 80) || "(\u672C\u8F6E\u4EFB\u52A1)",
12047
12374
  requesterOpenId: msg.senderId,
12375
+ requestedAt: msg.createTime || tIntake,
12048
12376
  // 编织完成 → turn/start 之间不再读盘:首轮直接用预取的会话记录
12049
12377
  // (prior=undefined 即确知是全新会话,刚 upsert 的记录还没有 model)。
12050
12378
  firstRec: prior ?? null,
@@ -12163,6 +12491,7 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12163
12491
  cwd,
12164
12492
  summary: stripFileTokens(text).slice(0, 80) || "(\u7A7A)",
12165
12493
  requesterOpenId: msg.senderId,
12494
+ requestedAt: msg.createTime || tIntake,
12166
12495
  roleSuffix: perm.roleSuffix,
12167
12496
  backendId: be.id,
12168
12497
  timing: { tResolve: tResolveDone - tIntake, tWeave: Date.now() - tIntake }
@@ -12527,6 +12856,35 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12527
12856
  }
12528
12857
  st.interrupt?.();
12529
12858
  log.info("card", "action", { actionId: "run.stop", stopped: Boolean(st.interrupt) });
12859
+ }).on(RC.remind, ({ evt, value }) => {
12860
+ const key = typeof value.m === "string" ? value.m : evt.messageId;
12861
+ if (!runAllowed(evt)) return;
12862
+ const st = runsByCard.get(key);
12863
+ if (!st) {
12864
+ if (!runControlNotes.seen(`remind-ended:${key}:${evt.operator?.openId ?? ""}`)) {
12865
+ void channel.send(evt.chatId, { markdown: "\u2139\uFE0F \u8BE5\u4EFB\u52A1\u5DF2\u7ED3\u675F\uFF0C\u65E0\u9700\u518D\u5F00\u542F\u5B8C\u6210\u63D0\u9192\u3002" }, { replyTo: evt.messageId }).catch(() => void 0);
12866
+ }
12867
+ log.info("card", "action", { actionId: RC.remind, ended: true });
12868
+ return;
12869
+ }
12870
+ const op = evt.operator?.openId;
12871
+ if (!isCompletionReminderRequester(op, st.requesterOpenId)) {
12872
+ if (!runControlNotes.seen(`remind-deny:${key}:${op ?? ""}`)) {
12873
+ void channel.send(evt.chatId, { markdown: "\u26A0\uFE0F \u4EC5\u672C\u8F6E\u53D1\u8D77\u4EBA\u53EF\u5F00\u542F\u5B8C\u6210\u63D0\u9192\u3002" }, { replyTo: evt.messageId }).catch(() => void 0);
12874
+ }
12875
+ log.info("card", "action", { actionId: RC.remind, denied: true });
12876
+ return;
12877
+ }
12878
+ if (!shouldShowCompletionReminderButton(cfg)) {
12879
+ completionReminderRefreshers.get(key)?.();
12880
+ log.info("card", "action", { actionId: RC.remind, stale: true });
12881
+ return;
12882
+ }
12883
+ if (!st.completionReminderRequested) {
12884
+ st.completionReminderRequested = true;
12885
+ completionReminderRefreshers.get(key)?.();
12886
+ }
12887
+ log.info("card", "action", { actionId: RC.remind, requested: true });
12530
12888
  }).on(RC.endGoal, ({ evt, value }) => {
12531
12889
  const key = typeof value.m === "string" ? value.m : evt.messageId;
12532
12890
  if (!runAllowed(evt)) return;
@@ -12554,12 +12912,49 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12554
12912
  return m;
12555
12913
  };
12556
12914
  function applyPref(evt, mut, opts) {
12557
- if (!dmAdmin(evt.operator?.openId)) return;
12558
- const prefs = { ...cfg.preferences ?? {} };
12559
- mut(prefs);
12560
- cfg.preferences = prefs;
12561
- void saveConfig(cfg).catch((err) => log.fail("console", err, { phase: "save-config" }));
12562
- if (opts?.render !== false) void patch(evt, renderSettings);
12915
+ if (!dmAdmin(evt.operator?.openId)) return Promise.resolve(false);
12916
+ const saved = writePreferences(mut).then(
12917
+ () => true,
12918
+ (err) => {
12919
+ log.fail("console", err, { phase: "save-config" });
12920
+ return false;
12921
+ }
12922
+ );
12923
+ if (opts?.render !== false) {
12924
+ void patch(evt, async () => {
12925
+ await saved;
12926
+ return renderSettings();
12927
+ });
12928
+ }
12929
+ return saved;
12930
+ }
12931
+ let cliEnabledTransition = Promise.resolve();
12932
+ function setCliBridgeEnabled(evt, enabled) {
12933
+ const run = cliEnabledTransition.then(async () => {
12934
+ if (getCliBridgePreferences(cfg).enabled === enabled) return;
12935
+ try {
12936
+ if (enabled) await cliBridge?.start?.();
12937
+ else await cliBridge?.shutdown?.();
12938
+ const saved = await applyPref(
12939
+ evt,
12940
+ (p) => {
12941
+ p.cliBridge = { ...p.cliBridge ?? {}, enabled };
12942
+ },
12943
+ { render: false }
12944
+ );
12945
+ if (!saved) {
12946
+ if (enabled) await cliBridge?.shutdown?.();
12947
+ else await cliBridge?.start?.();
12948
+ }
12949
+ } catch (err) {
12950
+ log.fail("cli-bridge", err, { phase: enabled ? "enable" : "disable" });
12951
+ }
12952
+ });
12953
+ cliEnabledTransition = run.then(
12954
+ () => void 0,
12955
+ () => void 0
12956
+ );
12957
+ return run;
12563
12958
  }
12564
12959
  let cliHookStatuses;
12565
12960
  function renderSettings() {
@@ -12581,20 +12976,36 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12581
12976
  function commentBackendOptions() {
12582
12977
  return visibleCatalog().filter((e) => e.id === DEFAULT_BACKEND_ID || isBackendEntryInstalled(e)).map((e) => ({ id: e.id, label: e.displayName }));
12583
12978
  }
12979
+ let commentSettingsRenderGeneration = 0;
12584
12980
  async function renderCommentSettings(notice) {
12585
- const comments = getCommentsConfig(cfg);
12586
- const models = await listModels(backendFor(comments.backend)).catch(() => []);
12587
- return buildCommentSettingsCard(cfg, commentBackendOptions(), models, notice);
12981
+ ++commentSettingsRenderGeneration;
12982
+ for (; ; ) {
12983
+ const generation = commentSettingsRenderGeneration;
12984
+ const comments = getCommentsConfig(cfg);
12985
+ const models = await listModels(backendFor(comments.backend)).catch(() => []);
12986
+ if (generation !== commentSettingsRenderGeneration) continue;
12987
+ const latest = getCommentsConfig(cfg);
12988
+ if (latest.backend !== comments.backend || latest.model !== comments.model || latest.effort !== comments.effort) {
12989
+ continue;
12990
+ }
12991
+ const snapshot = {
12992
+ ...cfg,
12993
+ preferences: { ...cfg.preferences ?? {}, comments: { ...comments } }
12994
+ };
12995
+ return buildCommentSettingsCard(snapshot, commentBackendOptions(), models, notice);
12996
+ }
12588
12997
  }
12589
12998
  function applyCommentsPref(evt, mut) {
12590
12999
  if (!dmAdmin(evt.operator?.openId)) return;
12591
- const prefs = { ...cfg.preferences ?? {} };
12592
- const comments = { ...prefs.comments ?? {} };
12593
- mut(comments);
12594
- prefs.comments = comments;
12595
- cfg.preferences = prefs;
12596
- void saveConfig(cfg).catch((err) => log.fail("console", err, { phase: "save-config" }));
12597
- void patch(evt, () => renderCommentSettings());
13000
+ const saved = writePreferences((prefs) => {
13001
+ const comments = { ...prefs.comments ?? {} };
13002
+ mut(comments);
13003
+ prefs.comments = comments;
13004
+ }).catch((err) => log.fail("console", err, { phase: "save-config" }));
13005
+ void patch(evt, async () => {
13006
+ await saved;
13007
+ return renderCommentSettings();
13008
+ });
12598
13009
  }
12599
13010
  const freshMenu = (evt) => {
12600
13011
  patch(evt, buildDmMenuCard({ webConsoleUrl: webConsoleUrl(), version: bridgeVersion() }));
@@ -12699,7 +13110,14 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12699
13110
  else if (!op) result = buildNewProjectFormCard({ name, cwd: cwdIn, error: "\u65E0\u6CD5\u8BC6\u522B\u64CD\u4F5C\u8005\u8EAB\u4EFD", backends: backends2 });
12700
13111
  else {
12701
13112
  try {
12702
- const p = await createProject(channel, { name, ownerOpenId: op, existingPath: cwdIn || void 0, kind, backend: backend2 });
13113
+ const p = await createProject(channel, {
13114
+ name,
13115
+ ownerOpenId: op,
13116
+ existingPath: cwdIn || void 0,
13117
+ projectsRootDir: cfg.preferences?.projectsRootDir,
13118
+ kind,
13119
+ backend: backend2
13120
+ });
12703
13121
  log.info("console", "new-project", { name: p.name, blank: p.blank, backend: p.backend });
12704
13122
  result = buildNewProjectDoneCard(p);
12705
13123
  } catch (err) {
@@ -12727,7 +13145,16 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12727
13145
  else if (!op) result = buildJoinGroupFormCard({ chatId, name, cwd: cwdIn, error: "\u65E0\u6CD5\u8BC6\u522B\u64CD\u4F5C\u8005\u8EAB\u4EFD", backends: backends2 });
12728
13146
  else {
12729
13147
  try {
12730
- const p = await joinExistingGroup(channel, { name, chatId, addedBy: op, existingPath: cwdIn || void 0, kind, backend: backend2, mode: bindModeFor(backend2) });
13148
+ const p = await joinExistingGroup(channel, {
13149
+ name,
13150
+ chatId,
13151
+ addedBy: op,
13152
+ existingPath: cwdIn || void 0,
13153
+ projectsRootDir: cfg.preferences?.projectsRootDir,
13154
+ kind,
13155
+ backend: backend2,
13156
+ mode: bindModeFor(backend2)
13157
+ });
12731
13158
  log.info("console", "join-group", { name: p.name, blank: p.blank, backend: p.backend, mode: p.mode });
12732
13159
  result = buildNewProjectDoneCard(p);
12733
13160
  } catch (err) {
@@ -12746,26 +13173,15 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12746
13173
  if (dmAdmin(evt.operator?.openId)) await patch(evt, renderSettings);
12747
13174
  }).on(DM.coffeeSettings, async ({ evt }) => {
12748
13175
  if (dmAdmin(evt.operator?.openId)) await patch(evt, () => renderCoffeeSettings(true));
12749
- }).on(CLI.toggleEnabled, async ({ evt, value }) => {
13176
+ }).on(CLI.toggleEnabled, ({ evt, value }) => {
12750
13177
  if (!dmAdmin(evt.operator?.openId)) return;
12751
13178
  const enabled = value.v === "on";
12752
13179
  if (enabled && !canEnableCliBridge(cfg).ok) return;
12753
- try {
12754
- if (enabled) {
12755
- await cliBridge?.start?.();
12756
- applyPref(evt, (p) => {
12757
- p.cliBridge = { ...p.cliBridge ?? {}, enabled: true };
12758
- }, { render: false });
12759
- } else {
12760
- applyPref(evt, (p) => {
12761
- p.cliBridge = { ...p.cliBridge ?? {}, enabled: false };
12762
- }, { render: false });
12763
- await cliBridge?.shutdown?.();
12764
- }
12765
- } catch (err) {
12766
- log.fail("cli-bridge", err, { phase: enabled ? "enable" : "disable" });
12767
- }
12768
- void patch(evt, renderCoffeeSettings);
13180
+ const transition = setCliBridgeEnabled(evt, enabled);
13181
+ void patch(evt, async () => {
13182
+ await transition;
13183
+ return renderCoffeeSettings();
13184
+ });
12769
13185
  }).on(CLI.repairHooks, async ({ evt }) => {
12770
13186
  if (!dmAdmin(evt.operator?.openId)) return;
12771
13187
  try {
@@ -12780,27 +13196,36 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12780
13196
  }).on(CLI.setNotifyScope, ({ evt, value }) => {
12781
13197
  if (!dmAdmin(evt.operator?.openId)) return;
12782
13198
  const scope = value.v === "bound_projects" || value.v === "none" ? value.v : "all";
12783
- applyPref(evt, (p) => {
13199
+ const saved = applyPref(evt, (p) => {
12784
13200
  p.cliBridge = { ...p.cliBridge ?? {}, notifyScope: scope };
12785
13201
  }, { render: false });
12786
- void patch(evt, renderCoffeeSettings);
13202
+ void patch(evt, async () => {
13203
+ await saved;
13204
+ return renderCoffeeSettings();
13205
+ });
12787
13206
  }).on(CLI.toggleAgent, ({ evt, value }) => {
12788
13207
  if (!dmAdmin(evt.operator?.openId)) return;
12789
13208
  const agent = value.agent === "codex" ? "codex" : "claude";
12790
13209
  const on = value.v === "on";
12791
- applyPref(evt, (p) => {
13210
+ const saved = applyPref(evt, (p) => {
12792
13211
  const cur = p.cliBridge ?? {};
12793
13212
  p.cliBridge = { ...cur, agents: { ...cur.agents ?? {}, [agent]: on } };
12794
13213
  }, { render: false });
12795
- void patch(evt, renderCoffeeSettings);
13214
+ void patch(evt, async () => {
13215
+ await saved;
13216
+ return renderCoffeeSettings();
13217
+ });
12796
13218
  }).on(CLI.toggleKeepAwake, ({ evt, value }) => {
12797
13219
  if (!dmAdmin(evt.operator?.openId)) return;
12798
13220
  const on = value.v === "on";
12799
- applyPref(evt, (p) => {
13221
+ const saved = applyPref(evt, (p) => {
12800
13222
  const cur = p.cliBridge ?? {};
12801
13223
  p.cliBridge = { ...cur, keepAwake: { ...cur.keepAwake ?? {}, enabled: on } };
12802
13224
  }, { render: false });
12803
- void patch(evt, renderCoffeeSettings);
13225
+ void patch(evt, async () => {
13226
+ await saved;
13227
+ return renderCoffeeSettings();
13228
+ });
12804
13229
  }).on(DM.doctor, async ({ evt }) => {
12805
13230
  if (!dmAdmin(evt.operator?.openId)) return;
12806
13231
  await sendManagedCard(channel, evt.chatId, buildDoctorCard(await buildDoctorInfo()), evt.messageId).catch(
@@ -12969,6 +13394,52 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
12969
13394
  }
12970
13395
  const sec = n === 0 ? 0 : Math.min(Math.max(Math.floor(n), RUN_IDLE_TIMEOUT_MIN_SEC), RUN_IDLE_TIMEOUT_MAX_SEC);
12971
13396
  applyPref(evt, (p) => p.runIdleTimeoutSeconds = sec);
13397
+ }).on(DM.setCompletionReminder, ({ evt, value }) => {
13398
+ if (!dmAdmin(evt.operator?.openId)) return;
13399
+ const mode = value.v;
13400
+ if (mode !== "manual" && mode !== "long" && mode !== "failures" && mode !== "always") return;
13401
+ const saved = performSetCompletionReminder({
13402
+ cfg,
13403
+ mode,
13404
+ writePreferences
13405
+ }).then(
13406
+ (result) => {
13407
+ if (!result.ok) log.warn("console", "completion-reminder-rejected", { reason: result.reason });
13408
+ else refreshCompletionReminderCards();
13409
+ },
13410
+ (err) => log.fail("console", err, { phase: "save-config" })
13411
+ );
13412
+ void patch(evt, async () => {
13413
+ await saved;
13414
+ return renderSettings();
13415
+ });
13416
+ }).on(DM.completionReminderCustom, ({ evt }) => {
13417
+ if (!dmAdmin(evt.operator?.openId)) return;
13418
+ void patch(evt, buildCompletionReminderCustomCard(cfg));
13419
+ }).on(DM.completionReminderCustomSubmit, ({ evt, formValue }) => {
13420
+ if (!dmAdmin(evt.operator?.openId)) return;
13421
+ const raw = String(formValue?.minutes ?? "").trim();
13422
+ const minutes = Number(raw);
13423
+ if (!Number.isInteger(minutes) || minutes < COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES || minutes > COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES) {
13424
+ void patch(evt, buildCompletionReminderCustomCard(cfg));
13425
+ return;
13426
+ }
13427
+ const saved = performSetCompletionReminder({
13428
+ cfg,
13429
+ mode: "long",
13430
+ longTaskMinutes: minutes,
13431
+ writePreferences
13432
+ }).then(
13433
+ (result) => {
13434
+ if (!result.ok) log.warn("console", "completion-reminder-rejected", { reason: result.reason });
13435
+ else refreshCompletionReminderCards();
13436
+ },
13437
+ (err) => log.fail("console", err, { phase: "save-config" })
13438
+ );
13439
+ void patch(evt, async () => {
13440
+ await saved;
13441
+ return renderSettings();
13442
+ });
12972
13443
  }).on(DM.setPending, ({ evt, value }) => {
12973
13444
  if (value.v === "steer" || value.v === "queue") applyPref(evt, (p) => p.pendingPolicy = value.v);
12974
13445
  }).on(DM.setConcurrency, ({ evt, value }) => {
@@ -13124,11 +13595,12 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13124
13595
  log.info("console", "admin-add", { picked: id?.slice(-6) ?? null });
13125
13596
  void (async () => {
13126
13597
  if (id) {
13127
- const access = { ...cfg.preferences?.access ?? {} };
13128
- access.ownerOpenId ??= resolveOwner(cfg);
13129
- access.admins = Array.from(/* @__PURE__ */ new Set([...access.admins ?? [], id]));
13130
- cfg.preferences = { ...cfg.preferences ?? {}, access };
13131
- await saveConfig(cfg).catch((e) => log.fail("console", e, { phase: "save-config" }));
13598
+ await writePreferences((preferences) => {
13599
+ const access = { ...preferences.access ?? {} };
13600
+ access.ownerOpenId ??= resolveOwner(cfg);
13601
+ access.admins = Array.from(/* @__PURE__ */ new Set([...access.admins ?? [], id]));
13602
+ preferences.access = access;
13603
+ }).catch((e) => log.fail("console", e, { phase: "save-config" }));
13132
13604
  }
13133
13605
  const ids = [resolveOwner(cfg), ...cfg.preferences?.access?.admins ?? []];
13134
13606
  const next = buildAdminsCard(cfg, await namesWithOperator(evt, ids));
@@ -13139,11 +13611,12 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13139
13611
  const id = typeof value.u === "string" ? value.u : "";
13140
13612
  patch(evt, async () => {
13141
13613
  if (id && id !== resolveOwner(cfg)) {
13142
- const access = { ...cfg.preferences?.access ?? {} };
13143
- access.ownerOpenId ??= resolveOwner(cfg);
13144
- access.admins = (access.admins ?? []).filter((x) => x !== id);
13145
- cfg.preferences = { ...cfg.preferences ?? {}, access };
13146
- await saveConfig(cfg).catch((e) => log.fail("console", e, { phase: "save-config" }));
13614
+ await writePreferences((preferences) => {
13615
+ const access = { ...preferences.access ?? {} };
13616
+ access.ownerOpenId ??= resolveOwner(cfg);
13617
+ access.admins = (access.admins ?? []).filter((x) => x !== id);
13618
+ preferences.access = access;
13619
+ }).catch((e) => log.fail("console", e, { phase: "save-config" }));
13147
13620
  }
13148
13621
  const ids = [resolveOwner(cfg), ...cfg.preferences?.access?.admins ?? []];
13149
13622
  return buildAdminsCard(cfg, await namesWithOperator(evt, ids));
@@ -13368,22 +13841,36 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13368
13841
  settleUpdate(evt.messageId, buildResumeErrorCard(state, err instanceof Error ? err.message : String(err)));
13369
13842
  }
13370
13843
  }
13844
+ async function sendCompletionReminder(input2) {
13845
+ await sendCompletionReminderReply({ channel, cfg, dedupe: completionReminderSent }, input2);
13846
+ }
13371
13847
  async function acquireRunSlot(opts, state, activeKey, reaction) {
13372
13848
  if (sema.hasFree()) return { release: await sema.acquire() };
13373
13849
  const stream2 = new RunCardStream();
13374
13850
  let msgId;
13851
+ const queueCard = (input2) => buildQueuedCard({
13852
+ ...input2,
13853
+ ...!state.isGoal ? { completionReminder: completionReminderView(state) } : {}
13854
+ });
13375
13855
  const q = sema.enqueue((pos) => {
13376
- if (msgId) stream2.streamCoalesced(channel, buildQueuedCard({ position: pos, cardKey: msgId }), null);
13856
+ if (msgId) stream2.streamCoalesced(channel, queueCard({ position: pos, cardKey: msgId }), null);
13377
13857
  });
13378
13858
  try {
13379
- msgId = await stream2.create(channel, opts.chatId, buildQueuedCard({ position: q.position() }), {
13859
+ msgId = await stream2.create(channel, opts.chatId, queueCard({ position: q.position() }), {
13380
13860
  replyTo: opts.replyTo,
13381
13861
  replyInThread: opts.flat ? false : opts.replyInThread ?? Boolean(opts.knownThreadId)
13382
13862
  });
13383
13863
  const pos = q.position();
13384
- if (pos > 0) await stream2.updateCard(channel, buildQueuedCard({ position: pos, cardKey: msgId }));
13864
+ if (pos > 0) await stream2.updateCard(channel, queueCard({ position: pos, cardKey: msgId }));
13385
13865
  runsByCard.set(msgId, state);
13386
13866
  runStreams.set(msgId, stream2);
13867
+ if (!state.isGoal) {
13868
+ const key = msgId;
13869
+ completionReminderRefreshers.set(key, () => {
13870
+ const currentPos = q.position();
13871
+ if (currentPos > 0) void stream2.updateLiveCard(channel, queueCard({ position: currentPos, cardKey: key }));
13872
+ });
13873
+ }
13387
13874
  } catch (err) {
13388
13875
  log.fail("card", err, { phase: "queued-card" });
13389
13876
  }
@@ -13396,7 +13883,8 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13396
13883
  if (msgId) {
13397
13884
  runsByCard.delete(msgId);
13398
13885
  runStreams.delete(msgId);
13399
- void stream2.updateCard(channel, buildQueuedCard({ cancelled: true, dropped: state.queue.length }));
13886
+ completionReminderRefreshers.delete(msgId);
13887
+ void stream2.updateCard(channel, queueCard({ cancelled: true, dropped: state.queue.length }));
13400
13888
  }
13401
13889
  reaction?.done();
13402
13890
  log.info("card", "action", { actionId: "run.stop", queuedCancel: true });
@@ -13451,10 +13939,17 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13451
13939
  let intake = opts.timing;
13452
13940
  let firstRec = opts.firstRec;
13453
13941
  try {
13454
- let turnInput = { text: opts.firstText, images: opts.images };
13942
+ let currentTurn = {
13943
+ input: { text: opts.firstText, images: opts.images },
13944
+ requesterOpenId: opts.requesterOpenId,
13945
+ requestedAt: opts.requestedAt ?? Date.now(),
13946
+ summary: opts.summary
13947
+ };
13455
13948
  let replyTo = opts.replyTo;
13456
13949
  let replyInThread = opts.flat ? false : opts.replyInThread ?? Boolean(opts.knownThreadId);
13457
13950
  for (; ; ) {
13951
+ const turnInput = currentTurn.input;
13952
+ state.requesterOpenId = currentTurn.requesterOpenId;
13458
13953
  const rec = firstRec !== void 0 ? firstRec ?? void 0 : topicThreadId ? await getSession(topicThreadId) : void 0;
13459
13954
  firstRec = void 0;
13460
13955
  const turnModel = rec?.model ?? opts.model;
@@ -13468,8 +13963,9 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13468
13963
  let cardMsgId;
13469
13964
  const rc = {
13470
13965
  rs: render.snapshot(),
13471
- requesterOpenId: opts.requesterOpenId,
13966
+ requesterOpenId: currentTurn.requesterOpenId,
13472
13967
  showTools: render.showTools,
13968
+ completionReminder: completionReminderView(state),
13473
13969
  // 模型显示档位:footnote 本轮 model·推理强度;always 档终态卡也保留。
13474
13970
  ...modelDisp !== "off" && turnModel ? { model: turnModel, effort: turnEffort, modelOnTerminal: modelDisp === "always" } : {}
13475
13971
  };
@@ -13513,6 +14009,11 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13513
14009
  rc.cardKey = cardMsgId;
13514
14010
  runsByCard.set(cardMsgId, state);
13515
14011
  runStreams.set(cardMsgId, stream2);
14012
+ completionReminderRefreshers.set(cardMsgId, () => {
14013
+ rc.completionReminder = completionReminderView(state);
14014
+ void stream2.updateLiveCard(channel, buildRunCard(rc));
14015
+ });
14016
+ stream2.streamCoalesced(channel, buildRunCard(rc), ANSWER_EID);
13516
14017
  await adoptThreadId(cardMsgId);
13517
14018
  if (!firstCardSent) {
13518
14019
  firstCardSent = true;
@@ -13570,6 +14071,7 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13570
14071
  }
13571
14072
  render.apply(ev);
13572
14073
  rc.rs = render.snapshot();
14074
+ rc.completionReminder = completionReminderView(state);
13573
14075
  stream2.streamCoalesced(channel, buildRunCard(rc), ANSWER_EID);
13574
14076
  }
13575
14077
  const doneAt = Date.now();
@@ -13578,12 +14080,15 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13578
14080
  state.interrupt = void 0;
13579
14081
  const interrupted = stopper.interrupted();
13580
14082
  const killed = timedOut || interrupted && stopper.forced();
13581
- if (interrupted) render.interrupt();
13582
- else if (timedOut) render.timeout(Math.round(idleMs / 1e3));
13583
- else render.finalize();
14083
+ const procDead = !killed && !opts.thread.isAlive();
14084
+ settleOrdinaryTurnRender(render, {
14085
+ interrupted,
14086
+ timedOut,
14087
+ idleTimeoutSeconds: Math.round(idleMs / 1e3),
14088
+ procDead
14089
+ });
13584
14090
  rc.rs = render.snapshot();
13585
14091
  if (interrupted) log.info("agent", "interrupt", { graceful: !stopper.forced(), threadId: topicThreadId ?? null });
13586
- const procDead = !killed && !opts.thread.isAlive();
13587
14092
  if (killed || procDead) {
13588
14093
  void opts.thread.close().catch(() => void 0);
13589
14094
  if (topicThreadId) sessions.delete(topicThreadId);
@@ -13601,7 +14106,9 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13601
14106
  if (imgSources.length > 0) {
13602
14107
  rc.images = await uploadOutboundImages(channel, imgSources, opts.cwd ?? fallbackCwd);
13603
14108
  }
13604
- await stream2.updateCard(channel, buildRunCard(rc));
14109
+ const manuallyRequested = Boolean(state.completionReminderRequested);
14110
+ completionReminderRefreshers.delete(cardMsgId);
14111
+ const terminalCardUpdated = await stream2.finalizeCard(channel, buildRunCard(rc));
13605
14112
  {
13606
14113
  const terminalAt = Date.now();
13607
14114
  const st = stream2.stats();
@@ -13641,6 +14148,16 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13641
14148
  touchSession(topicThreadId);
13642
14149
  await patchSession(topicThreadId, { updatedAt: Date.now() });
13643
14150
  }
14151
+ await sendCompletionReminder({
14152
+ cardMsgId: finalMsgId,
14153
+ requesterOpenId: currentTurn.requesterOpenId,
14154
+ outcome: rc.rs.terminal === "running" ? "done" : rc.rs.terminal,
14155
+ requestedAt: currentTurn.requestedAt,
14156
+ manuallyRequested,
14157
+ summary: currentTurn.summary,
14158
+ cardUpdated: terminalCardUpdated,
14159
+ replyInThread: !opts.flat
14160
+ });
13644
14161
  replyTo = finalMsgId;
13645
14162
  replyInThread = !opts.flat;
13646
14163
  log.info("card", "final", { terminal: render.terminal() });
@@ -13656,14 +14173,18 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13656
14173
  break;
13657
14174
  }
13658
14175
  if (state.queue.length === 0) break;
13659
- turnInput = state.queue.shift();
14176
+ currentTurn = state.queue.shift();
14177
+ activateQueuedTurn(state, currentTurn);
13660
14178
  }
13661
14179
  } catch (err) {
13662
14180
  log.fail("intake", err);
13663
14181
  await channel.send(opts.chatId, { markdown: `\u274C ${err instanceof Error ? err.message : String(err)}` }, { replyTo: opts.replyTo, replyInThread: !opts.flat }).catch(() => void 0);
13664
14182
  } finally {
13665
14183
  active.delete(activeKey);
13666
- if (curCardKey) runsByCard.delete(curCardKey);
14184
+ if (curCardKey) {
14185
+ runsByCard.delete(curCardKey);
14186
+ completionReminderRefreshers.delete(curCardKey);
14187
+ }
13667
14188
  if (activeKey.startsWith("pending:")) {
13668
14189
  void opts.thread.close().catch(() => void 0);
13669
14190
  log.warn("intake", "unadopted-thread-closed", { activeKey });
@@ -14242,7 +14763,11 @@ ${facts}`;
14242
14763
  log.info("bridge", "shutdown", { closed: live.length });
14243
14764
  }
14244
14765
  void backend.listModels().catch((err) => log.fail("agent", err, { phase: "models-prewarm" }));
14245
- const adminExecute = createAdminWriteExecutor({ backendFor, evictLiveSessionsForChat });
14766
+ const executeAdminWrite = createAdminWriteExecutor({ cfg, backendFor, evictLiveSessionsForChat, writePreferences });
14767
+ const adminExecute = async (op) => {
14768
+ await executeAdminWrite(op);
14769
+ if (op.kind === "setCompletionReminder") refreshCompletionReminderCards();
14770
+ };
14246
14771
  return { onMessage, onComment, onBotAddedToChat, onBotRemovedFromChat, onReaction, onBotMenu, dispatcher, adminExecute, shutdown };
14247
14772
  }
14248
14773
  async function getThreadId(channel, messageId, attempts = 1) {
@@ -14445,7 +14970,6 @@ init_paths();
14445
14970
  import { readFile as readFile14, rm as rm7 } from "fs/promises";
14446
14971
  init_schema();
14447
14972
  init_store();
14448
- init_schema();
14449
14973
 
14450
14974
  // src/bot/register-bot.ts
14451
14975
  init_store();
@@ -14628,6 +15152,13 @@ function createAdminService(deps = {}) {
14628
15152
  const live = await deps.liveStatus?.(botId).catch(() => void 0);
14629
15153
  return live ?? lockFileRunState(botId);
14630
15154
  }
15155
+ async function completionReminderFor(botId) {
15156
+ try {
15157
+ return getCompletionReminderConfig(await loadConfig(botPaths(botId).configFile));
15158
+ } catch {
15159
+ return getCompletionReminderConfig({});
15160
+ }
15161
+ }
14631
15162
  function executeWrite(botId, action, op) {
14632
15163
  if (!deps.executeWrite) throw new NotWiredYetError(action);
14633
15164
  return deps.executeWrite(botId, op);
@@ -14638,7 +15169,7 @@ function createAdminService(deps = {}) {
14638
15169
  const configured = reg.bots.some((b) => b.active !== void 0);
14639
15170
  const out = [];
14640
15171
  for (const b of reg.bots) {
14641
- const run = await runState(b.appId);
15172
+ const [run, completionReminder] = await Promise.all([runState(b.appId), completionReminderFor(b.appId)]);
14642
15173
  out.push({
14643
15174
  name: b.name,
14644
15175
  appId: b.appId,
@@ -14650,7 +15181,8 @@ function createAdminService(deps = {}) {
14650
15181
  running: run.running,
14651
15182
  pid: run.pid,
14652
15183
  startedAt: run.startedAt,
14653
- connection: run.connection
15184
+ connection: run.connection,
15185
+ completionReminder
14654
15186
  });
14655
15187
  }
14656
15188
  return out;
@@ -14679,6 +15211,13 @@ function createAdminService(deps = {}) {
14679
15211
  async setAutoCompact(botId, projectName, on) {
14680
15212
  await executeWrite(botId, "\u{1F5DC}\uFE0F \u81EA\u52A8\u538B\u7F29\u5F00\u5173", { kind: "setAutoCompact", project: projectName, on });
14681
15213
  },
15214
+ async setCompletionReminder(botId, value) {
15215
+ await executeWrite(botId, "\u{1F514} \u5B8C\u6210\u63D0\u9192", {
15216
+ kind: "setCompletionReminder",
15217
+ mode: value.mode,
15218
+ longTaskMinutes: value.longTaskMinutes
15219
+ });
15220
+ },
14682
15221
  doctorBackends() {
14683
15222
  return probeAllBackends();
14684
15223
  },
@@ -15166,6 +15705,7 @@ init_logger();
15166
15705
 
15167
15706
  // src/web/server.ts
15168
15707
  init_paths();
15708
+ init_schema();
15169
15709
  import { createServer } from "http";
15170
15710
  import { randomUUID as randomUUID7, timingSafeEqual } from "crypto";
15171
15711
  import { mkdirSync as mkdirSync3, watch } from "fs";
@@ -15750,6 +16290,11 @@ var UI_HTML = `<!doctype html>
15750
16290
  .drawer-mask.open { display: block; }
15751
16291
  .drawer h3 { margin: 0 0 4px; font-size: 16px; }
15752
16292
  .opt-row { display: flex; gap: 8px; margin: 6px 0 2px; flex-wrap: wrap; }
16293
+ .compact-input {
16294
+ width: 84px; border: 1px solid var(--border-2); border-radius: 8px; padding: 7px 9px;
16295
+ font: 13px var(--mono); background: #0c0e0c; color: var(--text);
16296
+ }
16297
+ .compact-input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
15753
16298
  .backend-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
15754
16299
  .backend-row .grow { flex: 1; min-width: 0; }
15755
16300
  .bk-group { margin: 6px 0 2px; }
@@ -17226,6 +17771,9 @@ ${UI_PURE_JS}
17226
17771
  left.appendChild(projCard);
17227
17772
  renderProjects(projList, pcount, b);
17228
17773
 
17774
+ // \u{1F514} \u666E\u901A\u4EFB\u52A1\u7ED3\u675F\u63D0\u9192\uFF08\u6BCF bot \u72EC\u7ACB\uFF09\u3002\u9009\u62E9\u5373\u4FDD\u5B58\uFF1B\u4EC5 long \u989D\u5916\u5C55\u793A\u5206\u949F\u9608\u503C\u3002
17775
+ renderCompletionReminderCard(right, b);
17776
+
17229
17777
  cols.appendChild(left);
17230
17778
  cols.appendChild(right);
17231
17779
  root.appendChild(cols);
@@ -17297,6 +17845,58 @@ ${UI_PURE_JS}
17297
17845
  });
17298
17846
  }
17299
17847
 
17848
+ function renderCompletionReminderCard(root, b) {
17849
+ var reminder = b.completionReminder || { mode: 'failures', longTaskMinutes: 3 };
17850
+ var card = el('div', 'card');
17851
+ card.appendChild(el('h2', null, '\u{1F514} \u5B8C\u6210\u63D0\u9192'));
17852
+ card.appendChild(el('div', 'note', '\u4EFB\u52A1\u7ED3\u675F\u540E\u662F\u5426\u53E6\u53D1\u4E00\u6761\u6D88\u606F @ \u53D1\u8D77\u4EBA\u3002\u6BCF\u4E2A\u673A\u5668\u4EBA\u72EC\u7ACB\u8BBE\u7F6E\uFF0C\u4FDD\u5B58\u540E\u7ACB\u5373\u751F\u6548\u3002'));
17853
+ var modes = [
17854
+ { label: '\u4EC5\u624B\u52A8', value: 'manual' },
17855
+ { label: '\u957F\u4EFB\u52A1', value: 'long' },
17856
+ { label: '\u5931\u8D25\u6216\u8D85\u65F6', value: 'failures' },
17857
+ { label: '\u6BCF\u6B21\u7ED3\u675F', value: 'always' },
17858
+ ];
17859
+ card.appendChild(optButtons(modes, reminder.mode, function (mode) {
17860
+ postWrite('/api/bots/' + encodeURIComponent(b.appId) + '/completion-reminder', {
17861
+ mode: mode,
17862
+ longTaskMinutes: reminder.longTaskMinutes,
17863
+ });
17864
+ }));
17865
+
17866
+ var descriptions = {
17867
+ manual: '\u8FD0\u884C\u5361\u663E\u793A\u300C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u300D\uFF0C\u53EA\u6709\u672C\u8F6E\u53D1\u8D77\u4EBA\u70B9\u8FC7\u624D\u901A\u77E5\u3002',
17868
+ long: '\u4EFB\u52A1\u8017\u65F6\u8FBE\u5230\u9608\u503C\u540E\u901A\u77E5\uFF1B\u8FD0\u884C\u5361\u4E0D\u663E\u793A\u63D0\u9192\u6309\u94AE\u3002',
17869
+ failures: '\u4EC5\u4EFB\u52A1\u5931\u8D25\u6216\u5047\u6B7B\u8D85\u65F6\u65F6\u901A\u77E5\uFF1B\u8FD0\u884C\u5361\u4E0D\u663E\u793A\u63D0\u9192\u6309\u94AE\u3002',
17870
+ always: '\u6BCF\u6B21\u6B63\u5E38\u7ED3\u675F\u3001\u5931\u8D25\u6216\u8D85\u65F6\u90FD\u901A\u77E5\uFF1B\u8FD0\u884C\u5361\u4E0D\u663E\u793A\u63D0\u9192\u6309\u94AE\u3002',
17871
+ };
17872
+ card.appendChild(el('div', 'note', descriptions[reminder.mode] || descriptions.failures));
17873
+
17874
+ if (reminder.mode === 'long') {
17875
+ var threshold = el('div', 'statline');
17876
+ threshold.appendChild(el('span', null, '\u957F\u4EFB\u52A1\u9608\u503C'));
17877
+ var input = el('input', 'compact-input');
17878
+ input.type = 'number'; input.min = '1'; input.max = '1440'; input.step = '1';
17879
+ input.value = String(reminder.longTaskMinutes);
17880
+ input.setAttribute('aria-label', '\u957F\u4EFB\u52A1\u9608\u503C\uFF08\u5206\u949F\uFF09');
17881
+ threshold.appendChild(input);
17882
+ threshold.appendChild(el('span', 'note', '\u5206\u949F\uFF081\u20131440\uFF09'));
17883
+ var save = el('button', 'btn primary sm', '\u4FDD\u5B58\u9608\u503C');
17884
+ save.onclick = function () {
17885
+ var minutes = Number(input.value);
17886
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440) {
17887
+ toast('\u274C \u957F\u4EFB\u52A1\u9608\u503C\u8BF7\u8F93\u5165 1\u20131440 \u7684\u6574\u6570\u5206\u949F');
17888
+ return;
17889
+ }
17890
+ postWrite('/api/bots/' + encodeURIComponent(b.appId) + '/completion-reminder', {
17891
+ mode: 'long', longTaskMinutes: minutes,
17892
+ });
17893
+ };
17894
+ threshold.appendChild(save);
17895
+ card.appendChild(threshold);
17896
+ }
17897
+ root.appendChild(card);
17898
+ }
17899
+
17300
17900
  function renderProjects(box, countEl, b) {
17301
17901
  box.textContent = '';
17302
17902
  var projects = (b && b.projects) || [];
@@ -17917,6 +18517,7 @@ var LOGO_PNG = Buffer.from(LOGO_PNG_BASE64, "base64");
17917
18517
  var DEFAULT_WEB_PORT = 51847;
17918
18518
  var COOKIE_NAME = "fcb_console_token";
17919
18519
  var SSE_INITIAL_TAIL_BYTES = 16 * 1024;
18520
+ var COMPLETION_REMINDER_MODES2 = ["manual", "long", "failures", "always"];
17920
18521
  function createWebServer(opts) {
17921
18522
  const token = opts.token ?? randomUUID7();
17922
18523
  const html = opts.html ?? UI_HTML;
@@ -18092,6 +18693,45 @@ function createWebServer(opts) {
18092
18693
  sendJson(res, 200, status);
18093
18694
  return;
18094
18695
  }
18696
+ const completionReminderMatch = /^\/api\/bots\/([^/]+)\/completion-reminder$/.exec(pathName);
18697
+ if (req.method === "POST" && completionReminderMatch) {
18698
+ let body;
18699
+ try {
18700
+ body = await readJsonBody(req);
18701
+ } catch {
18702
+ sendJson(res, 400, { error: "bad_body", message: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON" });
18703
+ return;
18704
+ }
18705
+ const mode = body.mode;
18706
+ const minutes = body.longTaskMinutes;
18707
+ if (!COMPLETION_REMINDER_MODES2.includes(mode)) {
18708
+ sendJson(res, 400, { error: "invalid_input", message: "mode \u5FC5\u987B\u662F manual / long / failures / always" });
18709
+ return;
18710
+ }
18711
+ if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES || minutes > COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES) {
18712
+ sendJson(res, 400, {
18713
+ error: "invalid_input",
18714
+ message: `longTaskMinutes \u5FC5\u987B\u662F ${COMPLETION_REMINDER_LONG_TASK_MIN_MINUTES}\u2013${COMPLETION_REMINDER_LONG_TASK_MAX_MINUTES} \u4E4B\u95F4\u7684\u6574\u6570`
18715
+ });
18716
+ return;
18717
+ }
18718
+ try {
18719
+ await opts.service.setCompletionReminder(decodeURIComponent(completionReminderMatch[1]), {
18720
+ mode,
18721
+ longTaskMinutes: minutes
18722
+ });
18723
+ sendJson(res, 200, { ok: true, completionReminder: { mode, longTaskMinutes: minutes } });
18724
+ } catch (err) {
18725
+ if (err instanceof NotWiredYetError) {
18726
+ sendJson(res, 501, { error: "not_wired_yet", message: err.message });
18727
+ } else if (err instanceof AdminWriteError) {
18728
+ sendJson(res, 409, { error: "write_rejected", message: err.message });
18729
+ } else {
18730
+ throw err;
18731
+ }
18732
+ }
18733
+ return;
18734
+ }
18095
18735
  const botMatch = /^\/api\/bots\/([^/]+)$/.exec(pathName);
18096
18736
  if (req.method === "PATCH" && botMatch) {
18097
18737
  const appId = decodeURIComponent(botMatch[1]);