@chenkai114/dsh-team-admin 0.1.1 → 0.1.2

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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ v0.2.x 的内置 Team roster 是 **append-only**:只能 `spawn_teammate` 加
18
18
  dsh plugin --profile web add @chenkai114/dsh-team-admin
19
19
  ```
20
20
 
21
- 重启 / 重载后,打开 **设置 → 插件 → dsh-team-admin**(或插件管理页里本包的配置卡),即可看到「智能体团队管理」面板。
21
+ 重载后左侧栏会出现 **团队管理** 入口(插件市场里本包的配置卡也保留)。
22
22
 
23
23
  未发布前用本地源码链接安装:
24
24
 
@@ -34,7 +34,13 @@ dsh plugin --profile web add link:/Users/chenkai2/data1/www/htdocs/dsh-team-admi
34
34
  }
35
35
  ```
36
36
 
37
- ![团队管理卡片](docs/images/team-admin.png)
37
+ 左侧栏「团队管理」入口(0.1.1 起):
38
+
39
+ ![侧栏入口](docs/images/sidebar-entry.png)
40
+
41
+ 打开后是完整管理面板,可直接对真实会话操作:
42
+
43
+ ![团队管理面板](docs/images/team-panel-real.png)
38
44
 
39
45
  ## 用法
40
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenkai114/dsh-team-admin",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DSH 插件:GUI 管理任意会话的智能体团队 —— 查看成员、删除角色并清理其持久会话(进回收站,可恢复)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/dismiss.mjs CHANGED
@@ -5,7 +5,7 @@ import { cp, lstat, mkdir, rename, rm } from 'node:fs/promises'
5
5
  import { basename, dirname, join } from 'node:path'
6
6
 
7
7
  import { AdminError } from './error.mjs'
8
- import { parseRoster, readSessionLog, statSnapshot } from './team-read.mjs'
8
+ import { invalidateSessionListCache, parseRoster, readSessionLog, statSnapshot } from './team-read.mjs'
9
9
 
10
10
  /** 停止成员的总等待上限(契约固定 15s)。 */
11
11
  const DEFAULT_INTERRUPT_TIMEOUT_MS = 15_000
@@ -225,6 +225,8 @@ export async function dismissMember(ctx, request, options = {}) {
225
225
  dirName: memberId + '-' + stamp,
226
226
  })
227
227
  sessionDeleted = true
