@modelzen/feishu-codex-bridge 0.6.7 → 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 +810 -126
  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
  }
@@ -4111,6 +4144,7 @@ ${b.thinking}` : b.thinking;
4111
4144
 
4112
4145
  // src/agent/claude-agent/thread.ts
4113
4146
  init_logger();
4147
+ import { readFile as readFile5 } from "fs/promises";
4114
4148
  var COMPACT_TIMEOUT_MS2 = 12e4;
4115
4149
  var ABORT_ESCALATE_MS = 4e3;
4116
4150
  var Inbox = class {
@@ -4158,15 +4192,53 @@ var PushablePrompt = class {
4158
4192
  function toSdkEffort(e) {
4159
4193
  if (!e) return void 0;
4160
4194
  if (e === "none" || e === "minimal") return "low";
4195
+ if (e === "ultra") return "max";
4161
4196
  return e;
4162
4197
  }
4163
- function toUserMessage(input2) {
4198
+ var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
4199
+ function sniffImageType(b) {
4200
+ if (b.length >= 8 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) return "image/png";
4201
+ if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255) return "image/jpeg";
4202
+ if (b.length >= 6 && b[0] === 71 && b[1] === 73 && b[2] === 70 && b[3] === 56) return "image/gif";
4203
+ if (b.length >= 12 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP") return "image/webp";
4204
+ return void 0;
4205
+ }
4206
+ async function toImageBlock(path) {
4207
+ let bytes;
4208
+ try {
4209
+ bytes = await readFile5(path);
4210
+ } catch (err) {
4211
+ log.warn("agent", "image-read-failed", { backend: "claude-agent", err: String(err) });
4212
+ return void 0;
4213
+ }
4214
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES) {
4215
+ log.warn("agent", "image-skip-size", { backend: "claude-agent", bytes: bytes.byteLength });
4216
+ return void 0;
4217
+ }
4218
+ const mediaType = sniffImageType(bytes);
4219
+ if (!mediaType) {
4220
+ log.info("agent", "image-skip-unsupported", { backend: "claude-agent", head: bytes.toString("hex", 0, 4) });
4221
+ return void 0;
4222
+ }
4223
+ return { type: "image", source: { type: "base64", media_type: mediaType, data: bytes.toString("base64") } };
4224
+ }
4225
+ function textUserMessage(text) {
4226
+ return { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null };
4227
+ }
4228
+ async function toUserMessage(input2) {
4164
4229
  const text = input2.text ?? "";
4165
- return {
4166
- type: "user",
4167
- message: { role: "user", content: text },
4168
- parent_tool_use_id: null
4169
- };
4230
+ const paths2 = input2.images ?? [];
4231
+ if (paths2.length === 0) return textUserMessage(text);
4232
+ const blocks = [];
4233
+ if (text) blocks.push({ type: "text", text });
4234
+ for (const path of paths2) {
4235
+ const block = await toImageBlock(path);
4236
+ if (block) blocks.push(block);
4237
+ }
4238
+ const imageCount = blocks.filter((b) => b.type === "image").length;
4239
+ if (imageCount === 0) return textUserMessage(text);
4240
+ log.info("agent", "images-attached", { backend: "claude-agent", count: imageCount, of: paths2.length });
4241
+ return { type: "user", message: { role: "user", content: blocks }, parent_tool_use_id: null };
4170
4242
  }
4171
4243
  function goalPrompt(objective) {
4172
4244
  return [
@@ -4253,7 +4325,13 @@ var ClaudeAgentThread = class {
4253
4325
  const inbox = new Inbox();
4254
4326
  const mySink = (item) => inbox.push(item);
4255
4327
  this.sink = mySink;
4256
- this.input.push(toUserMessage(input2));
4328
+ void toUserMessage(input2).then(
4329
+ (m) => this.input.push(m),
4330
+ (err) => {
4331
+ log.fail("agent", err, { backend: "claude-agent", phase: "build-user-message" });
4332
+ this.input.push(textUserMessage(input2.text ?? ""));
4333
+ }
4334
+ );
4257
4335
  const self = this;
4258
4336
  async function* gen() {
4259
4337
  yield { type: "turn_started", turnId };
@@ -4334,7 +4412,7 @@ var ClaudeAgentThread = class {
4334
4412
  const inbox = new Inbox();
4335
4413
  const mySink = (item) => inbox.push(item);
4336
4414
  this.sink = mySink;
4337
- this.input.push(toUserMessage({ text: goalPrompt(objective) }));
4415
+ this.input.push(textUserMessage(goalPrompt(objective)));
4338
4416
  const self = this;
4339
4417
  async function* gen() {
4340
4418
  yield { type: "goal_update", status: "active", objective, tokensUsed: 0, timeUsedSeconds: 0, tokenBudget: null };
@@ -4430,7 +4508,7 @@ var ClaudeAgentThread = class {
4430
4508
  });
4431
4509
  let compacted = false;
4432
4510
  try {
4433
- this.input.push(toUserMessage({ text: "/compact" }));
4511
+ this.input.push(textUserMessage("/compact"));
4434
4512
  while (true) {
4435
4513
  const step = await Promise.race([inbox.next(), timeout]);
4436
4514
  if (step === "timeout") throw new Error(`\u538B\u7F29\u8D85\u65F6\uFF08Claude \u672A\u5728 ${COMPACT_TIMEOUT_MS2 / 1e3}s \u5185\u5B8C\u6210\uFF09`);
@@ -4683,7 +4761,7 @@ async function effectiveDefaultBackend(opts) {
4683
4761
  }
4684
4762
 
4685
4763
  // src/agent/installer.ts
4686
- import { mkdir as mkdir4, rm as rm2, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
4764
+ import { mkdir as mkdir4, rm as rm2, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
4687
4765
  import { existsSync as existsSync4 } from "fs";
4688
4766
  import { join as join8 } from "path";
4689
4767
  init_paths();
@@ -4872,7 +4950,7 @@ async function rollback(bareName) {
4872
4950
  async function removeBackendsDep(bareName) {
4873
4951
  const pkgFile = join8(paths.backendsDir, "package.json");
4874
4952
  try {
4875
- const raw = await readFile5(pkgFile, "utf8");
4953
+ const raw = await readFile6(pkgFile, "utf8");
4876
4954
  const json = JSON.parse(raw);
4877
4955
  if (json.dependencies && bareName in json.dependencies) {
4878
4956
  delete json.dependencies[bareName];
@@ -5371,7 +5449,7 @@ init_schema();
5371
5449
 
5372
5450
  // src/project/registry.ts
5373
5451
  init_paths();
5374
- import { mkdir as mkdir5, readFile as readFile6, rename as rename4, writeFile as writeFile5 } from "fs/promises";
5452
+ import { mkdir as mkdir5, readFile as readFile7, rename as rename4, writeFile as writeFile5 } from "fs/promises";
5375
5453
  import { randomUUID as randomUUID3 } from "crypto";
5376
5454
  import { dirname as dirname5 } from "path";
5377
5455
  function defaultNoMention(p) {
@@ -5398,7 +5476,7 @@ async function read() {
5398
5476
  }
5399
5477
  async function listProjectsIn(file) {
5400
5478
  try {
5401
- const text = await readFile6(file, "utf8");
5479
+ const text = await readFile7(file, "utf8");
5402
5480
  const parsed = JSON.parse(text);
5403
5481
  return Array.isArray(parsed.projects) ? parsed.projects : [];
5404
5482
  } catch (err) {
@@ -5488,8 +5566,13 @@ var EFFORT_LABEL = {
5488
5566
  low: "\u4F4E",
5489
5567
  medium: "\u4E2D",
5490
5568
  high: "\u9AD8",
5491
- xhigh: "\u6781\u9AD8"
5569
+ xhigh: "\u6781\u9AD8",
5570
+ max: "\u6700\u9AD8",
5571
+ ultra: "\u8D85\u5F3A"
5492
5572
  };
5573
+ function reasoningEffortLabel(effort) {
5574
+ return EFFORT_LABEL[effort] ?? (effort || "\u672A\u77E5");
5575
+ }
5493
5576
  function buildModelCard(state) {
5494
5577
  const visible = state.models.filter((m) => !m.hidden);
5495
5578
  const cur = state.models.find((m) => m.id === state.model);
@@ -5517,7 +5600,7 @@ function buildModelCard(state) {
5517
5600
  actionId: MC.effort,
5518
5601
  placeholder: "effort",
5519
5602
  initial: state.effort,
5520
- options: efforts.map((e) => ({ label: `effort\uFF1A${EFFORT_LABEL[e]}`, value: e }))
5603
+ options: efforts.map((e) => ({ label: `effort\uFF1A${reasoningEffortLabel(e)}`, value: e }))
5521
5604
  })
5522
5605
  );
5523
5606
  }
@@ -5735,6 +5818,10 @@ var DM = {
5735
5818
  // 假死超时「自定义…」:watchdogCustom 打开输入卡,watchdogCustomSubmit 保存任意秒数
5736
5819
  watchdogCustom: "dm.set.watchdog.custom",
5737
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",
5738
5825
  setPending: "dm.set.pending",
5739
5826
  setConcurrency: "dm.set.concurrency",
5740
5827
  // 权限管理:全局 admins(settings 卡进入)+ 项目响应白名单(项目列表 / 建项目完成卡进入)
@@ -6270,6 +6357,7 @@ function settingItem(name, desc, actionId, current, opts) {
6270
6357
  }
6271
6358
  function buildSettingsCard(cfg) {
6272
6359
  const watchdogSec = cfg.preferences?.runIdleTimeoutSeconds ?? 120;
6360
+ const completionReminder = getCompletionReminderConfig(cfg);
6273
6361
  return card(
6274
6362
  [
6275
6363
  settingSection("\u{1F4E4} \u8F93\u51FA\u5C55\u793A"),
@@ -6304,6 +6392,23 @@ function buildSettingsCard(cfg) {
6304
6392
  ),
6305
6393
  button("\u81EA\u5B9A\u4E49\u2026", { a: DM.watchdogCustom })
6306
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
+ ] : [],
6307
6412
  ...settingItem(
6308
6413
  "\u{1F4E5} \u8FD0\u884C\u4E2D\u6765\u65B0\u6D88\u606F",
6309
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",
@@ -6401,7 +6506,7 @@ function buildCommentSettingsCard(cfg, backendOptions, models, notice) {
6401
6506
  selectMenu({
6402
6507
  name: "effort",
6403
6508
  placeholder: "\u9009\u62E9\u63A8\u7406\u5F3A\u5EA6",
6404
- options: unionEfforts.map((e) => ({ label: EFFORT_LABEL[e], value: e })),
6509
+ options: unionEfforts.map((e) => ({ label: reasoningEffortLabel(e), value: e })),
6405
6510
  initial: curEffort
6406
6511
  })
6407
6512
  );
@@ -6506,6 +6611,25 @@ function buildWatchdogCustomCard(cfg) {
6506
6611
  { header: { title: "\u23F1 \u81EA\u5B9A\u4E49\u8D85\u65F6", template: "blue" } }
6507
6612
  );
6508
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
+ }
6509
6633
  function buildGroupSettingsCard(project) {
6510
6634
  const kind = project.kind ?? "multi";
6511
6635
  const noMention = project.noMention ?? defaultNoMention(project);
@@ -6644,10 +6768,10 @@ function buildPermissionCard(p) {
6644
6768
  { header: { title: "\u{1F510} \u6743\u9650", template: "blue" } }
6645
6769
  );
6646
6770
  }
6647
- var EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh"];
6771
+ var EFFORT_ORDER = REASONING_EFFORTS;
6648
6772
  function modelDefaultSummary(p) {
6649
6773
  if (!p.defaultModel) return "\u540E\u7AEF\u9ED8\u8BA4\uFF08\u672A\u8BBE\uFF09";
6650
- const eff = p.defaultEffort ? ` \xB7 \u5F3A\u5EA6 ${EFFORT_LABEL[p.defaultEffort]}` : "";
6774
+ const eff = p.defaultEffort ? ` \xB7 \u5F3A\u5EA6 ${reasoningEffortLabel(p.defaultEffort)}` : "";
6651
6775
  return `${p.defaultModel}${eff}`;
6652
6776
  }
6653
6777
  function buildModelDefaultCard(p, models, ctx, notice) {
@@ -6698,7 +6822,7 @@ function buildModelDefaultCard(p, models, ctx, notice) {
6698
6822
  selectMenu({
6699
6823
  name: "effort",
6700
6824
  placeholder: "\u9009\u62E9\u9ED8\u8BA4\u63A8\u7406\u5F3A\u5EA6",
6701
- 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 })),
6702
6826
  initial: curEffort
6703
6827
  })
6704
6828
  );
@@ -6822,6 +6946,8 @@ function buildAddAllowedCard(projectName, members) {
6822
6946
  }
6823
6947
 
6824
6948
  // src/admin/ops.ts
6949
+ init_schema();
6950
+ init_store();
6825
6951
  var AdminWriteError = class extends Error {
6826
6952
  code = "ADMIN_WRITE_REJECTED";
6827
6953
  constructor(reason) {
@@ -6829,7 +6955,26 @@ var AdminWriteError = class extends Error {
6829
6955
  this.name = "AdminWriteError";
6830
6956
  }
6831
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
+ }
6832
6976
  var TIERS = ["qa", "write", "full"];
6977
+ var COMPLETION_REMINDER_MODES = ["manual", "long", "failures", "always"];
6833
6978
  function validateBackendSwitch(opts) {
6834
6979
  if (!opts.registered.includes(opts.target)) {
6835
6980
  return `\u672A\u77E5\u540E\u7AEF\u300C${opts.target}\u300D\uFF08\u53EF\u7528\uFF1A${opts.registered.join("\u3001")}\uFF09`;
@@ -6928,9 +7073,30 @@ async function performSetModelDefault(opts) {
6928
7073
  await updateProject(opts.projectName, { defaultModel: opts.model, defaultEffort: opts.effort });
6929
7074
  return { ok: true, project: await freshOr(opts.projectName, { ...p, defaultModel: opts.model, defaultEffort: opts.effort }) };
6930
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
+ }
6931
7096
  function createAdminWriteExecutor(deps) {
7097
+ const writePreferences = deps.writePreferences ?? (deps.cfg ? createAppPreferencesWriter({ cfg: deps.cfg, persistConfig: deps.persistConfig }) : void 0);
6932
7098
  return async (op) => {
6933
- const outcome = await runAdminWriteOp(op, deps);
7099
+ const outcome = await runAdminWriteOp(op, { ...deps, writePreferences });
6934
7100
  if (!outcome.ok) throw new AdminWriteError(outcome.reason);
6935
7101
  };
6936
7102
  }
@@ -6954,12 +7120,20 @@ async function runAdminWriteOp(op, deps) {
6954
7120
  on: op.on,
6955
7121
  evictLiveSessionsForChat: deps.evictLiveSessionsForChat
6956
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
+ });
6957
7132
  }
6958
7133
  }
6959
7134
 
6960
7135
  // src/bot/handle-message.ts
6961
7136
  init_schema();
6962
- init_store();
6963
7137
 
6964
7138
  // src/card/dispatcher.ts
6965
7139
  init_logger();
@@ -7574,6 +7748,8 @@ function gaugeEl(state) {
7574
7748
  }
7575
7749
  var RC = {
7576
7750
  stop: "run.stop",
7751
+ /** manual completion-reminder mode: notify this turn's requester when it ends. */
7752
+ remind: "run.remind",
7577
7753
  /** goal-only: clear the goal but let the in-flight turn finish (no auto-continue). */
7578
7754
  endGoal: "goal.end"
7579
7755
  };
@@ -7628,8 +7804,16 @@ function renderRunning(state, rc) {
7628
7804
  )
7629
7805
  );
7630
7806
  }
7631
- } else if (rc.cardKey && !rc.hideStop) {
7632
- 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));
7633
7817
  }
7634
7818
  return elements;
7635
7819
  }
@@ -7743,7 +7927,14 @@ function buildQueuedCard(qc) {
7743
7927
  md(`\u23F3 \u6392\u961F\u4E2D\uFF08\u7B2C **${qc.position ?? 1}** \u4F4D\uFF09`),
7744
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")
7745
7929
  ];
7746
- 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
+ }
7747
7938
  return card(els, { summary: "\u6392\u961F\u4E2D" });
7748
7939
  }
7749
7940
  function* groupBlocks(blocks) {
@@ -7825,7 +8016,9 @@ var EFFORT_TIER = {
7825
8016
  low: { label: "\u4F4E", color: "yellow" },
7826
8017
  medium: { label: "\u4E2D", color: "green" },
7827
8018
  high: { label: "\u9AD8", color: "violet" },
7828
- 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" }
7829
8022
  };
7830
8023
  function modelEffortMd(model, effort) {
7831
8024
  if (!effort) return model;
@@ -7970,6 +8163,16 @@ var RunCardStream = class {
7970
8163
  lastAnswerText = "";
7971
8164
  // Per-chat pacer shared with the chat's other streams (set in create()).
7972
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;
7973
8176
  get messageId() {
7974
8177
  return this._messageId;
7975
8178
  }
@@ -8161,8 +8364,10 @@ var RunCardStream = class {
8161
8364
  return false;
8162
8365
  }
8163
8366
  }
8164
- /** Forced whole-card replace for structural/terminal updates. A terminal
8165
- * 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.
8166
8371
  *
8167
8372
  * The terminal frame MUST land — losing it leaves the card "streaming"
8168
8373
  * forever (cursor + dead ⏹) while the run is over and `runsByCard` already
@@ -8170,10 +8375,43 @@ var RunCardStream = class {
8170
8375
  * with exponential backoff (1s/2s/4s, {@link TERMINAL_RL_RETRIES} retries);
8171
8376
  * anything else — typically 200810 "card in ongoing interaction" when the
8172
8377
  * update fires inside a ⏹ click's 3s window — waits out the window and
8173
- * retries once. */
8174
- async updateCard(channel, fullCard) {
8175
- 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);
8176
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;
8177
8415
  const push = async () => {
8178
8416
  await channel.rawClient.cardkit.v1.card.update({
8179
8417
  path: { card_id: this.cardId },
@@ -8185,13 +8423,13 @@ var RunCardStream = class {
8185
8423
  await this.pacer?.wait();
8186
8424
  try {
8187
8425
  await push();
8188
- return;
8426
+ return true;
8189
8427
  } catch (err) {
8190
8428
  const rl = isRateLimited(err);
8191
8429
  if (rl) this.pacer?.penalize();
8192
8430
  if (i >= (rl ? TERMINAL_RL_RETRIES : 1)) {
8193
8431
  log.fail("card", err, { phase: "run-update-retry", cardId: this.cardId, seq: this.seq });
8194
- return;
8432
+ return false;
8195
8433
  }
8196
8434
  log.fail("card", err, { phase: "run-update", cardId: this.cardId, seq: this.seq, retry: true, rateLimited: rl });
8197
8435
  await new Promise((r) => setTimeout(r, rl ? RL_BACKOFF_BASE_MS * 2 ** i : 3200));
@@ -8221,7 +8459,7 @@ function structureSig(card2, eid) {
8221
8459
 
8222
8460
  // src/card/outbound-images.ts
8223
8461
  init_logger();
8224
- import { readFile as readFile7, stat as stat2 } from "fs/promises";
8462
+ import { readFile as readFile8, stat as stat2 } from "fs/promises";
8225
8463
  import { extname as extname2, isAbsolute, resolve as resolve2, sep } from "path";
8226
8464
  var MAX_IMAGES = 9;
8227
8465
  var MAX_BYTES = 10 * 1024 * 1024;
@@ -8308,7 +8546,7 @@ async function loadLocal(src, cwd) {
8308
8546
  log.warn("outbound", "image-size", { size, src: src.slice(0, 80) });
8309
8547
  return { cacheKey: `local:${abs}:${size}` };
8310
8548
  }
8311
- const buffer = await readFile7(abs);
8549
+ const buffer = await readFile8(abs);
8312
8550
  return { buffer, cacheKey: `local:${abs}:${mtimeMs}:${size}` };
8313
8551
  }
8314
8552
  async function loadRemote(url) {
@@ -8361,7 +8599,7 @@ init_logger();
8361
8599
  init_cards2();
8362
8600
 
8363
8601
  // src/cli-bridge/hooks.ts
8364
- import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
8602
+ import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile6 } from "fs/promises";
8365
8603
  import { homedir as homedir3 } from "os";
8366
8604
  import { join as join10, resolve as resolve3 } from "path";
8367
8605
  var AGENT2LARK_MARKER = "agent2lark-hook";
@@ -8382,7 +8620,7 @@ async function inspectCliBridgeHooks(opts = {}) {
8382
8620
  const codex = await readJson(join10(home, ".codex", "hooks.json"));
8383
8621
  let codexStatus = inspectAgent("codex", codex, [...CODEX_EVENTS]);
8384
8622
  if (codexStatus.status === "installed") {
8385
- const toml = await readFile8(join10(home, ".codex", "config.toml"), "utf8").catch(() => "");
8623
+ const toml = await readFile9(join10(home, ".codex", "config.toml"), "utf8").catch(() => "");
8386
8624
  if (!hasCodexHooksFeature(toml)) {
8387
8625
  codexStatus = {
8388
8626
  agent: "codex",
@@ -8410,7 +8648,7 @@ async function installCliBridgeHooks(opts) {
8410
8648
  json.hooks = installAgentGroups(json.hooks, "codex", [...CODEX_EVENTS], opts.command);
8411
8649
  await writeJson(file, json);
8412
8650
  const tomlPath = join10(home, ".codex", "config.toml");
8413
- const existing = await readFile8(tomlPath, "utf8").catch(() => "");
8651
+ const existing = await readFile9(tomlPath, "utf8").catch(() => "");
8414
8652
  await mkdir6(join10(home, ".codex"), { recursive: true });
8415
8653
  await writeFile6(tomlPath, withCodexHooksFeature(existing), "utf8");
8416
8654
  }
@@ -8419,7 +8657,7 @@ function resolveHome(homeDir) {
8419
8657
  return homeDir ?? homedir3();
8420
8658
  }
8421
8659
  async function readJson(path) {
8422
- const text = await readFile8(path, "utf8").catch(() => "{}");
8660
+ const text = await readFile9(path, "utf8").catch(() => "{}");
8423
8661
  const parsed = JSON.parse(text || "{}");
8424
8662
  return typeof parsed === "object" && parsed ? parsed : {};
8425
8663
  }
@@ -8539,6 +8777,81 @@ function withCodexHooksFeature(text) {
8539
8777
  return [...lines, ""].join("\n");
8540
8778
  }
8541
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
+
8542
8855
  // src/service/update.ts
8543
8856
  init_paths();
8544
8857
  import { closeSync, existsSync as existsSync9, openSync as openSync2, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync2, writeSync } from "fs";
@@ -8556,7 +8869,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
8556
8869
  // src/service/common.ts
8557
8870
  init_paths();
8558
8871
  import { appendFileSync, createReadStream, statSync as statSync2 } from "fs";
8559
- import { appendFile, mkdir as mkdir7, readFile as readFile9 } from "fs/promises";
8872
+ import { appendFile, mkdir as mkdir7, readFile as readFile10 } from "fs/promises";
8560
8873
  import { dirname as dirname6, join as join11, resolve as resolve4 } from "path";
8561
8874
  import { fileURLToPath as fileURLToPath2 } from "url";
8562
8875
  function serviceStdoutPath() {
@@ -8625,7 +8938,7 @@ function fileSize(file) {
8625
8938
  }
8626
8939
  async function lastLines(file, n) {
8627
8940
  try {
8628
- const text = await readFile9(file, "utf8");
8941
+ const text = await readFile10(file, "utf8");
8629
8942
  return text.split("\n").slice(-n - 1).join("\n").trimEnd();
8630
8943
  } catch {
8631
8944
  return "";
@@ -9435,7 +9748,7 @@ async function restartDaemon() {
9435
9748
 
9436
9749
  // src/agent/codex-appserver/usage.ts
9437
9750
  init_logger();
9438
- import { readFile as readFile10 } from "fs/promises";
9751
+ import { readFile as readFile11 } from "fs/promises";
9439
9752
  import { homedir as homedir6 } from "os";
9440
9753
  import { join as join16 } from "path";
9441
9754
  var DEFAULT_BASE_URL = "https://chatgpt.com/backend-api";
@@ -9453,7 +9766,7 @@ async function readCodexAuth() {
9453
9766
  for (let i = 0; i < 3; i++) {
9454
9767
  let raw;
9455
9768
  try {
9456
- raw = await readFile10(file, "utf8");
9769
+ raw = await readFile11(file, "utf8");
9457
9770
  } catch (err) {
9458
9771
  throw new UsageError("no-auth", `\u8BFB\u4E0D\u5230 ${file}\uFF1A${err instanceof Error ? err.message : String(err)}`);
9459
9772
  }
@@ -9482,7 +9795,7 @@ function jwtExpMs(token) {
9482
9795
  }
9483
9796
  async function chatgptBaseUrl() {
9484
9797
  try {
9485
- const raw = await readFile10(join16(resolveCodexHome(), "config.toml"), "utf8");
9798
+ const raw = await readFile11(join16(resolveCodexHome(), "config.toml"), "utf8");
9486
9799
  for (const line of raw.split("\n")) {
9487
9800
  const t = line.trim();
9488
9801
  if (t.startsWith("[")) break;
@@ -9845,7 +10158,15 @@ function heatmapElements(p, today) {
9845
10158
  return [md("\u{1F4C8} **\u6BCF\u65E5 Token \u7528\u91CF**"), heatmapChartEl(p.dailyBuckets, today)];
9846
10159
  }
9847
10160
  function effortLabel(effort) {
9848
- 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
+ };
9849
10170
  return m[effort] ?? effort;
9850
10171
  }
9851
10172
  function insightsElements(p) {
@@ -10086,6 +10407,7 @@ init_paths();
10086
10407
  init_logger();
10087
10408
  import { mkdir as mkdir11 } from "fs/promises";
10088
10409
  import { existsSync as existsSync10 } from "fs";
10410
+ import { homedir as homedir7 } from "os";
10089
10411
  import { isAbsolute as isAbsolute2, join as join17, resolve as resolve7 } from "path";
10090
10412
 
10091
10413
  // src/project/announcement.ts
@@ -10275,22 +10597,38 @@ function assertBackendUsable(backend, mode) {
10275
10597
  }
10276
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`);
10277
10599
  }
