botmux 3.2.1 → 3.3.0

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 (150) hide show
  1. package/dist/adapters/cli/pi.d.ts.map +1 -1
  2. package/dist/adapters/cli/pi.js +4 -0
  3. package/dist/adapters/cli/pi.js.map +1 -1
  4. package/dist/adapters/cli/types.d.ts +8 -1
  5. package/dist/adapters/cli/types.d.ts.map +1 -1
  6. package/dist/cli/deferred-topic-send.d.ts +37 -0
  7. package/dist/cli/deferred-topic-send.d.ts.map +1 -0
  8. package/dist/cli/deferred-topic-send.js +69 -0
  9. package/dist/cli/deferred-topic-send.js.map +1 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +160 -34
  12. package/dist/cli.js.map +1 -1
  13. package/dist/core/command-handler.d.ts.map +1 -1
  14. package/dist/core/command-handler.js +17 -12
  15. package/dist/core/command-handler.js.map +1 -1
  16. package/dist/core/dashboard-ipc-server.d.ts +4 -1
  17. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  18. package/dist/core/dashboard-ipc-server.js +78 -8
  19. package/dist/core/dashboard-ipc-server.js.map +1 -1
  20. package/dist/core/deferred-schedule-settlement.d.ts +20 -0
  21. package/dist/core/deferred-schedule-settlement.d.ts.map +1 -0
  22. package/dist/core/deferred-schedule-settlement.js +18 -0
  23. package/dist/core/deferred-schedule-settlement.js.map +1 -0
  24. package/dist/core/deferred-topic-binding.d.ts +19 -0
  25. package/dist/core/deferred-topic-binding.d.ts.map +1 -0
  26. package/dist/core/deferred-topic-binding.js +55 -0
  27. package/dist/core/deferred-topic-binding.js.map +1 -0
  28. package/dist/core/scheduler.d.ts +26 -20
  29. package/dist/core/scheduler.d.ts.map +1 -1
  30. package/dist/core/scheduler.js +114 -56
  31. package/dist/core/scheduler.js.map +1 -1
  32. package/dist/core/session-discovery.d.ts.map +1 -1
  33. package/dist/core/session-discovery.js +47 -15
  34. package/dist/core/session-discovery.js.map +1 -1
  35. package/dist/core/session-manager.d.ts +17 -1
  36. package/dist/core/session-manager.d.ts.map +1 -1
  37. package/dist/core/session-manager.js +178 -84
  38. package/dist/core/session-manager.js.map +1 -1
  39. package/dist/core/types.d.ts +4 -0
  40. package/dist/core/types.d.ts.map +1 -1
  41. package/dist/core/types.js +10 -0
  42. package/dist/core/types.js.map +1 -1
  43. package/dist/core/worker-pool.d.ts +7 -0
  44. package/dist/core/worker-pool.d.ts.map +1 -1
  45. package/dist/core/worker-pool.js +51 -1
  46. package/dist/core/worker-pool.js.map +1 -1
  47. package/dist/daemon.d.ts.map +1 -1
  48. package/dist/daemon.js +90 -5
  49. package/dist/daemon.js.map +1 -1
  50. package/dist/dashboard/schedule-card-model.d.ts +12 -3
  51. package/dist/dashboard/schedule-card-model.d.ts.map +1 -1
  52. package/dist/dashboard/schedule-card-model.js +32 -8
  53. package/dist/dashboard/schedule-card-model.js.map +1 -1
  54. package/dist/dashboard/web/dashboard-components.d.ts +7 -0
  55. package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
  56. package/dist/dashboard/web/dashboard-components.js +11 -2
  57. package/dist/dashboard/web/dashboard-components.js.map +1 -1
  58. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  59. package/dist/dashboard/web/i18n.js +30 -6
  60. package/dist/dashboard/web/i18n.js.map +1 -1
  61. package/dist/dashboard/web/schedules-page.d.ts +2 -0
  62. package/dist/dashboard/web/schedules-page.d.ts.map +1 -1
  63. package/dist/dashboard/web/schedules-page.js +74 -27
  64. package/dist/dashboard/web/schedules-page.js.map +1 -1
  65. package/dist/dashboard-web/app.js +1 -1
  66. package/dist/dashboard-web/chunks/{bot-defaults-page-O37SAQD5.js → bot-defaults-page-B5AY4B2J.js} +1 -1
  67. package/dist/dashboard-web/chunks/{chunk-6QHMLMIM.js → chunk-CUV65BIQ.js} +3 -3
  68. package/dist/dashboard-web/chunks/{chunk-O2DPQL7C.js → chunk-JNRSPKLS.js} +1 -1
  69. package/dist/dashboard-web/chunks/{chunk-FYALHCPG.js → chunk-P6GO5KT4.js} +1 -1
  70. package/dist/dashboard-web/chunks/chunk-QFMH6ITB.js +1 -0
  71. package/dist/dashboard-web/chunks/{chunk-B42PCL5P.js → chunk-UBOLHQCK.js} +1 -1
  72. package/dist/dashboard-web/chunks/{connectors-page-DZBQ42MH.js → connectors-page-KTC2QWNW.js} +1 -1
  73. package/dist/dashboard-web/chunks/{groups-page-3IEGRZKQ.js → groups-page-B3EKGOLY.js} +1 -1
  74. package/dist/dashboard-web/chunks/{insights-page-X3ADRON3.js → insights-page-X6RXNCWX.js} +1 -1
  75. package/dist/dashboard-web/chunks/{monitor-room-IV36RGQY.js → monitor-room-LLRA5LQW.js} +1 -1
  76. package/dist/dashboard-web/chunks/{monitoring-page-ADFKVON4.js → monitoring-page-7MT5FEOS.js} +1 -1
  77. package/dist/dashboard-web/chunks/{office-page-CALAOWNS.js → office-page-KD46D5TD.js} +1 -1
  78. package/dist/dashboard-web/chunks/{overview-page-SLMGLK3M.js → overview-page-JUWNH32O.js} +1 -1
  79. package/dist/dashboard-web/chunks/{roles-page-L7JJ4FFV.js → roles-page-P6F7NLOI.js} +1 -1
  80. package/dist/dashboard-web/chunks/schedules-page-XIHWZST4.js +1 -0
  81. package/dist/dashboard-web/chunks/{sessions-page-OKJ5YWUZ.js → sessions-page-2VECQ27T.js} +1 -1
  82. package/dist/dashboard-web/chunks/{settings-page-P6MI2ZUE.js → settings-page-HMIUE2JA.js} +1 -1
  83. package/dist/dashboard-web/chunks/{skills-page-FT7BVZ6X.js → skills-page-A46KVWBL.js} +1 -1
  84. package/dist/dashboard-web/chunks/{team-federation-page-DEYCPUFB.js → team-federation-page-SDUC56DF.js} +1 -1
  85. package/dist/dashboard-web/chunks/{v3-page-4IOWREZG.js → v3-page-J5JOTUSQ.js} +1 -1
  86. package/dist/dashboard-web/chunks/{whiteboards-page-2VMBETZH.js → whiteboards-page-U7LSOAWI.js} +1 -1
  87. package/dist/dashboard-web/style.css +34 -4
  88. package/dist/dashboard.js +16 -1
  89. package/dist/dashboard.js.map +1 -1
  90. package/dist/i18n/en.d.ts.map +1 -1
  91. package/dist/i18n/en.js +16 -7
  92. package/dist/i18n/en.js.map +1 -1
  93. package/dist/i18n/zh.d.ts.map +1 -1
  94. package/dist/i18n/zh.js +16 -7
  95. package/dist/i18n/zh.js.map +1 -1
  96. package/dist/im/lark/card-handler.d.ts.map +1 -1
  97. package/dist/im/lark/card-handler.js +3 -0
  98. package/dist/im/lark/card-handler.js.map +1 -1
  99. package/dist/im/lark/event-dispatcher.d.ts +1 -0
  100. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  101. package/dist/im/lark/event-dispatcher.js +1 -1
  102. package/dist/im/lark/event-dispatcher.js.map +1 -1
  103. package/dist/im/lark/schedules-card.d.ts +2 -2
  104. package/dist/im/lark/schedules-card.d.ts.map +1 -1
  105. package/dist/im/lark/schedules-card.js +50 -34
  106. package/dist/im/lark/schedules-card.js.map +1 -1
  107. package/dist/index-daemon.js +13 -1
  108. package/dist/index-daemon.js.map +1 -1
  109. package/dist/services/schedule-store.d.ts +6 -2
  110. package/dist/services/schedule-store.d.ts.map +1 -1
  111. package/dist/services/schedule-store.js +19 -4
  112. package/dist/services/schedule-store.js.map +1 -1
  113. package/dist/skills/definitions.d.ts.map +1 -1
  114. package/dist/skills/definitions.js +4 -5
  115. package/dist/skills/definitions.js.map +1 -1
  116. package/dist/types.d.ts +36 -8
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/utils/child-env.d.ts +40 -1
  119. package/dist/utils/child-env.d.ts.map +1 -1
  120. package/dist/utils/child-env.js +45 -0
  121. package/dist/utils/child-env.js.map +1 -1
  122. package/dist/utils/pending-input-queue.d.ts +10 -0
  123. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  124. package/dist/utils/pending-input-queue.js +15 -0
  125. package/dist/utils/pending-input-queue.js.map +1 -1
  126. package/dist/worker.js +102 -9
  127. package/dist/worker.js.map +1 -1
  128. package/dist/workflows/hostExecutors/botmux-schedule.d.ts +3 -1
  129. package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -1
  130. package/dist/workflows/hostExecutors/botmux-schedule.js +14 -3
  131. package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -1
  132. package/dist/workflows/v3/architect.js +1 -1
  133. package/dist/workflows/v3/architect.js.map +1 -1
  134. package/dist/workflows/v3/contract.d.ts +8 -0
  135. package/dist/workflows/v3/contract.d.ts.map +1 -1
  136. package/dist/workflows/v3/contract.js.map +1 -1
  137. package/dist/workflows/v3/daemon-run.d.ts.map +1 -1
  138. package/dist/workflows/v3/daemon-run.js +2 -0
  139. package/dist/workflows/v3/daemon-run.js.map +1 -1
  140. package/dist/workflows/v3/ephemeral-pool.d.ts.map +1 -1
  141. package/dist/workflows/v3/ephemeral-pool.js +10 -2
  142. package/dist/workflows/v3/ephemeral-pool.js.map +1 -1
  143. package/dist/workflows/v3/run-envelope.d.ts +24 -24
  144. package/dist/workflows/v3/runtime.d.ts +5 -0
  145. package/dist/workflows/v3/runtime.d.ts.map +1 -1
  146. package/dist/workflows/v3/runtime.js +1 -0
  147. package/dist/workflows/v3/runtime.js.map +1 -1
  148. package/package.json +1 -1
  149. package/dist/dashboard-web/chunks/chunk-I35DXGM4.js +0 -1
  150. package/dist/dashboard-web/chunks/schedules-page-Z3672POL.js +0 -1
