botmux 2.86.0 → 2.88.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 (247) hide show
  1. package/README.en.md +27 -0
  2. package/README.md +27 -0
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +12 -1
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/liveness-gate.d.ts +38 -0
  7. package/dist/adapters/backend/liveness-gate.d.ts.map +1 -0
  8. package/dist/adapters/backend/liveness-gate.js +53 -0
  9. package/dist/adapters/backend/liveness-gate.js.map +1 -0
  10. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  11. package/dist/adapters/backend/pty-backend.js +4 -1
  12. package/dist/adapters/backend/pty-backend.js.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.d.ts +22 -1
  14. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/tmux-backend.js +124 -36
  16. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  17. package/dist/adapters/backend/tmux-pipe-backend.d.ts +20 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  19. package/dist/adapters/backend/tmux-pipe-backend.js +86 -20
  20. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  21. package/dist/adapters/backend/types.d.ts +9 -0
  22. package/dist/adapters/backend/types.d.ts.map +1 -1
  23. package/dist/adapters/backend/types.js.map +1 -1
  24. package/dist/adapters/backend/zellij-backend.js +1 -1
  25. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  26. package/dist/adapters/backend/zellij-observe-backend.d.ts +13 -0
  27. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  28. package/dist/adapters/backend/zellij-observe-backend.js +51 -12
  29. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  30. package/dist/adapters/cli/codex.d.ts.map +1 -1
  31. package/dist/adapters/cli/codex.js +8 -5
  32. package/dist/adapters/cli/codex.js.map +1 -1
  33. package/dist/adapters/cli/shared-hints.d.ts +2 -1
  34. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  35. package/dist/adapters/cli/shared-hints.js +25 -3
  36. package/dist/adapters/cli/shared-hints.js.map +1 -1
  37. package/dist/adapters/cli/traex.d.ts.map +1 -1
  38. package/dist/adapters/cli/traex.js +4 -0
  39. package/dist/adapters/cli/traex.js.map +1 -1
  40. package/dist/adapters/cli/types.d.ts +6 -0
  41. package/dist/adapters/cli/types.d.ts.map +1 -1
  42. package/dist/bot-registry.d.ts +23 -0
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +9 -0
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +368 -16
  48. package/dist/cli.js.map +1 -1
  49. package/dist/core/command-handler.d.ts.map +1 -1
  50. package/dist/core/command-handler.js +7 -5
  51. package/dist/core/command-handler.js.map +1 -1
  52. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  53. package/dist/core/dashboard-ipc-server.js +210 -5
  54. package/dist/core/dashboard-ipc-server.js.map +1 -1
  55. package/dist/core/dashboard-rows.d.ts +3 -0
  56. package/dist/core/dashboard-rows.d.ts.map +1 -1
  57. package/dist/core/dashboard-rows.js +4 -1
  58. package/dist/core/dashboard-rows.js.map +1 -1
  59. package/dist/core/maintenance.d.ts +18 -0
  60. package/dist/core/maintenance.d.ts.map +1 -1
  61. package/dist/core/maintenance.js +58 -30
  62. package/dist/core/maintenance.js.map +1 -1
  63. package/dist/core/per-bot-env.d.ts +38 -0
  64. package/dist/core/per-bot-env.d.ts.map +1 -0
  65. package/dist/core/per-bot-env.js +79 -0
  66. package/dist/core/per-bot-env.js.map +1 -0
  67. package/dist/core/session-activity.d.ts +1 -0
  68. package/dist/core/session-activity.d.ts.map +1 -1
  69. package/dist/core/session-activity.js +7 -4
  70. package/dist/core/session-activity.js.map +1 -1
  71. package/dist/core/session-create.d.ts +62 -0
  72. package/dist/core/session-create.d.ts.map +1 -0
  73. package/dist/core/session-create.js +131 -0
  74. package/dist/core/session-create.js.map +1 -0
  75. package/dist/core/session-manager.d.ts +43 -0
  76. package/dist/core/session-manager.d.ts.map +1 -1
  77. package/dist/core/session-manager.js +306 -8
  78. package/dist/core/session-manager.js.map +1 -1
  79. package/dist/core/skills/discovery.d.ts +7 -0
  80. package/dist/core/skills/discovery.d.ts.map +1 -1
  81. package/dist/core/skills/discovery.js +51 -0
  82. package/dist/core/skills/discovery.js.map +1 -1
  83. package/dist/core/trigger-session.d.ts.map +1 -1
  84. package/dist/core/trigger-session.js +5 -2
  85. package/dist/core/trigger-session.js.map +1 -1
  86. package/dist/core/update-check.d.ts +67 -0
  87. package/dist/core/update-check.d.ts.map +1 -0
  88. package/dist/core/update-check.js +159 -0
  89. package/dist/core/update-check.js.map +1 -0
  90. package/dist/core/worker-pool.d.ts.map +1 -1
  91. package/dist/core/worker-pool.js +82 -4
  92. package/dist/core/worker-pool.js.map +1 -1
  93. package/dist/daemon.d.ts.map +1 -1
  94. package/dist/daemon.js +40 -11
  95. package/dist/daemon.js.map +1 -1
  96. package/dist/dashboard/bot-payload.d.ts +2 -0
  97. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  98. package/dist/dashboard/bot-payload.js +2 -0
  99. package/dist/dashboard/bot-payload.js.map +1 -1
  100. package/dist/dashboard/session-cleanup.d.ts +30 -0
  101. package/dist/dashboard/session-cleanup.d.ts.map +1 -0
  102. package/dist/dashboard/session-cleanup.js +47 -0
  103. package/dist/dashboard/session-cleanup.js.map +1 -0
  104. package/dist/dashboard/skill-install-request.d.ts +10 -0
  105. package/dist/dashboard/skill-install-request.d.ts.map +1 -1
  106. package/dist/dashboard/skill-install-request.js +17 -0
  107. package/dist/dashboard/skill-install-request.js.map +1 -1
  108. package/dist/dashboard/web/app.d.ts.map +1 -1
  109. package/dist/dashboard/web/app.js +29 -1
  110. package/dist/dashboard/web/app.js.map +1 -1
  111. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  112. package/dist/dashboard/web/bot-defaults.js +82 -6
  113. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  114. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  115. package/dist/dashboard/web/i18n.js +312 -2
  116. package/dist/dashboard/web/i18n.js.map +1 -1
  117. package/dist/dashboard/web/insights.d.ts.map +1 -1
  118. package/dist/dashboard/web/insights.js +355 -30
  119. package/dist/dashboard/web/insights.js.map +1 -1
  120. package/dist/dashboard/web/sessions.d.ts +2 -0
  121. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  122. package/dist/dashboard/web/sessions.js +457 -30
  123. package/dist/dashboard/web/sessions.js.map +1 -1
  124. package/dist/dashboard/web/settings.d.ts.map +1 -1
  125. package/dist/dashboard/web/settings.js +289 -0
  126. package/dist/dashboard/web/settings.js.map +1 -1
  127. package/dist/dashboard/web/skills.d.ts.map +1 -1
  128. package/dist/dashboard/web/skills.js +183 -10
  129. package/dist/dashboard/web/skills.js.map +1 -1
  130. package/dist/dashboard/web/whiteboards.d.ts +2 -0
  131. package/dist/dashboard/web/whiteboards.d.ts.map +1 -0
  132. package/dist/dashboard/web/whiteboards.js +267 -0
  133. package/dist/dashboard/web/whiteboards.js.map +1 -0
  134. package/dist/dashboard-web/app.js +879 -591
  135. package/dist/dashboard-web/index.html +1 -0
  136. package/dist/dashboard-web/style.css +528 -8
  137. package/dist/dashboard.js +472 -5
  138. package/dist/dashboard.js.map +1 -1
  139. package/dist/global-config.d.ts +6 -0
  140. package/dist/global-config.d.ts.map +1 -1
  141. package/dist/global-config.js +12 -0
  142. package/dist/global-config.js.map +1 -1
  143. package/dist/i18n/en.d.ts.map +1 -1
  144. package/dist/i18n/en.js +21 -1
  145. package/dist/i18n/en.js.map +1 -1
  146. package/dist/i18n/zh.d.ts.map +1 -1
  147. package/dist/i18n/zh.js +21 -1
  148. package/dist/i18n/zh.js.map +1 -1
  149. package/dist/im/lark/card-builder.d.ts +4 -5
  150. package/dist/im/lark/card-builder.d.ts.map +1 -1
  151. package/dist/im/lark/card-builder.js +113 -11
  152. package/dist/im/lark/card-builder.js.map +1 -1
  153. package/dist/im/lark/card-handler.d.ts +3 -2
  154. package/dist/im/lark/card-handler.d.ts.map +1 -1
  155. package/dist/im/lark/card-handler.js +174 -14
  156. package/dist/im/lark/card-handler.js.map +1 -1
  157. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  158. package/dist/im/lark/event-dispatcher.js +19 -5
  159. package/dist/im/lark/event-dispatcher.js.map +1 -1
  160. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  161. package/dist/im/lark/grant-pending.js +9 -2
  162. package/dist/im/lark/grant-pending.js.map +1 -1
  163. package/dist/index-daemon.js +5 -0
  164. package/dist/index-daemon.js.map +1 -1
  165. package/dist/platform/bind.d.ts +2 -0
  166. package/dist/platform/bind.d.ts.map +1 -0
  167. package/dist/platform/bind.js +91 -0
  168. package/dist/platform/bind.js.map +1 -0
  169. package/dist/platform/binding.d.ts +22 -0
  170. package/dist/platform/binding.d.ts.map +1 -0
  171. package/dist/platform/binding.js +33 -0
  172. package/dist/platform/binding.js.map +1 -0
  173. package/dist/platform/tunnel-client.d.ts +15 -0
  174. package/dist/platform/tunnel-client.d.ts.map +1 -0
  175. package/dist/platform/tunnel-client.js +193 -0
  176. package/dist/platform/tunnel-client.js.map +1 -0
  177. package/dist/services/bot-config-store.d.ts.map +1 -1
  178. package/dist/services/bot-config-store.js +20 -0
  179. package/dist/services/bot-config-store.js.map +1 -1
  180. package/dist/services/git-worktree.d.ts +10 -0
  181. package/dist/services/git-worktree.d.ts.map +1 -1
  182. package/dist/services/git-worktree.js +71 -26
  183. package/dist/services/git-worktree.js.map +1 -1
  184. package/dist/services/grant-prefs-store.d.ts +3 -0
  185. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  186. package/dist/services/grant-prefs-store.js +17 -2
  187. package/dist/services/grant-prefs-store.js.map +1 -1
  188. package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
  189. package/dist/services/insight/claude-span-reader.js +18 -2
  190. package/dist/services/insight/claude-span-reader.js.map +1 -1
  191. package/dist/services/insight/codex-span-reader.d.ts.map +1 -1
  192. package/dist/services/insight/codex-span-reader.js +17 -1
  193. package/dist/services/insight/codex-span-reader.js.map +1 -1
  194. package/dist/services/insight/report.d.ts.map +1 -1
  195. package/dist/services/insight/report.js +48 -6
  196. package/dist/services/insight/report.js.map +1 -1
  197. package/dist/services/insight/types.d.ts +9 -0
  198. package/dist/services/insight/types.d.ts.map +1 -1
  199. package/dist/services/insight/types.js.map +1 -1
  200. package/dist/services/resumable-session-discovery.d.ts.map +1 -1
  201. package/dist/services/resumable-session-discovery.js +21 -4
  202. package/dist/services/resumable-session-discovery.js.map +1 -1
  203. package/dist/services/skill-registry-store.d.ts +1 -0
  204. package/dist/services/skill-registry-store.d.ts.map +1 -1
  205. package/dist/services/skill-registry-store.js +32 -0
  206. package/dist/services/skill-registry-store.js.map +1 -1
  207. package/dist/services/whiteboard-store.d.ts +67 -0
  208. package/dist/services/whiteboard-store.d.ts.map +1 -0
  209. package/dist/services/whiteboard-store.js +450 -0
  210. package/dist/services/whiteboard-store.js.map +1 -0
  211. package/dist/services/worktree-slug-ai.d.ts.map +1 -1
  212. package/dist/services/worktree-slug-ai.js +3 -1
  213. package/dist/services/worktree-slug-ai.js.map +1 -1
  214. package/dist/setup/bot-config-editor.d.ts +8 -0
  215. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  216. package/dist/setup/bot-config-editor.js +9 -0
  217. package/dist/setup/bot-config-editor.js.map +1 -1
  218. package/dist/skills/definitions.d.ts.map +1 -1
  219. package/dist/skills/definitions.js +118 -0
  220. package/dist/skills/definitions.js.map +1 -1
  221. package/dist/types.d.ts +15 -0
  222. package/dist/types.d.ts.map +1 -1
  223. package/dist/utils/crash-log.d.ts +22 -0
  224. package/dist/utils/crash-log.d.ts.map +1 -0
  225. package/dist/utils/crash-log.js +37 -0
  226. package/dist/utils/crash-log.js.map +1 -0
  227. package/dist/utils/input-gate.d.ts +10 -0
  228. package/dist/utils/input-gate.d.ts.map +1 -1
  229. package/dist/utils/input-gate.js +7 -0
  230. package/dist/utils/input-gate.js.map +1 -1
  231. package/dist/utils/install-diagnostics.d.ts +52 -0
  232. package/dist/utils/install-diagnostics.d.ts.map +1 -0
  233. package/dist/utils/install-diagnostics.js +155 -0
  234. package/dist/utils/install-diagnostics.js.map +1 -0
  235. package/dist/utils/install-info.d.ts +4 -0
  236. package/dist/utils/install-info.d.ts.map +1 -1
  237. package/dist/utils/install-info.js +6 -0
  238. package/dist/utils/install-info.js.map +1 -1
  239. package/dist/utils/stdio-epipe-guard.d.ts +39 -0
  240. package/dist/utils/stdio-epipe-guard.d.ts.map +1 -0
  241. package/dist/utils/stdio-epipe-guard.js +67 -0
  242. package/dist/utils/stdio-epipe-guard.js.map +1 -0
  243. package/dist/worker.js +222 -26
  244. package/dist/worker.js.map +1 -1
  245. package/dist/workflows/daemon-spawn.d.ts +2 -0
  246. package/dist/workflows/daemon-spawn.d.ts.map +1 -1
  247. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { KANBAN_TEAM_STORAGE_KEY, normalizeSessionsViewMode, readStoredBoardOrde
3
3
  import { computeDropPosition, deriveKanbanColumn, effectiveKanbanPosition, } from './kanban-model.js';
4
4
  import { store } from './store.js';
5
5
  import { botDisplayName, botAvatarHtml, chatAvatarHtml, chatDisplayTitle, attentionWaitSince, escapeHtml, loadNameMaps, relTime, stripMentionPrefix, t, ui, } from './ui.js';
6
+ import { IDLE_CLEANUP_HOUR_OPTIONS, parseIdleCleanupHours, selectIdleCleanupCandidates, } from '../session-cleanup.js';
6
7
  function th(sort, label) {
7
8
  return `<th data-sort="${sort}" data-label="${escapeHtml(label)}">${escapeHtml(label)}</th>`;
8
9
  }
@@ -100,6 +101,7 @@ const ICON = {
100
101
  close: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 4.2 11.8 11.8"/><path d="M11.8 4.2 4.2 11.8"/></svg>',
101
102
  edit: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.7 3.3 12.7 5.3 6.3 11.7 3.7 12.3 4.3 9.7 10.7 3.3z"/></svg>',
102
103
  history: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.2 4.8a2 2 0 0 1 2-2h7.6a2 2 0 0 1 2 2v4.6a2 2 0 0 1-2 2H6.6l-2.9 2.4v-2.4h-.5a2 2 0 0 1-2-2z"/><path d="M5.2 6.2h5.6M5.2 8.4h3.6"/></svg>',
104
+ restart: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M12.4 6.2a4.8 4.8 0 1 0 1.1 3.1"/><path d="M12.4 2.9v3.3H9.1"/></svg>',
103
105
  // 飞书:两片交叠的羽毛向右上展翅(还原 Lark 彩色 logo 的飞鸟造型),单色
104
106
  // stroke:currentColor 适配本组线性图标,圆角端点呼应原 logo 的圆润羽尖。
105
107
  feishu: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.6 4.4C6.4 4 10.4 5.4 13.4 8.2 9.8 7 6.4 6.6 3.4 7.4"/><path d="M13.4 8.2C9.6 8.7 6 10 2.9 12 5.6 9 8.8 7.6 13.4 8.2"/></svg>',
@@ -181,6 +183,22 @@ export function renderCliFilterGroup() {
181
183
  </div>
182
184
  </details>`;
183
185
  }
186
+ export function restartConfirmMessage(s) {
187
+ const status = String(s.status ?? 'unknown');
188
+ const cli = String(s.cliId ?? 'unknown');
189
+ const sep = ui.locale === 'zh' ? ':' : ': ';
190
+ return [
191
+ t('sessions.restartConfirmIntro'),
192
+ '',
193
+ `${t('sessions.restartConfirmStatus')}${sep}${status}`,
194
+ `${t('sessions.restartConfirmCli')}${sep}${cli}`,
195
+ '',
196
+ t('sessions.restartConfirmQuestion'),
197
+ ].join('\n');
198
+ }
199
+ export function canRestartSession(s) {
200
+ return s.status !== 'closed' && !s.adopt && !s.pendingRepo;
201
+ }
184
202
  function pageHtml() {
185
203
  return `<section class="page">
186
204
  <div class="page-heading">
@@ -190,6 +208,7 @@ function pageHtml() {
190
208
  <p>${t('sessions.subtitle')}</p>
191
209
  </div>
192
210
  <div class="sessions-view-controls">
211
+ <button type="button" id="create-session-btn" class="primary create-session-btn">+ ${t('sessions.create.button')}</button>
193
212
  <span id="kanban-team-stats" class="kanban-team-stats" hidden></span>
194
213
  <select id="kanban-team" class="kanban-team-select" aria-label="${t('sessions.kanban.groupTeam')}" hidden></select>
195
214
  <div class="segmented kanban-groupby" id="kanban-groupby" role="group" aria-label="${t('sessions.kanban.groupBy')}" hidden>
@@ -219,6 +238,20 @@ function pageHtml() {
219
238
  ${renderCliFilterGroup()}
220
239
  <label class="filter-toggle"><input type="checkbox" name="active" checked> <span>${t('sessions.activeOnly')}</span></label>
221
240
  </form>
241
+ <div id="idle-cleanup-bar" class="idle-cleanup-bar">
242
+ <div class="idle-cleanup-summary">
243
+ <span class="idle-cleanup-dot" aria-hidden="true"></span>
244
+ <span id="idle-cleanup-count" class="idle-cleanup-count"></span>
245
+ </div>
246
+ <div class="idle-cleanup-controls">
247
+ <span class="idle-cleanup-label">${t('sessions.idleCleanupOlderThan')}</span>
248
+ <div id="idle-cleanup-threshold" class="idle-cleanup-thresholds" role="group" aria-label="${t('sessions.idleCleanupThreshold')}">
249
+ ${IDLE_CLEANUP_HOUR_OPTIONS.map(hours => `<button type="button" data-hours="${hours}" aria-pressed="${hours === 24 ? 'true' : 'false'}">${hours === 168 ? '7d' : `${hours}H`}</button>`).join('')}
250
+ </div>
251
+ <button type="button" id="idle-cleanup-run" class="contrast idle-cleanup-run">${t('sessions.idleCleanupRun')}</button>
252
+ </div>
253
+ <span id="idle-cleanup-status" class="idle-cleanup-status" aria-live="polite"></span>
254
+ </div>
222
255
  <div id="bulk-bar" class="bulk-bar" hidden>
223
256
  <span id="bulk-count"></span>
224
257
  <button type="button" id="bulk-close" class="contrast">${t('sessions.closeSelected')}</button>
@@ -246,8 +279,208 @@ function pageHtml() {
246
279
  <dialog id="drawer"></dialog>
247
280
  <dialog id="term-modal" class="term-modal"></dialog>
248
281
  <dialog id="history-modal" class="history-modal"></dialog>
282
+ <dialog id="create-session-modal" class="create-session-modal"></dialog>
249
283
  </section>`;
250
284
  }
285
+ async function fetchPickerBots() {
286
+ try {
287
+ const r = await fetch('/api/groups');
288
+ if (!r.ok)
289
+ return [];
290
+ const data = await r.json();
291
+ const bots = Array.isArray(data?.bots) ? data.bots : [];
292
+ return bots
293
+ .filter((b) => b && typeof b.larkAppId === 'string')
294
+ .map((b) => ({ larkAppId: b.larkAppId, botName: typeof b.botName === 'string' && b.botName ? b.botName : b.larkAppId }));
295
+ }
296
+ catch {
297
+ return [];
298
+ }
299
+ }
300
+ function renderCreateSessionForm(bots) {
301
+ const botRows = bots.map(b => `
302
+ <label class="cs-bot"><input type="checkbox" name="bot" value="${escapeHtml(b.larkAppId)}"> <span>${escapeHtml(b.botName)}</span></label>`).join('');
303
+ return `
304
+ <article class="cs-card">
305
+ <header><h3>${t('sessions.create.title')}</h3></header>
306
+ <form id="cs-form">
307
+ <label class="form-row">
308
+ <span>${t('sessions.create.content')}</span>
309
+ <textarea name="content" rows="5" placeholder="${escapeHtml(t('sessions.create.contentPlaceholder'))}" required></textarea>
310
+ </label>
311
+ <fieldset class="cs-bots">
312
+ <legend>${t('sessions.create.bots')}</legend>
313
+ ${botRows || `<p class="cs-empty">${t('sessions.create.noBots')}</p>`}
314
+ </fieldset>
315
+ <fieldset class="cs-mode">
316
+ <legend>${t('sessions.create.mode')}</legend>
317
+ <label><input type="radio" name="mode" value="lead" checked> ${t('sessions.create.modeLead')}</label>
318
+ <label><input type="radio" name="mode" value="all"> ${t('sessions.create.modeAll')}</label>
319
+ <small>${t('sessions.create.modeHelp')}</small>
320
+ </fieldset>
321
+ <div class="cs-lead-row form-row" hidden>
322
+ <span>${t('sessions.create.lead')}</span>
323
+ <select name="lead"></select>
324
+ <small>${t('sessions.create.leadHelp')}</small>
325
+ </div>
326
+ <fieldset class="cs-column">
327
+ <legend>${t('sessions.create.column')}</legend>
328
+ <label><input type="radio" name="column" value="in_progress" checked> ${t('sessions.create.columnInProgress')}</label>
329
+ <label><input type="radio" name="column" value="backlog"> ${t('sessions.create.columnBacklog')}</label>
330
+ <small>${t('sessions.create.columnHelp')}</small>
331
+ </fieldset>
332
+ <details class="cs-advanced">
333
+ <summary>${t('sessions.create.advanced')}</summary>
334
+ <label class="form-row">
335
+ <span>${t('sessions.create.groupName')}</span>
336
+ <input type="text" name="name" maxlength="60" placeholder="${escapeHtml(t('sessions.create.groupNamePlaceholder'))}">
337
+ </label>
338
+ <label class="form-row">
339
+ <span>${t('sessions.create.workingDir')}</span>
340
+ <input type="text" name="bindWorkingDir" placeholder="e.g. ~/projects/foo">
341
+ <small>${t('sessions.create.workingDirHelp')}</small>
342
+ </label>
343
+ </details>
344
+ <div class="actions cs-actions">
345
+ <button type="submit" class="primary">${t('sessions.create.submit')}</button>
346
+ <button type="button" id="cs-cancel">${t('sessions.create.cancel')}</button>
347
+ </div>
348
+ </form>
349
+ </article>`;
350
+ }
351
+ function setupCreateSessionModal(modal, btn) {
352
+ btn.onclick = async () => {
353
+ btn.disabled = true;
354
+ try {
355
+ const bots = await fetchPickerBots();
356
+ if (bots.length === 0) {
357
+ alert(t('sessions.create.noBots'));
358
+ return;
359
+ }
360
+ modal.innerHTML = renderCreateSessionForm(bots);
361
+ modal.showModal();
362
+ wireCreateSessionForm(modal, bots);
363
+ }
364
+ finally {
365
+ btn.disabled = false;
366
+ }
367
+ };
368
+ }
369
+ function wireCreateSessionForm(modal, bots) {
370
+ const form = modal.querySelector('#cs-form');
371
+ const leadRow = modal.querySelector('.cs-lead-row');
372
+ const leadSelect = modal.querySelector('select[name=lead]');
373
+ const nameOf = (id) => bots.find(b => b.larkAppId === id)?.botName ?? id;
374
+ const checkedBotIds = () => Array.from(form.querySelectorAll('input[name=bot]:checked')).map(i => i.value);
375
+ const currentMode = () => (form.querySelector('input[name=mode]:checked')?.value) ?? 'all';
376
+ // lead 下拉只列「已勾选」的 bot;勾选/模式变化时重建,尽量保留原选中项。
377
+ function repopulateLead() {
378
+ const ids = checkedBotIds();
379
+ const prev = leadSelect.value;
380
+ // 没勾选任何机器人时,空 <select> 会渲染成一个空白小框,看着像 bug——给一个
381
+ // 禁用的占位项并禁用整个选择器,提示先去上面勾选。
382
+ if (ids.length === 0) {
383
+ leadSelect.innerHTML = `<option value="" disabled selected>${escapeHtml(t('sessions.create.leadPickFirst'))}</option>`;
384
+ leadSelect.disabled = true;
385
+ return;
386
+ }
387
+ leadSelect.disabled = false;
388
+ leadSelect.innerHTML = ids.map(id => `<option value="${escapeHtml(id)}">${escapeHtml(nameOf(id))}</option>`).join('');
389
+ if (ids.includes(prev))
390
+ leadSelect.value = prev;
391
+ }
392
+ function syncMode() {
393
+ if (currentMode() === 'lead') {
394
+ leadRow.hidden = false;
395
+ repopulateLead();
396
+ }
397
+ else {
398
+ leadRow.hidden = true;
399
+ }
400
+ }
401
+ form.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', syncMode));
402
+ form.querySelectorAll('input[name=bot]').forEach(c => c.addEventListener('change', () => {
403
+ if (currentMode() === 'lead')
404
+ repopulateLead();
405
+ }));
406
+ // 按默认模式(Lead 分配)初始化 Lead 行的显隐——一起开工时整行不展示。
407
+ syncMode();
408
+ modal.querySelector('#cs-cancel').onclick = () => modal.close();
409
+ form.onsubmit = async (ev) => {
410
+ ev.preventDefault();
411
+ const fd = new FormData(form);
412
+ const content = (fd.get('content') ?? '').trim();
413
+ const larkAppIds = checkedBotIds();
414
+ const mode = currentMode();
415
+ const column = (fd.get('column') ?? 'in_progress');
416
+ const name = (fd.get('name') ?? '').trim();
417
+ const bindWorkingDir = (fd.get('bindWorkingDir') ?? '').trim();
418
+ const leadLarkAppId = (fd.get('lead') ?? '');
419
+ if (!content) {
420
+ alert(t('sessions.create.errContent'));
421
+ return;
422
+ }
423
+ if (larkAppIds.length === 0) {
424
+ alert(t('sessions.create.errNoBot'));
425
+ return;
426
+ }
427
+ if (mode === 'lead' && (!leadLarkAppId || !larkAppIds.includes(leadLarkAppId))) {
428
+ alert(t('sessions.create.errLead'));
429
+ return;
430
+ }
431
+ const submitBtn = form.querySelector('button[type=submit]');
432
+ if (submitBtn) {
433
+ submitBtn.disabled = true;
434
+ submitBtn.textContent = t('sessions.create.submitting');
435
+ }
436
+ try {
437
+ const r = await fetch('/api/sessions/create', {
438
+ method: 'POST',
439
+ headers: { 'content-type': 'application/json' },
440
+ body: JSON.stringify({
441
+ content, larkAppIds, mode, column,
442
+ leadLarkAppId: mode === 'lead' ? leadLarkAppId : undefined,
443
+ name: name || undefined,
444
+ bindWorkingDir: bindWorkingDir || undefined,
445
+ }),
446
+ });
447
+ const body = await r.json().catch(() => null);
448
+ if (r.ok && body?.ok) {
449
+ renderCreateSessionSuccess(modal, body);
450
+ }
451
+ else if (r.status !== 401) {
452
+ alert(`${t('sessions.create.failed')}: ${body?.error ?? r.status}`);
453
+ if (submitBtn) {
454
+ submitBtn.disabled = false;
455
+ submitBtn.textContent = t('sessions.create.submit');
456
+ }
457
+ }
458
+ }
459
+ catch (e) {
460
+ alert(`${t('sessions.create.failed')}: ${e}`);
461
+ if (submitBtn) {
462
+ submitBtn.disabled = false;
463
+ submitBtn.textContent = t('sessions.create.submit');
464
+ }
465
+ }
466
+ };
467
+ }
468
+ function renderCreateSessionSuccess(modal, body) {
469
+ const link = typeof body.shareLink === 'string' && body.shareLink ? body.shareLink : '';
470
+ const failedN = Array.isArray(body.failed) ? body.failed.length : 0;
471
+ const spawnedN = Array.isArray(body.spawned) ? body.spawned.length : 0;
472
+ const colNote = body.column === 'backlog' ? t('sessions.create.doneBacklog') : t('sessions.create.doneInProgress');
473
+ const failNote = failedN > 0 ? `<p class="cs-warn">${t('sessions.create.partialFail', { n: String(failedN) })}</p>` : '';
474
+ modal.innerHTML = `
475
+ <article class="cs-card">
476
+ <header><h3>${t('sessions.create.doneTitle')}</h3></header>
477
+ <p>${escapeHtml(colNote)}(${spawnedN})</p>
478
+ ${failNote}
479
+ ${link ? `<p><a href="${escapeHtml(link)}" target="_blank" rel="noopener">${t('sessions.create.openChat')}</a></p>` : ''}
480
+ <div class="actions"><button type="button" id="cs-done" class="primary">${t('sessions.create.close')}</button></div>
481
+ </article>`;
482
+ modal.querySelector('#cs-done').onclick = () => modal.close();
483
+ }
251
484
  export function renderSessionsPage(root) {
252
485
  root.innerHTML = pageHtml();
253
486
  const tbody = root.querySelector('#sessions-table tbody');
@@ -258,6 +491,11 @@ export function renderSessionsPage(root) {
258
491
  const bulkCountSpan = root.querySelector('#bulk-count');
259
492
  const bulkCloseBtn = root.querySelector('#bulk-close');
260
493
  const bulkClearBtn = root.querySelector('#bulk-clear');
494
+ const idleCleanupBar = root.querySelector('#idle-cleanup-bar');
495
+ const idleCleanupThreshold = root.querySelector('#idle-cleanup-threshold');
496
+ const idleCleanupBtn = root.querySelector('#idle-cleanup-run');
497
+ const idleCleanupCount = root.querySelector('#idle-cleanup-count');
498
+ const idleCleanupStatus = root.querySelector('#idle-cleanup-status');
261
499
  const table = root.querySelector('#sessions-table');
262
500
  const board = root.querySelector('#sessions-board');
263
501
  const kanban = root.querySelector('#sessions-kanban');
@@ -267,6 +505,9 @@ export function renderSessionsPage(root) {
267
505
  const teamSelect = root.querySelector('#kanban-team');
268
506
  const teamStats = root.querySelector('#kanban-team-stats');
269
507
  const viewButtons = root.querySelectorAll('.sessions-view-toggle [data-view]');
508
+ const createSessionBtn = root.querySelector('#create-session-btn');
509
+ const createSessionModal = root.querySelector('#create-session-modal');
510
+ setupCreateSessionModal(createSessionModal, createSessionBtn);
270
511
  const selected = new Set();
271
512
  let sortKey = 'lastMessageAt';
272
513
  let sortDir = 'desc';
@@ -311,6 +552,75 @@ export function renderSessionsPage(root) {
311
552
  return '';
312
553
  }
313
554
  })();
555
+ let idleCleanupBusy = false;
556
+ let idleCleanupHours = 24;
557
+ function selectedIdleCleanupHours() {
558
+ return idleCleanupHours;
559
+ }
560
+ function idleCleanupLabel(hours) {
561
+ return hours === 168 ? '7d' : `${hours}H`;
562
+ }
563
+ /** Chat IDs that belong to `team` in the kanban "team" view — the same
564
+ * whitelist renderKanban uses to narrow the board (team-bound group chats +
565
+ * groups where a same-team bot was /introduce'd). Extracted so idle cleanup
566
+ * can scope to exactly what the team board shows. */
567
+ function teamChatIdsFor(team) {
568
+ const teamChats = new Set();
569
+ if (!team)
570
+ return teamChats;
571
+ for (const chatId of team.groupChats)
572
+ teamChats.add(chatId);
573
+ if (kanbanChatBots) {
574
+ for (const [chatId, c] of kanbanChatBots) {
575
+ if (teamChats.has(chatId))
576
+ continue;
577
+ let hasTeamBot = false;
578
+ for (const id of team.botIds) {
579
+ if (c.botIds.has(id)) {
580
+ hasTeamBot = true;
581
+ break;
582
+ }
583
+ }
584
+ if (!hasTeamBot)
585
+ continue;
586
+ for (const n of c.observedNames) {
587
+ if (team.botNames.has(n)) {
588
+ teamChats.add(chatId);
589
+ break;
590
+ }
591
+ }
592
+ }
593
+ }
594
+ return teamChats;
595
+ }
596
+ /** Local rows actually visible in the current view — the basis for idle
597
+ * cleanup so "所见即所关" holds everywhere. Table/board and kanban
598
+ * (by-status / by-bot) render the full `filtered()` set; only the team
599
+ * kanban narrows further to the selected team's chats AND overlays remote
600
+ * rows from other deployments (which this dashboard can't close). Mirror the
601
+ * team narrowing and never include remote rows. */
602
+ function currentCleanupVisibleRows() {
603
+ const rows = filtered();
604
+ if (viewMode === 'kanban' && kanbanGroupBy === 'team') {
605
+ const team = kanbanTeams.find(tm => tm.key === kanbanTeamKey) ?? kanbanTeams[0];
606
+ const teamChats = teamChatIdsFor(team);
607
+ return rows.filter(r => teamChats.has(String(r.chatId)));
608
+ }
609
+ return rows;
610
+ }
611
+ function currentIdleCleanupCandidates() {
612
+ // Scope to the rows actually visible in the current view (WYSIWYG): the
613
+ // count, the confirm dialog, and the sessionIds we POST all describe exactly
614
+ // what the operator sees — never other bots'/teams' sessions off-screen.
615
+ return selectIdleCleanupCandidates(currentCleanupVisibleRows(), selectedIdleCleanupHours());
616
+ }
617
+ function paintIdleCleanupThresholds() {
618
+ idleCleanupThreshold.querySelectorAll('button[data-hours]').forEach(btn => {
619
+ const active = parseIdleCleanupHours(btn.dataset.hours) === idleCleanupHours;
620
+ btn.classList.toggle('active', active);
621
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
622
+ });
623
+ }
314
624
  async function loadKanbanTeams() {
315
625
  if (kanbanTeamsLoading || kanbanTeamsLoaded)
316
626
  return;
@@ -584,6 +894,7 @@ export function renderSessionsPage(root) {
584
894
  <div class="session-card-actions">
585
895
  ${chatScopeLink(s) ?? cardActBtn('locate', ICON.pin, t('sessions.locate'))}
586
896
  ${cardActBtn('details', ICON.details, t('sessions.details'))}
897
+ ${canRestartSession(s) ? cardActBtn('restart', ICON.restart, t('sessions.restart')) : ''}
587
898
  ${terminalControlsHtml(terminal)}
588
899
  ${cardActBtn('close', ICON.close, t('sessions.close'), 'danger')}
589
900
  </div>
@@ -661,7 +972,8 @@ export function renderSessionsPage(root) {
661
972
  <span class="kanban-card-dot" data-status="${escapeHtml(status)}" title="${escapeHtml(status)}"></span>
662
973
  ${remote ? '' : `<button type="button" class="card-act kanban-card-act" data-action="history" title="${escapeHtml(t('sessions.history.title'))}" aria-label="${escapeHtml(t('sessions.history.title'))}">${ICON.history}</button>
663
974
  ${s.feishuChatLink ? `<a class="card-act kanban-card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.kanban.openFeishu'))}" aria-label="${escapeHtml(t('sessions.kanban.openFeishu'))}">${ICON.feishu}</a>` : ''}
664
- <button type="button" class="card-act kanban-card-act" data-action="details" title="${escapeHtml(t('sessions.details'))}" aria-label="${escapeHtml(t('sessions.details'))}">${ICON.details}</button>`}
975
+ <button type="button" class="card-act kanban-card-act" data-action="details" title="${escapeHtml(t('sessions.details'))}" aria-label="${escapeHtml(t('sessions.details'))}">${ICON.details}</button>
976
+ ${canRestartSession(s) ? `<button type="button" class="card-act kanban-card-act" data-action="restart" title="${escapeHtml(t('sessions.restart'))}" aria-label="${escapeHtml(t('sessions.restart'))}">${ICON.restart}</button>` : ''}`}
665
977
  </span>
666
978
  </div>
667
979
  <p class="kanban-card-title" title="${escapeHtml(String(s.title ?? title))}">${escapeHtml(String(title).slice(0, 140))}</p>
@@ -796,35 +1108,8 @@ export function renderSessionsPage(root) {
796
1108
  // B. 群里 /introduce 过该团队成员机器人的群——介绍记录按名字与团队
797
1109
  // roster 匹配;介绍过的若不是本团队成员,不算(防误筛)
798
1110
  // 命中群里所有 bot 的会话都展示(本质 = 同团队 bot 所在群/话题的会话)。
799
- let teamRows = [];
800
- const teamChats = new Set();
801
- if (team) {
802
- for (const chatId of team.groupChats)
803
- teamChats.add(chatId);
804
- if (kanbanChatBots) {
805
- for (const [chatId, c] of kanbanChatBots) {
806
- if (teamChats.has(chatId))
807
- continue;
808
- // 自家团队 bot 在场,且群里介绍过同团队的外部机器人
809
- let hasTeamBot = false;
810
- for (const id of team.botIds) {
811
- if (c.botIds.has(id)) {
812
- hasTeamBot = true;
813
- break;
814
- }
815
- }
816
- if (!hasTeamBot)
817
- continue;
818
- for (const n of c.observedNames) {
819
- if (team.botNames.has(n)) {
820
- teamChats.add(chatId);
821
- break;
822
- }
823
- }
824
- }
825
- }
826
- teamRows = rows.filter(r => teamChats.has(String(r.chatId)));
827
- }
1111
+ const teamChats = teamChatIdsFor(team);
1112
+ const teamRows = team ? rows.filter(r => teamChats.has(String(r.chatId))) : [];
828
1113
  // ── hub 团队看板合并(申晗架构:编排存团队 host)──────────────────────
829
1114
  // 本地行(实时)+ 对方部署上报的裁剪行(host 快照);共享编排的列/排序
830
1115
  // 覆盖个人看板字段——团队视图里大家看到同一份摆放。
@@ -1243,6 +1528,16 @@ export function renderSessionsPage(root) {
1243
1528
  selectAllBox.checked = selectedInView === selectable.length;
1244
1529
  selectAllBox.indeterminate = selectedInView > 0 && selectedInView < selectable.length;
1245
1530
  }
1531
+ function syncIdleCleanupUi() {
1532
+ const count = currentIdleCleanupCandidates().length;
1533
+ idleCleanupCount.textContent = t('sessions.idleCleanupCount', { count });
1534
+ idleCleanupBtn.disabled = idleCleanupBusy || count === 0;
1535
+ // Keep the bar (and its threshold switcher) visible so the operator can
1536
+ // probe other thresholds, but drop the danger-red dot to neutral when
1537
+ // there's nothing to clean — a red alarm at 0 candidates is misleading.
1538
+ idleCleanupBar.classList.toggle('is-empty', count === 0);
1539
+ paintIdleCleanupThresholds();
1540
+ }
1246
1541
  function paintViewToggle() {
1247
1542
  viewButtons.forEach(btn => {
1248
1543
  const active = btn.dataset.view === viewMode;
@@ -1299,6 +1594,7 @@ export function renderSessionsPage(root) {
1299
1594
  paintSortHeaders();
1300
1595
  paintCliFilterCount();
1301
1596
  syncBulkUi(visibleRows);
1597
+ syncIdleCleanupUi();
1302
1598
  }
1303
1599
  async function locateSession(s, locateBtn) {
1304
1600
  if (locateBtn) {
@@ -1368,6 +1664,42 @@ export function renderSessionsPage(root) {
1368
1664
  closeBtn.disabled = false;
1369
1665
  }
1370
1666
  }
1667
+ // Per-session restart cooldown. The route returns 200 the instant the IPC is
1668
+ // sent — long before the worker's ~500ms+ respawn — so re-enabling the button
1669
+ // immediately let a second restart land inside the respawn window, which trips
1670
+ // the worker's tier-2 crash-loop guard (2 restarts before the CLI reaches its
1671
+ // prompt) and silently drops --resume, losing conversation context. This Set
1672
+ // debounces independently of the button DOM, which the table/board/kanban
1673
+ // rebuild via innerHTML on every SSE update (a DOM-only `disabled` is wiped).
1674
+ const restartCooldownIds = new Set();
1675
+ async function restartSession(s, restartBtn) {
1676
+ if (restartCooldownIds.has(s.sessionId))
1677
+ return false;
1678
+ if (!confirm(restartConfirmMessage(s)))
1679
+ return false;
1680
+ if (restartBtn)
1681
+ restartBtn.disabled = true;
1682
+ try {
1683
+ const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/restart`, { method: 'POST' });
1684
+ const body = await r.json().catch(() => ({}));
1685
+ if (!r.ok || body?.ok === false) {
1686
+ if (r.status !== 401)
1687
+ alert(`${t('sessions.restartFailed')}: ${body?.error ?? r.status}`);
1688
+ return false;
1689
+ }
1690
+ restartCooldownIds.add(s.sessionId);
1691
+ setTimeout(() => restartCooldownIds.delete(s.sessionId), 5000);
1692
+ return true;
1693
+ }
1694
+ catch (e) {
1695
+ alert(`${t('sessions.restartFailed')}: ${e}`);
1696
+ return false;
1697
+ }
1698
+ finally {
1699
+ if (restartBtn)
1700
+ restartBtn.disabled = false;
1701
+ }
1702
+ }
1371
1703
  // ── Insight (owner-only): render a SafeInsightReport into the drawer. All
1372
1704
  // free text (suggestions, span input/output summaries) is escapeHtml'd —
1373
1705
  // the report is already fail-closed redacted upstream, this is defense in
@@ -1445,6 +1777,8 @@ export function renderSessionsPage(root) {
1445
1777
  ${chatScopeLink(s) ?? `<button id="locate-btn" type="button">${t('sessions.locate')}</button>`}
1446
1778
  <button id="history-drawer-btn" type="button">${t('sessions.history.title')}</button>
1447
1779
  ${terminalControlsHtml(terminal)}
1780
+ ${canRestartSession(s) ? `<button id="restart-btn" type="button">${t('sessions.restart')}</button>` : ''}
1781
+ ${s.queued && !closed ? `<button id="start-btn" type="button" class="primary">${t('sessions.create.start')}</button>` : ''}
1448
1782
  ${closed ? `<button id="resume-btn" type="button" class="primary">${t('sessions.resume')}</button>` : ''}
1449
1783
  ${!closed ? `<button id="close-btn" type="button" class="contrast">${t('sessions.close')}</button>` : ''}
1450
1784
  <button id="land-btn" type="button">${t('sessions.land')}</button>
@@ -1495,6 +1829,13 @@ export function renderSessionsPage(root) {
1495
1829
  }
1496
1830
  };
1497
1831
  }
1832
+ const restartBtn = drawer.querySelector('#restart-btn');
1833
+ if (restartBtn) {
1834
+ restartBtn.onclick = async () => {
1835
+ if (await restartSession(s, restartBtn))
1836
+ drawer.close();
1837
+ };
1838
+ }
1498
1839
  const closeBtn = drawer.querySelector('#close-btn');
1499
1840
  if (closeBtn) {
1500
1841
  closeBtn.onclick = async () => {
@@ -1502,6 +1843,28 @@ export function renderSessionsPage(root) {
1502
1843
  drawer.close();
1503
1844
  };
1504
1845
  }
1846
+ // 待办池会话「开始」:激活 parked 会话(发首轮、起 CLI)。
1847
+ const startBtn = drawer.querySelector('#start-btn');
1848
+ if (startBtn) {
1849
+ startBtn.onclick = async () => {
1850
+ startBtn.disabled = true;
1851
+ try {
1852
+ const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/start`, { method: 'POST' });
1853
+ const body = await r.json().catch(() => ({}));
1854
+ if (!r.ok || body.ok === false) {
1855
+ if (r.status !== 401)
1856
+ alert(`${t('sessions.create.startFailed')}: ${body?.error ?? r.status}`);
1857
+ startBtn.disabled = false;
1858
+ return;
1859
+ }
1860
+ drawer.close();
1861
+ }
1862
+ catch (e) {
1863
+ alert(`${t('sessions.create.startFailed')}: ${e}`);
1864
+ startBtn.disabled = false;
1865
+ }
1866
+ };
1867
+ }
1505
1868
  // Sandbox landing: fetch the clone's diff, show it, then apply/discard.
1506
1869
  const landBtn = drawer.querySelector('#land-btn');
1507
1870
  const landArea = drawer.querySelector('#land-area');
@@ -1626,6 +1989,8 @@ export function renderSessionsPage(root) {
1626
1989
  void openWriteLink(s, actionButton);
1627
1990
  else if (action === 'locate')
1628
1991
  void locateSession(s, actionButton);
1992
+ else if (action === 'restart')
1993
+ void restartSession(s, actionButton);
1629
1994
  else if (action === 'close')
1630
1995
  void closeSession(s, actionButton).then(ok => {
1631
1996
  if (ok) {
@@ -1740,6 +2105,8 @@ export function renderSessionsPage(root) {
1740
2105
  startKanbanRename(card, s);
1741
2106
  else if (actionButton.dataset.action === 'history')
1742
2107
  void openHistoryModal(s);
2108
+ else if (actionButton.dataset.action === 'restart')
2109
+ void restartSession(s, actionButton);
1743
2110
  return;
1744
2111
  }
1745
2112
  if (target.closest('a, button, input, label'))
@@ -1973,6 +2340,66 @@ export function renderSessionsPage(root) {
1973
2340
  if (failed > 0)
1974
2341
  alert(`Failed: ${failed}/${ids.length}`);
1975
2342
  });
2343
+ idleCleanupThreshold.addEventListener('click', e => {
2344
+ // Don't let a threshold switch mid-cleanup repaint the count/pill against a
2345
+ // different threshold than the request that's already running.
2346
+ if (idleCleanupBusy)
2347
+ return;
2348
+ const btn = e.target?.closest('button[data-hours]');
2349
+ if (!btn)
2350
+ return;
2351
+ const next = parseIdleCleanupHours(btn.dataset.hours);
2352
+ if (!next || next === idleCleanupHours)
2353
+ return;
2354
+ idleCleanupHours = next;
2355
+ idleCleanupStatus.textContent = '';
2356
+ syncIdleCleanupUi();
2357
+ });
2358
+ idleCleanupBtn.addEventListener('click', async () => {
2359
+ const hours = selectedIdleCleanupHours();
2360
+ const candidates = currentIdleCleanupCandidates();
2361
+ if (candidates.length === 0)
2362
+ return;
2363
+ const label = idleCleanupLabel(hours);
2364
+ if (!confirm(t('sessions.idleCleanupConfirm', { count: candidates.length, threshold: label })))
2365
+ return;
2366
+ idleCleanupBusy = true;
2367
+ idleCleanupBtn.disabled = true;
2368
+ idleCleanupStatus.textContent = t('sessions.idleCleanupRunning');
2369
+ try {
2370
+ const r = await fetch('/api/sessions/cleanup-idle', {
2371
+ method: 'POST',
2372
+ headers: { 'content-type': 'application/json' },
2373
+ // Send the filtered candidate ids so the server closes exactly what the
2374
+ // operator saw and confirmed (it re-validates each is still idle).
2375
+ body: JSON.stringify({ olderThanHours: hours, sessionIds: candidates.map(c => c.sessionId) }),
2376
+ });
2377
+ const body = await r.json().catch(() => ({}));
2378
+ if (!r.ok) {
2379
+ if (r.status !== 401)
2380
+ alert(`${t('sessions.idleCleanupFailed')}: ${body?.error ?? r.status}`);
2381
+ idleCleanupStatus.textContent = '';
2382
+ return;
2383
+ }
2384
+ for (const item of body?.results ?? []) {
2385
+ if (item?.ok && item?.sessionId)
2386
+ selected.delete(String(item.sessionId));
2387
+ }
2388
+ idleCleanupStatus.textContent = t('sessions.idleCleanupDone', {
2389
+ closed: Number(body?.closed ?? 0),
2390
+ failed: Number(body?.failed ?? 0),
2391
+ });
2392
+ rerender();
2393
+ }
2394
+ catch (e) {
2395
+ alert(`${t('sessions.idleCleanupFailed')}: ${e}`);
2396
+ idleCleanupStatus.textContent = '';
2397
+ }
2398
+ finally {
2399
+ idleCleanupBusy = false;
2400
+ syncIdleCleanupUi();
2401
+ }
2402
+ });
1976
2403
  table.querySelectorAll('th[data-sort]').forEach(header => {
1977
2404
  header.addEventListener('click', () => {
1978
2405
  const key = header.dataset.sort;