10278
- async function resolveCwd(name, existingPath) {
10600
+ async function resolveCwd(name, existingPath, configuredProjectsRootDir) {
10279
10601
  if (existingPath) {
10280
10602
  const cwd2 = isAbsolute2(existingPath) ? existingPath : resolve7(existingPath);
10281
10603
  if (!existsSync10(cwd2)) throw new Error(`\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\uFF1A${cwd2}`);
10282
10604
  return { cwd: cwd2, blank: false };
10283
10605
  }
10284
- const cwd = join17(paths.projectsRootDir, name);
10606
+ const cwd = join17(resolveProjectsRootDir(configuredProjectsRootDir), name);
10285
10607
  await mkdir11(cwd, { recursive: true });
10286
10608
  return { cwd, blank: true };
10287
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
+ }
10288
10626
  async function createProject(channel, input2) {
10289
10627
  const name = input2.name.trim();
10290
10628
  if (!name) throw new Error("\u9879\u76EE\u540D\u4E0D\u80FD\u4E3A\u7A7A");
10291
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`);
10292
10630
  assertBackendUsable(input2.backend, input2.mode ?? "full");
10293
- const { cwd, blank } = await resolveCwd(name, input2.existingPath);
10631
+ const { cwd, blank } = await resolveCwd(name, input2.existingPath, input2.projectsRootDir);
10294
10632
  const res = await channel.rawClient.im.v1.chat.create({
10295
10633
  params: { user_id_type: "open_id" },
10296
10634
  data: { name, user_id_list: [input2.ownerOpenId] }
@@ -10327,7 +10665,7 @@ async function joinExistingGroup(channel, input2) {
10327
10665
  const bound = await getProjectByChatId(input2.chatId);
10328
10666
  if (bound) throw new Error(`\u8BE5\u7FA4\u5DF2\u7ED1\u5B9A\u4E3A\u9879\u76EE\u300C${bound.name}\u300D`);
10329
10667
  assertBackendUsable(input2.backend, input2.mode ?? "qa");
10330
- const { cwd, blank } = await resolveCwd(name, input2.existingPath);
10668
+ const { cwd, blank } = await resolveCwd(name, input2.existingPath, input2.projectsRootDir);
10331
10669
  const project = {
10332
10670
  name,
10333
10671
  chatId: input2.chatId,
@@ -10367,7 +10705,7 @@ async function leaveChat(channel, chatId) {
10367
10705
 
10368
10706
  // src/bot/session-store.ts
10369
10707
  init_paths();
10370
- import { mkdir as mkdir12, readFile as readFile11, rename as rename5, writeFile as writeFile10 } from "fs/promises";
10708
+ import { mkdir as mkdir12, readFile as readFile12, rename as rename5, writeFile as writeFile10 } from "fs/promises";
10371
10709
  import { randomUUID as randomUUID5 } from "crypto";
10372
10710
  import { dirname as dirname12 } from "path";
10373
10711
  var FILE_VERSION3 = 2;
@@ -10386,7 +10724,7 @@ async function read2() {
10386
10724
  }
10387
10725
  async function listSessionsIn(file) {
10388
10726
  try {
10389
- const text = await readFile11(file, "utf8");
10727
+ const text = await readFile12(file, "utf8");
10390
10728
  const parsed = JSON.parse(text);
10391
10729
  if (!Array.isArray(parsed.sessions)) return [];
10392
10730
  return parsed.sessions.map(migrate);
@@ -11020,7 +11358,7 @@ function weaveSender(text, sender) {
11020
11358
 
11021
11359
  // src/bot/comments.ts
11022
11360
  init_logger();
11023
- import { mkdir as mkdir14, readdir as readdir4, readFile as readFile12, writeFile as writeFile11 } from "fs/promises";
11361
+ import { mkdir as mkdir14, readdir as readdir4, readFile as readFile13, writeFile as writeFile11 } from "fs/promises";
11024
11362
  import { dirname as dirname13, join as join19 } from "path";
11025
11363
  var SUPPORTED_FILE_TYPES = /* @__PURE__ */ new Set(["doc", "docx", "sheet", "bitable"]);
11026
11364
  var REPLY_MAX_CHARS = 2e3;
@@ -11149,7 +11487,7 @@ function commentCwd(projectsRoot, fileType, fileToken) {
11149
11487
  }
11150
11488
  async function loadCommentInstructions(masterFile) {
11151
11489
  try {
11152
- const existing = await readFile12(masterFile, "utf8");
11490
+ const existing = await readFile13(masterFile, "utf8");
11153
11491
  return existing.trim() ? existing : DEFAULT_COMMENT_INSTRUCTIONS;
11154
11492
  } catch {
11155
11493
  }
@@ -11461,7 +11799,6 @@ function selectValue(formValue, name) {
11461
11799
  function asTier(v) {
11462
11800
  return v === "qa" || v === "write" || v === "full" ? v : void 0;
11463
11801
  }
11464
- var REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
11465
11802
  function asEffort(v) {
11466
11803
  return v !== void 0 && REASONING_EFFORTS.includes(v) ? v : void 0;
11467
11804
  }
@@ -11488,6 +11825,22 @@ function safeBackendId(formValue) {
11488
11825
  const v = selectValue(formValue, "backend");
11489
11826
  return v && backendIds().includes(v) ? v : void 0;
11490
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
+ }
11491
11844
  function parseGoalTrigger(text) {
11492
11845
  if (!/(^|\s)\/goal(?=\s|$)/i.test(text)) return null;
11493
11846
  const objective = text.replace(/(^|\s)\/goal(?=\s|$)/gi, " ").replace(/\s+/g, " ").trim();
@@ -11584,14 +11937,21 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11584
11937
  const commentInstrUsed = /* @__PURE__ */ new Map();
11585
11938
  const sema = new Semaphore(getMaxConcurrentRuns(cfg));
11586
11939
  const currentIdleMs = () => getRunIdleTimeoutMs(cfg) ?? 0;
11940
+ const writePreferences = createAppPreferencesWriter({ cfg });
11587
11941
  const resumePending = /* @__PURE__ */ new Map();
11588
11942
  const modelPending = /* @__PURE__ */ new Map();
11589
11943
  const runsByCard = /* @__PURE__ */ new Map();
11590
11944
  const runCards = /* @__PURE__ */ new Map();
11591
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);
11592
11951
  const lastRunCard = /* @__PURE__ */ new Map();
11593
11952
  const lastUsage = /* @__PURE__ */ new Map();
11594
11953
  const seenInbound = new RecentIdCache();
11954
+ const completionReminderView = (state) => shouldShowCompletionReminderButton(cfg) ? state.completionReminderRequested ? "requested" : "available" : void 0;
11595
11955
  const listModels = (be = backend) => be.listModels();
11596
11956
  async function addReaction(messageId, emoji) {
11597
11957
  try {
@@ -11899,7 +12259,12 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11899
12259
  }
11900
12260
  }
11901
12261
  }
11902
- 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
+ });
11903
12268
  log.info("intake", "queued", { depth: cur.queue.length });
11904
12269
  return;
11905
12270
  }
@@ -11924,7 +12289,12 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11924
12289
  void replyGoalBusy(msg, flat);
11925
12290
  return;
11926
12291
  }
11927
- 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
+ });
11928
12298
  log.info("intake", "queued", { depth: existing.queue.length });
11929
12299
  return;
11930
12300
  }
@@ -12000,7 +12370,9 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12000
12370
  firstText,
12001
12371
  images,
12002
12372
  knownThreadId: sessionKey,
12373
+ summary: stripFileTokens(summaryText2 ?? text).slice(0, 80) || "(\u672C\u8F6E\u4EFB\u52A1)",
12003
12374
  requesterOpenId: msg.senderId,
12375
+ requestedAt: msg.createTime || tIntake,
12004
12376
  // 编织完成 → turn/start 之间不再读盘:首轮直接用预取的会话记录
12005
12377
  // (prior=undefined 即确知是全新会话,刚 upsert 的记录还没有 model)。
12006
12378
  firstRec: prior ?? null,
@@ -12119,6 +12491,7 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12119
12491
  cwd,
12120
12492
  summary: stripFileTokens(text).slice(0, 80) || "(\u7A7A)",
12121
12493
  requesterOpenId: msg.senderId,
12494
+ requestedAt: msg.createTime || tIntake,
12122
12495
  roleSuffix: perm.roleSuffix,
12123
12496
  backendId: be.id,
12124
12497
  timing: { tResolve: tResolveDone - tIntake, tWeave: Date.now() - tIntake }
@@ -12483,6 +12856,35 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12483
12856
  }
12484
12857
  st.interrupt?.();
12485
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 });
12486
12888
  }).on(RC.endGoal, ({ evt, value }) => {
12487
12889
  const key = typeof value.m === "string" ? value.m : evt.messageId;
12488
12890
  if (!runAllowed(evt)) return;
@@ -12510,12 +12912,49 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12510
12912
  return m;
12511
12913
  };
12512
12914
  function applyPref(evt, mut, opts) {
12513
- if (!dmAdmin(evt.operator?.openId)) return;
12514
- const prefs = { ...cfg.preferences ?? {} };
12515
- mut(prefs);
12516
- cfg.preferences = prefs;
12517
- void saveConfig(cfg).catch((err) => log.fail("console", err, { phase: "save-config" }));
12518
- 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;
12519
12958
  }
12520
12959
  let cliHookStatuses;
12521
12960
  function renderSettings() {
@@ -12537,20 +12976,36 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12537
12976
  function commentBackendOptions() {
12538
12977
  return visibleCatalog().filter((e) => e.id === DEFAULT_BACKEND_ID || isBackendEntryInstalled(e)).map((e) => ({ id: e.id, label: e.displayName }));
12539
12978
  }
12979
+ let commentSettingsRenderGeneration = 0;
12540
12980
  async function renderCommentSettings(notice) {
12541
- const comments = getCommentsConfig(cfg);
12542
- const models = await listModels(backendFor(comments.backend)).catch(() => []);
12543
- 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
+ }
12544
12997
  }
12545
12998
  function applyCommentsPref(evt, mut) {
12546
12999
  if (!dmAdmin(evt.operator?.openId)) return;
12547
- const prefs = { ...cfg.preferences ?? {} };
12548
- const comments = { ...prefs.comments ?? {} };
12549
- mut(comments);
12550
- prefs.comments = comments;
12551
- cfg.preferences = prefs;
12552
- void saveConfig(cfg).catch((err) => log.fail("console", err, { phase: "save-config" }));
12553
- 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
+ });
12554
13009
  }
12555
13010
  const freshMenu = (evt) => {
12556
13011
  patch(evt, buildDmMenuCard({ webConsoleUrl: webConsoleUrl(), version: bridgeVersion() }));
@@ -12655,7 +13110,14 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12655
13110
  else if (!op) result = buildNewProjectFormCard({ name, cwd: cwdIn, error: "\u65E0\u6CD5\u8BC6\u522B\u64CD\u4F5C\u8005\u8EAB\u4EFD", backends: backends2 });
12656
13111
  else {
12657
13112
  try {
12658
- 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
+ });
12659
13121
  log.info("console", "new-project", { name: p.name, blank: p.blank, backend: p.backend });
12660
13122
  result = buildNewProjectDoneCard(p);
12661
13123
  } catch (err) {
@@ -12683,7 +13145,16 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12683
13145
  else if (!op) result = buildJoinGroupFormCard({ chatId, name, cwd: cwdIn, error: "\u65E0\u6CD5\u8BC6\u522B\u64CD\u4F5C\u8005\u8EAB\u4EFD", backends: backends2 });
12684
13146
  else {
12685
13147
  try {
12686
- 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
+ });
12687
13158
  log.info("console", "join-group", { name: p.name, blank: p.blank, backend: p.backend, mode: p.mode });
12688
13159
  result = buildNewProjectDoneCard(p);
12689
13160
  } catch (err) {
@@ -12702,26 +13173,15 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12702
13173
  if (dmAdmin(evt.operator?.openId)) await patch(evt, renderSettings);
12703
13174
  }).on(DM.coffeeSettings, async ({ evt }) => {
12704
13175
  if (dmAdmin(evt.operator?.openId)) await patch(evt, () => renderCoffeeSettings(true));
12705
- }).on(CLI.toggleEnabled, async ({ evt, value }) => {
13176
+ }).on(CLI.toggleEnabled, ({ evt, value }) => {
12706
13177
  if (!dmAdmin(evt.operator?.openId)) return;
12707
13178
  const enabled = value.v === "on";
12708
13179
  if (enabled && !canEnableCliBridge(cfg).ok) return;
12709
- try {
12710
- if (enabled) {
12711
- await cliBridge?.start?.();
12712
- applyPref(evt, (p) => {
12713
- p.cliBridge = { ...p.cliBridge ?? {}, enabled: true };
12714
- }, { render: false });
12715
- } else {
12716
- applyPref(evt, (p) => {
12717
- p.cliBridge = { ...p.cliBridge ?? {}, enabled: false };
12718
- }, { render: false });
12719
- await cliBridge?.shutdown?.();
12720
- }
12721
- } catch (err) {
12722
- log.fail("cli-bridge", err, { phase: enabled ? "enable" : "disable" });
12723
- }
12724
- void patch(evt, renderCoffeeSettings);
13180
+ const transition = setCliBridgeEnabled(evt, enabled);
13181
+ void patch(evt, async () => {
13182
+ await transition;
13183
+ return renderCoffeeSettings();
13184
+ });
12725
13185
  }).on(CLI.repairHooks, async ({ evt }) => {
12726
13186
  if (!dmAdmin(evt.operator?.openId)) return;
12727
13187
  try {
@@ -12736,27 +13196,36 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
12736
13196
  }).on(CLI.setNotifyScope, ({ evt, value }) => {
12737
13197
  if (!dmAdmin(evt.operator?.openId)) return;
12738
13198
  const scope = value.v === "bound_projects" || value.v === "none" ? value.v : "all";
12739
- applyPref(evt, (p) => {
13199
+ const saved = applyPref(evt, (p) => {
12740
13200
  p.cliBridge = { ...p.cliBridge ?? {}, notifyScope: scope };
12741
13201
  }, { render: false });
12742
- void patch(evt, renderCoffeeSettings);
13202
+ void patch(evt, async () => {
13203
+ await saved;
13204
+ return renderCoffeeSettings();
13205
+ });
12743
13206
  }).on(CLI.toggleAgent, ({ evt, value }) => {
12744
13207
  if (!dmAdmin(evt.operator?.openId)) return;
12745
13208
  const agent = value.agent === "codex" ? "codex" : "claude";
12746
13209
  const on = value.v === "on";
12747
- applyPref(evt, (p) => {
13210
+ const saved = applyPref(evt, (p) => {
12748
13211
  const cur = p.cliBridge ?? {};
12749
13212
  p.cliBridge = { ...cur, agents: { ...cur.agents ?? {}, [agent]: on } };
12750
13213
  }, { render: false });
12751
- void patch(evt, renderCoffeeSettings);
13214
+ void patch(evt, async () => {
13215
+ await saved;
13216
+ return renderCoffeeSettings();
13217
+ });
12752
13218
  }).on(CLI.toggleKeepAwake, ({ evt, value }) => {
12753
13219
  if (!dmAdmin(evt.operator?.openId)) return;
12754
13220
  const on = value.v === "on";
12755
- applyPref(evt, (p) => {
13221
+ const saved = applyPref(evt, (p) => {
12756
13222
  const cur = p.cliBridge ?? {};
12757
13223
  p.cliBridge = { ...cur, keepAwake: { ...cur.keepAwake ?? {}, enabled: on } };
12758
13224
  }, { render: false });
12759
- void patch(evt, renderCoffeeSettings);
13225
+ void patch(evt, async () => {
13226
+ await saved;
13227
+ return renderCoffeeSettings();
13228
+ });
12760
13229
  }).on(DM.doctor, async ({ evt }) => {
12761
13230
  if (!dmAdmin(evt.operator?.openId)) return;
12762
13231
  await sendManagedCard(channel, evt.chatId, buildDoctorCard(await buildDoctorInfo()), evt.messageId).catch(
@@ -12925,6 +13394,52 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
12925
13394
  }
12926
13395
  const sec = n === 0 ? 0 : Math.min(Math.max(Math.floor(n), RUN_IDLE_TIMEOUT_MIN_SEC), RUN_IDLE_TIMEOUT_MAX_SEC);
12927
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
+ });
12928
13443
  }).on(DM.setPending, ({ evt, value }) => {
12929
13444
  if (value.v === "steer" || value.v === "queue") applyPref(evt, (p) => p.pendingPolicy = value.v);
12930
13445
  }).on(DM.setConcurrency, ({ evt, value }) => {
@@ -13080,11 +13595,12 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13080
13595
  log.info("console", "admin-add", { picked: id?.slice(-6) ?? null });
13081
13596
  void (async () => {
13082
13597
  if (id) {
13083
- const access = { ...cfg.preferences?.access ?? {} };
13084
- access.ownerOpenId ??= resolveOwner(cfg);
13085
- access.admins = Array.from(/* @__PURE__ */ new Set([...access.admins ?? [], id]));
13086
- cfg.preferences = { ...cfg.preferences ?? {}, access };
13087
- 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" }));
13088
13604
  }
13089
13605
  const ids = [resolveOwner(cfg), ...cfg.preferences?.access?.admins ?? []];
13090
13606
  const next = buildAdminsCard(cfg, await namesWithOperator(evt, ids));
@@ -13095,11 +13611,12 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13095
13611
  const id = typeof value.u === "string" ? value.u : "";
13096
13612
  patch(evt, async () => {
13097
13613
  if (id && id !== resolveOwner(cfg)) {
13098
- const access = { ...cfg.preferences?.access ?? {} };
13099
- access.ownerOpenId ??= resolveOwner(cfg);
13100
- access.admins = (access.admins ?? []).filter((x) => x !== id);
13101
- cfg.preferences = { ...cfg.preferences ?? {}, access };
13102
- 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" }));
13103
13620
  }
13104
13621
  const ids = [resolveOwner(cfg), ...cfg.preferences?.access?.admins ?? []];
13105
13622
  return buildAdminsCard(cfg, await namesWithOperator(evt, ids));
@@ -13324,22 +13841,36 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13324
13841
  settleUpdate(evt.messageId, buildResumeErrorCard(state, err instanceof Error ? err.message : String(err)));
13325
13842
  }
13326
13843
  }
13844
+ async function sendCompletionReminder(input2) {
13845
+ await sendCompletionReminderReply({ channel, cfg, dedupe: completionReminderSent }, input2);
13846
+ }
13327
13847
  async function acquireRunSlot(opts, state, activeKey, reaction) {
13328
13848
  if (sema.hasFree()) return { release: await sema.acquire() };
13329
13849
  const stream2 = new RunCardStream();
13330
13850
  let msgId;
13851
+ const queueCard = (input2) => buildQueuedCard({
13852
+ ...input2,
13853
+ ...!state.isGoal ? { completionReminder: completionReminderView(state) } : {}
13854
+ });
13331
13855
  const q = sema.enqueue((pos) => {
13332
- if (msgId) stream2.streamCoalesced(channel, buildQueuedCard({ position: pos, cardKey: msgId }), null);
13856
+ if (msgId) stream2.streamCoalesced(channel, queueCard({ position: pos, cardKey: msgId }), null);
13333
13857
  });
13334
13858
  try {
13335
- msgId = await stream2.create(channel, opts.chatId, buildQueuedCard({ position: q.position() }), {
13859
+ msgId = await stream2.create(channel, opts.chatId, queueCard({ position: q.position() }), {
13336
13860
  replyTo: opts.replyTo,
13337
13861
  replyInThread: opts.flat ? false : opts.replyInThread ?? Boolean(opts.knownThreadId)
13338
13862
  });
13339
13863
  const pos = q.position();
13340
- 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 }));
13341
13865
  runsByCard.set(msgId, state);
13342
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
+ }
13343
13874
  } catch (err) {
13344
13875
  log.fail("card", err, { phase: "queued-card" });
13345
13876
  }
@@ -13352,7 +13883,8 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13352
13883
  if (msgId) {
13353
13884
  runsByCard.delete(msgId);
13354
13885
  runStreams.delete(msgId);
13355
- 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 }));
13356
13888
  }
13357
13889
  reaction?.done();
13358
13890
  log.info("card", "action", { actionId: "run.stop", queuedCancel: true });
@@ -13407,10 +13939,17 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13407
13939
  let intake = opts.timing;
13408
13940
  let firstRec = opts.firstRec;
13409
13941
  try {
13410
- 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
+ };
13411
13948
  let replyTo = opts.replyTo;
13412
13949
  let replyInThread = opts.flat ? false : opts.replyInThread ?? Boolean(opts.knownThreadId);
13413
13950
  for (; ; ) {
13951
+ const turnInput = currentTurn.input;
13952
+ state.requesterOpenId = currentTurn.requesterOpenId;
13414
13953
  const rec = firstRec !== void 0 ? firstRec ?? void 0 : topicThreadId ? await getSession(topicThreadId) : void 0;
13415
13954
  firstRec = void 0;
13416
13955
  const turnModel = rec?.model ?? opts.model;
@@ -13424,8 +13963,9 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13424
13963
  let cardMsgId;
13425
13964
  const rc = {
13426
13965
  rs: render.snapshot(),
13427
- requesterOpenId: opts.requesterOpenId,
13966
+ requesterOpenId: currentTurn.requesterOpenId,
13428
13967
  showTools: render.showTools,
13968
+ completionReminder: completionReminderView(state),
13429
13969
  // 模型显示档位:footnote 本轮 model·推理强度;always 档终态卡也保留。
13430
13970
  ...modelDisp !== "off" && turnModel ? { model: turnModel, effort: turnEffort, modelOnTerminal: modelDisp === "always" } : {}
13431
13971
  };
@@ -13469,6 +14009,11 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13469
14009
  rc.cardKey = cardMsgId;
13470
14010
  runsByCard.set(cardMsgId, state);
13471
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);
13472
14017
  await adoptThreadId(cardMsgId);
13473
14018
  if (!firstCardSent) {
13474
14019
  firstCardSent = true;
@@ -13526,6 +14071,7 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13526
14071
  }
13527
14072
  render.apply(ev);
13528
14073
  rc.rs = render.snapshot();
14074
+ rc.completionReminder = completionReminderView(state);
13529
14075
  stream2.streamCoalesced(channel, buildRunCard(rc), ANSWER_EID);
13530
14076
  }
13531
14077
  const doneAt = Date.now();
@@ -13534,12 +14080,15 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13534
14080
  state.interrupt = void 0;
13535
14081
  const interrupted = stopper.interrupted();
13536
14082
  const killed = timedOut || interrupted && stopper.forced();
13537
- if (interrupted) render.interrupt();
13538
- else if (timedOut) render.timeout(Math.round(idleMs / 1e3));
13539
- 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
+ });
13540
14090
  rc.rs = render.snapshot();
13541
14091
  if (interrupted) log.info("agent", "interrupt", { graceful: !stopper.forced(), threadId: topicThreadId ?? null });
13542
- const procDead = !killed && !opts.thread.isAlive();
13543
14092
  if (killed || procDead) {
13544
14093
  void opts.thread.close().catch(() => void 0);
13545
14094
  if (topicThreadId) sessions.delete(topicThreadId);
@@ -13557,7 +14106,9 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13557
14106
  if (imgSources.length > 0) {
13558
14107
  rc.images = await uploadOutboundImages(channel, imgSources, opts.cwd ?? fallbackCwd);
13559
14108
  }
13560
- 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));
13561
14112
  {
13562
14113
  const terminalAt = Date.now();
13563
14114
  const st = stream2.stats();
@@ -13597,6 +14148,16 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13597
14148
  touchSession(topicThreadId);
13598
14149
  await patchSession(topicThreadId, { updatedAt: Date.now() });
13599
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
+ });
13600
14161
  replyTo = finalMsgId;
13601
14162
  replyInThread = !opts.flat;
13602
14163
  log.info("card", "final", { terminal: render.terminal() });
@@ -13612,14 +14173,18 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
13612
14173
  break;
13613
14174
  }
13614
14175
  if (state.queue.length === 0) break;
13615
- turnInput = state.queue.shift();
14176
+ currentTurn = state.queue.shift();
14177
+ activateQueuedTurn(state, currentTurn);
13616
14178
  }
13617
14179
  } catch (err) {
13618
14180
  log.fail("intake", err);
13619
14181
  await channel.send(opts.chatId, { markdown: `\u274C ${err instanceof Error ? err.message : String(err)}` }, { replyTo: opts.replyTo, replyInThread: !opts.flat }).catch(() => void 0);
13620
14182
  } finally {
13621
14183
  active.delete(activeKey);
13622
- if (curCardKey) runsByCard.delete(curCardKey);
14184
+ if (curCardKey) {
14185
+ runsByCard.delete(curCardKey);
14186
+ completionReminderRefreshers.delete(curCardKey);
14187
+ }
13623
14188
  if (activeKey.startsWith("pending:")) {
13624
14189
  void opts.thread.close().catch(() => void 0);
13625
14190
  log.warn("intake", "unadopted-thread-closed", { activeKey });
@@ -14198,7 +14763,11 @@ ${facts}`;
14198
14763
  log.info("bridge", "shutdown", { closed: live.length });
14199
14764
  }
14200
14765
  void backend.listModels().catch((err) => log.fail("agent", err, { phase: "models-prewarm" }));
14201
- 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
+ };
14202
14771
  return { onMessage, onComment, onBotAddedToChat, onBotRemovedFromChat, onReaction, onBotMenu, dispatcher, adminExecute, shutdown };