@@ -24,7 +24,7 @@ import { composeRowFromActive } from './dashboard-rows.js';
24
24
  import { composeSpawnCodexAppContext, composeSpawnUserContent, deriveSessionTitleFromContent, } from './session-create.js';
25
25
  import { validateZellijAdoptTarget } from './zellij-adopt-discovery.js';
26
26
  import { addCodexAppContext } from '../utils/codex-app-context.js';
27
- import { activeSessionKey, sessionKey, sessionAnchorId } from './types.js';
27
+ import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId } from './types.js';
28
28
  import { announceSessionRow, markSessionActivity, announcePendingRepoSession } from './session-activity.js';
29
29
  import { scanMultipleProjects } from '../services/project-scanner.js';
30
30
  import { buildRepoSelectCard } from '../im/lark/card-builder.js';
@@ -37,6 +37,9 @@ import { ensureDefaultWhiteboard, getWhiteboard, whiteboardEnabled } from '../se
37
37
  import { botAutoWorktreeEnabled } from '../services/default-worktree.js';
38
38
  import { armSilentScheduledTurn, disarmSilentScheduledTurn } from './silent-schedule-turns.js';
39
39
  import { getAttachmentsDir } from './attachment-path.js';
40
+ import { resolveRegularGroupMode } from '../services/chat-reply-mode-store.js';
41
+ import { beginReplyTargetTurn } from './reply-target.js';
42
+ import { readDeferredTopicBinding, removeDeferredTopicBinding } from './deferred-topic-binding.js';
40
43
  export { getAttachmentsDir } from './attachment-path.js';