228
+ // 会话文件已消失:让下一轮列表 / 团队详情立刻重扫,而不是等 60s 缓存过期。
229
+ invalidateSessionListCache(ctx)
228
230
  } catch (error) {
229
231
  logSystem('[team-admin] 移动成员 ' + memberId + ' 的会话失败:' + String(error?.stack ?? error))
230
232
  throw new AdminError('成员会话移动失败,请检查服务端日志', 'system', 500)
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ const DATA_DIR_ENV = 'DSH_TEAM_ADMIN_DATA_DIR'
17
17
  /** 路由前缀:webServer 只支持 exact/prefix 注册。 */
18
18
  const ROUTE_PREFIX = '/api/team-admin'
19
19
  /** 与 package.json 版本保持一致(发布前由测试校验)。 */
20
- const PLUGIN_VERSION = '0.1.1'
20
+ const PLUGIN_VERSION = '0.1.2'
21
21
 
22
22
  /**
23
23
  * 解析插件数据目录。
package/src/team-read.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  // 只读半区:列根会话、读某会话的团队(成员快照 + 任务归属 + 实时状态)。
2
- // 标题优先走宿主的 sessionQuery 折叠;不可用时按需读会话日志并做 TTL 缓存,
3
- // 列表页只对真正需要的会话取标题,绝不为了标题全量解压所有会话。
2
+ //
3
+ // 性能约定(列表页冷启动 / 暖调用):
4
+ // - 会话快照列表 + mtime + live 按 ctx 缓存 60s,同一 ctx 的并发调用共享一次扫描(inFlight 去重);
5
+ // query 与 limit 都在缓存结果上做,绝不每次调用都重新 list() 一遍整个会话库。
6
+ // - 标题优先取宿主的 projection cache(sessionProjectionCache,零 I/O 的已折叠行),
7
+ // 其次 sessionQuery 批量折叠,最后按上限读日志前缀;不会为了标题全量解压会话。
4
8
  import { stat } from 'node:fs/promises'
5
9
 
6
10
  import { AdminError } from './error.mjs'
@@ -8,16 +12,25 @@ import { AdminError } from './error.mjs'
8
12
  /** 默认列表条数与上限(与 src/api.mjs 保持一致)。 */
9
13
  const DEFAULT_LIMIT = 50
10
14
  const MAX_LIMIT = 200
11
- /** 无 sessionQuery 时最多为多少个会话折叠标题(按最近活动取前 N)。 */
12
- const TITLE_SCAN_LIMIT = 400
13
- /** 标题内存缓存有效期。 */
14
- const TITLE_TTL_MS = 30_000
15
- /** 回退读日志时只取前 N 条事件(用于取首条 user/message)。 */
16
- const TITLE_PREFIX_EVENTS = 80
17
- /** 并发读日志折叠标题的上限。 */
18
- const TITLE_CONCURRENCY = 4
19
-
20
- /** 标题缓存按 ctx 归属,避免跨插件实例 / 跨测试串味。 */
15
+ /** 会话快照扫描缓存有效期(列表允许 1 分钟陈旧)。 */
16
+ const SCAN_TTL_MS = 60_000
17
+ /** 扫描时读 mtime 的并发上限,避免几千个会话同时开 fd。 */
18
+ const SCAN_CONCURRENCY = 16
19
+ /** 冷扫描超过该耗时打一条 warn,便于从服务端日志定位宿主存储变慢。 */
20
+ const COLD_WARN_MS = 2_000
21
+ /** 标题缓存有效期(与扫描缓存对齐)。 */
22
+ const TITLE_TTL_MS = 60_000
23
+ /** 一次 sessionQuery 批量折叠标题的上限(它读日志实现,冷启动时不能放大)。 */
24
+ const TITLE_QUERY_LIMIT = 32
25
+ /** 一次为标题读日志前缀的会话数上限。 */
26
+ const PREFIX_READ_LIMIT = 16
27
+ /** 读日志前缀时最多取的事件数。 */
28
+ const PREFIX_EVENTS = 80
29
+ /** 前缀读取并发上限。 */
30
+ const PREFIX_CONCURRENCY = 2
31
+
32
+ /** 会话扫描缓存与标题缓存都按 ctx 归属,避免跨插件实例 / 跨测试串味。 */
33
+ const scanStates = new WeakMap()
21
34
  const titleCaches = new WeakMap()
22
35
 
23
36
  /** 取宿主服务(可选依赖用 ctx.get,避免未声明的 inject 抛错)。 */
@@ -38,14 +51,13 @@ function service(ctx, name) {
38
51
  }
39
52
  }
40
53
 
