@modesto_ma/dsh-channels 0.5.0-beta.3 → 0.5.0-beta.4

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/src/index.ts CHANGED
@@ -51,7 +51,7 @@ export type { CatalogWarn, SettingsCatalog } from './settings/catalog.ts'
51
51
  export { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
52
52
  export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './settings/directory-picker.ts'
53
53
  export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
54
- export type { HostWebServer } from './settings/routes.ts'
54
+ export type { HostRequestGate, HostWebServer } from './settings/routes.ts'
55
55
  export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
56
56
  export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
57
57
  export { ApprovalAnswerer } from './approvals.ts'
@@ -20,8 +20,13 @@ export interface InteractionRoute {
20
20
  cardMessageId: string
21
21
  /** Whether the anchor lives in a real topic (replyInThread). */
22
22
  inThread: boolean
23
- /** The session owner (user_id) — the only operator allowed to decide. */
23
+ /** The session owner (user_id); the only operator allowed to decide unless `mayDecide` is set. */
24
24
  senderUserId: string
25
+ /**
26
+ * Decision gate of a group-shared route: replaces the owner-only check with
27
+ * the route's member admission. Absent = only `senderUserId` decides.
28
+ */
29
+ mayDecide?(userId: string): boolean
25
30
  }
26
31
 
27
32
  /** Fields every pending interaction card carries. */
@@ -96,7 +101,11 @@ export class PendingCardTable<E extends PendingCardEntry> {
96
101
  }
97
102
  }
98
103
  if (entry === undefined || entry.settled) return undefined
