@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1

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 (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Host bridge, attachment surface: the minimal `ctx.attachments` face this
3
+ * plugin consumes (durable image/file storage) plus the content-block and
4
+ * prompt-part shapes it produces.
5
+ *
6
+ * Everything here is declared locally and structurally. The DSH attachment
7
+ * packages are never imported at runtime and are not runtime dependencies of
8
+ * this plugin, so these types mirror their published shapes by convention:
9
+ * only the members this plugin actually reads or calls are modeled, and every
10
+ * service lookup tolerates a deployment without the service. The exact
11
+ * upstream package (`@deepseek-ai/dsh-attachment`, DSH 0.2.1-rc.2) has more
12
+ * operations (batches, request projection, encoded uploads); add them here
13
+ * only when a task needs them.
14
+ *
15
+ * @module dsh-channels/host/attachments
16
+ */
17
+
18
+ // ---- Durable attachment vocabulary --------------------------------------
19
+
20
+ /** Opaque content-addressed attachment identifier (never a filesystem path). */
21
+ export type AttachmentId = string
22
+
23
+ /** Raster image formats accepted by the version-one attachment path. */
24
+ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
25
+
26
+ /** Durable, serializable reference to one immutable normalized image. */
27
+ export interface ImageAttachmentRef {
28
+ /** Opaque storage identifier; never a filesystem path or bearer URL. */
29
+ attachmentId: AttachmentId
30
+ /** Media type verified from the stored bytes. */
31
+ mediaType: ImageMediaType
32
+ /** Exact encoded byte length. */
33
+ bytes: number
34
+ /** Intrinsic encoded width in pixels. */
35
+ width: number
36
+ /** Intrinsic encoded height in pixels. */
37
+ height: number
38
+ /** Optional display name stripped of local path information. */
39
+ name?: string
40
+ /**
41
+ * Input dimensions after applying EXIF orientation and before normalization
42
+ * scaling. Present only when normalization reduced the image.
43
+ */
44
+ originalDimensions?: {
45
+ width: number
46
+ height: number
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Durable, serializable reference to one verbatim stored file. Files are
52
+ * stored byte-for-byte with no normalization; `attachmentId` is the sha256
53
+ * digest of exactly those bytes.
54
+ */
55
+ export interface FileAttachmentRef {
56
+ /** Opaque content-addressed storage identifier; never a filesystem path. */
57
+ attachmentId: AttachmentId
58
+ /** Sanitized display filename, also the stored object's leaf name. */
59
+ name: string
60
+ /** Exact byte length. */
61
+ bytes: number
62
+ }
63
+
64
+ /** Deployment-resolved image limits used by upload admission. */
65
+ export interface ImageAttachmentLimits {
66
+ maxImageBytes: number
67
+ maxImagesPerMessage: number
68
+ maxMessageImageBytes: number
69
+ maxImagePixels: number
70
+ /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */
71
+ maxImageDimension: number
72
+ mediaTypes: readonly ImageMediaType[]
73
+ }
74
+
75
+ /** Request to validate and durably commit one image. */
76
+ export interface SaveImageAttachment {
77
+ /** Encoded bytes, checked against the declared media type by the service. */
78
+ data: Uint8Array
79
+ /** Caller-declared media type, checked against fully decoded bytes. */
80
+ mediaType: ImageMediaType
81
+ /** Optional display name; never interpreted as a path. */
82
+ name?: string
83
+ }
84
+
85
+ /** Request to durably commit one file from bounded byte chunks. */
86
+ export interface SaveFileStreamAttachment {
87
+ /** Exact file bytes in order; providers must not retain the whole sequence. */
88
+ data: AsyncIterable<Uint8Array>
89
+ /** Optional cancellation for source reads and storage writes. */
90
+ signal?: AbortSignal
91
+ /** Optional display name; never interpreted as a path. */
92
+ name?: string
93
+ }
94
+
95
+ /** One text/image part of host prompt content whose images still need admission. */
96
+ export type PromptContentPart = {
97
+ readonly type: 'text'
98
+ readonly text: string
99
+ } | {
100
+ readonly type: 'image'
101
+ readonly mediaType: ImageMediaType
102
+ readonly data: string
103
+ readonly name?: string
104
+ }
105
+
106
+ /** Host prompt content whose file receipts are resolved and whose image bytes await admission. */
107
+ export type AttachmentAdmissionPart = PromptContentPart | {
108
+ readonly type: 'file'
109
+ readonly attachment: FileAttachmentRef
110
+ }
111
+
112
+ /** Host-admitted prompt content with every attachment represented by its durable reference. */
113
+ export type AdmittedPromptContentPart = {
114
+ readonly type: 'text'
115
+ readonly text: string
116
+ } | {
117
+ readonly type: 'image'
118
+ readonly attachment: ImageAttachmentRef
119
+ } | {
120
+ readonly type: 'file'
121
+ readonly attachment: FileAttachmentRef
122
+ }
123
+
124
+ // ---- Content blocks -----------------------------------------------------
125
+
126
+ /**
127
+ * A durable raster image reference as it appears in model content
128
+ * (`ImageBlock` in `@deepseek-ai/dsh-llm`). The block is role-neutral; this
129
+ * plugin only produces it for user content.
130
+ */
131
+ export interface ImageBlock {
132
+ type: 'image'
133
+ /** Immutable bytes and intrinsic display metadata owned by the service. */
134
+ attachment: ImageAttachmentRef
135
+ }
136
+
137
+ /**
138
+ * A durable verbatim file reference as it appears in model content
139
+ * (`FileBlock` in `@deepseek-ai/dsh-llm`). Files never reach a provider as
140
+ * bytes: request assembly projects them to handle text, so a channel tool that
141
+ * cannot deliver this block must fall back to {@link formatFileHandle}-style
142
+ * text instead.
143
+ */
144
+ export interface FileBlock {
145
+ type: 'file'
146
+ /** Immutable verbatim bytes and display metadata owned by the service. */
147
+ attachment: FileAttachmentRef
148
+ }
149
+
150
+ // ---- Failure vocabulary -------------------------------------------------
151
+
152
+ /**
153
+ * Stable attachment failure codes the host routes on
154
+ * (`AttachmentErrorCode` in `@deepseek-ai/dsh-attachment`). Kept as a
155
+ * type-only union; the runtime classifier lives in the Feishu storage module.
156
+ */
157
+ export type AttachmentErrorCode =
158
+ | 'TOO_MANY_IMAGES'
159
+ | 'IMAGES_TOO_LARGE'
160
+ | 'UNSUPPORTED_IMAGE_TYPE'
161
+ | 'INVALID_IMAGE_BASE64'
162
+ | 'INVALID_IMAGE'
163
+ | 'IMAGE_TYPE_MISMATCH'
164
+ | 'IMAGE_TOO_LARGE'
165
+ | 'IMAGE_TOO_MANY_PIXELS'
166
+ | 'IMAGE_DIMENSION_TOO_LARGE'
167
+ | 'INVALID_FILE_BASE64'
168
+ | 'INVALID_ATTACHMENT_REF'
169
+ | 'ATTACHMENT_CORRUPT'
170
+ | 'ATTACHMENT_WRITE_FAILED'
171
+ | 'ATTACHMENT_NOT_FOUND'
172
+ | 'ATTACHMENT_READ_FAILED'
173
+ | 'ATTACHMENT_PROJECTION_UNSUPPORTED'
174
+ | 'ATTACHMENT_FILES_UNSUPPORTED'
175
+
176
+ // ---- Service surface ----------------------------------------------------
177
+
178
+ /**
179
+ * The narrow `ctx.attachments` service face. Every method mirrors the
180
+ * upstream contract; callers must go through a module that tolerates absence
181
+ * (for example `src/feishu/native-attachments.ts`) rather than branching on
182
+ * this type themselves, so the degradation path exists in exactly one place.
183
+ */
184
+ export interface AttachmentStore {
185
+ /** Deployment-resolved image policy; absent on minimal embedding hosts. */
186
+ readonly imageLimits?: ImageAttachmentLimits
187
+ /** Validate, normalize, and durably commit one image. */
188
+ saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
189
+ /** Durably commit one verbatim file from bounded chunks (backpressure, no full buffering). */
190
+ saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef>
191
+ /**
192
+ * Locate the verbatim stored file object in the harness host filesystem.
193
+ * @returns an absolute host path, or undefined when this backend is not
194
+ * host-file-backed.
195
+ * @throws an attachment error when the durable reference is invalid.
196
+ */
197
+ fileHostPath(ref: FileAttachmentRef): string | undefined
198
+ /**
199
+ * Locate the provider-owned normalized image object in the harness host
200
+ * filesystem.
201
+ * @returns an absolute host path, or undefined when this backend is not
202
+ * host-file-backed.
203
+ * @throws an attachment error when the durable reference is invalid.
204
+ */
205
+ imageHostPath(ref: ImageAttachmentRef): string | undefined
206
+ /** Replace every uploaded image in one host prompt with its durable reference. */
207
+ admitPromptContent(content: readonly AttachmentAdmissionPart[]): Promise<AdmittedPromptContentPart[]>
208
+ /** Optional host-provided classifier for attachment failures (cross-install safe). */
209
+ isAttachmentError?(error: unknown): boolean
210
+ }
package/src/host/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  export * from './core.ts'
9
9
  export * from './agent.ts'
10
+ export * from './attachments.ts'
10
11
  export * from './commands.ts'
11
12
  export * from './interaction.ts'
12
13
  export * from './services.ts'
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@ export type * from './questions.ts'
41
41
  export type * from './settings/contract.ts'
42
42
  export * from './access/index.ts'
43
43
  export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
44
- export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
44
+ export { CATALOG_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
45
45
  export type { PickDirectoryResponse } from './settings/contract.ts'
46
46
  export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
47
47
  export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface, withDirectoryBrowse } from './settings/surface.ts'
@@ -63,7 +63,7 @@ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT
63
63
  export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
64
64
  export { FeishuService } from './service.ts'
65
65
  export { TurnStreamer } from './streamer.ts'
66
- export { ensureWorkspace, legacyTopicWorkspaceRoot, stagingPathFor, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
66
+ export { ensureWorkspace, stagingPathFor, stagingRoot, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
67
67
  export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
68
68
 
69
69
  /** Cordis function-plugin name. */
@@ -105,9 +105,10 @@ export function apply(ctx: HostContext, config?: unknown): void {
105
105
  const resolved = resolveConfig(config)
106
106
  const factory: ChannelFactory = (config as FeishuPluginOptions | undefined)?.channelFactory
107
107
  ?? defaultChannelFactory
108
- // Web settings overlay (optional file; degrades to "no overrides").
108
+ // Web settings overlay at `$DSH_HOME/dsh-channels/settings.json` (optional
109
+ // file; degrades to "no overrides").
109
110
  const overlay = new OverlayStore(
110
- resolved.settingsFile ?? defaultOverlayPath(),
111
+ defaultOverlayPath(),
111
112
  { warn: (message, ...args) => ctx.logger?.warn?.(message, ...args) },
112
113
  )
113
114
  const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
@@ -4,8 +4,16 @@
4
4
  *
5
5
  * The answerer is registered with `prepend: true` on the plugin context so it
6
6
  * runs BEFORE generic answerers: a request for a session this channel routes
7
- * is claimed here, and the decision card becomes the only way to answer.
8
- * Requests for sessions the channel does NOT route delegate through `next()`.
7
+ * is claimed here, and the decision card (or a typed topic reply) becomes the
8
+ * only way to answer. Requests for sessions the channel does NOT route
9
+ * delegate through `next()`.
10
+ *
11
+ * The host request carries no tool arguments, so the answerer keeps the
12
+ * current turn's `tool/call` arguments per session (fed from `session/event`)
13
+ * and looks the asked call up by `callId` to show what will run. Standing
14
+ * "stop asking in this topic" grants live only in memory, keyed by session,
15
+ * tool name, and requested sandbox mode; they end with the session (`/renew`),
16
+ * the topic, or the process.
9
17
  *
10
18
  * Rendering lives in `cards/approval.ts`; the shared pending-card lifecycle
11
19
  * (delivery, owner gating, timeout, settle-once, withdrawal) lives in
@@ -17,7 +25,7 @@
17
25
  import { randomUUID } from 'node:crypto'
18
26
  import { buildApprovalCard, buildApprovalSettledCard } from '../cards/approval.ts'
19
27
  import type { ApprovalCardState, ApprovalQuestion } from '../cards/approval.ts'
20
- import type { ApprovalOutcome, ApprovalRequest } from '../dsh-types.ts'
28
+ import type { ApprovalOutcome, ApprovalRequest, SessionEvent } from '../dsh-types.ts'
21
29
  import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
22
30
  import { PendingCardTable } from './pending.ts'
23
31
  import type { InteractionRoute, PendingCardEntry } from './pending.ts'
@@ -33,16 +41,87 @@ export interface ApprovalHooks {
33
41
  routeFor(sessionId: string): ApprovalRoute | undefined
34
42
  /** Log a non-fatal error (delivery/update failures). */
35
43
  onError(sessionId: string, error: unknown): void
44
+ /**
45
+ * The tool names a session is currently waiting on for approval (empty when
46
+ * none), called on every open and settle so the streaming card can show it.
47
+ */
48
+ onWaitingChange?(sessionId: string, toolNames: readonly string[]): void
36
49
  }
37
50
 
51
+ /**
52
+ * How one typed topic reply was consumed by {@link ApprovalAnswerer.submitText}:
53
+ * a keyword decision (`allowed`, `remembered`, `denied`), or any other text
54
+ * that rejected every pending request of the session (`superseded`) and must
55
+ * still be delivered as the next instruction.
56
+ */
57
+ export type ApprovalTextResult = 'allowed' | 'remembered' | 'denied' | 'superseded'
58
+
38
59
  /** One in-flight approval question: its card content and its resolver. */
39
60
  interface Pending extends PendingCardEntry {
40
61
  question: ApprovalQuestion
62
+ /** Standing-grant key for "stop asking in this topic". */
63
+ grantKey: string
64
+ /** Open order, so a typed reply answers the newest card. */
65
+ openedAt: number
41
66
  resolve(outcome: ApprovalOutcome): void
42
67
  }
43
68
 
69
+ /** One recorded `tool/call` of a session's current turn. */
70
+ interface RecordedCall {
71
+ turn: number
72
+ name: string
73
+ arguments: string
74
+ }
75
+
76
+ const ALLOW_WORDS = new Set(['允许', '同意', '可以', '好', '好的', '行', '确认', 'y', 'yes', 'ok', 'allow', 'approve'])
77
+ const DENY_WORDS = new Set(['拒绝', '不允许', '不行', '不', '否', '不同意', 'n', 'no', 'deny', 'reject'])
78
+ const ALWAYS_WORDS = new Set(['全部允许', '都允许', '允许所有', '允许全部', '总是允许', '始终允许', 'allow all', 'always'])
79
+
80
+ /**
81
+ * Classify one typed reply. Trailing punctuation and case are ignored; the
82
+ * whole message must be the keyword so ordinary sentences never match.
83
+ * @param text - the reply text with mentions already stripped.
84
+ * @returns The keyword decision, or `undefined` for any other text.
85
+ */
86
+ export function classifyApprovalReply(text: string): 'allow' | 'always' | 'deny' | undefined {
87
+ const word = text.trim().toLowerCase().replace(/[\s。.!!~~]+$/u, '')
88
+ if (ALWAYS_WORDS.has(word)) return 'always'
89
+ if (ALLOW_WORDS.has(word)) return 'allow'
90
+ if (DENY_WORDS.has(word)) return 'deny'
91
+ return undefined
92
+ }
93
+
94
+ /**
95
+ * Read what a call will run and the sandbox mode it asks for. `bash` shows
96
+ * its `command`; other tools show their raw arguments. Arguments are
97
+ * model-produced JSON, so a parse failure shows the raw text.
98
+ */
99
+ function describeCall(call: RecordedCall | undefined): { command?: string; escalateTo?: string } {
100
+ if (call === undefined) return {}
101
+ let args: Record<string, unknown> | undefined
102
+ try {
103
+ const parsed: unknown = JSON.parse(call.arguments)
104
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) args = parsed as Record<string, unknown>
105
+ } catch {
106
+ // Unparseable arguments: shown verbatim below.
107
+ }
108
+ if (args === undefined) return call.arguments === '' ? {} : { command: call.arguments }
109
+ const escalateTo = typeof args.sandbox_permissions === 'string' && args.sandbox_permissions !== ''
110
+ ? args.sandbox_permissions
111
+ : undefined
112
+ const command = typeof args.command === 'string' && args.command !== ''
113
+ ? args.command
114
+ : JSON.stringify(args, null, 2)
115
+ return { command, ...(escalateTo === undefined ? {} : { escalateTo }) }
116
+ }
117
+
44
118
  export class ApprovalAnswerer {
45
119
  private readonly table: PendingCardTable<Pending>
120
+ /** sessionId → callId → the current turn's recorded tool calls. */
121
+ private readonly calls = new Map<string, Map<string, RecordedCall>>()
122
+ /** sessionId → standing "stop asking in this topic" grant keys. */
123
+ private readonly grants = new Map<string, Set<string>>()
124
+ private opened = 0
46
125
 
47
126
  constructor(
48
127
  channel: FeishuChannel,
@@ -57,18 +136,52 @@ export class ApprovalAnswerer {
57
136
  return this.table.count()
58
137
  }
59
138
 
139
+ /** Whether a session currently has an undecided approval card. */
140
+ hasPendingFor(sessionId: string): boolean {
141
+ return this.pendingOf(sessionId).length > 0
142
+ }
143
+
144
+ /**
145
+ * Track the current turn's tool calls so a later approval can show what it
146
+ * approves. Only `tool/call` and `turn/end` are read; nothing earlier than
147
+ * the current turn is kept.
148
+ */
149
+ onSessionEvent(sessionId: string, event: SessionEvent): void {
150
+ if (event.type === 'tool/call') {
151
+ let calls = this.calls.get(sessionId)
152
+ if (calls === undefined) {
153
+ calls = new Map()
154
+ this.calls.set(sessionId, calls)
155
+ }
156
+ calls.set(event.data.callId, { turn: event.data.turn, name: event.data.name, arguments: event.data.arguments })
157
+ } else if (event.type === 'turn/end') {
158
+ const calls = this.calls.get(sessionId)
159
+ if (calls === undefined) return
160
+ for (const [callId, call] of calls) {
161
+ if (call.turn <= event.data.turn) calls.delete(callId)
162
+ }
163
+ if (calls.size === 0) this.calls.delete(sessionId)
164
+ }
165
+ }
166
+
60
167
  /**
61
168
  * The `approval/request` waterfall listener. Never rejects: the host service
62
169
  * already contains answerer failures to the fail-closed `'unavailable'`.
63
170
  */
64
171
  onRequest(req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome> {
65
- const route = this.hooks.routeFor(req.agent.session.id)
172
+ const sessionId = req.agent.session.id
173
+ const route = this.hooks.routeFor(sessionId)
66
174
  if (route === undefined) return next()
67
175
  // The abort window between the service's own signal check and this
68
176
  // listener is closed by settling synchronously: an aborted ask never
69
177
  // opens a question.
70
178
  if (req.signal?.aborted === true) return Promise.resolve('cancelled')
71
179
 
180
+ const call = req.callId === undefined ? undefined : this.calls.get(sessionId)?.get(req.callId)
181
+ const described = describeCall(call)
182
+ const grantKey = `${req.toolName}\u0000${described.escalateTo ?? ''}`
183
+ if (this.grants.get(sessionId)?.has(grantKey) === true) return Promise.resolve('allowed-once')
184
+
72
185
  const token = randomUUID()
73
186
  // `displayReason` (DSH 0.2.0) is presentation-only and locale-aware;
74
187
  // `reason` remains the audit-trail fallback.
@@ -77,13 +190,14 @@ export class ApprovalAnswerer {
77
190
  token,
78
191
  toolName: req.toolName,
79
192
  ...(displayReason === undefined ? {} : { reason: displayReason }),
193
+ ...described,
80
194
  timeoutMs: this.timeoutMs,
81
- sessionId: req.agent.session.id,
195
+ sessionId,
82
196
  }
83
197
  return new Promise<ApprovalOutcome>((resolve) => {
84
198
  const entry: Pending = {
85
199
  token,
86
- sessionId: req.agent.session.id,
200
+ sessionId,
87
201
  route,
88
202
  messageId: undefined,
89
203
  settled: false,
@@ -92,6 +206,8 @@ export class ApprovalAnswerer {
92
206
  onAbort: undefined,
93
207
  unauthorizedNoted: false,
94
208
  question,
209
+ grantKey,
210
+ openedAt: ++this.opened,
95
211
  resolve,
96
212
  }
97
213
  const onAbort = (): void => {
@@ -105,6 +221,7 @@ export class ApprovalAnswerer {
105
221
  // discards a late resolution (worst case: the card times out).
106
222
  req.signal?.addEventListener('abort', onAbort, { once: true })
107
223
  this.table.add(entry)
224
+ this.notifyWaiting(sessionId)
108
225
  void this.table.deliver(entry, {
109
226
  card: buildApprovalCard(question),
110
227
  onWithdrawn: e => this.table.patch(e, buildApprovalSettledCard(e.question, 'cancelled')),
@@ -114,6 +231,7 @@ export class ApprovalAnswerer {
114
231
  // No card → no human answerer → fail closed.
115
232
  onFailure: e => {
116
233
  this.table.close(e, () => {})
234
+ this.notifyWaiting(e.sessionId)
117
235
  e.resolve('unavailable')
118
236
  },
119
237
  })
@@ -134,12 +252,72 @@ export class ApprovalAnswerer {
134
252
  if (entry === undefined) return
135
253
  const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
136
254
  if (decision === 'allow') this.settle(entry, 'allowed-once', 'allowed')
255
+ else if (decision === 'always') this.remember(entry)
137
256
  else if (decision === 'deny') this.settle(entry, 'rejected', 'denied')
138
257
  }
139
258
 
259
+ /**
260
+ * Answer a session's pending approvals with one typed topic reply. The
261
+ * caller has already checked that the sender owns the topic. A keyword
262
+ * decides the newest card; any other text rejects every pending card of the
263
+ * session and is reported as `superseded` so the caller still delivers it.
264
+ * @param sessionId - the session the reply was routed to.
265
+ * @param text - the reply text.
266
+ * @returns How the reply was consumed, or `undefined` when nothing is pending.
267
+ */
268
+ submitText(sessionId: string, text: string): ApprovalTextResult | undefined {
269
+ const pending = this.pendingOf(sessionId)
270
+ const newest = pending.at(-1)
271
+ if (newest === undefined) return undefined
272
+ switch (classifyApprovalReply(text)) {
273
+ case 'allow':
274
+ this.settle(newest, 'allowed-once', 'allowed-text')
275
+ return 'allowed'
276
+ case 'always':
277
+ this.remember(newest)
278
+ return 'remembered'
279
+ case 'deny':
280
+ this.settle(newest, 'rejected', 'denied-text')
281
+ return 'denied'
282
+ case undefined:
283
+ for (const entry of pending) this.settle(entry, 'rejected', 'superseded')
284
+ return 'superseded'
285
+ }
286
+ }
287
+
140
288
  /** Withdraw every in-flight question (plugin shutdown). */
141
289
  dispose(): void {
142
290
  this.table.withdrawAll(entry => this.settle(entry, 'cancelled'))
291
+ this.calls.clear()
292
+ this.grants.clear()
293
+ }
294
+
295
+ /** Undecided entries of one session, oldest first. */
296
+ private pendingOf(sessionId: string): Pending[] {
297
+ return [...this.table.values()]
298
+ .filter(entry => entry.sessionId === sessionId && !entry.settled)
299
+ .sort((left, right) => left.openedAt - right.openedAt)
300
+ }
301
+
302
+ /**
303
+ * Record a standing grant for the entry's tool + mode, allow it, and allow
304
+ * every other pending card of the session the grant now covers.
305
+ */
306
+ private remember(entry: Pending): void {
307
+ let grants = this.grants.get(entry.sessionId)
308
+ if (grants === undefined) {
309
+ grants = new Set()
310
+ this.grants.set(entry.sessionId, grants)
311
+ }
312
+ grants.add(entry.grantKey)
313
+ for (const candidate of this.pendingOf(entry.sessionId)) {
314
+ if (candidate.grantKey === entry.grantKey) this.settle(candidate, 'allowed-once', 'remembered')
315
+ }
316
+ }
317
+
318
+ /** Report the session's remaining waits to the streaming card. */
319
+ private notifyWaiting(sessionId: string): void {
320
+ this.hooks.onWaitingChange?.(sessionId, this.pendingOf(sessionId).map(entry => entry.question.toolName))
143
321
  }
144
322
 
145
323
  /** Settle once, then patch the card to its terminal state. */
@@ -149,6 +327,8 @@ export class ApprovalAnswerer {
149
327
  this.table.patch(entry, buildApprovalSettledCard(entry.question, cardState))
150
328
  }
151
329
  })
152
- if (closed) entry.resolve(outcome)
330
+ if (!closed) return
331
+ this.notifyWaiting(entry.sessionId)
332
+ entry.resolve(outcome)
153
333
  }
154
334
  }
@@ -20,8 +20,8 @@ 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 (open_id) — the only operator allowed to decide. */
24
- senderOpenId: string
23
+ /** The session owner (user_id) — the only operator allowed to decide. */
24
+ senderUserId: string
25
25
  }
26
26
 
27
27
  /** Fields every pending interaction card carries. */
@@ -96,7 +96,7 @@ export class PendingCardTable<E extends PendingCardEntry> {
96
96
  }
97
97
  }
98
98
  if (entry === undefined || entry.settled) return undefined
99
- if (event.operator.openId !== entry.route.senderOpenId) {
99
+ if (event.operator.userId !== entry.route.senderUserId) {
100
100
  onUnauthorized(entry)
101
101
  return undefined
102
102
  }