botmux 2.88.1 → 2.90.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 (217) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/dist/bot-registry.d.ts +2 -0
  4. package/dist/bot-registry.d.ts.map +1 -1
  5. package/dist/bot-registry.js +4 -0
  6. package/dist/bot-registry.js.map +1 -1
  7. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  8. package/dist/cli/dashboard-endpoint.js +9 -3
  9. package/dist/cli/dashboard-endpoint.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +11 -3
  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 +9 -1
  15. package/dist/core/command-handler.js.map +1 -1
  16. package/dist/core/dashboard-command/groups.d.ts +20 -0
  17. package/dist/core/dashboard-command/groups.d.ts.map +1 -0
  18. package/dist/core/dashboard-command/groups.js +52 -0
  19. package/dist/core/dashboard-command/groups.js.map +1 -0
  20. package/dist/core/dashboard-command/index.d.ts +30 -0
  21. package/dist/core/dashboard-command/index.d.ts.map +1 -0
  22. package/dist/core/dashboard-command/index.js +81 -0
  23. package/dist/core/dashboard-command/index.js.map +1 -0
  24. package/dist/core/dashboard-command/overview.d.ts +29 -0
  25. package/dist/core/dashboard-command/overview.d.ts.map +1 -0
  26. package/dist/core/dashboard-command/overview.js +61 -0
  27. package/dist/core/dashboard-command/overview.js.map +1 -0
  28. package/dist/core/dashboard-command/owner-gate.d.ts +41 -0
  29. package/dist/core/dashboard-command/owner-gate.d.ts.map +1 -0
  30. package/dist/core/dashboard-command/owner-gate.js +40 -0
  31. package/dist/core/dashboard-command/owner-gate.js.map +1 -0
  32. package/dist/core/dashboard-command/schedules.d.ts +20 -0
  33. package/dist/core/dashboard-command/schedules.d.ts.map +1 -0
  34. package/dist/core/dashboard-command/schedules.js +47 -0
  35. package/dist/core/dashboard-command/schedules.js.map +1 -0
  36. package/dist/core/dashboard-command/sessions.d.ts +22 -0
  37. package/dist/core/dashboard-command/sessions.d.ts.map +1 -0
  38. package/dist/core/dashboard-command/sessions.js +43 -0
  39. package/dist/core/dashboard-command/sessions.js.map +1 -0
  40. package/dist/core/dashboard-command/settings.d.ts +21 -0
  41. package/dist/core/dashboard-command/settings.d.ts.map +1 -0
  42. package/dist/core/dashboard-command/settings.js +55 -0
  43. package/dist/core/dashboard-command/settings.js.map +1 -0
  44. package/dist/core/dashboard-command/stub.d.ts +16 -0
  45. package/dist/core/dashboard-command/stub.d.ts.map +1 -0
  46. package/dist/core/dashboard-command/stub.js +28 -0
  47. package/dist/core/dashboard-command/stub.js.map +1 -0
  48. package/dist/core/dashboard-command/workflows.d.ts +20 -0
  49. package/dist/core/dashboard-command/workflows.d.ts.map +1 -0
  50. package/dist/core/dashboard-command/workflows.js +45 -0
  51. package/dist/core/dashboard-command/workflows.js.map +1 -0
  52. package/dist/core/passthrough-commands.d.ts.map +1 -1
  53. package/dist/core/passthrough-commands.js +1 -1
  54. package/dist/core/passthrough-commands.js.map +1 -1
  55. package/dist/core/skills/discovery.d.ts +16 -0
  56. package/dist/core/skills/discovery.d.ts.map +1 -1
  57. package/dist/core/skills/discovery.js +48 -2
  58. package/dist/core/skills/discovery.js.map +1 -1
  59. package/dist/core/worker-pool.d.ts.map +1 -1
  60. package/dist/core/worker-pool.js +9 -2
  61. package/dist/core/worker-pool.js.map +1 -1
  62. package/dist/daemon-internal-client-wrapper.d.ts +41 -0
  63. package/dist/daemon-internal-client-wrapper.d.ts.map +1 -0
  64. package/dist/daemon-internal-client-wrapper.js +65 -0
  65. package/dist/daemon-internal-client-wrapper.js.map +1 -0
  66. package/dist/dashboard/aggregator.d.ts +12 -0
  67. package/dist/dashboard/aggregator.d.ts.map +1 -1
  68. package/dist/dashboard/aggregator.js +16 -0
  69. package/dist/dashboard/aggregator.js.map +1 -1
  70. package/dist/dashboard/auth.d.ts +7 -0
  71. package/dist/dashboard/auth.d.ts.map +1 -1
  72. package/dist/dashboard/auth.js +21 -0
  73. package/dist/dashboard/auth.js.map +1 -1
  74. package/dist/dashboard/card-model-types.d.ts +50 -0
  75. package/dist/dashboard/card-model-types.d.ts.map +1 -0
  76. package/dist/dashboard/card-model-types.js +9 -0
  77. package/dist/dashboard/card-model-types.js.map +1 -0
  78. package/dist/dashboard/daemon-internal-api.d.ts +90 -0
  79. package/dist/dashboard/daemon-internal-api.d.ts.map +1 -0
  80. package/dist/dashboard/daemon-internal-api.js +534 -0
  81. package/dist/dashboard/daemon-internal-api.js.map +1 -0
  82. package/dist/dashboard/daemon-internal-auth.d.ts +124 -0
  83. package/dist/dashboard/daemon-internal-auth.d.ts.map +1 -0
  84. package/dist/dashboard/daemon-internal-auth.js +188 -0
  85. package/dist/dashboard/daemon-internal-auth.js.map +1 -0
  86. package/dist/dashboard/daemon-internal-client.d.ts +67 -0
  87. package/dist/dashboard/daemon-internal-client.d.ts.map +1 -0
  88. package/dist/dashboard/daemon-internal-client.js +157 -0
  89. package/dist/dashboard/daemon-internal-client.js.map +1 -0
  90. package/dist/dashboard/dashboard-admins.d.ts +8 -0
  91. package/dist/dashboard/dashboard-admins.d.ts.map +1 -0
  92. package/dist/dashboard/dashboard-admins.js +15 -0
  93. package/dist/dashboard/dashboard-admins.js.map +1 -0
  94. package/dist/dashboard/federation-spoke-api.d.ts +3 -0
  95. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  96. package/dist/dashboard/federation-spoke-api.js +11 -8
  97. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  98. package/dist/dashboard/groups-action-helpers.d.ts +69 -0
  99. package/dist/dashboard/groups-action-helpers.d.ts.map +1 -0
  100. package/dist/dashboard/groups-action-helpers.js +141 -0
  101. package/dist/dashboard/groups-action-helpers.js.map +1 -0
  102. package/dist/dashboard/groups-card-model.d.ts +120 -0
  103. package/dist/dashboard/groups-card-model.d.ts.map +1 -0
  104. package/dist/dashboard/groups-card-model.js +175 -0
  105. package/dist/dashboard/groups-card-model.js.map +1 -0
  106. package/dist/dashboard/schedule-card-model.d.ts +172 -0
  107. package/dist/dashboard/schedule-card-model.d.ts.map +1 -0
  108. package/dist/dashboard/schedule-card-model.js +290 -0
  109. package/dist/dashboard/schedule-card-model.js.map +1 -0
  110. package/dist/dashboard/session-card-model.d.ts +83 -0
  111. package/dist/dashboard/session-card-model.d.ts.map +1 -0
  112. package/dist/dashboard/session-card-model.js +196 -0
  113. package/dist/dashboard/session-card-model.js.map +1 -0
  114. package/dist/dashboard/settings-card-model.d.ts +73 -0
  115. package/dist/dashboard/settings-card-model.d.ts.map +1 -0
  116. package/dist/dashboard/settings-card-model.js +94 -0
  117. package/dist/dashboard/settings-card-model.js.map +1 -0
  118. package/dist/dashboard/settings-owner-resolver.d.ts +32 -0
  119. package/dist/dashboard/settings-owner-resolver.d.ts.map +1 -0
  120. package/dist/dashboard/settings-owner-resolver.js +43 -0
  121. package/dist/dashboard/settings-owner-resolver.js.map +1 -0
  122. package/dist/dashboard/settings-write-applier.d.ts +84 -0
  123. package/dist/dashboard/settings-write-applier.d.ts.map +1 -0
  124. package/dist/dashboard/settings-write-applier.js +119 -0
  125. package/dist/dashboard/settings-write-applier.js.map +1 -0
  126. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  127. package/dist/dashboard/web/i18n.js +60 -2
  128. package/dist/dashboard/web/i18n.js.map +1 -1
  129. package/dist/dashboard/web/insights.d.ts.map +1 -1
  130. package/dist/dashboard/web/insights.js +514 -68
  131. package/dist/dashboard/web/insights.js.map +1 -1
  132. package/dist/dashboard/web/skills.d.ts.map +1 -1
  133. package/dist/dashboard/web/skills.js +1 -1
  134. package/dist/dashboard/web/skills.js.map +1 -1
  135. package/dist/dashboard/web/whiteboards.js +3 -3
  136. package/dist/dashboard/workflow-api.d.ts.map +1 -1
  137. package/dist/dashboard/workflow-api.js +38 -119
  138. package/dist/dashboard/workflow-api.js.map +1 -1
  139. package/dist/dashboard/workflow-card-model.d.ts +127 -0
  140. package/dist/dashboard/workflow-card-model.d.ts.map +1 -0
  141. package/dist/dashboard/workflow-card-model.js +186 -0
  142. package/dist/dashboard/workflow-card-model.js.map +1 -0
  143. package/dist/dashboard/workflows-action-helpers.d.ts +105 -0
  144. package/dist/dashboard/workflows-action-helpers.d.ts.map +1 -0
  145. package/dist/dashboard/workflows-action-helpers.js +196 -0
  146. package/dist/dashboard/workflows-action-helpers.js.map +1 -0
  147. package/dist/dashboard-web/app.js +883 -795
  148. package/dist/dashboard-web/style.css +105 -3
  149. package/dist/dashboard.js +196 -269
  150. package/dist/dashboard.js.map +1 -1
  151. package/dist/i18n/en.d.ts.map +1 -1
  152. package/dist/i18n/en.js +266 -0
  153. package/dist/i18n/en.js.map +1 -1
  154. package/dist/i18n/zh.d.ts.map +1 -1
  155. package/dist/i18n/zh.js +266 -0
  156. package/dist/i18n/zh.js.map +1 -1
  157. package/dist/im/lark/card-builder.d.ts +2 -0
  158. package/dist/im/lark/card-builder.d.ts.map +1 -1
  159. package/dist/im/lark/card-builder.js +2 -1
  160. package/dist/im/lark/card-builder.js.map +1 -1
  161. package/dist/im/lark/card-handler.d.ts +54 -2
  162. package/dist/im/lark/card-handler.d.ts.map +1 -1
  163. package/dist/im/lark/card-handler.js +127 -1
  164. package/dist/im/lark/card-handler.js.map +1 -1
  165. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  166. package/dist/im/lark/event-dispatcher.js +22 -0
  167. package/dist/im/lark/event-dispatcher.js.map +1 -1
  168. package/dist/im/lark/groups-card.d.ts +90 -0
  169. package/dist/im/lark/groups-card.d.ts.map +1 -0
  170. package/dist/im/lark/groups-card.js +832 -0
  171. package/dist/im/lark/groups-card.js.map +1 -0
  172. package/dist/im/lark/overview-card.d.ts +101 -0
  173. package/dist/im/lark/overview-card.d.ts.map +1 -0
  174. package/dist/im/lark/overview-card.js +475 -0
  175. package/dist/im/lark/overview-card.js.map +1 -0
  176. package/dist/im/lark/schedules-card.d.ts +96 -0
  177. package/dist/im/lark/schedules-card.d.ts.map +1 -0
  178. package/dist/im/lark/schedules-card.js +892 -0
  179. package/dist/im/lark/schedules-card.js.map +1 -0
  180. package/dist/im/lark/sessions-card.d.ts +117 -0
  181. package/dist/im/lark/sessions-card.d.ts.map +1 -0
  182. package/dist/im/lark/sessions-card.js +870 -0
  183. package/dist/im/lark/sessions-card.js.map +1 -0
  184. package/dist/im/lark/settings-card.d.ts +125 -0
  185. package/dist/im/lark/settings-card.d.ts.map +1 -0
  186. package/dist/im/lark/settings-card.js +433 -0
  187. package/dist/im/lark/settings-card.js.map +1 -0
  188. package/dist/im/lark/workflows-card.d.ts +102 -0
  189. package/dist/im/lark/workflows-card.d.ts.map +1 -0
  190. package/dist/im/lark/workflows-card.js +737 -0
  191. package/dist/im/lark/workflows-card.js.map +1 -0
  192. package/dist/services/insight/classify.d.ts +2 -0
  193. package/dist/services/insight/classify.d.ts.map +1 -1
  194. package/dist/services/insight/classify.js +25 -0
  195. package/dist/services/insight/classify.js.map +1 -1
  196. package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
  197. package/dist/services/insight/claude-span-reader.js +7 -4
  198. package/dist/services/insight/claude-span-reader.js.map +1 -1
  199. package/dist/services/insight/report.d.ts.map +1 -1
  200. package/dist/services/insight/report.js +102 -23
  201. package/dist/services/insight/report.js.map +1 -1
  202. package/dist/services/insight/subagent-reader.d.ts +19 -0
  203. package/dist/services/insight/subagent-reader.d.ts.map +1 -0
  204. package/dist/services/insight/subagent-reader.js +111 -0
  205. package/dist/services/insight/subagent-reader.js.map +1 -0
  206. package/dist/services/insight/types.d.ts +43 -0
  207. package/dist/services/insight/types.d.ts.map +1 -1
  208. package/dist/services/insight/types.js.map +1 -1
  209. package/dist/skills/definitions.d.ts +8 -0
  210. package/dist/skills/definitions.d.ts.map +1 -1
  211. package/dist/skills/definitions.js +8 -2
  212. package/dist/skills/definitions.js.map +1 -1
  213. package/dist/skills/installer.d.ts +15 -0
  214. package/dist/skills/installer.d.ts.map +1 -1
  215. package/dist/skills/installer.js +39 -1
  216. package/dist/skills/installer.js.map +1 -1
  217. package/package.json +1 -1