99
- if (event.operator.userId !== entry.route.senderUserId) {
104
+ const operator = event.operator.userId
105
+ const allowed = operator !== undefined && (entry.route.mayDecide === undefined
106
+ ? operator === entry.route.senderUserId
107
+ : entry.route.mayDecide(operator))
108
+ if (!allowed) {
100
109
  onUnauthorized(entry)
101
110
  return undefined
102
111
  }
@@ -14,7 +14,7 @@ import type {
14
14
  AgentHandle, AttachmentStore, ContentBlock, HostContext, SessionId, UserMessage, Workspace,
15
15
  } from '../dsh-types.ts'
16
16
  import type { AttachmentStoreMode, FeishuConfig } from '../config.ts'
17
- import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
17
+ import { buildCard, buildOnboardingCard, initialStreamingCard, queuedStreamingCard } from '../cards.ts'
18
18
  import { RouteOperationQueue } from '../commands.ts'
19
19
  import { chatRouteKey, sessionDirOf } from '../domain.ts'
20
20
  import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
@@ -228,6 +228,21 @@ interface TurnDeliveryOptions {
228
228
  excludeMessageIds?: readonly string[]
229
229
  }
230
230
 
231
+ /**
232
+ * Register one release gate under `key` in `gates`. The returned closer
233
+ * resolves the gate and removes it only while it is still the registered one.
234
+ */
235
+ function openGate(gates: Map<string, { promise: Promise<void>; release(): void }>, key: string): () => void {
236
+ let settle!: () => void
237
+ const promise = new Promise<void>((resolve) => { settle = resolve })
238
+ const release = (): void => {
239
+ if (gates.get(key)?.promise === promise) gates.delete(key)
240
+ settle()
241
+ }
242
+ gates.set(key, { promise, release })
243
+ return release
244
+ }
245
+
231
246
  export class InboundRouter {
232
247
  /**
233
248
  * In-process dedup guard: message id → accept timestamp. Set synchronously
@@ -251,6 +266,13 @@ export class InboundRouter {
251
266
  * racing it, otherwise the topic's turns reach the model out of order.
252
267
  */
253
268
  private readonly firstDeliveries = new Map<string, { promise: Promise<void>; release(): void }>()
269
+ /**
270
+ * topic id → reservation by an external starter (the analyze launcher) that
271
+ * opened the topic but has not published its route yet. A reply in the topic
272
+ * waits for the release and then routes against the published record, or
273
+ * falls through as an unbound topic when the start failed.
274
+ */
275
+ private readonly reservedTopics = new Map<string, { promise: Promise<void>; release(): void }>()
254
276
 
255
277
  constructor(private readonly deps: InboundDeps) {}
256
278
 
@@ -282,14 +304,7 @@ export class InboundRouter {
282
304
  * observes its gate. The returned closer must be called exactly once.
283
305
  */
284
306
  private openDeliveryGate(key: string): () => void {
285
- let settle!: () => void
286
- const promise = new Promise<void>((resolve) => { settle = resolve })
287
- const release = (): void => {
288
- if (this.firstDeliveries.get(key)?.promise === promise) this.firstDeliveries.delete(key)
289
- settle()
290
- }
291
- this.firstDeliveries.set(key, { promise, release })
292
- return release
307
+ return openGate(this.firstDeliveries, key)
293
308
  }
294
309
 
295
310
  /** Wait for the in-flight first followup of a route, when one is pending. */
@@ -298,7 +313,19 @@ export class InboundRouter {
298
313
  if (pending !== undefined) await pending.promise.catch(() => undefined)
299
314
  }
300
315
 
301
- /**
316
+ /**
317
+ * Reserve one topic whose route is about to be published by an external
318
+ * starter. Replies in the topic wait until the returned closer runs; the
319
+ * closer must be called exactly once, after the route (and its first turn)
320
+ * is published or the start was abandoned.
321
+ * @param threadId - the topic id.
322
+ * @returns the closer releasing waiting replies.
323
+ */
324
+ reserveTopic(threadId: string): () => void {
325
+ return openGate(this.reservedTopics, threadId)
326
+ }
327
+
328
+ /**
302
329
  * Send the one-time onboarding card for a private chat, then remember the
303
330
  * chat in the domain global. Best-effort: a storage failure only risks a
304
331
  * repeated card, never a blocked session.
@@ -327,6 +354,8 @@ export class InboundRouter {
327
354
  // first-turn delivery never stays suspended across a teardown.
328
355
  for (const entry of [...this.firstDeliveries.values()]) entry.release()
329
356
  this.firstDeliveries.clear()
357
+ for (const entry of [...this.reservedTopics.values()]) entry.release()
358
+ this.reservedTopics.clear()
330
359
  }
331
360
 
332
361
  /** Entry point: one normalized inbound message. */
@@ -370,6 +399,14 @@ export class InboundRouter {
370
399
  // TTL tombstone, attachment archive, workspace, Session, or model call.
371
400
  if (!(await this.access.admit(msg))) return
372
401
 
402
+ // A reserved topic has no route yet: wait for its starter so the TTL,
403
+ // attachment, mention, and route decisions below see the published record.
404
+ const reservation = msg.threadId === undefined ? undefined : this.reservedTopics.get(msg.threadId)
405
+ if (reservation !== undefined) {
406
+ await reservation.promise
407
+ if (!this.started) return
408
+ }
409
+
373
410
  // Enforce TTL at admission as well as in the periodic sweep. A message that
374
411
  // arrives after the deadline must not get one chance to revive the route
375
412
  // merely because the background GC has not run yet.
@@ -1112,7 +1149,12 @@ export class InboundRouter {
1112
1149
  // deployment follow its next turn (one-time legacy adoption, D12).
1113
1150
  await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
1114
1151
 
1115
- const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId))
1152
+ // A message behind a running turn opens as a queued card whose button can
1153
+ // move it into that turn instead (see `TurnStreamer.mergeQueued`).
1154
+ const firstCard = this.streamer?.hasLiveTurn(current.sessionId) === true
1155
+ ? queuedStreamingCard(current.sessionId)
1156
+ : initialStreamingCard(current.sessionId)
1157
+ const sent = await this.delivery.replyFirstCard(msg, firstCard)
1116
1158
  const typingId = await typingPromise
1117
1159
 
1118
1160
  // Repair snapshot first — same crash-window reasoning as `startSession`.
@@ -1132,9 +1174,10 @@ export class InboundRouter {
1132
1174
  cardMessageId: sent.messageId,
1133
1175
  lastActivityAt: Date.now(),
1134
1176
  })
1135
- this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId)
1136
- live.agent.followup(await this.deliverTurnMessage(msg, current, content, {
1177
+ const message = await this.deliverTurnMessage(msg, current, content, {
1137
1178
  excludeMessageIds: [msg.messageId, sent.messageId],
1138
- }))
1179
+ })
1180
+ this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId, { message })
1181
+ live.agent.followup(message)
1139
1182
  }
1140
1183
  }