41
44
  function sessionCreatedAtMs(session) {
42
45
  return session.createdAt ? (Date.parse(session.createdAt) || Date.now()) : Date.now();
@@ -1052,8 +1055,9 @@ export async function restoreActiveSessions(activeSessions) {
1052
1055
  // Restored sessions persisted before the scope field was added default to
1053
1056
  // 'thread' — that matches the legacy thread-only behaviour.
1054
1057
  const scope = session.scope === 'chat' ? 'chat' : 'thread';
1055
- // Adopt sessions: restore if original CLI is still alive, otherwise close
1056
- if (session.title?.startsWith('Adopt:') && session.adoptedFrom) {
1058
+ // Persisted metadata is the authoritative adopt marker. The title is
1059
+ // user-editable via /rename and must not change restore semantics.
1060
+ if (session.adoptedFrom) {
1057
1061
  const adopted = session.adoptedFrom;
1058
1062
  const validation = adopted.zellijPaneId
1059
1063
  ? (typeof adopted.originalCliPid === 'number' && validateZellijAdoptTarget(adopted.zellijSession ?? '', adopted.zellijPaneId, adopted.originalCliPid, adopted.cliId) ? 'alive' : 'missing')
@@ -1118,7 +1122,8 @@ export async function restoreActiveSessions(activeSessions) {
1118
1122
  logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adoptTargetLabel(adopted)}, scope: ${scope})`);
1119
1123
  continue;
1120
1124
  }
1121
- // Adopt sessions without persisted metadata close (legacy)
1125
+ // Title-only adopt sessions have no target metadata and can only come from
1126
+ // legacy records. They cannot be validated or safely restored.
1122
1127
  if (session.title?.startsWith('Adopt:')) {
1123
1128
  logger.debug(`Closing adopt session ${session.sessionId} (no persisted metadata)`);
1124
1129
  sessionStore.closeSession(session.sessionId);
@@ -1195,6 +1200,33 @@ export async function restoreActiveSessions(activeSessions) {
1195
1200
  // patch a streaming card. Cleared on the first real CLI input.
1196
1201
  suppressRecoveryCard: true,
1197
1202
  };
1203
+ if (session.deferredScheduleRun) {
1204
+ const binding = readDeferredTopicBinding(config.session.dataDir, session.sessionId);
1205
+ if (!binding) {
1206
+ // A daemon restart makes an unmaterialized hidden run ambiguous: there
1207
+ // is no visible conversation to resume and no safe user-facing place
1208
+ // to report recovery. Fence its backing process and close the audit row
1209
+ // instead of resurrecting an invisible worker.
1210
+ const backendType = getSessionPersistentBackendType(ds);
1211
+ if (backendType) {
1212
+ killPersistentSession(backendType, persistentSessionName(backendType, session.sessionId));
1213
+ }
1214
+ sessionStore.closeSession(session.sessionId);
1215
+ removeDeferredTopicBinding(config.session.dataDir, session.sessionId);
1216
+ logger.info(`[${session.sessionId.substring(0, 8)}] Closed unmaterialized deferred schedule run during restore`);
1217
+ continue;
1218
+ }
1219
+ const nowIso = new Date().toISOString();
1220
+ const aliases = { ...(session.replyThreadAliases ?? {}) };
1221
+ aliases[binding.rootMessageId] = {
1222
+ createdAt: aliases[binding.rootMessageId]?.createdAt ?? binding.createdAt,
1223
+ lastUsedAt: nowIso,
1224
+ };
1225
+ session.replyThreadAliases = aliases;
1226
+ session.rootMessageId = binding.rootMessageId;
1227
+ ds.replyThreadAliases = aliases;
1228
+ sessionStore.updateSession(session);
1229
+ }
1198
1230
  if (await closeActiveSessionIfCliMismatch(ds))
1199
1231
  continue;
1200
1232
  const anchor = sessionAnchorId(ds);
@@ -1229,6 +1261,12 @@ export async function restoreActiveSessions(activeSessions) {
1229
1261
  // 会把「找不到 backing」误判成僵尸而关掉它。
1230
1262
  if (ds.session.queued)
1231
1263
  continue;
1264
+ // External /adopt sessions were already validated and re-forked above
1265
+ // against their real tmux/zellij target. They never own Botmux's
1266
+ // deterministic bmx-<sessionId> backing session, so probing that name here
1267
+ // would immediately zombie-close the session we just restored.
1268
+ if (ds.adoptedFrom)
1269
+ continue;
1232
1270
  const backendType = getSessionPersistentBackendType(ds);
1233
1271
  if (!backendType)
1234
1272
  continue;
@@ -1362,6 +1400,8 @@ export async function ensureTerminalWorkerPort(ds) {
1362
1400
  * - 'vc_receiver_managed' — dedicated meeting receivers are reconstructed
1363
1401
  * through the meeting membership/hub lifecycle; a
1364
1402
  * manual resume could resurrect a stale member epoch
1403
+ * - 'deferred_unmaterialized' — a silent fresh-topic run finished without
1404
+ * publishing, so it has no conversation to resume
1365
1405
  */
1366
1406
  export async function resumeSession(sessionId, activeSessions) {
1367
1407
  const session = sessionStore.getSession(sessionId);
@@ -1380,6 +1420,14 @@ export async function resumeSession(sessionId, activeSessions) {
1380
1420
  if (session.vcMeetingReceiver) {
1381
1421
  return { ok: false, error: 'vc_receiver_managed' };
1382
1422
  }
1423
+ // Auto-closed invisible schedule runs are audit records, not conversations.
1424
+ // Without a materialized binding, resuming one would wake a virtual chat-
1425
+ // scope anchor that no inbound Lark message can reach; a later botmux send
1426
+ // would also fall through to the ordinary group top level. Keep it closed.
1427
+ if (session.deferredScheduleRun
1428
+ && !readDeferredTopicBinding(config.session.dataDir, session.sessionId)) {
1429
+ return { ok: false, error: 'deferred_unmaterialized' };
1430
+ }
1383
1431
  // Adopt sessions don't survive /close — the user's tmux pane and original
1384
1432
  // CLI pid have already moved on, and bringing the bridge back without a live
1385
1433
  // pane is meaningless.
@@ -1388,7 +1436,7 @@ export async function resumeSession(sessionId, activeSessions) {
1388
1436
  }
1389
1437
  const scope = session.scope === 'chat' ? 'chat' : 'thread';
1390
1438
  const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
1391
- const anchor = scope === 'thread' ? session.rootMessageId : session.chatId;
1439
+ const anchor = storedSessionAnchorId(session);
1392
1440
  const key = sessionKey(anchor, larkAppId);
1393
1441
  // In-memory occupant check. A daemon-command scratch (e.g. an unconfirmed
1394
1442
  // `/relay` picker, a bare `/help`) parks a worker:null placeholder at this
@@ -1439,7 +1487,7 @@ export async function resumeSession(sessionId, activeSessions) {
1439
1487
  && s.status === 'active'
1440
1488
  && (s.larkAppId ?? '') === larkAppId
1441
1489
  && (s.scope === 'chat' ? 'chat' : 'thread') === scope
1442
- && (scope === 'thread' ? s.rootMessageId === anchor : s.chatId === anchor));
1490
+ && storedSessionAnchorId(s) === anchor);
1443
1491
  const realConflict = conflicts.find(s => !!s.cliId || !!s.lastCliInput);
1444
1492
  if (realConflict) {
1445
1493
  return { ok: false, error: 'anchor_occupied', activeSessionId: realConflict.sessionId };
@@ -1516,6 +1564,40 @@ export function buildSilentScheduleHint(taskName, locale) {
1516
1564
  '</botmux_silent_schedule>',
1517
1565
  ].join('\n');
1518
1566
  }
1567
+ /**
1568
+ * Resolve the durable execution position of a scheduled task.
1569
+ *
1570
+ * Schedule execution position is task-level state: top-level starts from the
1571
+ * group top level, topic continues under a retained root, and new-topic posts a
1572
+ * fresh seed on every run. Legacy `deliver:new-topic` rows resolve to that third
1573
+ * state until the store normalizes them.
1574
+ *
1575
+ * A malformed/legacy `scope:'thread'` task without a root cannot reply in a
1576
+ * thread. Treat it as chat-scope so silent runs remain genuinely silent rather
1577
+ * than posting a banner merely to manufacture an anchor.
1578
+ */
1579
+ export function resolveScheduledTaskScope(task) {
1580
+ if (task.executionPosition === 'topic' && task.rootMessageId)
1581
+ return 'thread';
1582
+ if (task.executionPosition === 'top-level' || task.executionPosition === 'new-topic')
1583
+ return 'chat';
1584
+ if (task.deliver === 'new-topic')
1585
+ return 'chat';
1586
+ if (task.scope === 'chat')
1587
+ return 'chat';
1588
+ return task.rootMessageId ? 'thread' : 'chat';
1589
+ }
1590
+ export function resolveScheduledTaskExecutionPosition(task) {
1591
+ if (task.executionPosition === 'new-topic')
1592
+ return 'new-topic';
1593
+ if (task.executionPosition === 'topic' && task.rootMessageId)
1594
+ return 'topic';
1595
+ if (task.executionPosition === 'top-level')
1596
+ return 'top-level';
1597
+ if (task.deliver === 'new-topic')
1598
+ return 'new-topic';
1599
+ return task.scope !== 'chat' && task.rootMessageId ? 'topic' : 'top-level';
1600
+ }
1519
1601
  export async function executeScheduledTask(task, activeSessions, refreshCliVersion) {
1520
1602
  // Resolve which bot to use — prefer the task's original bot so replies come from
1521
1603
  // the same account the user set up the schedule with.
@@ -1535,29 +1617,18 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
1535
1617
  }
1536
1618
  const larkAppId = bot.config.larkAppId;
1537
1619
  const { getChatMode, sendMessage, replyMessage } = await import('../im/lark/client.js');
1538
- // Scope resolution explicit task.scope wins; otherwise fall back to legacy
1539
- // semantics (rootMessageId present thread, absent → chat). Restoring an
1540
- // older schedule without scope keeps current behaviour.
1541
- const scope = task.scope === 'chat'
1542
- ? 'chat'
1543
- : task.scope === 'thread'
1544
- ? 'thread'
1545
- : (task.rootMessageId ? 'thread' : 'chat');
1546
- // Silent execution: post no "🕐 task started" banner / creator notice and
1547
- // reuse the pre-existing anchor (thread rootMessageId or chatId) directly.
1548
- // Paths where the banner message ITSELF creates the anchor (new-topic, or a
1549
- // thread task without rootMessageId) cannot be silent — creation rejects
1550
- // those combinations; if a stored task still reaches here, fall back to a
1551
- // loud fire rather than dropping the run.
1552
- let silent = task.silent === true;
1553
- if (silent && task.deliver === 'new-topic') {
1554
- logger.warn(`[scheduler] Task ${task.id} is silent+new-topic (should be rejected at creation); firing loud`);
1555
- silent = false;
1556
- }
1557
- if (silent && scope === 'thread' && !task.rootMessageId) {
1558
- logger.warn(`[scheduler] Silent task ${task.id} has no thread anchor; firing loud`);
1559
- silent = false;
1560
- }
1620
+ // Prefer the explicit three-state position, with scope/deliver fallbacks for
1621
+ // schedules persisted by older versions.
1622
+ const executionPosition = resolveScheduledTaskExecutionPosition(task);
1623
+ const scope = resolveScheduledTaskScope(task);
1624
+ // Silent execution posts no "🕐 task started" banner / creator notice.
1625
+ // A silent fresh-topic run receives a durable virtual anchor below; its real
1626
+ // Lark root is materialized only by the first successful `botmux send`.
1627
+ const silent = task.silent === true;
1628
+ // Every fire gets a stable identity before it enters a worker queue. Besides
1629
+ // silent-output suppression, this identifies the exact shared-topic reply
1630
+ // target when a chat-scope session already has another turn queued.
1631
+ const scheduledTurnId = `schedule:${task.id}:${randomUUID()}`;
1561
1632
  // Decide where to route the "🕐 task started" notification and where the
1562
1633
  // session conversation lands.
1563
1634
  //
@@ -1570,68 +1641,77 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
1570
1641
  // post in the chat (one-shot session)
1571
1642
  //
1572
1643
  // Chat-scope (auto-adopt / 普通群): post the start notification straight to
1573
- // the chat without reply_in_thread; the chat IS the session anchor.
1644
+ // the chat without reply_in_thread. The bot/chat regular-group mode then
1645
+ // decides whether that top-level trigger stays flat, opens a shared topic, or
1646
+ // starts an independent topic/session. Silent top-level fires have no
1647
+ // trigger message, so they retain the ordinary chat-scope behavior.
1574
1648
  let anchor;
1575
1649
  let isContinuation = false;
1576
- if (task.deliver === 'new-topic') {
1577
- // Every fire opens a brand-new topic and runs in a fresh session. A
1578
- // top-level sendMessage in a topic group creates a new topic; in a plain
1579
- // group it's just a new top-level message. Either way we never reply
1580
- // in-thread and never reuse a prior session, so successive runs stay fully
1581
- // isolated. The returned message_id becomes this run's thread anchor.
1582
- anchor = await sendMessage(larkAppId, task.chatId, t('scheduler.task_started', { name: task.name }, localeForBot(larkAppId)));
1583
- isContinuation = false;
1650
+ let sharedTopicRootId;
1651
+ if (executionPosition === 'new-topic') {
1652
+ if (silent) {
1653
+ anchor = `schedule-run:${task.id}:${scheduledTurnId.slice(scheduledTurnId.lastIndexOf(':') + 1)}`;
1654
+ isContinuation = false;
1655
+ }
1656
+ else {
1657
+ if (task.creatorRootMessageId && task.creatorChatId !== task.chatId) {
1658
+ const creatorAppId = task.creatorLarkAppId ?? larkAppId;
1659
+ replyMessage(creatorAppId, task.creatorRootMessageId, t('scheduler.task_triggered_target_chat', { name: task.name }, localeForBot(creatorAppId)), 'text', true).catch((err) => {
1660
+ logger.warn(`[scheduler] Failed to notify creator thread ${task.creatorRootMessageId} (${err.message})`);
1661
+ });
1662
+ }
1663
+ const topicSeed = task.topicTitle?.trim()
1664
+ || t('scheduler.task_started', { name: task.name }, localeForBot(larkAppId));
1665
+ anchor = await sendMessage(larkAppId, task.chatId, topicSeed);
1666
+ isContinuation = false;
1667
+ }
1584
1668
  }
1585
1669
  else if (scope === 'chat') {
1586
- // A group may have been converted from 普通群 to 话题群 after the schedule
1587
- // was created. In topic mode, a top-level sendMessage creates a new topic;
1588
- // keep scheduled continuations in the original thread when we have one.
1670
+ // Explicit task choice: chat scope always starts at the group top level.
1671
+ // A retained rootMessageId is only a bookmark that lets the user switch the
1672
+ // task back to topic execution later; it must not override this choice.
1589
1673
  const chatMode = await getChatMode(larkAppId, task.chatId, { forceRefresh: true });
1590
- if (chatMode === 'topic' && task.rootMessageId) {
1591
- if (silent) {
1592
- // No banner probetrust the stored anchor. If the topic was deleted,
1593
- // the model's own `botmux send` surfaces the failure.
1594
- anchor = task.rootMessageId;
1595
- isContinuation = true;
1596
- }
1597
- else {
1598
- try {
1599
- await replyMessage(larkAppId, task.rootMessageId, t('scheduler.task_started', { name: task.name }, localeForBot(larkAppId)), 'text', true);
1600
- anchor = task.rootMessageId;
1601
- isContinuation = true;
1602
- }
1603
- catch (err) {
1604
- logger.warn(`[scheduler] Failed to reply in converted topic chat ${task.rootMessageId} (${err.message}); falling back to new thread`);
1605
- anchor = await sendMessage(larkAppId, task.chatId, t('scheduler.task_started', { name: task.name }, localeForBot(larkAppId)));
1606
- }
1607
- }
1674
+ let topLevelTriggerId;
1675
+ if (silent) {
1676
+ // No banner / creator notice — the chat itself is the anchor.
1608
1677
  }
1609
1678
  else {
1610
- if (silent) {
1611
- // No banner / creator notice the chat itself is the anchor.
1612
- }
1613
- else if (task.creatorRootMessageId && task.creatorChatId !== task.chatId) {
1679
+ // A cross-chat task keeps the creator informed, but the target chat must
1680
+ // still receive its own top-level trigger. Besides making the execution
1681
+ // visible where it runs, that message is the anchor required by the
1682
+ // target bot/chat's shared or independent-topic regular-group mode.
1683
+ if (task.creatorRootMessageId && task.creatorChatId !== task.chatId) {
1614
1684
  const creatorAppId = task.creatorLarkAppId ?? larkAppId;
1615
1685
  replyMessage(creatorAppId, task.creatorRootMessageId, t('scheduler.task_triggered_target_chat', { name: task.name }, localeForBot(creatorAppId)), 'text', true).catch((err) => {
1616
1686
  logger.warn(`[scheduler] Failed to notify creator thread ${task.creatorRootMessageId} (${err.message})`);
1617
1687
  });
1618
1688
  }
1619
- else {
1620
- // Same-chat: post the start banner to the chat as a plain message.
1621
- try {
1622
- await sendMessage(larkAppId, task.chatId, t('scheduler.task_started', { name: task.name }, localeForBot(larkAppId)));
1623
- }
1624
- catch (err) {
1625
- logger.warn(`[scheduler] Failed to post start banner in chat ${task.chatId} (${err.message})`);
1626
- }
1689
+ try {
1690
+ topLevelTriggerId = await sendMessage(larkAppId, task.chatId, t('scheduler.task_started', { name: task.name }, localeForBot(larkAppId)));
1691
+ }
1692
+ catch (err) {
1693
+ logger.warn(`[scheduler] Failed to post start banner in chat ${task.chatId} (${err.message})`);
1627
1694
  }
1628
- // Assign the chat anchor ONLY on this branch. This used to run
1629
- // unconditionally after the whole chain, clobbering the converted-topic
1630
- // branch's rootMessageId anchor which made the runtimeScope 'thread'
1631
- // promotion below unreachable and re-posted every scheduled reply as a
1632
- // new top-level topic in converted groups.
1695
+ }
1696
+ // Mirror ordinary top-level message routing. A topic group always owns a
1697
+ // thread per top-level message; a regular group only forks an independent
1698
+ // thread when its resolved mode is `new-topic`. `shared` keeps the stable
1699
+ // chat-scope session but routes this exact turn's output under the banner.
1700
+ const regularGroupMode = task.chatType === 'p2p'
1701
+ ? 'chat'
1702
+ : resolveRegularGroupMode(larkAppId, task.chatId);
1703
+ const opensIndependentTopic = !!topLevelTriggerId
1704
+ && (chatMode === 'topic' || regularGroupMode === 'new-topic');
1705
+ if (opensIndependentTopic) {
1706
+ anchor = topLevelTriggerId;
1707
+ isContinuation = false;
1708
+ }
1709
+ else {
1633
1710
  anchor = task.chatId;
1634
1711
  isContinuation = !!activeSessions.get(sessionKey(anchor, larkAppId));
1712
+ if (topLevelTriggerId && chatMode === 'group' && regularGroupMode === 'shared') {
1713
+ sharedTopicRootId = topLevelTriggerId;
1714
+ }
1635
1715
  }
1636
1716
  }
1637
1717
  else {
@@ -1678,16 +1758,16 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
1678
1758
  const firePrompt = silent
1679
1759
  ? `${buildSilentScheduleHint(task.name, localeForBot(larkAppId))}\n\n${task.prompt}`
1680
1760
  : task.prompt;
1681
- // Every fire gets a stable identity before it enters a worker queue. Silent
1682
- // output suppression follows this exact id instead of mutable session state,
1683
- // so overlapping/queued normal turns retain their own delivery semantics.
1684
- const scheduledTurnId = `schedule:${task.id}:${randomUUID()}`;
1685
1761
  // Inject into a live session if one already exists at this anchor.
1686
1762
  const existing = activeSessions.get(sessionKey(anchor, larkAppId));
1687
1763
  if (isContinuation && existing?.worker && !existing.worker.killed) {
1688
1764
  markSessionActivity(existing);
1689
1765
  try {
1690
1766
  ensureSessionWhiteboard(existing);
1767
+ if (sharedTopicRootId) {
1768
+ beginReplyTargetTurn(existing, sharedTopicRootId, scheduledTurnId);
1769
+ sessionStore.updateSession(existing.session);
1770
+ }
1691
1771
  const input = buildFollowUpCliInput(firePrompt, existing.session.sessionId, {
1692
1772
  isAdoptMode: false,
1693
1773
  cliId: existing.session.cliId ?? bot.config.cliId,
@@ -1719,13 +1799,23 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
1719
1799
  // chatId-as-seed for audit (sessionAnchorId() returns chatId via scope). If a
1720
1800
  // formerly chat-scope task was redirected into a converted topic chat, promote
1721
1801
  // the runtime session to thread-scope so follow-up replies stay in-thread.
1722
- const runtimeScope = task.deliver === 'new-topic' ? 'thread'
1723
- : scope === 'chat' && anchor !== task.chatId ? 'thread'
1724
- : scope;
1802
+ const deferredFreshTopic = executionPosition === 'new-topic' && silent;
1803
+ const runtimeScope = deferredFreshTopic
1804
+ ? 'chat'
1805
+ : scope === 'chat' && anchor !== task.chatId ? 'thread' : scope;
1725
1806
  const session = sessionStore.createSession(task.chatId, anchor, `${t('schedule.title_prefix', undefined, localeForBot(larkAppId))} ${task.name}`, task.chatType === 'p2p' ? 'p2p' : 'group');
1726
1807
  const now = Date.now();
1727
1808
  session.larkAppId = larkAppId;
1728
1809
  session.scope = runtimeScope;
1810
+ if (deferredFreshTopic) {
1811
+ session.deferredScheduleRun = {
1812
+ taskId: task.id,
1813
+ turnId: scheduledTurnId,
1814
+ routingAnchor: anchor,
1815
+ ...(task.topicTitle?.trim() ? { topicTitle: task.topicTitle.trim() } : {}),
1816
+ createdAt: new Date(now).toISOString(),
1817
+ };
1818
+ }
1729
1819
  session.lastMessageAt = new Date(now).toISOString();
1730
1820
  sessionStore.updateSession(session);
1731
1821
  messageQueue.ensureQueue(anchor);
@@ -1744,6 +1834,10 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
1744
1834
  hasHistory: isContinuation,
1745
1835
  workingDir: task.workingDir,
1746
1836
  };
1837
+ if (sharedTopicRootId) {
1838
+ beginReplyTargetTurn(ds, sharedTopicRootId, scheduledTurnId);
1839
+ sessionStore.updateSession(ds.session);
1840
+ }
1747
1841
  ensureSessionWhiteboard(ds);
1748
1842
  const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId });
1749
1843
  activeSessions.set(sessionKey(anchor, larkAppId), ds);
@@ -1758,7 +1852,7 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
1758
1852
  disarmSilentScheduledTurn(ds, scheduledTurnId);
1759
1853
  throw err;
1760
1854
  }
1761
- logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, scope: ${scope}, anchor: ${anchor}, continuation: ${isContinuation}${silent ? ', silent' : ''})`);
1855
+ logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, scope: ${runtimeScope}, anchor: ${anchor}, continuation: ${isContinuation}${silent ? ', silent' : ''})`);
1762
1856
  }
1763
1857
  // ─── Dashboard「创建会话」spawn / activate ───────────────────────────────────
1764
1858
  /** 解析 dashboard 创建会话的 pinned workingDir:本群 oncall 绑定优先(弹框填了工作目录会