@@ -0,0 +1,870 @@
1
+ /**
2
+ * Sessions dashboard card.
3
+ *
4
+ * The list view is compact, paginated, refreshable, and read-only except for
5
+ * per-row detail entry. The detail view exposes locate, terminal, close/resume,
6
+ * and back actions.
7
+ *
8
+ * Close/resume callbacks block on Route B and return a rebuilt card on success.
9
+ * Failures return toast-only and leave the visible card unchanged for retry.
10
+ * Chat-scope locate uses a direct `multi_url`; thread-scope locate posts a
11
+ * Route B notification and returns toast-only.
12
+ *
13
+ * Security:
14
+ * - `invokerOpenId` pins callbacks to the admin who opened the card.
15
+ * - sender union_id never lands on `action.value`.
16
+ * - command entry and callbacks both enforce the dashboard admin gate.
17
+ * - row ids are routing keys only; Route B still enforces row ownership.
18
+ * - write actions re-run the model availability matrix before POSTing.
19
+ */
20
+ import { isDashboardAdmin } from '../../dashboard/dashboard-admins.js';
21
+ import { composeEntries, sortByStatus, paginate, composeDetail } from '../../dashboard/session-card-model.js';
22
+ import { config } from '../../config.js';
23
+ import { t } from '../../i18n/index.js';
24
+ import { terminalMultiUrl } from './card-builder.js';
25
+ export const SESSIONS_ACTION_REFRESH = 'dash_sessions_refresh';
26
+ export const SESSIONS_ACTION_PAGE = 'dash_sessions_page';
27
+ export const SESSIONS_ACTION_DETAIL = 'dash_sessions_detail';
28
+ export const SESSIONS_ACTION_CLOSE = 'dash_sessions_close';
29
+ export const SESSIONS_ACTION_BACK_TO_LIST = 'dash_sessions_back_to_list';
30
+ /** Thread-scope locate sends a mention into the original topic. */
31
+ export const SESSIONS_ACTION_LOCATE = 'dash_sessions_locate';
32
+ /** Replaces close when status === 'closed'; refetches after resume. */
33
+ export const SESSIONS_ACTION_RESUME = 'dash_sessions_resume';
34
+ /** Action emitted by the "🔙 返回总览" button on overview-origin sub-cards.
35
+ * Same string as overview-card's OVERVIEW_ACTION_REFRESH (avoids a circular
36
+ * import). card-handler routes by action prefix, so dispatch lands on the
37
+ * overview handler regardless of which sub-card emitted it. */
38
+ const BACK_TO_OVERVIEW_ACTION = 'dash_overview_refresh';
39
+ /** Default page size for standalone and overview-drilldown list cards. */
40
+ const PAGE_SIZE = 5;
41
+ const MAX_PAGE_SIZE = 100;
42
+ /** Hard cap on `select_static` option count for the "jump to page" picker.
43
+ * Lark caps select options around this; we also keep payload small. Above
44
+ * the cap we fall back to prev/next only. */
45
+ const JUMP_PAGE_MAX_OPTIONS = 50;
46
+ /** Mapping from `StatusDot.tone` to a stable colour-emoji prefix. Pure. */
47
+ function toneIcon(tone) {
48
+ switch (tone) {
49
+ case 'success': return '🟢';
50
+ case 'info': return '🔵';
51
+ case 'warning': return '🟡';
52
+ case 'neutral': return '⚪';
53
+ default: return '⚫';
54
+ }
55
+ }
56
+ function clampPageSize(pageSize) {
57
+ if (typeof pageSize !== 'number' || !Number.isFinite(pageSize) || pageSize <= 0)
58
+ return PAGE_SIZE;
59
+ return Math.min(Math.floor(pageSize), MAX_PAGE_SIZE);
60
+ }
61
+ /** Build the sessions list card JSON from raw rows. Pure (composes + paginates). */
62
+ export function buildSessionsCard(rows, opts, nowMs) {
63
+ const effectivePageSize = clampPageSize(opts.pageSize);
64
+ const sorted = sortByStatus(composeEntries(rows, nowMs));
65
+ const { items, meta } = paginate(sorted, opts.page, effectivePageSize);
66
+ // Plumb origin + page_size + scope into every button.value so refresh/
67
+ // page/detail/detail-back rebuilds keep the same dashboard context.
68
+ const navFields = {};
69
+ if (opts.origin === 'overview')
70
+ navFields.origin = 'overview';
71
+ if (effectivePageSize !== PAGE_SIZE)
72
+ navFields.page_size = String(effectivePageSize);
73
+ if (opts.scope === 'global')
74
+ navFields.dashboard_scope = 'global';
75
+ const activeCount = sorted.filter(e => e.status !== 'closed').length;
76
+ const closedCount = sorted.length - activeCount;
77
+ const elements = [];
78
+ // Sub-header summary — counts + page indicator. Plain `div` markdown.
79
+ elements.push({
80
+ tag: 'div',
81
+ text: {
82
+ tag: 'lark_md',
83
+ content: t('card.dashboard.sessions.count_summary', {
84
+ active: String(activeCount),
85
+ closed: String(closedCount),
86
+ page: String(meta.page),
87
+ totalPages: String(meta.totalPages),
88
+ }, opts.locale),
89
+ },
90
+ });
91
+ elements.push({ tag: 'hr' });
92
+ if (items.length === 0) {
93
+ elements.push({
94
+ tag: 'div',
95
+ text: {
96
+ tag: 'lark_md',
97
+ content: t('card.dashboard.sessions.empty', undefined, opts.locale),
98
+ },
99
+ });
100
+ }
101
+ else {
102
+ for (const e of items) {
103
+ // Row text element.
104
+ elements.push(renderRow(e, opts.locale));
105
+ // Per-row action element holding ONLY the "📂 详情" button.
106
+ // Keeping each row's actions in its own `action` element (rather than
107
+ // one shared element for the whole page) makes the visual layout
108
+ // align with the row above and lets us pass the row's sessionId
109
+ // through `value.session_id` as the routing key.
110
+ elements.push({
111
+ tag: 'action',
112
+ actions: [
113
+ {
114
+ tag: 'button',
115
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.row_detail', undefined, opts.locale) },
116
+ type: 'default',
117
+ value: {
118
+ action: SESSIONS_ACTION_DETAIL,
119
+ invoker_open_id: opts.invokerOpenId,
120
+ session_id: e.sessionId,
121
+ page: String(meta.page),
122
+ ...navFields,
123
+ },
124
+ },
125
+ ],
126
+ });
127
+ }
128
+ }
129
+ elements.push({ tag: 'hr' });
130
+ // Pagination + refresh — pagination row only if more than one page.
131
+ const actions = [];
132
+ if (meta.totalPages > 1) {
133
+ actions.push({
134
+ tag: 'button',
135
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.prev', undefined, opts.locale) },
136
+ type: 'default',
137
+ disabled: meta.page <= 1,
138
+ value: {
139
+ action: SESSIONS_ACTION_PAGE,
140
+ invoker_open_id: opts.invokerOpenId,
141
+ page: String(Math.max(1, meta.page - 1)),
142
+ ...navFields,
143
+ },
144
+ });
145
+ actions.push({
146
+ tag: 'button',
147
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.next', undefined, opts.locale) },
148
+ type: 'default',
149
+ disabled: meta.page >= meta.totalPages,
150
+ value: {
151
+ action: SESSIONS_ACTION_PAGE,
152
+ invoker_open_id: opts.invokerOpenId,
153
+ page: String(Math.min(meta.totalPages, meta.page + 1)),
154
+ ...navFields,
155
+ },
156
+ });
157
+ // "Jump to page" select — same action as prev/next, page comes via
158
+ // action.option instead of value.page. Handler reads `value.page ??
159
+ // action.option ?? '1'` so both paths converge on one branch. Capped at
160
+ // JUMP_PAGE_MAX_OPTIONS to keep payload small / inside Lark's option
161
+ // limit (above the cap, prev/next still works).
162
+ if (meta.totalPages > 2 && meta.totalPages <= JUMP_PAGE_MAX_OPTIONS) {
163
+ const options = Array.from({ length: meta.totalPages }, (_, i) => {
164
+ const n = i + 1;
165
+ return {
166
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.jump_page', { n: String(n), total: String(meta.totalPages) }, opts.locale) },
167
+ value: String(n),
168
+ };
169
+ });
170
+ actions.push({
171
+ tag: 'select_static',
172
+ placeholder: {
173
+ tag: 'plain_text',
174
+ content: t('card.dashboard.sessions.jump_page', { n: String(meta.page), total: String(meta.totalPages) }, opts.locale),
175
+ },
176
+ initial_option: String(meta.page),
177
+ options,
178
+ value: {
179
+ action: SESSIONS_ACTION_PAGE,
180
+ invoker_open_id: opts.invokerOpenId,
181
+ ...navFields,
182
+ },
183
+ });
184
+ }
185
+ }
186
+ actions.push({
187
+ tag: 'button',
188
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.refresh', undefined, opts.locale) },
189
+ type: 'default',
190
+ value: {
191
+ action: SESSIONS_ACTION_REFRESH,
192
+ invoker_open_id: opts.invokerOpenId,
193
+ ...navFields,
194
+ },
195
+ });
196
+ // Overview drilldown only — "🔙 返回总览" reuses the overview-refresh
197
+ // action; card-handler routes by action prefix, so dispatch lands on
198
+ // overview-card.ts which rebuilds the parent card cleanly.
199
+ if (opts.origin === 'overview') {
200
+ actions.push({
201
+ tag: 'button',
202
+ text: { tag: 'plain_text', content: t('card.dashboard.overview.back_button', undefined, opts.locale) },
203
+ type: 'default',
204
+ value: {
205
+ action: BACK_TO_OVERVIEW_ACTION,
206
+ invoker_open_id: opts.invokerOpenId,
207
+ },
208
+ });
209
+ }
210
+ elements.push({ tag: 'action', actions });
211
+ // Footer security note (matches /dashboard settings idiom).
212
+ elements.push({
213
+ tag: 'note',
214
+ elements: [
215
+ { tag: 'lark_md', content: t('card.dashboard.settings.footer.security', undefined, opts.locale) },
216
+ ],
217
+ });
218
+ return JSON.stringify({
219
+ config: { wide_screen_mode: true },
220
+ header: {
221
+ title: { tag: 'plain_text', content: t('card.dashboard.sessions.title', undefined, opts.locale) },
222
+ template: 'blue',
223
+ },
224
+ elements,
225
+ });
226
+ }
227
+ function renderRow(entry, _locale) {
228
+ const icon = toneIcon(entry.dot.tone);
229
+ // primary in bold; secondary on its own line in grey.
230
+ // entry.primary is already truncated by composeEntries.
231
+ return {
232
+ tag: 'div',
233
+ text: {
234
+ tag: 'lark_md',
235
+ content: `${icon} **${escapeLarkMd(entry.primary)}**` +
236
+ (entry.secondary ? `\n<font color="grey">${escapeLarkMd(entry.secondary)}</font>` : ''),
237
+ },
238
+ };
239
+ }
240
+ /**
241
+ * Build the session detail card: metadata, locate/terminal controls, close or
242
+ * resume depending on status, and the back button.
243
+ */
244
+ export function buildSessionsDetailCard(detail, opts) {
245
+ const icon = toneIcon(detail.dot.tone);
246
+ const elements = [];
247
+ // ─── Title — status dot + bold title + sessionId monospace ─────────────
248
+ elements.push({
249
+ tag: 'div',
250
+ text: {
251
+ tag: 'lark_md',
252
+ content: `${icon} **${escapeLarkMd(detail.title)}**` +
253
+ `\n\`${escapeLarkMd(detail.sessionId)}\``,
254
+ },
255
+ });
256
+ elements.push({ tag: 'hr' });
257
+ // ─── Secondary info block ──────────────────────────────────────────────
258
+ const infoLines = [];
259
+ infoLines.push(t('card.dashboard.sessions.detail.status_label', { status: escapeLarkMd(detail.status) }, opts.locale));
260
+ infoLines.push(t('card.dashboard.sessions.detail.cli_label', { cli: escapeLarkMd(detail.cliId) }, opts.locale));
261
+ if (detail.workingDir) {
262
+ infoLines.push(t('card.dashboard.sessions.detail.workingdir_label', { dir: escapeLarkMd(detail.workingDir) }, opts.locale));
263
+ }
264
+ infoLines.push(t('card.dashboard.sessions.detail.chat_label', { chat: escapeLarkMd(detail.chatId) }, opts.locale));
265
+ const lastMessageAt = detail.raw.lastMessageAt;
266
+ if (Number.isFinite(lastMessageAt) && lastMessageAt > 0) {
267
+ const now = opts.nowMs ?? Date.now();
268
+ infoLines.push(t('card.dashboard.sessions.detail.last_message_label', { rel: formatRelativeForDetail(lastMessageAt, now) }, opts.locale));
269
+ }
270
+ elements.push({
271
+ tag: 'div',
272
+ text: {
273
+ tag: 'lark_md',
274
+ content: infoLines.map(l => `<font color="grey">${l}</font>`).join('\n'),
275
+ },
276
+ });
277
+ elements.push({ tag: 'hr' });
278
+ // ─── Action row — locate / terminal / (close OR resume) / back ────────
279
+ // Detail card matches the Web dashboard's four primary actions.
280
+ // active state shows close (danger); closed state replaces close with
281
+ // resume so the user can revive the session.
282
+ const backNav = {};
283
+ const effectivePageSize = clampPageSize(opts.pageSize);
284
+ if (opts.origin === 'overview')
285
+ backNav.origin = 'overview';
286
+ if (typeof opts.sourcePage === 'number' && Number.isFinite(opts.sourcePage) && opts.sourcePage >= 1) {
287
+ backNav.page = String(Math.floor(opts.sourcePage));
288
+ }
289
+ if (effectivePageSize !== PAGE_SIZE)
290
+ backNav.page_size = String(effectivePageSize);
291
+ if (opts.scope === 'global')
292
+ backNav.dashboard_scope = 'global';
293
+ // Track reason notes to render below the action row in row order.
294
+ const reasonNotes = [];
295
+ // 1) Locate button. The matrix says it's always enabled; we still gate
296
+ // on matrix to stay matrix-driven.
297
+ const locateEnabled = detail.actions.locate.enabled === true;
298
+ const locateButton = {
299
+ tag: 'button',
300
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.btn.locate', undefined, opts.locale) },
301
+ type: 'default',
302
+ };
303
+ if (locateEnabled) {
304
+ if (detail.actions.locateMode === 'openChat'
305
+ && typeof opts.feishuChatLink === 'string'
306
+ && opts.feishuChatLink.length > 0) {
307
+ // chat-scope: jump straight into the group via multi_url. No callback.
308
+ locateButton.multi_url = {
309
+ url: opts.feishuChatLink,
310
+ pc_url: opts.feishuChatLink,
311
+ android_url: opts.feishuChatLink,
312
+ ios_url: opts.feishuChatLink,
313
+ };
314
+ }
315
+ else {
316
+ // thread-scope (default) OR chat-scope missing the link: POST to
317
+ // Route B locate which sends a @mention into the original topic.
318
+ locateButton.value = {
319
+ action: SESSIONS_ACTION_LOCATE,
320
+ invoker_open_id: opts.invokerOpenId,
321
+ session_id: detail.sessionId,
322
+ ...backNav,
323
+ };
324
+ }
325
+ }
326
+ else {
327
+ locateButton.disabled = true;
328
+ }
329
+ // 2) Open Terminal button. multi_url-based — no callback.
330
+ // Disabled when actions.openTerminal.enabled === false OR no terminalUrl.
331
+ const terminalEnabled = detail.actions.openTerminal.enabled === true
332
+ && typeof opts.terminalUrl === 'string'
333
+ && opts.terminalUrl.length > 0;
334
+ const terminalButton = {
335
+ tag: 'button',
336
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.btn.terminal', undefined, opts.locale) },
337
+ type: 'default',
338
+ };
339
+ if (terminalEnabled) {
340
+ // Wrap with the project's terminal multi-url policy (PC sidebar vs direct).
341
+ terminalButton.multi_url = terminalMultiUrl(opts.terminalUrl);
342
+ }
343
+ else {
344
+ terminalButton.disabled = true;
345
+ const reasonKey = mapTerminalDisabledReason(detail.actions.openTerminal.reasonKey);
346
+ if (reasonKey)
347
+ reasonNotes.push({ key: reasonKey });
348
+ }
349
+ // 3) close OR resume — mutually exclusive based on row status.
350
+ // closed → resume button replaces close; otherwise → close stays.
351
+ const isClosed = detail.actions.resume.enabled === true;
352
+ let writeButton;
353
+ if (isClosed) {
354
+ // Resume button: green primary, confirm dialog.
355
+ writeButton = {
356
+ tag: 'button',
357
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.btn.resume', undefined, opts.locale) },
358
+ type: 'primary',
359
+ value: {
360
+ action: SESSIONS_ACTION_RESUME,
361
+ invoker_open_id: opts.invokerOpenId,
362
+ session_id: detail.sessionId,
363
+ ...backNav,
364
+ },
365
+ confirm: {
366
+ title: { tag: 'plain_text', content: t('card.dashboard.sessions.confirm.resume.title', undefined, opts.locale) },
367
+ text: {
368
+ tag: 'plain_text',
369
+ content: t('card.dashboard.sessions.confirm.resume.text', { title: detail.title }, opts.locale),
370
+ },
371
+ },
372
+ };
373
+ }
374
+ else {
375
+ const closeEnabled = detail.actions.close.enabled === true;
376
+ writeButton = {
377
+ tag: 'button',
378
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.btn.close', undefined, opts.locale) },
379
+ type: 'danger',
380
+ value: {
381
+ action: SESSIONS_ACTION_CLOSE,
382
+ invoker_open_id: opts.invokerOpenId,
383
+ session_id: detail.sessionId,
384
+ ...backNav,
385
+ },
386
+ };
387
+ if (closeEnabled) {
388
+ writeButton.confirm = {
389
+ title: { tag: 'plain_text', content: t('card.dashboard.sessions.confirm.close.title', undefined, opts.locale) },
390
+ text: {
391
+ tag: 'plain_text',
392
+ content: t('card.dashboard.sessions.confirm.close.text', { title: detail.title }, opts.locale),
393
+ },
394
+ };
395
+ }
396
+ else {
397
+ writeButton.disabled = true;
398
+ const reasonKey = mapCloseDisabledReason(detail.actions.close.reasonKey);
399
+ if (reasonKey)
400
+ reasonNotes.push({ key: reasonKey });
401
+ }
402
+ }
403
+ // 4) Back button — always.
404
+ const backButton = {
405
+ tag: 'button',
406
+ text: { tag: 'plain_text', content: t('card.dashboard.sessions.btn.back', undefined, opts.locale) },
407
+ type: 'default',
408
+ value: {
409
+ action: SESSIONS_ACTION_BACK_TO_LIST,
410
+ invoker_open_id: opts.invokerOpenId,
411
+ ...backNav,
412
+ },
413
+ };
414
+ elements.push({
415
+ tag: 'action',
416
+ actions: [locateButton, terminalButton, writeButton, backButton],
417
+ });
418
+ for (const r of reasonNotes) {
419
+ elements.push({
420
+ tag: 'note',
421
+ elements: [{ tag: 'lark_md', content: t(r.key, undefined, opts.locale) }],
422
+ });
423
+ }
424
+ // Footer security note (mirrors list card).
425
+ elements.push({
426
+ tag: 'note',
427
+ elements: [
428
+ { tag: 'lark_md', content: t('card.dashboard.settings.footer.security', undefined, opts.locale) },
429
+ ],
430
+ });
431
+ return JSON.stringify({
432
+ config: { wide_screen_mode: true },
433
+ header: {
434
+ title: { tag: 'plain_text', content: t('card.dashboard.sessions.detail.title', undefined, opts.locale) },
435
+ template: 'blue',
436
+ },
437
+ elements,
438
+ });
439
+ }
440
+ /** Map composeDetail close reason keys to card i18n keys. */
441
+ function mapCloseDisabledReason(reasonKey) {
442
+ switch (reasonKey) {
443
+ case 'sessions.action.close.starting':
444
+ return 'card.dashboard.sessions.close.disabled.starting';
445
+ case 'sessions.action.close.alreadyClosed':
446
+ return 'card.dashboard.sessions.close.disabled.alreadyClosed';
447
+ default:
448
+ return undefined;
449
+ }
450
+ }
451
+ /** Map open-terminal reason keys to card i18n keys. */
452
+ function mapTerminalDisabledReason(reasonKey) {
453
+ switch (reasonKey) {
454
+ case 'sessions.action.terminal.noPort':
455
+ return 'card.dashboard.sessions.terminal.disabled.noPort';
456
+ default:
457
+ return undefined;
458
+ }
459
+ }
460
+ /** Map resume reason keys to card i18n keys. */
461
+ function mapResumeDisabledReason(reasonKey) {
462
+ switch (reasonKey) {
463
+ case 'sessions.action.resume.onlyClosed':
464
+ return 'card.dashboard.sessions.resume.disabled.onlyClosed';
465
+ default:
466
+ return undefined;
467
+ }
468
+ }
469
+ /** Compute the Web Terminal URL for a SessionRow. Mirrors
470
+ * `src/dashboard/web/sessions.ts:terminalHref`: proxy port wins (with the
471
+ * `/s/{sessionId}` suffix); otherwise direct worker port. Returns null when
472
+ * the session has no port at all (e.g. closed / starting).
473
+ *
474
+ * Closed sessions can carry a stale webPort, so closed rows are always
475
+ * rejected here even if the raw row still has a port value. */
476
+ export function buildSessionTerminalUrl(row) {
477
+ if (row.status === 'closed')
478
+ return null;
479
+ const host = config.web.externalHost;
480
+ if (typeof row.proxyPort === 'number' && row.proxyPort > 0) {
481
+ return `http://${host}:${row.proxyPort}/s/${encodeURIComponent(row.sessionId)}`;
482
+ }
483
+ if (typeof row.webPort === 'number' && row.webPort > 0) {
484
+ return `http://${host}:${row.webPort}`;
485
+ }
486
+ return null;
487
+ }
488
+ function formatRelativeForDetail(fromMs, nowMs) {
489
+ const diff = nowMs - fromMs;
490
+ if (!Number.isFinite(diff) || diff < 0)
491
+ return 'just now';
492
+ const sec = Math.floor(diff / 1000);
493
+ if (sec < 60)
494
+ return `${sec}s ago`;
495
+ const min = Math.floor(sec / 60);
496
+ if (min < 60)
497
+ return `${min}m ago`;
498
+ const hour = Math.floor(min / 60);
499
+ if (hour < 24)
500
+ return `${hour}h ago`;
501
+ const day = Math.floor(hour / 24);
502
+ return `${day}d ago`;
503
+ }
504
+ /**
505
+ * Sanitize user/filesystem-supplied text for inclusion in a Lark `lark_md`
506
+ * element — particularly inside our `<font color="grey">…</font>` wrapper.
507
+ *
508
+ * Session titles come from chat content and workingDir comes from the
509
+ * filesystem. Both flow into a span we wrap with `<font>`; without escaping, a payload containing
510
+ * `</font><at id=ou_x></at>` would close our wrapper and inject a
511
+ * @mention-looking element. We also need to handle `*_~\``-style markdown
512
+ * controls so plain filenames don't render as bold/italic.
513
+ *
514
+ * Order matters: escape `&` FIRST so a later `<` → `&lt;` doesn't get
515
+ * re-encoded as `&amp;lt;`.
516
+ */
517
+ function escapeLarkMd(text) {
518
+ return text
519
+ .replace(/&/g, '&amp;')
520
+ .replace(/</g, '&lt;')
521
+ .replace(/>/g, '&gt;')
522
+ .replace(/([*_~`])/g, '\\$1');
523
+ }
524
+ function ackToast(textKey, locale) {
525
+ return { toast: { type: 'info', content: t(textKey, undefined, locale) } };
526
+ }
527
+ function errorToast(textKey, params, locale) {
528
+ return { toast: { type: 'error', content: t(textKey, params, locale) } };
529
+ }
530
+ /**
531
+ * Dispatch a `dash_sessions_*` action callback. Awaits the Route B GET
532
+ * inline and returns the rebuilt card body in the SAME response.
533
+ */
534
+ export async function handleSessionsCardAction(data, larkAppId, deps) {
535
+ const locale = deps.locale ?? 'zh';
536
+ const value = (data.action?.value ?? {});
537
+ const operatorOpenId = data.operator?.open_id;
538
+ const action = value.action;
539
+ // ─── 1) Invoker lock — fail-closed ──────────────────────────────────
540
+ const invokerOpenId = value.invoker_open_id;
541
+ if (typeof invokerOpenId !== 'string' || !invokerOpenId) {
542
+ return ackToast('card.dashboard.settings.not_invoker', locale);
543
+ }
544
+ if (typeof operatorOpenId !== 'string' || !operatorOpenId) {
545
+ return ackToast('card.dashboard.settings.not_invoker', locale);
546
+ }
547
+ if (invokerOpenId !== operatorOpenId) {
548
+ return ackToast('card.dashboard.settings.not_invoker', locale);
549
+ }
550
+ // ─── 2) Per-bot admin gate ──────────────────────────────────────────
551
+ if (!isDashboardAdmin(larkAppId, operatorOpenId, deps)) {
552
+ return ackToast('card.dashboard.settings.owner_only', locale);
553
+ }
554
+ // Validate the action BEFORE creating the Route B client — an unknown
555
+ // action shouldn't even open a connection.
556
+ const validActions = new Set([
557
+ SESSIONS_ACTION_REFRESH,
558
+ SESSIONS_ACTION_PAGE,
559
+ SESSIONS_ACTION_DETAIL,
560
+ SESSIONS_ACTION_CLOSE,
561
+ SESSIONS_ACTION_BACK_TO_LIST,
562
+ SESSIONS_ACTION_LOCATE,
563
+ SESSIONS_ACTION_RESUME,
564
+ ]);
565
+ if (!validActions.has(action)) {
566
+ return ackToast('card.dashboard.settings.invalid_action', locale);
567
+ }
568
+ const client = deps.createClient(larkAppId);
569
+ const now = () => (deps.nowMs ? deps.nowMs() : Date.now());
570
+ // ─── Nav state (overview drilldown) ─────────────────────────────────
571
+ // Threaded by buildSessionsCard onto every button.value; we parse here
572
+ // so the rebuild path keeps the same shape (5/page + 🔙 返回总览).
573
+ const navOrigin = value.origin === 'overview' ? 'overview' : undefined;
574
+ const parsedPageSize = Number.parseInt(value.page_size ?? '', 10);
575
+ const navPageSize = Number.isFinite(parsedPageSize) && parsedPageSize > 0 ? parsedPageSize : undefined;
576
+ const parsedNavPage = Number.parseInt(value.page ?? '', 10);
577
+ const navPage = Number.isFinite(parsedNavPage) && parsedNavPage >= 1 ? parsedNavPage : undefined;
578
+ const navScope = value.dashboard_scope === 'global' ? 'global' : undefined;
579
+ const pathSuffix = navScope === 'global' ? '?scope=global' : '';
580
+ // ─── 3a) DETAIL — open the per-session detail card ──────────────────
581
+ if (action === SESSIONS_ACTION_DETAIL) {
582
+ const sessionId = value.session_id;
583
+ if (typeof sessionId !== 'string' || !sessionId) {
584
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
585
+ }
586
+ const r = await safeGetSessionsList(client, locale, pathSuffix);
587
+ if ('errorResult' in r)
588
+ return r.errorResult;
589
+ const row = r.rows.find(s => s.sessionId === sessionId);
590
+ if (!row) {
591
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
592
+ }
593
+ const detail = composeDetail(row, now());
594
+ const cardJson = buildSessionsDetailCard(detail, {
595
+ invokerOpenId: operatorOpenId,
596
+ locale,
597
+ nowMs: now(),
598
+ origin: navOrigin,
599
+ pageSize: navPageSize,
600
+ sourcePage: navPage,
601
+ scope: navScope,
602
+ terminalUrl: buildSessionTerminalUrl(row),
603
+ feishuChatLink: row.feishuChatLink ?? null,
604
+ });
605
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
606
+ }
607
+ // ─── 3b) CLOSE — synchronous close, in-process overlay, redraw ──────
608
+ if (action === SESSIONS_ACTION_CLOSE) {
609
+ const sessionId = value.session_id;
610
+ if (typeof sessionId !== 'string' || !sessionId) {
611
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
612
+ }
613
+ // Pre-POST snapshot confirms the row still exists and lets us synthesize
614
+ // the closed-state card without racing list propagation.
615
+ const pre = await safeGetSessionsList(client, locale, pathSuffix);
616
+ if ('errorResult' in pre)
617
+ return pre.errorResult;
618
+ const before = pre.rows.find(s => s.sessionId === sessionId);
619
+ if (!before) {
620
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
621
+ }
622
+ // Client-side `disabled` is UX only; replayed or crafted callbacks can
623
+ // still arrive. Re-run the same availability matrix server-side and
624
+ // fail closed before POSTing.
625
+ const beforeDetail = composeDetail(before, now());
626
+ if (beforeDetail.actions.close.enabled !== true) {
627
+ // Reuse the same reasonKey → i18n mapping the builder uses
628
+ // for the inline disabled-button note (`mapCloseDisabledReason`) so
629
+ // toast text matches what the user already sees on the card. NEVER
630
+ // POST; NEVER redraw the card.
631
+ const mappedKey = mapCloseDisabledReason(beforeDetail.actions.close.reasonKey)
632
+ ?? 'card.dashboard.sessions.close_failed';
633
+ return errorToast(mappedKey, undefined, locale);
634
+ }
635
+ // Route B owner routing is the authority on whether this session can be
636
+ // closed; we only sanitize the routing key above.
637
+ let resp;
638
+ try {
639
+ resp = await client.request({
640
+ method: 'POST',
641
+ path: `/__daemon/sessions/${encodeURIComponent(sessionId)}/close${pathSuffix}`,
642
+ });
643
+ }
644
+ catch (e) {
645
+ return errorToast('card.dashboard.sessions.close_failed', { reason: e.message }, locale);
646
+ }
647
+ if (resp.status !== 200) {
648
+ const body = (resp.body ?? {});
649
+ const reason = String(body.error ?? `http_${resp.status}`);
650
+ // Preserve user state — do NOT redraw card on failure.
651
+ return errorToast('card.dashboard.sessions.close_failed', { reason }, locale);
652
+ }
653
+ // Synthesize the closed-state row from the pre-POST snapshot. Merge
654
+ // closedAt/cliResumeCommand from the close response if the upstream
655
+ // ever surfaces them (defensive — current closeSession returns only
656
+ // `{ ok, alreadyClosed }`, but the proxy may evolve).
657
+ const body = (resp.body ?? {});
658
+ const synthClosedAt = typeof body.closedAt === 'number' && Number.isFinite(body.closedAt)
659
+ ? body.closedAt
660
+ : (typeof body.closedAt === 'string' && Number.isFinite(Date.parse(body.closedAt))
661
+ ? Date.parse(body.closedAt)
662
+ : (before.closedAt ?? now()));
663
+ // Clear port fields so the closed-state detail card never advertises a
664
+ // dead Web Terminal URL even if the daemon row still has stale ports.
665
+ const synth = {
666
+ ...before,
667
+ status: 'closed',
668
+ closedAt: synthClosedAt,
669
+ webPort: null,
670
+ proxyPort: undefined,
671
+ };
672
+ const detail = composeDetail(synth, now());
673
+ const cardJson = buildSessionsDetailCard(detail, {
674
+ invokerOpenId: operatorOpenId,
675
+ locale,
676
+ nowMs: now(),
677
+ origin: navOrigin,
678
+ pageSize: navPageSize,
679
+ sourcePage: navPage,
680
+ scope: navScope,
681
+ terminalUrl: buildSessionTerminalUrl(synth),
682
+ feishuChatLink: synth.feishuChatLink ?? null,
683
+ });
684
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
685
+ }
686
+ // ─── 3b2) LOCATE — POST + toast-only (thread-scope only) ────────────
687
+ // Sync block: await POST → success toast (or error toast on non-200 /
688
+ // network throw). Card is NEVER redrawn — UX matches the Web dashboard's
689
+ // "fire a locate notification" behavior.
690
+ //
691
+ // Chat-scope sessions must not hit the notification POST path. Builder
692
+ // output uses a direct `multi_url`, but replayed or crafted callbacks can
693
+ // still arrive, so re-check the fresh row before posting.
694
+ if (action === SESSIONS_ACTION_LOCATE) {
695
+ const sessionId = value.session_id;
696
+ if (typeof sessionId !== 'string' || !sessionId) {
697
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
698
+ }
699
+ const pre = await safeGetSessionsList(client, locale, pathSuffix);
700
+ if ('errorResult' in pre)
701
+ return pre.errorResult;
702
+ const row = pre.rows.find(s => s.sessionId === sessionId);
703
+ if (!row) {
704
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
705
+ }
706
+ const beforeDetail = composeDetail(row, now());
707
+ if (beforeDetail.actions.locateMode !== 'openTopic') {
708
+ // chat-scope: refuse to POST. Surface a locate_failed with an
709
+ // explicit reason so the test can pin the gate behavior.
710
+ return errorToast('card.dashboard.sessions.locate_failed', { reason: 'chat_scope_not_supported' }, locale);
711
+ }
712
+ let resp;
713
+ try {
714
+ resp = await client.request({
715
+ method: 'POST',
716
+ path: `/__daemon/sessions/${encodeURIComponent(sessionId)}/locate${pathSuffix}`,
717
+ });
718
+ }
719
+ catch (e) {
720
+ return errorToast('card.dashboard.sessions.locate_failed', { reason: e.message }, locale);
721
+ }
722
+ if (resp.status !== 200) {
723
+ const body = (resp.body ?? {});
724
+ const reason = String(body.error ?? `http_${resp.status}`);
725
+ return errorToast('card.dashboard.sessions.locate_failed', { reason }, locale);
726
+ }
727
+ return { toast: { type: 'success', content: t('card.dashboard.sessions.locate.success', undefined, locale) } };
728
+ }
729
+ // ─── 3b3) RESUME — server-side matrix check, sync POST, 2nd GET ──────
730
+ // closed → idle/active. Mirrors the close path but inverted: pre-GET
731
+ // confirms session exists and resume is matrix-permitted; on success
732
+ // we issue a 2nd GET to read the post-resume row (status/webPort/proxyPort
733
+ // all change), falling back to a synth only when the row vanished.
734
+ if (action === SESSIONS_ACTION_RESUME) {
735
+ const sessionId = value.session_id;
736
+ if (typeof sessionId !== 'string' || !sessionId) {
737
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
738
+ }
739
+ const pre = await safeGetSessionsList(client, locale, pathSuffix);
740
+ if ('errorResult' in pre)
741
+ return pre.errorResult;
742
+ const before = pre.rows.find(s => s.sessionId === sessionId);
743
+ if (!before) {
744
+ return errorToast('card.dashboard.sessions.session_not_found', undefined, locale);
745
+ }
746
+ // Re-run the availability matrix against the fresh snapshot. A replayed
747
+ // event on an active session must not POST resume.
748
+ const beforeDetail = composeDetail(before, now());
749
+ if (beforeDetail.actions.resume.enabled !== true) {
750
+ const mappedKey = mapResumeDisabledReason(beforeDetail.actions.resume.reasonKey)
751
+ ?? 'card.dashboard.sessions.resume_failed';
752
+ return errorToast(mappedKey, undefined, locale);
753
+ }
754
+ let resp;
755
+ try {
756
+ resp = await client.request({
757
+ method: 'POST',
758
+ path: `/__daemon/sessions/${encodeURIComponent(sessionId)}/resume${pathSuffix}`,
759
+ });
760
+ }
761
+ catch (e) {
762
+ return errorToast('card.dashboard.sessions.resume_failed', { reason: e.message }, locale);
763
+ }
764
+ if (resp.status !== 200) {
765
+ const body = (resp.body ?? {});
766
+ const reason = String(body.error ?? `http_${resp.status}`);
767
+ return errorToast('card.dashboard.sessions.resume_failed', { reason }, locale);
768
+ }
769
+ // 2nd GET — read the fresh row. Resume regenerates worker port + can
770
+ // toggle status to 'idle' or 'starting', so we don't synth this; we
771
+ // refetch. If the refetch fails OR the row vanished (unlikely — resume
772
+ // creates a NEW session id in some flows; but our resume verb keeps the
773
+ // same id), fall back to a synth with cleared closedAt and a hint
774
+ // status. The fresh row may be one render-cycle stale; the next user
775
+ // interaction will converge it.
776
+ const postRefetch = await safeGetSessionsList(client, locale, pathSuffix);
777
+ let after;
778
+ if (!('errorResult' in postRefetch)) {
779
+ after = postRefetch.rows.find(s => s.sessionId === sessionId);
780
+ }
781
+ if (!after) {
782
+ // Fallback: synth a minimally-recovered row. status='idle' is the
783
+ // most likely post-resume state for a session that just came up;
784
+ // the actual status may differ — one render of staleness, then the
785
+ // next refresh converges.
786
+ after = { ...before, status: 'idle', closedAt: undefined };
787
+ }
788
+ const detail = composeDetail(after, now());
789
+ const cardJson = buildSessionsDetailCard(detail, {
790
+ invokerOpenId: operatorOpenId,
791
+ locale,
792
+ nowMs: now(),
793
+ origin: navOrigin,
794
+ pageSize: navPageSize,
795
+ sourcePage: navPage,
796
+ scope: navScope,
797
+ terminalUrl: buildSessionTerminalUrl(after),
798
+ feishuChatLink: after.feishuChatLink ?? null,
799
+ });
800
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
801
+ }
802
+ // ─── 3c) BACK TO LIST — rebuild list card at page 1 ─────────────────
803
+ if (action === SESSIONS_ACTION_BACK_TO_LIST) {
804
+ const r = await safeGetSessionsList(client, locale, pathSuffix);
805
+ if ('errorResult' in r)
806
+ return r.errorResult;
807
+ const cardJson = buildSessionsCard(r.rows, {
808
+ invokerOpenId: operatorOpenId,
809
+ locale,
810
+ page: navPage ?? 1,
811
+ pageSize: navPageSize,
812
+ origin: navOrigin,
813
+ scope: navScope,
814
+ }, now());
815
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
816
+ }
817
+ // ─── 3d) REFRESH + PAGE actions ─────────────────────────────────────
818
+ // `action` is already constrained to validActions above; the only ones
819
+ // left here are REFRESH + PAGE (the other 3 returned early).
820
+ let page = 1;
821
+ if (action === SESSIONS_ACTION_PAGE) {
822
+ // Page comes from value.page (prev/next button) OR action.option
823
+ // (select_static "jump to page" picker). Same action key, different
824
+ // dispatch field — handler converges on one branch.
825
+ const raw = value.page ?? data.action?.option ?? '1';
826
+ const parsed = Number.parseInt(raw, 10);
827
+ if (Number.isFinite(parsed) && parsed >= 1)
828
+ page = parsed;
829
+ }
830
+ const r = await safeGetSessionsList(client, locale, pathSuffix);
831
+ if ('errorResult' in r)
832
+ return r.errorResult;
833
+ const cardJson = buildSessionsCard(r.rows, {
834
+ invokerOpenId: operatorOpenId,
835
+ locale,
836
+ page,
837
+ pageSize: navPageSize,
838
+ origin: navOrigin,
839
+ scope: navScope,
840
+ }, now());
841
+ return {
842
+ // Card-only success path — see settings-card.ts docblock for why no toast.
843
+ card: { type: 'raw', data: JSON.parse(cardJson) },
844
+ };
845
+ }
846
+ /**
847
+ * GET `/__daemon/sessions-list` and surface non-200 / network errors as
848
+ * caller-facing error toasts. Returns either `{ rows }` or
849
+ * `{ errorResult }` — exactly one is set.
850
+ *
851
+ * createDaemonClient.request resolves 4xx/5xx responses, so status must be
852
+ * checked explicitly. Otherwise a backend failure would look like an empty
853
+ * sessions list.
854
+ */
855
+ async function safeGetSessionsList(client, locale, pathSuffix = '') {
856
+ let r;
857
+ try {
858
+ r = await client.request({ method: 'GET', path: `/__daemon/sessions-list${pathSuffix}` });
859
+ }
860
+ catch (e) {
861
+ return { errorResult: errorToast('card.dashboard.sessions.list_failed', { reason: e.message }, locale) };
862
+ }
863
+ if (r.status !== 200) {
864
+ const reason = String(r.body?.error ?? `http_${r.status}`);
865
+ return { errorResult: errorToast('card.dashboard.sessions.list_failed', { reason }, locale) };
866
+ }
867
+ const rows = (r.body?.sessions) ?? [];
868
+ return { rows };
869
+ }
870
+ //# sourceMappingURL=sessions-card.js.map