package/src/service.ts CHANGED
@@ -39,7 +39,7 @@ import type {
39
39
  SessionProjections,
40
40
  TokenMeter,
41
41
  } from './dsh-types.ts'
42
- import { buildPickerCard, parsePickAction, standaloneInfoCard, STOP_ACTION } from './cards.ts'
42
+ import { buildPickerCard, parsePickAction, standaloneInfoCard, STEER_ACTION, STOP_ACTION } from './cards.ts'
43
43
  import type { PickerOption } from './cards.ts'
44
44
  import {
45
45
  armEffortCommand, armModelCommand, armRenewCommand, registerModelCommand, commandResultCardKind, parseSlashCommand,
@@ -52,10 +52,13 @@ import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
52
52
  import { FEISHU_USER_ID_PATTERN, feishuDomain, sessionDirOf } from './domain.ts'
53
53
  import type {
54
54
  AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
55
- ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
55
+ AnalyzeRequestRecord, ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
56
56
  ProcessedRecord, RawDomainHandle, RouteRecord,
57
57
  } from './domain.ts'
58
58
  import { RouteStore } from './session/routes.ts'
59
+ import { AnalyzeLauncher } from './analyze/launcher.ts'
60
+ import { startAnalyzeServer } from './analyze/server.ts'
61
+ import type { AnalyzeServer } from './analyze/server.ts'
59
62
  import { InflightRepair } from './session/repair.ts'
60
63
  import { SessionManager } from './session/manager.ts'
61
64
  import type { LiveAgent, RenewCommit } from './session/manager.ts'
@@ -94,7 +97,7 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
94
97
  import type { ProjectedStats, StreamerHooks } from './streamer.ts'
95
98
  import type { FeishuConfig } from './config.ts'
96
99
  import type {
97
- AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
100
+ AccessMutationResponse, AccessRequestWire, AccessUserWire, AccessWire, CredentialField, ScopeCheckWire, SlashCommandSyncWire,
98
101
  } from './settings/contract.ts'
99
102
  import { checkScopes } from './feishu/scopes.ts'
100
103
  import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
@@ -149,6 +152,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
149
152
  const accessUsersRaw = domain.table('access_users')
150
153
  const accessRequestsRaw = domain.table('access_requests')
151
154
  const accessWikiUsersRaw = domain.table('access_wiki_users')
155
+ const analyzeRequestsRaw = domain.table('analyze_requests')
152
156
  return {
153
157
  routes: {
154
158
  get: key => table.get(key) as RouteRecord | undefined,
@@ -198,6 +202,11 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
198
202
  delete: key => accessWikiUsersRaw.delete(key),
199
203
  entries: () => accessWikiUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
200
204
  },
205
+ analyzeRequests: {
206
+ get: key => analyzeRequestsRaw.get(key) as AnalyzeRequestRecord | undefined,
207
+ put: (key, value) => analyzeRequestsRaw.put(key, value),
208
+ entries: () => analyzeRequestsRaw.entries() as IterableIterator<[string, AnalyzeRequestRecord]>,
209
+ },
201
210
  getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
202
211
  setGlobal: value => domain.global.set(value),
203
212
  close: () => domain.close(),
@@ -230,6 +239,16 @@ async function resolveCredentialRef(ctx: HostContext, ref: string): Promise<stri
230
239
  }
231
240
 
232
241
  /** Best-effort human-readable error text for the settings panel. */
242
+ /** One whitelisted user as the panel shows it. */
243
+ function userWire(record: AccessUserRecord): AccessUserWire {
244
+ return {
245
+ userId: record.userId,
246
+ ...(record.name === undefined ? {} : { name: record.name }),
247
+ source: record.source,
248
+ addedAt: record.addedAt,
249
+ }
250
+ }
251
+
233
252
  function messageOf(error: unknown): string {
234
253
  return error instanceof Error ? error.message : String(error)
235
254
  }
@@ -270,6 +289,12 @@ export class FeishuService {
270
289
  private readonly messageReader = new MessageReader({ channel: () => this.channel })
271
290
  /** Inbound routing: dedup, admission, decision tree, session establishment. */
272
291
  private readonly inbound: InboundRouter
292
+ /** Log-platform analyze API business side (requests → shared analysis topics). */
293
+ private readonly analyzeLauncher: AnalyzeLauncher
294
+ /** Bound analyze listener while connected with `analyzeListen` set. */
295
+ private analyzeServer: AnalyzeServer | undefined
296
+ /** Why the analyze listener could not bind at the last connect. */
297
+ private analyzeListenError: string | undefined
273
298
  /** Agent/session lifecycle: live registry, resume, presets, renew. */
274
299
  private readonly sessions: SessionManager
275
300
  /**
@@ -425,6 +450,7 @@ export class FeishuService {
425
450
  chatId: record.chatId,
426
451
  chatType: record.chatType,
427
452
  senderUserId: record.senderUserId,
453
+ ...(record.shared === true ? { shared: true } : {}),
428
454
  }
429
455
  },
430
456
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -441,6 +467,7 @@ export class FeishuService {
441
467
  chatId: record.chatId,
442
468
  chatType: record.chatType,
443
469
  senderUserId: record.senderUserId,
470
+ ...(record.shared === true ? { shared: true } : {}),
444
471
  }
445
472
  },
446
473
  noteKnownMessage: (route, message, source) => {
@@ -539,6 +566,21 @@ export class FeishuService {
539
566
  approvalAnswerer: () => this.approvalAnswerer,
540
567
  commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
541
568
  })
569
+ this.analyzeLauncher = new AnalyzeLauncher({
570
+ ctx: this.ctx,
571
+ config: this.config,
572
+ started: () => this.started,
573
+ channel: () => this.channel,
574
+ domain: () => this.domain,
575
+ routes: this.routes,
576
+ repair: this.repair,
577
+ sessions: this.sessions,
578
+ streamer: () => this.streamer,
579
+ queue: this.routeOperations,
580
+ reserveTopic: threadId => this.inbound.reserveTopic(threadId),
581
+ mayAnalyzeIn: chatId => this.accessController.mayAnalyzeIn(chatId),
582
+ resolveToken: () => resolveCredentialRef(this.ctx, this.config.analyzeToken).catch(() => undefined),
583
+ })
542
584
  }
543
585
 
544
586
  /** Effective inbound attachment backend (matches the inbound router). */
@@ -949,12 +991,8 @@ export class FeishuService {
949
991
  source: record.source,
950
992
  addedAt: record.addedAt,
951
993
  })),
