@modesto_ma/dsh-channels 0.0.0-stage → 0.1.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 (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Agent/session lifecycle for this channel.
3
+ *
4
+ * Owns the live-agent registry (including agents BORROWED from another owner),
5
+ * cold resume serialization, agent-preset composition, the per-session
6
+ * reasoning-effort policy, and the `/renew` transaction that atomically binds
7
+ * a new Session to an existing Feishu route.
8
+ *
9
+ * `agents.create()` never applies a preset on its own — the caller composes
10
+ * the agent's world through the `setup` callback, which the factory awaits
11
+ * before publishing the session. In web-profile deployments the model-facing
12
+ * tools exist only inside agent presets (the host disables the tool rows at
13
+ * the host layer), so sessions created without mounting one reach the model
14
+ * with an empty tool list: the agent writes `<tool_calls>` as plain text and
15
+ * stops. Web-UI sessions mount the default preset via `session.create`;
16
+ * channels sessions must do the same through `setup`, and record the preset id
17
+ * on the header meta exactly like the host's own `composeAgent` does.
18
+ *
19
+ * @module dsh-channels/session/manager
20
+ */
21
+
22
+ import { randomUUID } from 'node:crypto'
23
+ import type {
24
+ Agent, AgentContext, AgentHandle, AgentPresets, AgentSetup, HostContext, Session, SessionId,
25
+ } from '../dsh-types.ts'
26
+ import type { FeishuConfig } from '../config.ts'
27
+ import { mountChannelCommands } from '../commands.ts'
28
+ import { appendSessionHistory } from '../domain.ts'
29
+ import type { RouteRecord } from '../domain.ts'
30
+ import { mountChannelGuidance } from '../feishu/agent-context.ts'
31
+ import { attachmentsDir } from '../feishu/inbound-attachments.ts'
32
+ import { installRequestPolicy } from './request-policy.ts'
33
+ import type { RequestPolicyController } from './request-policy.ts'
34
+ import { workspaceTitleFor } from '../workspaces.ts'
35
+
36
+ /** One live agent plus this plugin's ownership handle. */
37
+ export interface LiveAgent {
38
+ agent: Agent
39
+ handle: AgentHandle
40
+ }
41
+
42
+ /** A committed `/renew`: the old and new route records plus the old handle. */
43
+ export interface RenewCommit {
44
+ previous: RouteRecord
45
+ current: RouteRecord
46
+ oldLive: LiveAgent
47
+ }
48
+
49
+ /** What `agents.create`/`resume` need to compose one agent from a preset. */
50
+ export interface PresetComposition {
51
+ /** Preset id recorded on the session header meta (absent without a roster). */
52
+ agentPreset?: string
53
+ /** Mounts the resolved preset inside the agent factory's setup. */
54
+ setup?: AgentSetup
55
+ }
56
+
57
+ export interface SessionManagerDeps {
58
+ ctx: HostContext
59
+ config: FeishuConfig
60
+ /** Persist one route record (the `/renew` commit writes the new binding). */
61
+ persistRoute(key: string, record: RouteRecord): Promise<void>
62
+ /** Whether the service still accepts work (shutdown race guard). */
63
+ started(): boolean
64
+ /** Drop streamer state for a session whose owner handle is being released. */
65
+ detachStream(sessionId: string): void
66
+ warn(message: string, ...args: unknown[]): void
67
+ }
68
+
69
+ /**
70
+ * Wrap a live agent owned by someone else (web UI api-remote resolver, a
71
+ * previous plugin instance, a won cold-resume race). The borrow is fully
72
+ * deliverable through `followup`; disposal releases only the channel-owned
73
+ * scoped policy and never the foreign Agent itself.
74
+ */
75
+ function borrowLiveAgent(agent: Agent, release: () => void = () => {}): LiveAgent {
76
+ return { agent, handle: { agent, dispose: async () => { release() } } }
77
+ }
78
+
79
+ export class SessionManager {
80
+ private readonly live = new Map<string, LiveAgent>()
81
+ private readonly pendingResume = new Map<string, Promise<LiveAgent>>()
82
+ private readonly policies = new WeakMap<AgentContext, RequestPolicyController>()
83
+
84
+ constructor(private readonly deps: SessionManagerDeps) {}
85
+
86
+ /** Live agent entry for a session, when this plugin holds one. */
87
+ get(sessionId: string): LiveAgent | undefined {
88
+ return this.live.get(sessionId)
89
+ }
90
+
91
+ /** Number of live agent entries this plugin owns or borrows. */
92
+ liveCount(): number {
93
+ return this.live.size
94
+ }
95
+
96
+ /** Provider/model/token caps sessions this plugin creates. */
97
+ agentOptions(): Record<string, unknown> {
98
+ const options: Record<string, unknown> = {}
99
+ const { provider, model, maxTokens } = this.deps.config
100
+ if (provider !== undefined) options.provider = provider
101
+ if (model !== undefined) options.model = model
102
+ if (maxTokens !== undefined) options.maxTokens = maxTokens
103
+ return options
104
+ }
105
+
106
+ /**
107
+ * Create the root agent for one freshly routed session and register it live.
108
+ * The caller owns the workspace attachment and the failure card.
109
+ */
110
+ async createSession(
111
+ sessionId: SessionId,
112
+ cwd: string,
113
+ agentOptions: Record<string, unknown> = this.agentOptions(),
114
+ ): Promise<AgentHandle> {
115
+ const composition = await this.presetComposition(undefined, true, attachmentsDir(cwd))
116
+ const handle = await this.deps.ctx.agents.create({
117
+ sessionId,
118
+ meta: {
119
+ cwd,
120
+ ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
121
+ },
122
+ agentOptions,
123
+ setup: composition.setup,
124
+ })
125
+ this.requestPolicy(handle.agent)
126
+ this.live.set(sessionId, { agent: handle.agent, handle })
127
+ return handle
128
+ }
129
+
130
+ /**
131
+ * Live agent for a route: this plugin's own → a foreign live agent (borrow)
132
+ * → a serialized cold resume.
133
+ *
134
+ * The session may already be live under another owner — the web UI's
135
+ * api-remote resolver, a previous plugin instance whose shutdown is still
136
+ * draining, or a concurrent cold resume that won the race. `agents.resume`
137
+ * refuses a live session ("cannot prepare … while it is live"), so BORROW
138
+ * the live agent instead: `followup` delivery works identically and the
139
+ * owning side keeps the dispose responsibility.
140
+ */
141
+ async ensureLive(record: RouteRecord): Promise<LiveAgent> {
142
+ const sessionId = record.sessionId
143
+ const own = this.live.get(sessionId)
144
+ if (own !== undefined) return own
145
+
146
+ const foreign = this.deps.ctx.agents.get(sessionId as SessionId)
147
+ if (foreign !== undefined) return this.borrow(sessionId, foreign)
148
+
149
+ let pending = this.pendingResume.get(sessionId)
150
+ if (pending === undefined) {
151
+ pending = this.resume(record).finally(() => {
152
+ this.pendingResume.delete(sessionId)
153
+ })
154
+ this.pendingResume.set(sessionId, pending)
155
+ }
156
+ return await pending
157
+ }
158
+
159
+ /** Borrow one foreign live agent and mount this channel's scoped policy. */
160
+ private borrow(sessionId: string, agent: Agent): LiveAgent {
161
+ const borrowed = borrowLiveAgent(agent, this.mountRequestPolicy(agent.ctx, agent.session))
162
+ this.live.set(sessionId, borrowed)
163
+ return borrowed
164
+ }
165
+
166
+ /** Cold-resume one persisted session, losing a resume race gracefully. */
167
+ private async resume(record: RouteRecord): Promise<LiveAgent> {
168
+ const sessionId = record.sessionId as SessionId
169
+ try {
170
+ // Compose the resumed world from the same preset resolution as creation
171
+ // (config override or roster default). A session persisted before the
172
+ // preset fix has no preset recorded on its header; re-resolving here is
173
+ // what repairs it on the first resume after a restart.
174
+ const composition = await this.presetComposition(
175
+ undefined, true, attachmentsDir(record.workspacePath),
176
+ )
177
+ const handle = await this.deps.ctx.agents.resume({
178
+ resumeSessionId: sessionId,
179
+ agentOptions: this.agentOptions(),
180
+ setup: composition.setup,
181
+ })
182
+ if (!this.deps.started()) {
183
+ // Shutdown raced this in-flight resume: dispose the just-published
184
+ // agent so it cannot leak into the live registry of the next instance
185
+ // (whose every resume would then fail as "while it is live").
186
+ void handle.dispose().catch(() => {})
187
+ throw new Error('dsh-channels: session resume aborted by shutdown')
188
+ }
189
+ this.requestPolicy(handle.agent)
190
+ const liveAgent: LiveAgent = { agent: handle.agent, handle }
191
+ this.live.set(sessionId, liveAgent)
192
+ return liveAgent
193
+ } catch (error) {
194
+ // Lost a cold-resume race: another owner published the session while our
195
+ // resume was in flight. Borrow the winner instead of failing the turn.
196
+ const winner = this.deps.ctx.agents.get(sessionId)
197
+ if (winner !== undefined) return this.borrow(sessionId, winner)
198
+ throw error
199
+ }
200
+ }
201
+
202
+ /** Mount one mutable Session-aware request policy outside every other wrapper. */
203
+ mountRequestPolicy(agentCtx: AgentContext, session?: Session): () => void {
204
+ const controller = installRequestPolicy(agentCtx, {
205
+ effort: this.deps.config.reasoningEffort,
206
+ provider: this.deps.config.provider,
207
+ model: this.deps.config.model,
208
+ })
209
+ if (session !== undefined) controller.sync(session.snapshotEvents())
210
+ this.policies.set(agentCtx, controller)
211
+ return () => {
212
+ controller.dispose()
213
+ if (this.policies.get(agentCtx) === controller) this.policies.delete(agentCtx)
214
+ }
215
+ }
216
+
217
+ /** Resolve and synchronize the policy composed for this exact Agent scope. */
218
+ requestPolicy(agent: Agent): RequestPolicyController {
219
+ const controller = this.policies.get(agent.ctx)
220
+ if (controller === undefined) {
221
+ throw new Error('dsh-channels: request policy is not mounted for this Agent')
222
+ }
223
+ controller.sync(agent.session.snapshotEvents())
224
+ return controller
225
+ }
226
+
227
+ /** Create, workspace-attach, and atomically publish one renewed route. */
228
+ async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
229
+ const sessionId = randomUUID() as SessionId
230
+ const preset = this.effectivePreset(oldLive.agent.session)
231
+ const composition = await this.presetComposition(preset, false, attachmentsDir(record.workspacePath))
232
+ const workspace = await this.deps.ctx.workspaceRegistry.create(
233
+ record.workspacePath,
234
+ workspaceTitleFor(
235
+ record.chatType, record.chatId, record.senderOpenId,
236
+ record.kind === 'thread' ? record.key : record.sessionId,
237
+ ),
238
+ )
239
+
240
+ let newHandle: AgentHandle | undefined
241
+ let attached = false
242
+ try {
243
+ newHandle = await this.deps.ctx.agents.create({
244
+ sessionId,
245
+ meta: {
246
+ cwd: record.workspacePath,
247
+ ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
248
+ },
249
+ agentOptions: { ...oldLive.agent.options },
250
+ setup: composition.setup,
251
+ })
252
+ this.requestPolicy(newHandle.agent)
253
+ await workspace.attachSession(sessionId)
254
+ attached = true
255
+ const createdAt = newHandle.agent.session.header.createdAt
256
+
257
+ const current: RouteRecord = {
258
+ ...record,
259
+ sessionId,
260
+ sessionHistory: appendSessionHistory(record, sessionId, createdAt),
261
+ lastActivityAt: Date.now(),
262
+ }
263
+ await this.deps.persistRoute(record.key, current)
264
+ this.live.set(sessionId, { agent: newHandle.agent, handle: newHandle })
265
+ return { previous: record, current, oldLive }
266
+ } catch (error) {
267
+ if (attached) {
268
+ await Promise.resolve(workspace.detachSession(sessionId)).catch((detachError: unknown) => {
269
+ this.deps.warn(
270
+ 'dsh-channels: rolling back workspace attachment for session %s failed: %s',
271
+ sessionId, String(detachError),
272
+ )
273
+ })
274
+ }
275
+ await newHandle?.dispose().catch(() => {})
276
+ throw error
277
+ }
278
+ }
279
+
280
+ /** Release only this plugin's old handle, after `command/done` has landed. */
281
+ async releaseRenewed(commit: RenewCommit): Promise<void> {
282
+ const oldSessionId = commit.previous.sessionId
283
+ if (this.live.get(oldSessionId) === commit.oldLive) {
284
+ this.live.delete(oldSessionId)
285
+ this.deps.detachStream(oldSessionId)
286
+ }
287
+ try {
288
+ await commit.oldLive.handle.dispose()
289
+ } catch (error) {
290
+ this.deps.warn(
291
+ 'dsh-channels: releasing renewed session %s handle failed: %s',
292
+ oldSessionId, String(error),
293
+ )
294
+ }
295
+ }
296
+
297
+ /** Stop and dispose every live agent this plugin owns. */
298
+ async disposeAll(): Promise<void> {
299
+ for (const { handle } of this.live.values()) {
300
+ await handle.dispose().catch(() => {})
301
+ }
302
+ this.live.clear()
303
+ this.pendingResume.clear()
304
+ }
305
+
306
+ /**
307
+ * Resolve the agent-preset composition for sessions this plugin creates or
308
+ * resumes. The id resolves BEFORE the session exists because the session
309
+ * boundary snapshots `meta` before asynchronous setup begins; mounting stays
310
+ * inside `setup`, where a failure rolls the whole creation back.
311
+ */
312
+ private async presetComposition(
313
+ requestedPreset: string | undefined = this.deps.config.agentPreset,
314
+ useRosterDefault = true,
315
+ attachmentsDir?: string,
316
+ ): Promise<PresetComposition> {
317
+ // Read through ctx.get (safe lookup): only the web profile ships a roster,
318
+ // and direct property access on a cordis context throws for services the
319
+ // plugin did not inject.
320
+ const presets = this.deps.ctx.get?.('agentPresets') as AgentPresets | undefined
321
+ const channelCommandsOnly = async (agentCtx: AgentContext): Promise<void> => {
322
+ // `prepend` is essential: standing preset listeners may have been
323
+ // mounted before this Agent existed, so setup registration order alone
324
+ // cannot guarantee that the explicit channel value wins.
325
+ this.mountRequestPolicy(agentCtx)
326
+ await mountChannelCommands(agentCtx)
327
+ await mountChannelGuidance(agentCtx, attachmentsDir)
328
+ }
329
+ if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
330
+ return { setup: channelCommandsOnly }
331
+ }
332
+ const resolved = await presets.resolve(requestedPreset)
333
+ return {
334
+ agentPreset: resolved.id,
335
+ setup: async (agentCtx) => {
336
+ this.mountRequestPolicy(agentCtx)
337
+ await presets.mount(agentCtx, resolved.id)
338
+ await mountChannelCommands(agentCtx)
339
+ await mountChannelGuidance(agentCtx, attachmentsDir)
340
+ },
341
+ }
342
+ }
343
+
344
+ /** Effective preset after folding any durable runtime preset switch. */
345
+ private effectivePreset(session: Session): string | undefined {
346
+ let selected = session.header.agentPreset
347
+ for (const event of session.snapshotEvents()) {
348
+ if (event.type === 'agent-preset/selected') selected = event.data.agentPreset
349
+ }
350
+ return selected
351
+ }
352
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * In-flight turn snapshots and the startup repair pass.
3
+ *
4
+ * Every streaming turn records the credentials needed to settle its cards if
5
+ * the process dies mid-turn (trigger message, Typing reaction, segment card
6
+ * ids). The snapshot is keyed by the turn's MAIN card message id — unique per
7
+ * turn — so overlapping turns never overwrite each other. On the next boot
8
+ * `repairAll()` patches each interrupted card to its terminal state and drops
9
+ * the snapshot.
10
+ *
11
+ * @module dsh-channels/session/repair
12
+ */
13
+
14
+ import { buildInterruptionCard } from '../cards/streaming.ts'
15
+ import type { InflightSnapshot } from '../domain.ts'
16
+ import type { FeishuChannel } from '../feishu-channel.ts'
17
+ import type { FeishuDomainHandle } from '../domain.ts'
18
+
19
+ export interface InflightRepairDeps {
20
+ /** The opened domain handle, or undefined before `start()` / after shutdown. */
21
+ domain(): FeishuDomainHandle | undefined
22
+ /** The live channel, or undefined before `start()` / after shutdown. */
23
+ channel(): FeishuChannel | undefined
24
+ /** Route retirement TTL; snapshots older than it are the GC pass's job. */
25
+ ttlMs(): number
26
+ info(message: string, ...args: unknown[]): void
27
+ warn(message: string, ...args: unknown[]): void
28
+ }
29
+
30
+ export class InflightRepair {
31
+ constructor(private readonly deps: InflightRepairDeps) {}
32
+
33
+ /** Write the turn's repair snapshot. Keyed by the main card message id. */
34
+ async write(snapshot: InflightSnapshot): Promise<void> {
35
+ await this.deps.domain()?.inflight.put(snapshot.key, snapshot)
36
+ }
37
+
38
+ /** Retire a snapshot (turn finalized / creation failed). Never throws. */
39
+ async clear(key: string): Promise<void> {
40
+ if (key === '') return
41
+ try {
42
+ await this.deps.domain()?.inflight.delete(key)
43
+ } catch (error) {
44
+ this.deps.warn('dsh-channels: clearing inflight snapshot %s failed: %s', key, String(error))
45
+ }
46
+ }
47
+
48
+ /** Outstanding repair snapshots (health diagnostics). */
49
+ count(): number {
50
+ let total = 0
51
+ try {
52
+ const table = this.deps.domain()?.inflight
53
+ if (table !== undefined) for (const _entry of table.entries()) total++
54
+ } catch {
55
+ // The storage dependency is reported separately; never expose details.
56
+ }
57
+ return total
58
+ }
59
+
60
+ /**
61
+ * Fold a resolved segment card id into the turn's snapshot (atomic
62
+ * read-modify-write). A missing snapshot means the turn already finalized
63
+ * (the finalize delete won the race) — silently skipped.
64
+ */
65
+ async recordSegment(
66
+ mainCardMessageId: string,
67
+ segmentIndex: number,
68
+ messageId: string,
69
+ mode: 'card' | 'text',
70
+ ): Promise<void> {
71
+ if (mainCardMessageId === '') return
72
+ try {
73
+ await this.deps.domain()?.inflight.update(mainCardMessageId, snapshot => {
74
+ // The snapshot starts with only the main card; segment sends are
75
+ // serialized in order, so a delivery for index `cards.length` is the
76
+ // next segment (append), anything below replaces, and a gap beyond
77
+ // the tail cannot happen under the serialized chain (ignored).
78
+ const cards = [...snapshot.cards]
79
+ if (segmentIndex === cards.length) {
80
+ cards.push({ messageId, mode })
81
+ } else if (segmentIndex >= 0 && segmentIndex < cards.length) {
82
+ cards[segmentIndex] = { messageId, mode }
83
+ } else {
84
+ return snapshot
85
+ }
86
+ return { ...snapshot, cards }
87
+ })
88
+ } catch (error) {
89
+ this.deps.warn(
90
+ 'dsh-channels: updating inflight snapshot %s failed: %s', mainCardMessageId, String(error),
91
+ )
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Startup repair pass: settle every card a previous instance left
97
+ * mid-flight. Runs in the background right after the long connection is
98
+ * established; each snapshot is independent and best-effort.
99
+ *
100
+ * Per snapshot: patch every `card`-mode card to the terminal interrupted
101
+ * state (the partial body is gone with the old process; patch is full
102
+ * replacement), best-effort swap Typing → DONE, then delete. A failed card
103
+ * patch keeps the snapshot for one more attempt (capped at 2) so a
104
+ * transient failure at boot does not lose the repair, while a medium that
105
+ * permanently rejects the patch cannot trigger an endless retry loop.
106
+ */
107
+ async repairAll(): Promise<void> {
108
+ const table = this.deps.domain()?.inflight
109
+ if (table === undefined) return
110
+ const snapshots = [...table.entries()]
111
+ if (snapshots.length === 0) return
112
+ // With GC enabled, snapshots older than the TTL are the GC pass's job:
113
+ // repairing an ancient card would surprise the user, so repair skips
114
+ // them and lets the GC drop them silently (both passes run at boot).
115
+ const ttlMs = this.deps.ttlMs()
116
+ const cutoff = ttlMs === 0 ? undefined : Date.now() - ttlMs
117
+ const due = snapshots.filter(([, snapshot]) => cutoff === undefined || snapshot.startedAt >= cutoff)
118
+ if (due.length === 0) return
119
+ this.deps.info('dsh-channels: repairing %d interrupted turn(s)', due.length)
120
+ for (const [key, snapshot] of due) {
121
+ try {
122
+ await this.repairSnapshot(snapshot)
123
+ await table.delete(key)
124
+ this.deps.info('dsh-channels: repaired interrupted turn card %s', key)
125
+ } catch (error) {
126
+ this.deps.warn('dsh-channels: repair of turn card %s failed: %s', key, String(error))
127
+ const attempts = snapshot.attempts + 1
128
+ try {
129
+ if (attempts >= 2) {
130
+ await table.delete(key)
131
+ } else {
132
+ await table.put(key, { ...snapshot, attempts })
133
+ }
134
+ } catch {
135
+ // The domain may already be closing (shutdown raced the repair).
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ /** Patch the cards of one interrupted turn to their terminal state. */
142
+ private async repairSnapshot(snapshot: InflightSnapshot): Promise<void> {
143
+ const channel = this.deps.channel()
144
+ if (channel === undefined) throw new Error('dsh-channels: channel not started')
145
+ for (const [index, card] of snapshot.cards.entries()) {
146
+ if (card.mode !== 'card') continue
147
+ // Full replacement: the interrupted card's streamed content lived only
148
+ // in the previous process. Continuation cards keep their segment header.
149
+ const options = index === 0 ? {} : { panel: false, segment: index + 1 }
150
+ await channel.updateCard(card.messageId, buildInterruptionCard(options))
151
+ }
152
+ if (snapshot.reactionId !== undefined) {
153
+ await channel.removeReaction(snapshot.triggerMessageId, snapshot.reactionId).catch(() => {})
154
+ }
155
+ await channel.addReaction(snapshot.triggerMessageId, 'DONE').catch(() => {})
156
+ }
157
+ }