41
- /** 当前 ctx 的标题缓存。 */
42
- function titleCache(ctx) {
43
- let cache = titleCaches.get(ctx)
44
- if (cache === undefined) {
45
- cache = new Map()
46
- titleCaches.set(ctx, cache)
54
+ /** 打一条宿主 warn(日志不可用时静默)。 */
55
+ function logWarn(ctx, message) {
56
+ try {
57
+ if (typeof ctx?.logger?.warn === 'function') ctx.logger.warn(message)
58
+ } catch {
59
+ // 日志输出失败不影响接口结果。
47
60
  }
48
- return cache
49
61
  }
50
62
 
51
63
  /** 普通对象判定。 */
@@ -98,7 +110,7 @@ export async function readSessionLog(persistence, sessionId) {
98
110
  }
99
111
  }
100
112
 
101
- /** 会话文件 mtime(未知 → undefined);用于「最近活动」近似值。 */
113
+ /** 会话产物文件 mtime(未知 → undefined);用于「最近活动」近似值。 */
102
114
  async function artifactMtime(path) {
103
115
  if (typeof path !== 'string' || path === '') return undefined
104
116
  try {
@@ -124,14 +136,132 @@ async function mapLimited(items, limit, fn) {
124
136
  return results
125
137
  }
126
138
 
139
+ /** 某个 ctx 的会话扫描缓存槽。 */
140
+ function scanState(ctx) {
141
+ let state = scanStates.get(ctx)
142
+ if (state === undefined) {
143
+ state = { at: 0, rows: undefined, headers: undefined, inFlight: undefined }
144
+ scanStates.set(ctx, state)
145
+ }
146
+ return state
147
+ }
148
+
149
+ /** 当前 ctx 的标题缓存。 */
150
+ function titleCache(ctx) {
151
+ let cache = titleCaches.get(ctx)
152
+ if (cache === undefined) {
153
+ cache = new Map()
154
+ titleCaches.set(ctx, cache)
155
+ }
156
+ return cache
157
+ }
158
+
127
159
  /**
128
- * 折叠一批会话的标题(缓存 + sessionQuery 批量优先 + 按需读日志兜底)。
160
+ * 丢弃某个 ctx 的会话扫描缓存(删除成员后可让列表 / 团队详情立刻反映文件变化)。
161
+ * @param {object} ctx - 宿主上下文。
162
+ */
163
+ export function invalidateSessionListCache(ctx) {
164
+ scanStates.delete(ctx)
165
+ }
166
+
167
+ /** 一个会话的最近活动时间(未知回退创建时间)。 */
168
+ async function lastActivityOf(snapshot) {
169
+ const mtime = await artifactMtime(snapshot?.location?.path)
170
+ return mtime ?? (Number(snapshot?.header?.createdAt) || 0)
171
+ }
172
+
173
+ /** 实时状态:ctx.agents.get(id) 有值即已加载。 */
174
+ function liveAgent(ctx, id) {
175
+ const agents = service(ctx, 'agents')
176
+ if (agents === undefined || typeof agents.get !== 'function') return undefined
177
+ try {
178
+ return agents.get(id)
179
+ } catch {
180
+ return undefined
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 扫一次会话库(含全部会话,不只是根会话),结果按 ctx 缓存。
186
+ * 同一 ctx 的并发冷调用共享同一次扫描;扫描失败且存在旧缓存时先给旧数据。
129
187
  * @param {object} ctx - 宿主上下文。
130
- * @param {string[]} ids - 需要标题的会话 id。
131
188
  * @param {object} persistence - ctx.sessionPersistence。
132
- * @returns {Promise<Map<string, string>>} 已知标题(缺标题的会话映射为 id)。
189
+ * @param {{maxAgeMs?: number}} [options] - 缓存有效期覆盖(默认 60s,0 表示强制重扫)。
190
+ * @returns {Promise<{at: number, rows: object[], headers: Map<string, object>}>} 缓存槽(含全部会话行)。
191
+ * @throws {AdminError} 首次扫描失败(无旧缓存可退)。
133
192
  */
134
- async function collectTitles(ctx, ids, persistence) {
193
+ async function scanSessions(ctx, persistence, { maxAgeMs = SCAN_TTL_MS } = {}) {
194
+ const state = scanState(ctx)
195
+ if (state.rows !== undefined && Date.now() - state.at < maxAgeMs) return state
196
+ if (state.inFlight !== undefined) return state.inFlight
197
+
198
+ const started = Date.now()
199
+ state.inFlight = (async () => {
200
+ const snapshots = await persistence.list()
201
+ const rows = []
202
+ const headers = new Map()
203
+ await mapLimited(snapshots, SCAN_CONCURRENCY, async (snapshot) => {
204
+ if (!isRecord(snapshot?.header)) return
205
+ const id = String(snapshot.header.id)
206
+ headers.set(id, snapshot.header)
207
+ rows.push({
208
+ id,
209
+ cwd: typeof snapshot.header.cwd === 'string' ? snapshot.header.cwd : '',
210
+ createdAt: Number(snapshot.header.createdAt) || 0,
211
+ lastActivityAt: await lastActivityOf(snapshot),
212
+ live: liveAgent(ctx, id) !== undefined,
213
+ parentSession: snapshot.header.parentSession,
214
+ })
215
+ })
216
+ state.rows = rows
217
+ state.headers = headers
218
+ state.at = Date.now()
219
+ const took = state.at - started
220
+ if (took >= COLD_WARN_MS) {
221
+ logWarn(ctx, '[team-admin] 会话列表冷扫描耗时 ' + took + 'ms(' + rows.length + ' 个会话,缓存 ' + Math.round(SCAN_TTL_MS / 1000) + 's)')
222
+ }
223
+ return state
224
+ })()
225
+
226
+ try {
227
+ return await state.inFlight
228
+ } catch (error) {
229
+ if (state.rows !== undefined) {
230
+ logWarn(ctx, '[team-admin] 会话扫描失败,继续使用 ' + Math.round((Date.now() - state.at) / 1000) + 's 前的缓存:' + String(error?.message ?? error))
231
+ return state
232
+ }
233
+ throw error
234
+ } finally {
235
+ state.inFlight = undefined
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 从宿主 projection cache 取已折叠标题(同步零 I/O,与宿主冷列表一致)。
241
+ * @param {object} projections - ctx.sessionProjectionCache。
242
+ * @param {object} header - 会话头部(生命周期身份校验用)。
243
+ * @returns {string|undefined} 标题;无可用行时 undefined。
244
+ */
245
+ function projectionTitle(projections, header) {
246
+ if (projections === undefined || header === undefined || typeof projections.cachedSnapshot !== 'function') return undefined
247
+ try {
248
+ const block = projections.cachedSnapshot(header, ['title'])
249
+ const value = block?.values?.title
250
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
251
+ } catch {
252
+ return undefined
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 折叠一批会话的标题(缓存 → projection cache → sessionQuery 批量 → 日志前缀 → 会话 id)。
258
+ * @param {object} ctx - 宿主上下文。
259
+ * @param {string[]} ids - 需要标题的会话 id。
260
+ * @param {object} persistence - ctx.sessionPersistence(前缀读取兜底用)。
261
+ * @param {Map<string, object>} [headers] - id → 会话头部(projection cache 身份校验用)。
262
+ * @returns {Promise<Map<string, string>>} 标题表(读不到的会话映射为 id)。
263
+ */
264
+ async function collectTitles(ctx, ids, persistence, headers = new Map()) {
135
265
  const cache = titleCache(ctx)
136
266
  const now = Date.now()
137
267
  const titles = new Map()
@@ -148,40 +278,46 @@ async function collectTitles(ctx, ids, persistence) {
148
278
  titles.set(id, title)
149
279
  }
150
280
 
151
- const query = service(ctx, 'sessionQuery')
152
- const usable = query !== undefined && typeof query.readTitleSnapshots === 'function'
281
+ // 1) 宿主 projection cache:同步命中,冷列表的主要标题来源。
282
+ const projections = service(ctx, 'sessionProjectionCache')
153
283
  const unresolved = []
154
- let batchWorked = false
155
- if (usable) {
284
+ for (const id of pending) {
285
+ const title = projectionTitle(projections, headers.get(id))
286
+ if (title === undefined) unresolved.push(id)
287
+ else remember(id, title)
288
+ }
289
+
290
+ // 2) sessionQuery 批量折叠:它按日志实现,只在剩余量可控时才调用,避免冷启动放大。
291
+ let remaining = unresolved
292
+ const query = service(ctx, 'sessionQuery')
293
+ if (remaining.length > 0 && remaining.length <= TITLE_QUERY_LIMIT
294
+ && query !== undefined && typeof query.readTitleSnapshots === 'function') {
156
295
  let results
157
296
  try {
158
- results = await query.readTitleSnapshots(pending)
297
+ results = await query.readTitleSnapshots(remaining)
159
298
  } catch {
160
299
  results = undefined
161
300
  }
162
301
  if (Array.isArray(results)) {
163
- batchWorked = true
302
+ const stillOpen = []
164
303
  for (const [index, item] of results.entries()) {
165
- const id = typeof item?.sessionId === 'string' ? item.sessionId : pending[index]
304
+ const id = typeof item?.sessionId === 'string' ? item.sessionId : remaining[index]
166
305
  const title = item?.status === 'fulfilled' ? item.value?.title?.title : undefined
167
306
  if (typeof title === 'string' && title.trim() !== '') remember(id, title.trim())
168
- else unresolved.push(id)
307
+ else stillOpen.push(id)
169
308
  }
170
- } else {
171
- unresolved.push(...pending)
309
+ remaining = stillOpen
172
310
  }
173
- } else {
174
- unresolved.push(...pending)
175
311
  }
176
312
 
177
- // 批量折叠成功说明「没有标题」是权威结论,此时只读前缀找首条用户消息;
178
- // 批量不可用(无 sessionQuery 或整批失败)才读全量日志折叠最后一条 session/title。
179
- await mapLimited(unresolved, TITLE_CONCURRENCY, async (id) => {
313
+ // 3) 有上限的日志前缀读取:取 session/title 或首条 user/message,绝不整篇解压。
314
+ const prefixIds = remaining.slice(0, PREFIX_READ_LIMIT)
315
+ await mapLimited(prefixIds, PREFIX_CONCURRENCY, async (id) => {
180
316
  try {
181
317
  const handle = await persistence.open(id, 'read')
182
318
  let events
183
319
  try {
184
- const slice = await handle.read(0, batchWorked ? TITLE_PREFIX_EVENTS : undefined)
320
+ const slice = await handle.read(0, PREFIX_EVENTS)
185
321
  events = slice.events
186
322
  } finally {
187
323
  await handle.close()
@@ -192,61 +328,53 @@ async function collectTitles(ctx, ids, persistence) {
192
328
  // 会话不可读(已进回收站 / 被打断写坏):标题回退会话 id,不影响列表。
193
329
  }
194
330
  })
195
- // 读不到标题的会话按会话 id 兜底,一并进缓存,避免每次列表都重读同一个坏会话。
196
- for (const id of unresolved) if (!titles.has(id)) remember(id, id)
331
+
332
+ // 4) 兜底:读不到标题的会话按会话 id 记住,避免每次列表都重读同一个坏会话。
333
+ for (const id of pending) if (!titles.has(id)) remember(id, id)
197
334
  return titles
198
335
  }
199
336
 
200
- /** 一个会话的最近活动时间(未知回退创建时间)。 */
201
- async function lastActivityOf(snapshot) {
202
- const mtime = await artifactMtime(snapshot?.location?.path)
203
- return mtime ?? (Number(snapshot?.header?.createdAt) || 0)
337
+ /** 把内部扫描行换成对外的 SessionRow(只输出契约字段)。 */
338
+ function toSessionRow(row, title) {
339
+ return {
340
+ id: row.id,
341
+ title,
342
+ cwd: row.cwd,
343
+ createdAt: row.createdAt,
344
+ lastActivityAt: row.lastActivityAt,
345
+ live: row.live,
346
+ }
204
347
  }
205
348
 
206
- /** 实时状态:ctx.agents.get(id) 有值即已加载。 */
207
- function liveAgent(ctx, id) {
208
- const agents = service(ctx, 'agents')
209
- if (agents === undefined || typeof agents.get !== 'function') return undefined
210
- try {
211
- return agents.get(id)
212
- } catch {
213
- return undefined
214
- }
349
+ /** 列表排序:最近活动降序,其次创建时间降序,最后按 id 保证稳定。 */
350
+ function compareActivity(left, right) {
351
+ return right.lastActivityAt - left.lastActivityAt
352
+ || right.createdAt - left.createdAt
353
+ || left.id.localeCompare(right.id)
215
354
  }
216
355
 
217
356
  /**
218
357
  * 列根会话(parentSession 为空),按最近活动降序。
219
358
  * @param {object} ctx - 宿主上下文。
220
- * @param {{query?: string, limit?: number}} [options] - 搜索词与条数上限。
359
+ * @param {{query?: string, limit?: number, maxAgeMs?: number}} [options] - 搜索词、条数上限、扫描缓存有效期覆盖。
221
360
  * @returns {Promise<object[]>} SessionRow 数组。
222
361
  */
223
- export async function listTeamSessions(ctx, { query = '', limit = DEFAULT_LIMIT } = {}) {
362
+ export async function listTeamSessions(ctx, { query = '', limit = DEFAULT_LIMIT, maxAgeMs = SCAN_TTL_MS } = {}) {
224
363
  const persistence = service(ctx, 'sessionPersistence')
225
364
  if (persistence === undefined || typeof persistence.list !== 'function') {
226
365
  throw new AdminError('宿主会话服务不可用,无法列出会话', 'system', 500)
227
366
  }
228
367
  const effectiveLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, MAX_LIMIT) : DEFAULT_LIMIT
229
- const snapshots = (await persistence.list()).filter((snapshot) => isRecord(snapshot?.header) && snapshot.header.parentSession === undefined)
230
-
231
- // mtime 读盘有并发上限:几千个会话时不至于同时开几千个 fd。
232
- const rows = await mapLimited(snapshots, 16, async (snapshot) => {
233
- const id = String(snapshot.header.id)
234
- const createdAt = Number(snapshot.header.createdAt) || 0
235
- return {
236
- id,
237
- title: id,
238
- cwd: typeof snapshot.header.cwd === 'string' ? snapshot.header.cwd : '',
239
- createdAt,
240
- lastActivityAt: await lastActivityOf(snapshot),
241
- live: liveAgent(ctx, id) !== undefined,
242
- }
243
- })
244
- rows.sort((left, right) => right.lastActivityAt - left.lastActivityAt || right.createdAt - left.createdAt)
368
+ const scan = await scanSessions(ctx, persistence, { maxAgeMs })
369
+ const roots = scan.rows.filter((row) => row.parentSession === undefined)
370
+ roots.sort(compareActivity)
245
371
 
246
372
  const needle = typeof query === 'string' ? query.trim().toLowerCase() : ''
247
- const titleIds = (needle === '' ? rows.slice(0, effectiveLimit) : rows.slice(0, TITLE_SCAN_LIMIT)).map((row) => row.id)
248
- const titles = await collectTitles(ctx, titleIds, persistence)
249
- for (const row of rows) row.title = titles.get(row.id) ?? row.id
373
+ // 搜索要对标题做子串匹配,因此搜索时给全部根会话取标题(标题来源都是缓存/零 I/O,
374
+ // 只有极少数会话才会触发前缀读取);无搜索时只取将返回的那一段。
375
+ const titleIds = (needle === '' ? roots.slice(0, effectiveLimit) : roots).map((row) => row.id)
376
+ const titles = await collectTitles(ctx, titleIds, persistence, scan.headers)
377
+ const rows = roots.map((row) => toSessionRow(row, titles.get(row.id) ?? row.id))
250
378
  if (needle === '') return rows.slice(0, effectiveLimit)
251
379
  return rows
252
380
  .filter((row) => row.title.toLowerCase().includes(needle)
@@ -266,7 +394,13 @@ export function parseRoster(events) {
266
394
  const tasks = new Map()
267
395
  let title
268
396
  for (const event of events ?? []) {
269
- if (!isRecord(event) || !isRecord(event.data)) continue
397
+ if (!isRecord(event)) continue
398
+ if (event.type === 'session/title' && isRecord(event.data) && typeof event.data.title === 'string') {
399
+ const text = event.data.title.trim()
400
+ if (text !== '') title = text
401
+ continue
402
+ }
403
+ if (!isRecord(event.data)) continue
270
404
  if (event.type === 'team/member' && isRecord(event.data.member)) {
271
405
  const member = event.data.member
272
406
  if (typeof member.id === 'string' && member.id !== '') members.set(member.id, { ...member })
@@ -280,10 +414,6 @@ export function parseRoster(events) {
280
414
  if (current === undefined || (Number.isFinite(revision) && revision >= Number(current.revision))) {
281
415
  tasks.set(task.id, { ...task })
282
416
  }
283
- continue
284
- }
285
- if (event.type === 'session/title' && typeof event.data.title === 'string' && event.data.title.trim() !== '') {
286
- title = event.data.title.trim()
287
417
  }
288
418
  }
289
419
  return {
@@ -316,7 +446,11 @@ export async function readTeam(ctx, { sessionId, store }) {
316
446
 
317
447
  const log = await readSessionLog(persistence, sessionId)
318
448
  const roster = parseRoster(log.events)
319
- const title = roster.title ?? (await collectTitles(ctx, [sessionId], persistence)).get(sessionId) ?? sessionId
449
+ const scan = await scanSessions(ctx, persistence)
450
+ const headers = new Map([[sessionId, snapshot.header]])
451
+ const title = roster.title
452
+ ?? (await collectTitles(ctx, [sessionId], persistence, headers)).get(sessionId)
453
+ ?? sessionId
320
454
  const session = {
321
455
  id: sessionId,
322
456
  title,
@@ -326,14 +460,7 @@ export async function readTeam(ctx, { sessionId, store }) {
326
460
  live: liveAgent(ctx, sessionId) !== undefined,
327
461
  }
328
462
 
329
- let childSnapshots = new Map()
330
- if (typeof persistence.list === 'function') {
331
- try {
332
- childSnapshots = new Map((await persistence.list()).map((item) => [String(item.header.id), item]))
333
- } catch {
334
- childSnapshots = new Map()
335
- }
336
- }
463
+ const childRows = new Map(scan.rows.map((row) => [row.id, row]))
337
464
  const leadAgent = liveAgent(ctx, sessionId)
338
465
  const dismissedRows = store === undefined || typeof store.listDismissed !== 'function'
339
466
  ? []
@@ -343,13 +470,13 @@ export async function readTeam(ctx, { sessionId, store }) {
343
470
  const teammates = roster.members.filter((member) => member.name !== 'lead' && String(member.id) !== sessionId)
344
471
  const memberIds = new Set(teammates.map((member) => member.id))
345
472
 
346
- const members = await Promise.all(teammates.map(async (member) => {
473
+ const members = teammates.map((member) => {
347
474
  const id = String(member.id)
348
475
  const live = liveAgent(ctx, id)
349
- const child = childSnapshots.get(id)
476
+ const child = childRows.get(id)
350
477
  const phase = member.phase === 'failed' || member.phase === 'provisioning' ? member.phase : 'active'
351
478
  const model = live?.options?.model ?? leadAgent?.options?.model
352
- const row = {
479
+ return {
353
480
  name: String(member.name ?? ''),
354
481
  id,
355
482
  phase,
@@ -362,10 +489,9 @@ export async function readTeam(ctx, { sessionId, store }) {
362
489
  dismissed: dismissedById.has(id),
363
490
  sessionExists: live !== undefined || child !== undefined,
364
491
  ownedTasks: roster.tasks.filter((task) => task.ownerId === id).length,
365
- lastActivityAt: child === undefined ? null : await lastActivityOf(child) ?? null,
492
+ lastActivityAt: child === undefined ? null : child.lastActivityAt,
366
493
  }
367
- return row
368
- }))
494
+ })
369
495
 
370
496
  const dismissed = dismissedRows.map((row) => ({
371
497
  name: row.name,