952
- users: this.accessStore.userRecords().map(record => ({
953
- userId: record.userId,
954
- ...(record.name === undefined ? {} : { name: record.name }),
955
- source: record.source,
956
- addedAt: record.addedAt,
957
- })),
994
+ users: this.accessStore.userRecords().map(userWire),
995
+ wikiUsers: this.accessStore.wikiUserRecords().map(userWire),
958
996
  requests: this.accessStore.pendingRequests().map(record => {
959
997
  const wire: AccessRequestWire = {
960
998
  token: record.token,
@@ -1010,13 +1048,37 @@ export class FeishuService {
1010
1048
 
1011
1049
  /** Panel: authorize one user (global across authorized groups). */
1012
1050
  async addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
1051
+ return this.addPanelUser(userId, name, id => this.accessStore.hasUser(id), record => this.accessStore.addUser(record))
1052
+ }
1053
+
1054
+ /** Panel: revoke one user. */
1055
+ async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
1056
+ return this.removePanelUser(userId, id => this.accessStore.removeUser(id))
1057
+ }
1058
+
1059
+ /** Panel: add one user to the wiki whitelist. */
1060
+ async addWikiUser(userId: string, name?: string): Promise<AccessMutationResponse> {
1061
+ return this.addPanelUser(userId, name, id => this.accessStore.hasWikiUser(id), record => this.accessStore.addWikiUser(record))
1062
+ }
1063
+
1064
+ /** Panel: remove one user from the wiki whitelist. */
1065
+ async removeWikiUser(userId: string): Promise<AccessMutationResponse> {
1066
+ return this.removePanelUser(userId, id => this.accessStore.removeWikiUser(id))
1067
+ }
1068
+
1069
+ private async addPanelUser(
1070
+ userId: string,
1071
+ name: string | undefined,
1072
+ has: (id: string) => boolean,
1073
+ add: (record: AccessUserRecord) => Promise<AccessUserRecord>,
1074
+ ): Promise<AccessMutationResponse> {
1013
1075
  const id = this.cleanAccessUserId(userId)
1014
1076
  if (id === undefined) return { ok: false, error: 'user_id 不合法(只能包含字母、数字、_ 和 -,不是 ou_ 开头的 open_id)' }
1015
1077
  const admin = this.effectiveAdminUserId()
1016
1078
  if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
1017
1079
  try {
1018
- if (!this.accessStore.hasUser(id)) {
1019
- await this.accessStore.addUser({
1080
+ if (!has(id)) {
1081
+ await add({
1020
1082
  userId: id,
1021
1083
  ...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
1022
1084
  source: 'panel',
@@ -1030,12 +1092,11 @@ export class FeishuService {
1030
1092
  }
1031
1093
  }
1032
1094
 
1033
- /** Panel: revoke one user. */
1034
- async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
1095
+ private async removePanelUser(userId: string, remove: (id: string) => Promise<boolean>): Promise<AccessMutationResponse> {
1035
1096
  const id = this.cleanAccessUserId(userId)
1036
1097
  if (id === undefined) return { ok: false, error: 'user_id 不合法' }
1037
1098
  try {
1038
- await this.accessStore.removeUser(id)
1099
+ await remove(id)
1039
1100
  return { ok: true, access: this.accessSnapshot() }
1040
1101
  } catch (error) {
1041
1102
  return { ok: false, error: `撤销授权失败:${messageOf(error)}` }
@@ -1250,10 +1311,10 @@ export class FeishuService {
1250
1311
  health(): HealthSnapshot { return this.healthSnapshot() }
1251
1312
 
1252
1313
  /**
1253
- * Credential facts for the two Feishu refs. Values are never returned:
1314
+ * Credential facts for the panel's credential refs. Values are never returned:
1254
1315
  * `describe()` reports presence/source/writability only.
1255
1316
  */
1256
- async credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }> {
1317
+ async credentialStates(): Promise<Record<CredentialField, CredentialInfo>> {
1257
1318
  const provider = this.credentialProvider()
1258
1319
  const read = async (ref: string): Promise<CredentialInfo> => {
1259
1320
  try {
@@ -1274,8 +1335,10 @@ export class FeishuService {
1274
1335
  return { configured: false, writable: false }
1275
1336
  }
1276
1337
  }
1277
- const [appId, appSecret] = await Promise.all([read(this.config.appId), read(this.config.appSecret)])
1278
- return { appId, appSecret }
1338
+ const [appId, appSecret, analyzeToken] = await Promise.all([
1339
+ read(this.config.appId), read(this.config.appSecret), read(this.config.analyzeToken),
1340
+ ])
1341
+ return { appId, appSecret, analyzeToken }
1279
1342
  }
1280
1343
 
1281
1344
  /** The host credentials seam, when mounted. */
@@ -1283,8 +1346,8 @@ export class FeishuService {
1283
1346
  return this.ctx.get?.('credentials') as CredentialProvider | undefined
1284
1347
  }
1285
1348
 
1286
- /** Store one Feishu credential value through the host seam; the value is never logged. */
1287
- async setCredential(field: 'appId' | 'appSecret', value: string): Promise<void> {
1349
+ /** Store one credential value through the host seam; the value is never logged. */
1350
+ async setCredential(field: CredentialField, value: string): Promise<void> {
1288
1351
  const provider = this.credentialProvider()
1289
1352
  if (provider?.set === undefined) {
1290
1353
  throw new Error('主机的凭据服务不可写(未挂载 credentials.set,或当前提供方只读)')
@@ -1298,8 +1361,8 @@ export class FeishuService {
1298
1361
  await this.refreshReadiness()
1299
1362
  }
1300
1363
 
1301
- /** Remove the stored value of one Feishu credential reference. */
1302
- async clearCredential(field: 'appId' | 'appSecret'): Promise<void> {
1364
+ /** Remove the stored value of one credential reference. */
1365
+ async clearCredential(field: CredentialField): Promise<void> {
1303
1366
  const provider = this.credentialProvider()
1304
1367
  if (provider?.unset === undefined) {
1305
1368
  throw new Error('主机的凭据服务不支持移除凭据')
@@ -1314,8 +1377,12 @@ export class FeishuService {
1314
1377
  }
1315
1378
 
1316
1379
  /** The overlay-independent ref name backing one editable credential slot. */
1317
- private credentialRef(field: 'appId' | 'appSecret'): string {
1318
- return field === 'appId' ? this.config.appId : this.config.appSecret
1380
+ private credentialRef(field: CredentialField): string {
1381
+ switch (field) {
1382
+ case 'appId': return this.config.appId
1383
+ case 'appSecret': return this.config.appSecret
1384
+ case 'analyzeToken': return this.config.analyzeToken
1385
+ }
1319
1386
  }
1320
1387
 
1321
1388
  /** Wire events and open the long connection. */
@@ -1417,6 +1484,7 @@ export class FeishuService {
1417
1484
  questions.onCardAction(event)
1418
1485
  this.wikiConsent.onCardAction(event)
1419
1486
  this.onStopAction(event)
1487
+ this.onSteerAction(event)
1420
1488
  void this.onPickAction(event).catch((error: unknown) => {
1421
1489
  this.ctx.logger?.warn?.('dsh-channels: picker card action failed: %s', String(error))
1422
1490
  })
@@ -1449,6 +1517,9 @@ export class FeishuService {
1449
1517
  // Background: the results only feed the panel and the log.
1450
1518
  void this.runScopeCheck(channel, appId, ownerLookup)
1451
1519
  if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
1520
+ // Before the listener opens, so every `running` record left is a previous instance's.
1521
+ await this.analyzeLauncher.markInterrupted()
1522
+ await this.openAnalyzeListener()
1452
1523
  // Repair cards a previous instance left mid-flight (best-effort, background).
1453
1524
  void this.repair.repairAll().catch((error: unknown) => {
1454
1525
  this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
@@ -1506,12 +1577,38 @@ export class FeishuService {
1506
1577
  )
1507
1578
  }
1508
1579
 
1580
+ /**
1581
+ * Bind the analyze listener when `analyzeListen` is set. A bind failure is
1582
+ * logged and folded into the health snapshot (panel reasons, `/health`); the Feishu connection stays up.
1583
+ */
1584
+ private async openAnalyzeListener(): Promise<void> {
1585
+ this.analyzeListenError = undefined
1586
+ const listen = this.config.analyzeListen
1587
+ if (listen === undefined) return
1588
+ try {
1589
+ this.analyzeServer = await startAnalyzeServer(
1590
+ listen,
1591
+ this.analyzeLauncher,
1592
+ (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
1593
+ )
1594
+ console.info('[dsh-channels] 日志分析接口已监听', listen)
1595
+ } catch (error) {
1596
+ this.analyzeListenError = messageOf(error)
1597
+ this.ctx.logger?.error?.('dsh-channels: analyze listener on %s failed: %s', listen, this.analyzeListenError)
1598
+ console.error('[dsh-channels] 日志分析接口监听失败:', listen, error)
1599
+ }
1600
+ }
1601
+
1509
1602
  /** Tear down: close the connection and dispose all live agents. */
1510
1603
  async shutdown(): Promise<void> {
1511
1604
  // Close admission first. In-flight Host commands receive cancellation
1512
1605
  // before their Agent/channel/storage dependencies are disposed, while
1513
1606
  // already queued route operations observe `started === false` and retire.
1514
1607
  this.started = false
1608
+ const analyzeServer = this.analyzeServer
1609
+ this.analyzeServer = undefined
1610
+ await analyzeServer?.close()
1611
+ await this.analyzeLauncher.drain()
1515
1612
  for (const controller of this.commandControllers) controller.abort()
1516
1613
  await Promise.allSettled([...this.commandOperations])
1517
1614
  this.commandControllers.clear()
@@ -1638,7 +1735,7 @@ export class FeishuService {
1638
1735
  return
1639
1736
  }
1640
1737
 
1641
- if (msg.senderId !== route.senderUserId) {
1738
+ if (!this.accessController.ownsRoute(msg, route)) {
1642
1739
  await this.delivery.commandCard(msg, {
1643
1740
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1644
1741
  })
@@ -1648,7 +1745,7 @@ export class FeishuService {
1648
1745
  const operation = this.routeOperations.run(route.key, async () => {
1649
1746
  if (!this.started) return
1650
1747
  const current = this.routes.get(route.key) ?? route
1651
- if (msg.senderId !== current.senderUserId) {
1748
+ if (!this.accessController.ownsRoute(msg, current)) {
1652
1749
  await this.delivery.commandCard(msg, {
1653
1750
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1654
1751
  })
@@ -1985,13 +2082,44 @@ export class FeishuService {
1985
2082
  if (sessionId === undefined) return
1986
2083
  const route = this.routes.findBySession(sessionId)
1987
2084
  if (route === undefined) return
1988
- if (event.operator.userId !== route.senderUserId || event.chatId !== route.chatId) {
2085
+ if (!this.accessController.mayActOnRoute(event.operator.userId, event.chatId, route)) {
1989
2086
  this.streamer?.markStopDenied(sessionId)
1990
2087
  return
1991
2088
  }
1992
2089
  this.cancelTurn(sessionId)
1993
2090
  }
1994
2091
 
2092
+ /**
2093
+ * Queued-card steer button: a user who may act on the route (the owner, or
2094
+ * any admitted member of a group-shared route), clicking inside the
2095
+ * session's own chat, moves the queued message out of the next-turn queue
2096
+ * and into the running turn's next step; another operator gets the
2097
+ * owner-only notice instead. A message the driver already claimed (its turn
2098
+ * started) or a turn that is stopping leaves the card to its own turn.
2099
+ */
2100
+ private onSteerAction(event: CardActionEvent): void {
2101
+ if (!this.started) return
2102
+ if ((event.action.value as { decision?: unknown } | undefined)?.decision !== STEER_ACTION) return
2103
+ const target = this.streamer?.steerTargetOf(event.messageId)
2104
+ if (target === undefined) return
2105
+ const route = this.routes.findBySession(target.sessionId)
2106
+ if (route === undefined) return
2107
+ if (!this.accessController.mayActOnRoute(event.operator.userId, event.chatId, route)) {
2108
+ this.streamer?.markQueuedDenied(event.messageId)
2109
+ return
2110
+ }
2111
+ const agent = this.sessions.get(target.sessionId)?.agent
2112
+ ?? this.ctx.agents.get(target.sessionId as SessionId)
2113
+ if (agent?.status !== 'running') return
2114
+ const merged = this.streamer?.mergeQueued(event.messageId, (_sessionId, message) => {
2115
+ if (!agent.inbox.remove(message.id)) return false
2116
+ agent.steer(message)
2117
+ return true
2118
+ }) === true
2119
+ // The queued card no longer owns a turn: retire its restart-repair snapshot.
2120
+ if (merged) void this.repair.clear(event.messageId)
2121
+ }
2122
+
1995
2123
  /**
1996
2124
  * Picker-card click: run the chosen `/<command> <arg>` exactly as if the
1997
2125
  * operator had typed it under the picker card, so access admission, route
@@ -2006,7 +2134,8 @@ export class FeishuService {
2006
2134
  if (action === undefined) return
2007
2135
  const route = this.routes.findBySession(action.sessionId)
2008
2136
  const userId = event.operator.userId
2009
- if (route === undefined || route.chatId !== event.chatId || userId !== route.senderUserId) return
2137
+ if (route === undefined || userId === undefined
2138
+ || !this.accessController.mayActOnRoute(userId, event.chatId, route)) return
2010
2139
  const line = `/${action.command} ${action.arg}`
2011
2140
  await this.delivery.replacingCard(event.messageId, () => this.handleSlashMessage({
2012
2141
  messageId: event.messageId,
@@ -2437,6 +2566,7 @@ export class FeishuService {
2437
2566
  routeCount: this.routes.size(),
2438
2567
  liveAgentCount: this.sessions.liveCount(),
2439
2568
  inflightTurnCount,
2569
+ ...(this.analyzeListenError === undefined ? {} : { analyzeListenError: this.analyzeListenError }),
2440
2570
  })
2441
2571
  }
2442
2572
 
@@ -2566,6 +2696,9 @@ export class FeishuService {
2566
2696
  cardMessageId: record.cardMessageId,
2567
2697
  inThread: record.kind === 'thread',
2568
2698
  senderUserId: record.senderUserId,
2699
+ ...(record.shared === true
2700
+ ? { mayDecide: (userId: string) => this.accessController.mayActOnRoute(userId, record.chatId, record) }
2701
+ : {}),
2569
2702
  }
2570
2703
  }
2571
2704