14203
14772
  }
14204
14773
  async function getThreadId(channel, messageId, attempts = 1) {
@@ -14398,10 +14967,9 @@ function createAdminIpcResponder(execute, send) {
14398
14967
  // src/admin/service.ts
14399
14968
  init_bots();
14400
14969
  init_paths();
14401
- import { readFile as readFile13, rm as rm7 } from "fs/promises";
14970
+ import { readFile as readFile14, rm as rm7 } from "fs/promises";
14402
14971
  init_schema();
14403
14972
  init_store();
14404
- init_schema();
14405
14973
 
14406
14974
  // src/bot/register-bot.ts
14407
14975
  init_store();
@@ -14571,7 +15139,7 @@ function createAdminService(deps = {}) {
14571
15139
  }
14572
15140
  async function lockFileRunState(botId) {
14573
15141
  try {
14574
- const raw = await readFile13(botPaths(botId).processesFile, "utf8");
15142
+ const raw = await readFile14(botPaths(botId).processesFile, "utf8");
14575
15143
  const rec = JSON.parse(raw);
14576
15144
  if (typeof rec.pid === "number" && isAlive2(rec.pid)) {
14577
15145
  return { running: true, pid: rec.pid, startedAt: rec.startedAt };
@@ -14584,6 +15152,13 @@ function createAdminService(deps = {}) {
14584
15152
  const live = await deps.liveStatus?.(botId).catch(() => void 0);
14585
15153
  return live ?? lockFileRunState(botId);
14586
15154
  }
15155
+ async function completionReminderFor(botId) {
15156
+ try {
15157
+ return getCompletionReminderConfig(await loadConfig(botPaths(botId).configFile));
15158
+ } catch {
15159
+ return getCompletionReminderConfig({});
15160
+ }
15161
+ }
14587
15162
  function executeWrite(botId, action, op) {
14588
15163
  if (!deps.executeWrite) throw new NotWiredYetError(action);
14589
15164
  return deps.executeWrite(botId, op);
@@ -14594,7 +15169,7 @@ function createAdminService(deps = {}) {
14594
15169
  const configured = reg.bots.some((b) => b.active !== void 0);
14595
15170
  const out = [];
14596
15171
  for (const b of reg.bots) {
14597
- const run = await runState(b.appId);
15172
+ const [run, completionReminder] = await Promise.all([runState(b.appId), completionReminderFor(b.appId)]);
14598
15173
  out.push({
14599
15174
  name: b.name,
14600
15175
  appId: b.appId,
@@ -14606,7 +15181,8 @@ function createAdminService(deps = {}) {
14606
15181
  running: run.running,
14607
15182
  pid: run.pid,
14608
15183
  startedAt: run.startedAt,
14609
- connection: run.connection
15184
+ connection: run.connection,
15185
+ completionReminder
14610
15186
  });
14611
15187
  }
14612
15188
  return out;
@@ -14635,6 +15211,13 @@ function createAdminService(deps = {}) {
14635
15211
  async setAutoCompact(botId, projectName, on) {
14636
15212
  await executeWrite(botId, "\u{1F5DC}\uFE0F \u81EA\u52A8\u538B\u7F29\u5F00\u5173", { kind: "setAutoCompact", project: projectName, on });
14637
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
+ },
14638
15221
  doctorBackends() {
14639
15222
  return probeAllBackends();
14640
15223
  },
@@ -15122,6 +15705,7 @@ init_logger();
15122
15705
 
15123
15706
  // src/web/server.ts
15124
15707
  init_paths();
15708
+ init_schema();
15125
15709
  import { createServer } from "http";
15126
15710
  import { randomUUID as randomUUID7, timingSafeEqual } from "crypto";
15127
15711
  import { mkdirSync as mkdirSync3, watch } from "fs";
@@ -15706,6 +16290,11 @@ var UI_HTML = `<!doctype html>
15706
16290
  .drawer-mask.open { display: block; }
15707
16291
  .drawer h3 { margin: 0 0 4px; font-size: 16px; }
15708
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); }
15709
16298
  .backend-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
15710
16299
  .backend-row .grow { flex: 1; min-width: 0; }
15711
16300
  .bk-group { margin: 6px 0 2px; }
@@ -17182,6 +17771,9 @@ ${UI_PURE_JS}
17182
17771
  left.appendChild(projCard);
17183
17772
  renderProjects(projList, pcount, b);
17184
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
+
17185
17777
  cols.appendChild(left);
17186
17778
  cols.appendChild(right);
17187
17779
  root.appendChild(cols);
@@ -17253,6 +17845,58 @@ ${UI_PURE_JS}
17253
17845
  });
17254
17846
  }
17255
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
+
17256
17900
  function renderProjects(box, countEl, b) {
17257
17901
  box.textContent = '';
17258
17902
  var projects = (b && b.projects) || [];
@@ -17873,6 +18517,7 @@ var LOGO_PNG = Buffer.from(LOGO_PNG_BASE64, "base64");
17873
18517
  var DEFAULT_WEB_PORT = 51847;
17874
18518
  var COOKIE_NAME = "fcb_console_token";
17875
18519
  var SSE_INITIAL_TAIL_BYTES = 16 * 1024;
18520
+ var COMPLETION_REMINDER_MODES2 = ["manual", "long", "failures", "always"];
17876
18521
  function createWebServer(opts) {
17877
18522
  const token = opts.token ?? randomUUID7();
17878
18523
  const html = opts.html ?? UI_HTML;
@@ -18048,6 +18693,45 @@ function createWebServer(opts) {
18048
18693
  sendJson(res, 200, status);
18049
18694
  return;
18050
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
+ }
18051
18735
  const botMatch = /^\/api\/bots\/([^/]+)$/.exec(pathName);
18052
18736
  if (req.method === "PATCH" && botMatch) {
18053
18737
  const appId = decodeURIComponent(botMatch[1]);