@krischoichoi/channel-harness 0.6.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 (202) hide show
  1. package/README.md +110 -0
  2. package/lib/access/controller.d.ts +37 -0
  3. package/lib/access/controller.d.ts.map +1 -0
  4. package/lib/access/controller.js +51 -0
  5. package/lib/access/controller.js.map +1 -0
  6. package/lib/access/decision.d.ts +18 -0
  7. package/lib/access/decision.d.ts.map +1 -0
  8. package/lib/access/decision.js +2 -0
  9. package/lib/access/decision.js.map +1 -0
  10. package/lib/access/resolver.d.ts +41 -0
  11. package/lib/access/resolver.d.ts.map +1 -0
  12. package/lib/access/resolver.js +31 -0
  13. package/lib/access/resolver.js.map +1 -0
  14. package/lib/agent-manager.d.ts +314 -0
  15. package/lib/agent-manager.d.ts.map +1 -0
  16. package/lib/agent-manager.js +603 -0
  17. package/lib/agent-manager.js.map +1 -0
  18. package/lib/agent-router.d.ts +37 -0
  19. package/lib/agent-router.d.ts.map +1 -0
  20. package/lib/agent-router.js +33 -0
  21. package/lib/agent-router.js.map +1 -0
  22. package/lib/bind-support.d.ts +23 -0
  23. package/lib/bind-support.d.ts.map +1 -0
  24. package/lib/bind-support.js +63 -0
  25. package/lib/bind-support.js.map +1 -0
  26. package/lib/binding-store.d.ts +104 -0
  27. package/lib/binding-store.d.ts.map +1 -0
  28. package/lib/binding-store.js +354 -0
  29. package/lib/binding-store.js.map +1 -0
  30. package/lib/bridge.d.ts +248 -0
  31. package/lib/bridge.d.ts.map +1 -0
  32. package/lib/bridge.js +961 -0
  33. package/lib/bridge.js.map +1 -0
  34. package/lib/channel-label.d.ts +46 -0
  35. package/lib/channel-label.d.ts.map +1 -0
  36. package/lib/channel-label.js +89 -0
  37. package/lib/channel-label.js.map +1 -0
  38. package/lib/channel-session-factory.d.ts +76 -0
  39. package/lib/channel-session-factory.d.ts.map +1 -0
  40. package/lib/channel-session-factory.js +187 -0
  41. package/lib/channel-session-factory.js.map +1 -0
  42. package/lib/commands/bind.d.ts +21 -0
  43. package/lib/commands/bind.d.ts.map +1 -0
  44. package/lib/commands/bind.js +91 -0
  45. package/lib/commands/bind.js.map +1 -0
  46. package/lib/commands/help.d.ts +15 -0
  47. package/lib/commands/help.d.ts.map +1 -0
  48. package/lib/commands/help.js +89 -0
  49. package/lib/commands/help.js.map +1 -0
  50. package/lib/commands/index.d.ts +122 -0
  51. package/lib/commands/index.d.ts.map +1 -0
  52. package/lib/commands/index.js +63 -0
  53. package/lib/commands/index.js.map +1 -0
  54. package/lib/commands/locale.d.ts +9 -0
  55. package/lib/commands/locale.d.ts.map +1 -0
  56. package/lib/commands/locale.js +27 -0
  57. package/lib/commands/locale.js.map +1 -0
  58. package/lib/commands/mirror.d.ts +14 -0
  59. package/lib/commands/mirror.d.ts.map +1 -0
  60. package/lib/commands/mirror.js +44 -0
  61. package/lib/commands/mirror.js.map +1 -0
  62. package/lib/commands/model.d.ts +20 -0
  63. package/lib/commands/model.d.ts.map +1 -0
  64. package/lib/commands/model.js +106 -0
  65. package/lib/commands/model.js.map +1 -0
  66. package/lib/commands/models.d.ts +14 -0
  67. package/lib/commands/models.d.ts.map +1 -0
  68. package/lib/commands/models.js +67 -0
  69. package/lib/commands/models.js.map +1 -0
  70. package/lib/commands/new.d.ts +14 -0
  71. package/lib/commands/new.d.ts.map +1 -0
  72. package/lib/commands/new.js +37 -0
  73. package/lib/commands/new.js.map +1 -0
  74. package/lib/commands/status.d.ts +12 -0
  75. package/lib/commands/status.d.ts.map +1 -0
  76. package/lib/commands/status.js +30 -0
  77. package/lib/commands/status.js.map +1 -0
  78. package/lib/commands/stop.d.ts +16 -0
  79. package/lib/commands/stop.d.ts.map +1 -0
  80. package/lib/commands/stop.js +27 -0
  81. package/lib/commands/stop.js.map +1 -0
  82. package/lib/commands/version.d.ts +25 -0
  83. package/lib/commands/version.d.ts.map +1 -0
  84. package/lib/commands/version.js +57 -0
  85. package/lib/commands/version.js.map +1 -0
  86. package/lib/config.d.ts +119 -0
  87. package/lib/config.d.ts.map +1 -0
  88. package/lib/config.js +60 -0
  89. package/lib/config.js.map +1 -0
  90. package/lib/debug-logger.d.ts +8 -0
  91. package/lib/debug-logger.d.ts.map +1 -0
  92. package/lib/debug-logger.js +32 -0
  93. package/lib/debug-logger.js.map +1 -0
  94. package/lib/dsh-home.d.ts +12 -0
  95. package/lib/dsh-home.d.ts.map +1 -0
  96. package/lib/dsh-home.js +31 -0
  97. package/lib/dsh-home.js.map +1 -0
  98. package/lib/failure-display.d.ts +10 -0
  99. package/lib/failure-display.d.ts.map +1 -0
  100. package/lib/failure-display.js +42 -0
  101. package/lib/failure-display.js.map +1 -0
  102. package/lib/file-provider.d.ts +96 -0
  103. package/lib/file-provider.d.ts.map +1 -0
  104. package/lib/file-provider.js +44 -0
  105. package/lib/file-provider.js.map +1 -0
  106. package/lib/index.d.ts +31 -0
  107. package/lib/index.d.ts.map +1 -0
  108. package/lib/index.js +31 -0
  109. package/lib/index.js.map +1 -0
  110. package/lib/interactions/index.d.ts +13 -0
  111. package/lib/interactions/index.d.ts.map +1 -0
  112. package/lib/interactions/index.js +13 -0
  113. package/lib/interactions/index.js.map +1 -0
  114. package/lib/interactions/question-backend.d.ts +133 -0
  115. package/lib/interactions/question-backend.d.ts.map +1 -0
  116. package/lib/interactions/question-backend.js +37 -0
  117. package/lib/interactions/question-backend.js.map +1 -0
  118. package/lib/interactions/question-presenter.d.ts +68 -0
  119. package/lib/interactions/question-presenter.d.ts.map +1 -0
  120. package/lib/interactions/question-presenter.js +425 -0
  121. package/lib/interactions/question-presenter.js.map +1 -0
  122. package/lib/interactions/question-renderer.d.ts +37 -0
  123. package/lib/interactions/question-renderer.d.ts.map +1 -0
  124. package/lib/interactions/question-renderer.js +79 -0
  125. package/lib/interactions/question-renderer.js.map +1 -0
  126. package/lib/interactions/question-state.d.ts +106 -0
  127. package/lib/interactions/question-state.d.ts.map +1 -0
  128. package/lib/interactions/question-state.js +99 -0
  129. package/lib/interactions/question-state.js.map +1 -0
  130. package/lib/interactions/question-text-answer.d.ts +37 -0
  131. package/lib/interactions/question-text-answer.d.ts.map +1 -0
  132. package/lib/interactions/question-text-answer.js +70 -0
  133. package/lib/interactions/question-text-answer.js.map +1 -0
  134. package/lib/interactions/question-waterfall-backend.d.ts +42 -0
  135. package/lib/interactions/question-waterfall-backend.d.ts.map +1 -0
  136. package/lib/interactions/question-waterfall-backend.js +162 -0
  137. package/lib/interactions/question-waterfall-backend.js.map +1 -0
  138. package/lib/lifecycle.d.ts +36 -0
  139. package/lib/lifecycle.d.ts.map +1 -0
  140. package/lib/lifecycle.js +303 -0
  141. package/lib/lifecycle.js.map +1 -0
  142. package/lib/loggable-error.d.ts +10 -0
  143. package/lib/loggable-error.d.ts.map +1 -0
  144. package/lib/loggable-error.js +32 -0
  145. package/lib/loggable-error.js.map +1 -0
  146. package/lib/message-converter.d.ts +64 -0
  147. package/lib/message-converter.d.ts.map +1 -0
  148. package/lib/message-converter.js +191 -0
  149. package/lib/message-converter.js.map +1 -0
  150. package/lib/model-selection.d.ts +54 -0
  151. package/lib/model-selection.d.ts.map +1 -0
  152. package/lib/model-selection.js +116 -0
  153. package/lib/model-selection.js.map +1 -0
  154. package/lib/outbox/binding-resolver.d.ts +23 -0
  155. package/lib/outbox/binding-resolver.d.ts.map +1 -0
  156. package/lib/outbox/binding-resolver.js +38 -0
  157. package/lib/outbox/binding-resolver.js.map +1 -0
  158. package/lib/outbox/capabilities.d.ts +48 -0
  159. package/lib/outbox/capabilities.d.ts.map +1 -0
  160. package/lib/outbox/capabilities.js +35 -0
  161. package/lib/outbox/capabilities.js.map +1 -0
  162. package/lib/outbox/index.d.ts +14 -0
  163. package/lib/outbox/index.d.ts.map +1 -0
  164. package/lib/outbox/index.js +14 -0
  165. package/lib/outbox/index.js.map +1 -0
  166. package/lib/outbox/service.d.ts +48 -0
  167. package/lib/outbox/service.d.ts.map +1 -0
  168. package/lib/outbox/service.js +74 -0
  169. package/lib/outbox/service.js.map +1 -0
  170. package/lib/outbox/target.d.ts +17 -0
  171. package/lib/outbox/target.d.ts.map +1 -0
  172. package/lib/outbox/target.js +15 -0
  173. package/lib/outbox/target.js.map +1 -0
  174. package/lib/outbox/tool-send.d.ts +19 -0
  175. package/lib/outbox/tool-send.d.ts.map +1 -0
  176. package/lib/outbox/tool-send.js +77 -0
  177. package/lib/outbox/tool-send.js.map +1 -0
  178. package/lib/outbox/types.d.ts +42 -0
  179. package/lib/outbox/types.d.ts.map +1 -0
  180. package/lib/outbox/types.js +30 -0
  181. package/lib/outbox/types.js.map +1 -0
  182. package/lib/plugin.d.ts +27 -0
  183. package/lib/plugin.d.ts.map +1 -0
  184. package/lib/plugin.js +47 -0
  185. package/lib/plugin.js.map +1 -0
  186. package/lib/reply-context-store.d.ts +69 -0
  187. package/lib/reply-context-store.d.ts.map +1 -0
  188. package/lib/reply-context-store.js +75 -0
  189. package/lib/reply-context-store.js.map +1 -0
  190. package/lib/reply-router.d.ts +138 -0
  191. package/lib/reply-router.d.ts.map +1 -0
  192. package/lib/reply-router.js +674 -0
  193. package/lib/reply-router.js.map +1 -0
  194. package/lib/session-router.d.ts +49 -0
  195. package/lib/session-router.d.ts.map +1 -0
  196. package/lib/session-router.js +45 -0
  197. package/lib/session-router.js.map +1 -0
  198. package/lib/workspace-resolver.d.ts +67 -0
  199. package/lib/workspace-resolver.d.ts.map +1 -0
  200. package/lib/workspace-resolver.js +102 -0
  201. package/lib/workspace-resolver.js.map +1 -0
  202. package/package.json +103 -0
package/lib/bridge.js ADDED
@@ -0,0 +1,961 @@
1
+ /**
2
+ * ChannelHarnessBridge — the inbound half: `ChannelEvent` -> session binding
3
+ * -> agent resolution -> command plane / `agent.followup` (doc H0.3–H0.7,
4
+ * command plane spec §2–§12, §36).
5
+ *
6
+ * Only `message.received` is handled in v1; every other event type is logged
7
+ * at debug level. Conversations are isolated by their canonical key
8
+ * (channel:account:conversation[:thread]), never by account alone.
9
+ *
10
+ * Two input planes: a **human command plane** (official
11
+ * `@deepseek-ai/dsh-commands` `parseCommand`/`commands.execute`) and a
12
+ * **model message plane** (`agent.followup`). A syntactically valid command
13
+ * is resolved through the official registry and its `CommandResult` is
14
+ * rendered directly to the channel — it is never sent to the model and never
15
+ * creates `assistant/message` (`ReplyRouter` is bypassed). An UNREGISTERED
16
+ * slash command follows official Host semantics: it is rejected with a
17
+ * direct channel notice and never enters the Agent prompt — `commands.execute`
18
+ * returns `undefined` for admission misses, which (given the syntax already
19
+ * parsed) means `ctx.commands.find(agent, name)` missed.
20
+ *
21
+ * Per-conversation serialization: all `message.received` handling for one
22
+ * canonical key runs through a lightweight per-key promise chain so a `/new`
23
+ * fully completes (Binding → B) before the next message on the SAME
24
+ * conversation starts, while different conversations run in parallel. Errors
25
+ * are caught + logged and never poison the chain.
26
+ *
27
+ * `/stop` is the one scheduling exception (spec §4–§10): its COMMAND
28
+ * semantics belong to the registry (see commands/stop.ts), but its SCHEDULING
29
+ * is a FAST PATH executed outside the serial chain — it bumps the
30
+ * per-conversation generation first (invalidating every stale queued message),
31
+ * cancels the live agent, acknowledges immediately (never waiting for
32
+ * `whenIdle`), and enqueues an internal stop barrier that re-cancels the
33
+ * LATEST binding's agent after prior chain work converges (covering the /new
34
+ * race).
35
+ */
36
+ import { randomUUID } from 'node:crypto';
37
+ import {} from '@deepseek-ai/cordis';
38
+ import { parseCommand } from '@deepseek-ai/dsh-commands';
39
+ import { PersistenceUnavailableError, SessionNotFoundError } from './agent-manager.js';
40
+ import { routesEqual } from './agent-router.js';
41
+ import { sessionKey, } from './session-router.js';
42
+ import { toHarnessUserMessage, } from './message-converter.js';
43
+ import { installAttachmentCompatibilityTools, } from './file-provider.js';
44
+ import { ReplyContextStore } from './reply-context-store.js';
45
+ import { installSendChannelMessageTool } from './outbox/tool-send.js';
46
+ import { installChannelCommands, } from './commands/index.js';
47
+ import { ChannelModelSelectionController } from './model-selection.js';
48
+ import { toLoggableError } from './loggable-error.js';
49
+ import { ChannelSessionFactory } from './channel-session-factory.js';
50
+ import { isReservedClaimCommand } from '@krischoichoi/channel-core';
51
+ import { InboundAccessController } from './access/controller.js';
52
+ import { commandLocaleFromSettings } from './commands/locale.js';
53
+ function trimBrandedId(value) {
54
+ return value.trim();
55
+ }
56
+ /**
57
+ * Error historically raised when a Workspace attach failed inside fresh Session
58
+ * creation. Workspace attach is now non-fatal: the freshly-created session is
59
+ * kept (grouped as ungrouped) and the binding + followup continue, so the
60
+ * bridge no longer produces this error.
61
+ *
62
+ * @deprecated Workspace attachment failures are non-fatal and no longer raise
63
+ * this error. Retained as a public export for compatibility with existing
64
+ * imports; do not add new uses.
65
+ */
66
+ export class ChannelWorkspaceAttachError extends Error {
67
+ sessionId;
68
+ workspaceId;
69
+ cwd;
70
+ channelId;
71
+ accountId;
72
+ constructor(input) {
73
+ super(`channel session '${input.sessionId}' could not attach to workspace '${input.workspaceId}'`);
74
+ this.name = 'ChannelWorkspaceAttachError';
75
+ this.sessionId = input.sessionId;
76
+ this.workspaceId = input.workspaceId;
77
+ this.cwd = input.cwd;
78
+ this.channelId = input.channelId;
79
+ this.accountId = input.accountId;
80
+ }
81
+ }
82
+ export class ChannelHarnessBridge {
83
+ options;
84
+ sessionFactory;
85
+ commandDisposers = new Set();
86
+ modelSelectionDisposers = new Set();
87
+ commandSetupsDisposed = false;
88
+ /** Thin view over Harness's session/default model semantics. */
89
+ modelSelection;
90
+ /** Normalized command deps handed to every agent setup. */
91
+ commandDeps;
92
+ /** Per-conversation generation counters, invalidated by /stop (spec §6). */
93
+ conversationGenerations = new Map();
94
+ /** Pure fail-closed access decision engine. No I/O. */
95
+ accessController = new InboundAccessController();
96
+ constructor(options) {
97
+ this.options = options;
98
+ if (!options.accessResolver) {
99
+ throw new Error('channel-harness requires an access policy resolver');
100
+ }
101
+ this.modelSelection =
102
+ options.commandDeps.modelSelection ?? new ChannelModelSelectionController(options.ctx);
103
+ // Every Harness service reach is bridged LAZILY from the plugin context
104
+ // (options.ctx): command handlers must never read services through
105
+ // invocation.agent.ctx — the agent-loop scoped context does not inject
106
+ // commands/llm, and Cordis throws "without inject" there. This mirrors the
107
+ // /new pattern: narrow deps, bridge-owned implementations (official
108
+ // compact/goal/plan commands close over their plugin ctx the same way).
109
+ this.commandDeps = {
110
+ ...options.commandDeps,
111
+ modelSelection: this.modelSelection,
112
+ listCommands: (agent) => this.options.ctx.commands.list(agent),
113
+ findCommand: (agent, name) => this.options.ctx.commands.find(agent, name),
114
+ locale: options.commandDeps.locale ?? (() => commandLocaleFromSettings(this.options.ctx.get('settings'))),
115
+ llm: {
116
+ listProviders: () => this.options.ctx.llm.listProviders(),
117
+ listModels: (provider) => this.options.ctx.llm.listModels(provider),
118
+ resolveModelInfo: (provider, model, signal) => this.options.ctx.llm.resolveModelInfo(provider, model, signal),
119
+ resolveCallConfig: (config, signal) => this.options.ctx.llm.resolveCallConfig(config, signal),
120
+ },
121
+ // /version's update hint: live probe of the (optional) control plane.
122
+ // `ctx.get` is the official detection API — safe on any scope, undefined
123
+ // when channel-control is not mounted (headless-without-control or the
124
+ // check disabled). The probe re-runs on every /version so an HMR reload
125
+ // of the control plugin is picked up without restarting the bridge.
126
+ versionInfo: async () => {
127
+ try {
128
+ const control = this.options.ctx.get('channelControl');
129
+ return await control?.getUpdateStatus();
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ },
135
+ };
136
+ this.sessionFactory = new ChannelSessionFactory({
137
+ ctx: options.ctx,
138
+ cwd: options.config.cwd,
139
+ bindingStore: options.bindingStore,
140
+ agentManager: options.agentManager,
141
+ workspaceResolver: options.workspaceResolver,
142
+ commandSetup: this.commandSetup,
143
+ logger: options.logger,
144
+ });
145
+ }
146
+ /** Per-conversation promise chains; entries self-clean on settle. */
147
+ chains = new Map();
148
+ /**
149
+ * Per-conversation serialization. Each canonical key has an owning promise
150
+ * chain; `fn` is appended onto the previous entry for that key so it starts
151
+ * only after the prior one settles, while distinct keys run in parallel. The
152
+ * returned promise resolves only after THIS operation has been handled; the
153
+ * chain entry absorbs errors (logged, never rethrown) so ONE failing message
154
+ * never poisons the conversation chain, while this call still surfaces THIS
155
+ * operation's error to its await-er (preserving prior rejection semantics).
156
+ */
157
+ async enqueueSelf(key, fn) {
158
+ const prev = this.chains.get(key) ?? Promise.resolve();
159
+ const task = prev.then(fn);
160
+ const chain = task.catch((error) => {
161
+ this.options.logger.error(`[channel-harness] message handling failed for conversation '${key}'`, toLoggableError(error));
162
+ });
163
+ this.chains.set(key, chain);
164
+ void chain.finally(() => {
165
+ if (this.chains.get(key) === chain)
166
+ this.chains.delete(key);
167
+ });
168
+ await task;
169
+ }
170
+ /**
171
+ * Append work onto the per-conversation chain WITHOUT awaiting its
172
+ * completion (used by the /stop stop barrier — spec §9). The returned
173
+ * promise never rejects.
174
+ */
175
+ enqueueConversation(key, fn) {
176
+ const prev = this.chains.get(key) ?? Promise.resolve();
177
+ const task = prev.then(fn);
178
+ const chain = task.catch((error) => {
179
+ this.options.logger.error(`[channel-harness] queued message handling failed for conversation '${key}'`, toLoggableError(error));
180
+ });
181
+ this.chains.set(key, chain);
182
+ void chain.finally(() => {
183
+ if (this.chains.get(key) === chain)
184
+ this.chains.delete(key);
185
+ });
186
+ return task.catch(() => undefined);
187
+ }
188
+ async handleChannelEvent(event) {
189
+ // Connection/auth state is consumed by the control plane and Web status
190
+ // surface. It is expected to be frequent during startup/reconnect and is
191
+ // not an inbound message for the Harness Agent bridge.
192
+ if (event.type === 'connection.changed' || event.type === 'auth.changed') {
193
+ return;
194
+ }
195
+ if (event.type === 'interaction.received') {
196
+ const normalized = this.normalizeInteractionIdentity(event);
197
+ if (await this.enforceInteractionAccessGate(normalized))
198
+ return;
199
+ if (await this.options.questionPresenter?.handleChannelEvent(normalized))
200
+ return;
201
+ this.options.logger.debug('[channel-harness] ignoring unmatched interaction.received');
202
+ return;
203
+ }
204
+ if (event.type !== 'message.received') {
205
+ this.options.logger.debug(`[channel-harness] ignoring channel event '${event.type}'`);
206
+ return;
207
+ }
208
+ // The command parser operates on the RAW user text (the concatenated plain
209
+ // text blocks), never on the '[channel=.. sender=.. message=..] ' metadata
210
+ // prefix the model-facing converter prepends, and never after a trim (the
211
+ // official parseCommand requires '/' at byte zero — spec §5).
212
+ const normalizedEvent = this.normalizeInboundIdentity(event);
213
+ const text = normalizedEvent.message.content
214
+ .filter((part) => part.type === 'text')
215
+ .map((part) => part.text)
216
+ .join('');
217
+ // ------------------------------------------------------------------
218
+ // FAIL-CLOSED ACCESS GATE. Runs BEFORE any side effect:
219
+ // before conversationKey / parseCommand / /stop / binding writes /
220
+ // session / workspace / agent. A drop here means NO side effect at all
221
+ // (incl. /stop fast path — an unauthorized user can never cancel a live
222
+ // agent or bump the generation).
223
+ // ------------------------------------------------------------------
224
+ const accessPolicy = await this.enforceAccessGate(normalizedEvent, text);
225
+ if (!accessPolicy)
226
+ return;
227
+ if (await this.options.questionPresenter?.handleChannelEvent(normalizedEvent))
228
+ return;
229
+ const parsed = parseCommand(text);
230
+ if (parsed &&
231
+ normalizedEvent.conversation.type === 'group' &&
232
+ normalizedEvent.sender.id !== accessPolicy.ownerId) {
233
+ const accessLogger = this.options.accessLogger ?? this.options.logger;
234
+ accessLogger.info('[channel-access] group command denied', {
235
+ channel: normalizedEvent.channel,
236
+ account: normalizedEvent.accountId,
237
+ conversationType: normalizedEvent.conversation.type,
238
+ reason: 'command_owner_required',
239
+ });
240
+ await this.sendCommandNotice(normalizedEvent, '群聊指令仅所有者可用。');
241
+ return;
242
+ }
243
+ const key = this.conversationKey(normalizedEvent);
244
+ // P0: /stop is handled on a FAST PATH AND RUNS IMMEDIATELY — it must
245
+ // NEVER be chained behind queued conversation work (spec §4/§5), because
246
+ // the whole point is to interrupt an in-flight turn. `handleImmediateStop`
247
+ // bumps the generation synchronously before its first await, so every
248
+ // already-queued message (captured with the OLD generation) is invalidated
249
+ // at its next generation check and can never re-wake the agent.
250
+ if (parsed?.name === 'stop') {
251
+ try {
252
+ await this.handleImmediateStop(normalizedEvent, key, text);
253
+ }
254
+ catch (error) {
255
+ this.options.logger.error(`[channel-harness] /stop handling failed for conversation '${key}'`, toLoggableError(error));
256
+ }
257
+ return;
258
+ }
259
+ const generation = this.generationOf(key);
260
+ await this.enqueueSelf(key, () => this.handleQueuedMessage(normalizedEvent, key, text, parsed, generation));
261
+ }
262
+ /** Apply the contract's only identity normalization before any side effect. */
263
+ normalizeInboundIdentity(event) {
264
+ const senderId = trimBrandedId(event.sender.id);
265
+ const conversationId = trimBrandedId(event.conversation.id);
266
+ if (senderId === event.sender.id && conversationId === event.conversation.id) {
267
+ return event;
268
+ }
269
+ return {
270
+ ...event,
271
+ sender: { ...event.sender, id: senderId },
272
+ conversation: { ...event.conversation, id: conversationId },
273
+ };
274
+ }
275
+ normalizeInteractionIdentity(event) {
276
+ return {
277
+ ...event,
278
+ sender: { ...event.sender, id: trimBrandedId(event.sender.id) },
279
+ conversation: {
280
+ ...event.conversation,
281
+ id: trimBrandedId(event.conversation.id),
282
+ },
283
+ };
284
+ }
285
+ /** Interaction admission reuses Security Gate semantics; the click is activation. */
286
+ async enforceInteractionAccessGate(event) {
287
+ const accessLogger = this.options.accessLogger ?? this.options.logger;
288
+ if (!event.sender.id || event.sender.id === 'unknown') {
289
+ this.dropInteraction(accessLogger, event, 'unidentified_sender');
290
+ return true;
291
+ }
292
+ if (!event.conversation.id) {
293
+ this.dropInteraction(accessLogger, event, 'invalid_conversation');
294
+ return true;
295
+ }
296
+ let resolved;
297
+ try {
298
+ resolved = await this.options.accessResolver.resolve(event.channel, event.accountId);
299
+ }
300
+ catch (error) {
301
+ this.options.logger.warn('[channel-access] policy resolution failed', toLoggableError(error));
302
+ return true;
303
+ }
304
+ if (resolved.state !== 'present') {
305
+ this.dropInteraction(accessLogger, event, resolved.state === 'missing' ? 'missing_policy' : 'invalid_policy');
306
+ return true;
307
+ }
308
+ const decision = this.accessController.authorize({
309
+ conversationType: event.conversation.type,
310
+ senderId: event.sender.id,
311
+ conversationId: event.conversation.id,
312
+ policy: resolved.policy,
313
+ });
314
+ if (!decision.authorized) {
315
+ this.dropInteraction(accessLogger, event, decision.reason);
316
+ return true;
317
+ }
318
+ return false;
319
+ }
320
+ dropInteraction(logger, event, reason) {
321
+ logger.info('[channel-access] interaction dropped', {
322
+ channel: event.channel,
323
+ account: event.accountId,
324
+ conversationType: event.conversation.type,
325
+ reason,
326
+ });
327
+ }
328
+ /**
329
+ * One-time Agent-scoped command and model-hook setup. Installed onto an
330
+ * Agent's scoped context by every create/resolve and by the Session
331
+ * factory's recreate (borrow + create) so a fresh, resumed OR recreated
332
+ * session gets the channel commands and channel hooks before any driving
333
+ * happens. Harness still resolves the Session model at creation/resume.
334
+ * Channel images are NOT rewritten here: the inbound converter hands raw
335
+ * images to the Harness Attachment Store and the official image
336
+ * pipeline owns model-capability projection (vision variant / text-only
337
+ * deterministic placeholder), so the Agent-scoped history keeps the
338
+ * original ImageBlock.
339
+ */
340
+ // Bound arrow: passed to create/resolve/borrowIfLive as the official
341
+ // AgentSetup (invoked as a bare setup(agentCtx)), so this must stay the
342
+ // bridge instance.
343
+ commandSetup = async (agentCtx) => {
344
+ const disposeCommands = await installChannelCommands(agentCtx, this.commandDeps);
345
+ const disposeModelSelection = this.modelSelection.install(agentCtx);
346
+ if (this.commandSetupsDisposed) {
347
+ await disposeCommands();
348
+ disposeModelSelection();
349
+ throw new Error('channel-harness command setup continued after bridge disposal');
350
+ }
351
+ this.commandDisposers.add(disposeCommands);
352
+ this.modelSelectionDisposers.add(disposeModelSelection);
353
+ // M4: Agent-scoped read_channel_attachment tool. Registered on the agent's
354
+ // own scope so it is disposed with the agent. Best-effort: a tool-install
355
+ // failure must never roll back the agent setup. The tool stays registered
356
+ // through the provider's OPTIONAL compatibility path while it
357
+ // is the only generic-attachment reader. The deprecated installTools name
358
+ // remains a fallback for one cycle; providers with neither hook skip it.
359
+ if (this.options.fileProvider) {
360
+ try {
361
+ await installAttachmentCompatibilityTools(this.options.fileProvider, agentCtx);
362
+ }
363
+ catch (error) {
364
+ this.options.logger.warn('[channel-harness] failed to install read_channel_attachment tool', error);
365
+ }
366
+ }
367
+ // M6: Agent-scoped send_channel_message tool. Only installed when the
368
+ // durable outbox is wired. Best-effort, mirroring the attachment tool.
369
+ if (this.options.outbox) {
370
+ try {
371
+ await installSendChannelMessageTool(agentCtx, { outbox: this.options.outbox });
372
+ }
373
+ catch (error) {
374
+ this.options.logger.warn('[channel-harness] failed to install send_channel_message tool', error);
375
+ }
376
+ }
377
+ };
378
+ /** Release this bridge's Agent-scoped command and model-hook registrations. */
379
+ async disposeCommandSetups() {
380
+ this.commandSetupsDisposed = true;
381
+ const commandDisposers = [...this.commandDisposers];
382
+ this.commandDisposers.clear();
383
+ const modelDisposers = [...this.modelSelectionDisposers];
384
+ this.modelSelectionDisposers.clear();
385
+ await Promise.all([
386
+ ...commandDisposers.map((dispose) => dispose()),
387
+ ...modelDisposers.map((dispose) => Promise.resolve(dispose())),
388
+ ]);
389
+ }
390
+ conversationKey(event) {
391
+ return sessionKey({
392
+ channelId: event.channel,
393
+ accountId: event.accountId,
394
+ conversationId: event.conversation.id,
395
+ ...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
396
+ });
397
+ }
398
+ /** Conversation identity of an inbound event, as a bindable SessionKeyInput. */
399
+ conversationInput(event) {
400
+ return {
401
+ channelId: event.channel,
402
+ accountId: event.accountId,
403
+ conversationId: event.conversation.id,
404
+ // v3: stable conversation identity captured for the durable binding.
405
+ conversationType: event.conversation.type,
406
+ ...(event.sender.id ? { senderId: event.sender.id } : {}),
407
+ ...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
408
+ };
409
+ }
410
+ /**
411
+ * Whether the durable session behind an existing binding is MISSING — the
412
+ * stale condition behind both the EXPLICIT stale-binding repair (/new) and
413
+ * the loud SessionNotFoundError for every other request. A live agent is
414
+ * never stale (live-first: no persistence probe), and without a mounted
415
+ * sessionPersistence there is no durable identity to lose (ephemeral
416
+ * deployments recreate instead). One ATOMIC probe decides all three cases
417
+ * (live capability resolved once — no canResume/exists TOCTOU across a
418
+ * persistence HMR).
419
+ */
420
+ async isDurableSessionMissing(binding) {
421
+ if (this.options.agentManager.getLiveAgent(binding.sessionId))
422
+ return false;
423
+ const probe = await this.options.agentManager.probePersisted(binding.sessionId);
424
+ return probe === 'missing';
425
+ }
426
+ /** Bindings without the field predate the stable policy; fail closed. */
427
+ bindingDurability(binding) {
428
+ return binding.durability ?? 'durable';
429
+ }
430
+ /**
431
+ * FAIL-CLOSED Access Gate. Returns the validated policy only when the
432
+ * message is admitted. `undefined` means DROP with NO side effect (agent /
433
+ * command / session / binding / workspace / generation / /stop fast path).
434
+ *
435
+ * Order:
436
+ * 1. Reserved claim suppression (/dsh-claim never reaches anything).
437
+ * 2. Identity validation: sender + conversation ids.
438
+ * 3. Resolve policy (missing/invalid -> drop, fail closed).
439
+ * 4. Authorize (security gate) + activate (activation gate).
440
+ *
441
+ * Logging follows the `channel-access` logger convention: minimal fields
442
+ * (channel / account / conversationType / reason), never message body,
443
+ * challenge code, raw payload or tokens.
444
+ */
445
+ async enforceAccessGate(event, text) {
446
+ const accessLogger = this.options.accessLogger ?? this.options.logger;
447
+ // 1. Reserved owner-claim suppression: /dsh-claim must
448
+ // NEVER reach model / command dispatcher / Session / Binding, even when
449
+ // no access policy exists. Static drop — no policy read is needed.
450
+ if (isReservedClaimCommand(text)) {
451
+ accessLogger.debug('[channel-access] reserved claim message suppressed', {
452
+ channel: event.channel,
453
+ account: event.accountId,
454
+ });
455
+ return undefined;
456
+ }
457
+ // 2. Identity validation: sender.id must be a non-empty string
458
+ // and !== 'unknown'; conversation.id must be non-empty.
459
+ const senderId = event.sender.id;
460
+ const conversationId = event.conversation.id;
461
+ if (typeof senderId !== 'string' ||
462
+ senderId.length === 0 ||
463
+ senderId === 'unknown') {
464
+ this.dropInbound(accessLogger, event, 'unidentified_sender');
465
+ return undefined;
466
+ }
467
+ if (typeof conversationId !== 'string' || conversationId.length === 0) {
468
+ this.dropInbound(accessLogger, event, 'invalid_conversation');
469
+ return undefined;
470
+ }
471
+ // 3. Resolve the policy (fail closed).
472
+ let resolved;
473
+ try {
474
+ resolved = await this.options.accessResolver.resolve(event.channel, event.accountId);
475
+ }
476
+ catch (error) {
477
+ this.options.logger.warn('[channel-access] policy resolution failed', toLoggableError(error));
478
+ return undefined;
479
+ }
480
+ if (resolved.state === 'missing') {
481
+ this.dropInbound(accessLogger, event, 'missing_policy');
482
+ return undefined;
483
+ }
484
+ if (resolved.state === 'invalid') {
485
+ this.dropInbound(accessLogger, event, 'invalid_policy');
486
+ return undefined;
487
+ }
488
+ // 4. Authorize (Security Gate) then activate (Activation Gate).
489
+ const decision = this.accessController.authorize({
490
+ conversationType: event.conversation.type,
491
+ senderId,
492
+ conversationId,
493
+ mentionedBot: event.message.activation?.mentionedBot,
494
+ policy: resolved.policy,
495
+ });
496
+ if (!decision.authorized) {
497
+ this.dropInbound(accessLogger, event, decision.reason);
498
+ return undefined;
499
+ }
500
+ if (!decision.activated) {
501
+ this.dropInbound(accessLogger, event, decision.reason);
502
+ return undefined;
503
+ }
504
+ return resolved.policy;
505
+ }
506
+ /** Log a fail-closed inbound drop with minimal plan-§42 fields. */
507
+ dropInbound(accessLogger, event, reason) {
508
+ accessLogger.info('[channel-access] inbound dropped', {
509
+ channel: event.channel,
510
+ account: event.accountId,
511
+ conversationType: event.conversation.type,
512
+ reason,
513
+ });
514
+ }
515
+ /**
516
+ * Queued (serialized) message handling for one conversation. Captures the
517
+ * generation at ENQUEUE time; /stop bumps it, so this callback drops out at
518
+ * either generation check and can never re-wake a stopped agent (spec §7).
519
+ */
520
+ async handleQueuedMessage(event, key, text, parsed, generation) {
521
+ // Check #1: fast-drop work already invalidated by /stop (spec §7).
522
+ if (!this.isGenerationCurrent(key, generation))
523
+ return;
524
+ const route = this.options.agentRouter.resolve({
525
+ channelId: event.channel,
526
+ accountId: event.accountId,
527
+ conversationId: event.conversation.id,
528
+ });
529
+ const now = Date.now();
530
+ const parsedName = parsed?.name ?? null;
531
+ let binding = await this.options.bindingStore.get(key);
532
+ let archivedSessionId;
533
+ if (binding && this.options.workspaceResolver.isSessionArchived?.(binding.sessionId)) {
534
+ archivedSessionId = binding.sessionId;
535
+ this.options.logger.info('[channel-harness] archived binding will roll to a fresh session', {
536
+ sessionId: archivedSessionId,
537
+ bindingKey: key,
538
+ });
539
+ // Treat an archived binding like an absent binding for admission. The
540
+ // durable entry is intentionally left untouched until the Session factory
541
+ // commits its replacement, preserving rollback semantics on failure.
542
+ binding = undefined;
543
+ }
544
+ // /new is the ONE EXPLICIT stale-binding repair path — NOT a
545
+ // session-recovery branch: the user is explicitly authorizing abandonment
546
+ // of the old session (its persisted data is gone, so ordinary recovery
547
+ // would throw session-not-found) and creation of a fresh one that replaces
548
+ // the binding. Every other request on the stale binding still fails loud:
549
+ // the inconsistency is never auto-repaired.
550
+ if (binding &&
551
+ parsed &&
552
+ parsedName === 'new' &&
553
+ (await this.isDurableSessionMissing(binding))) {
554
+ // Arg contract mirrors the registered handler (用法:/new).
555
+ if (parsed.rawInput.trim().length > 0) {
556
+ await this.sendCommandNotice(event, '用法:/new');
557
+ return;
558
+ }
559
+ const staleSessionId = binding.sessionId;
560
+ this.options.logger.info('[channel-harness] /new repairs a stale binding (persisted session missing)', {
561
+ sessionId: staleSessionId,
562
+ bindingKey: key,
563
+ });
564
+ await this.sessionFactory.create(this.conversationInput(event), route);
565
+ // Clear the stale session's reverse cache (never live/owned -> the
566
+ // retire is a no-op dispose); the factory has already overwritten the
567
+ // binding with the fresh session.
568
+ await this.options.agentManager.retireSession(staleSessionId);
569
+ await this.sendCommandNotice(event, '旧会话数据已丢失,已开启新会话。');
570
+ return;
571
+ }
572
+ let agentRef;
573
+ if (!binding) {
574
+ // --- Bootstrap: no receiving agent exists yet (spec §37/§38) -----------
575
+ if (parsed && parsedName === 'new') {
576
+ // First message is /new: boot a brand-new session directly — do NOT
577
+ // create session A and then run /new on it (no double-create). The
578
+ // arg contract mirrors the registered handler (用法:/new).
579
+ if (parsed.rawInput.trim().length > 0) {
580
+ await this.sendCommandNotice(event, '用法:/new');
581
+ return;
582
+ }
583
+ await this.sessionFactory.create(this.conversationInput(event), route);
584
+ if (archivedSessionId) {
585
+ await this.options.agentManager.retireSession(archivedSessionId);
586
+ }
587
+ await this.sendCommandNotice(event, '已开启新会话。');
588
+ return;
589
+ }
590
+ // Every other first message (ordinary text, /help, /status, /models,
591
+ // /model, or an unknown /foo) mints the session and continues below —
592
+ // first-message /help/status/models/model must work (spec §38). An
593
+ // unknown /foo is then rejected at command admission (Host parity;
594
+ // the session must exist first because channel commands register in the
595
+ // Agent scope and cannot be resolved without one).
596
+ const fresh = await this.sessionFactory.create(this.conversationInput(event), route);
597
+ binding = fresh.binding;
598
+ agentRef = fresh.agentRef;
599
+ if (archivedSessionId) {
600
+ await this.options.agentManager.retireSession(archivedSessionId);
601
+ }
602
+ }
603
+ else {
604
+ // --- Existing conversation: reconcile route snapshot + resolve ----------
605
+ if (!routesEqual(binding.route, route)) {
606
+ binding = { ...binding, route, updatedAt: now };
607
+ await this.options.bindingStore.put(binding);
608
+ }
609
+ // Existing-binding resolution follows the official Host resolver order
610
+ // (live agent -> persistence membership -> resume), never the reverse:
611
+ // ① a LIVE agent is borrowed FIRST — persistence is never scanned for
612
+ // an agent already live in this process (with thousands of sessions
613
+ // the per-inbound persistence scan would dominate);
614
+ // ② one ATOMIC probe decides the rest; availability is not durability:
615
+ // durable + unavailable -> fail loud (never recreate);
616
+ // ephemeral + unavailable -> recreate via the Session factory;
617
+ // ③ membership hit -> resume;
618
+ // ④ membership MISS -> durable binding => session-not-found, while an
619
+ // explicitly ephemeral binding may recreate the recorded id.
620
+ const borrowed = await this.options.agentManager.borrowIfLive(binding.sessionId, route, this.commandSetup);
621
+ if (borrowed) {
622
+ agentRef = borrowed;
623
+ }
624
+ else {
625
+ const probe = await this.options.agentManager.probePersisted(binding.sessionId);
626
+ if (probe === 'unavailable' && this.bindingDurability(binding) === 'ephemeral') {
627
+ const recreated = await this.sessionFactory.recreate(binding, route);
628
+ binding = recreated.binding;
629
+ agentRef = recreated.agentRef;
630
+ }
631
+ else if (probe === 'present') {
632
+ agentRef = await this.options.agentManager.resolve(binding.sessionId, route, this.commandSetup);
633
+ }
634
+ else if (probe === 'unavailable') {
635
+ throw new PersistenceUnavailableError(binding.sessionId, key);
636
+ }
637
+ else if (this.bindingDurability(binding) === 'ephemeral') {
638
+ const recreated = await this.sessionFactory.recreate(binding, route);
639
+ binding = recreated.binding;
640
+ agentRef = recreated.agentRef;
641
+ }
642
+ else {
643
+ throw new SessionNotFoundError(binding.sessionId, key);
644
+ }
645
+ }
646
+ this.options.agentManager.registerBinding(binding);
647
+ }
648
+ // --- Command admission (Host parity) ------------------------------------
649
+ // Registered commands run on the Human Command Plane; an UNREGISTERED
650
+ // slash command is always rejected with a direct channel notice and never
651
+ // enters the Agent prompt.
652
+ if (parsed) {
653
+ const beforeSessionId = binding.sessionId;
654
+ const controller = new AbortController();
655
+ // `commands.execute` takes base64 composer images; channel command
656
+ // admission is text-only for now (command image parity is a later phase).
657
+ const execution = await this.options.ctx.commands.execute(agentRef.agent, text, [], controller.signal);
658
+ if (execution !== undefined) {
659
+ await this.renderCommandResult(event, execution.result);
660
+ // Generic post-command cleanup: whichever command switched the active
661
+ // binding gets its previous session retired. No command-name
662
+ // special-casing.
663
+ const currentBinding = await this.options.bindingStore.get(key);
664
+ if (currentBinding && currentBinding.sessionId !== beforeSessionId) {
665
+ await this.options.agentManager.retireSession(beforeSessionId);
666
+ }
667
+ return;
668
+ }
669
+ // `execution === undefined` with syntax already parsed means the
670
+ // registry missed the name (`ctx.commands.find(agent, parsed.name)`
671
+ // returned nothing) — the official Host answers `unknown-command` and
672
+ // never forwards the line to the model.
673
+ this.options.logger.info('[channel-harness] rejected unknown command', {
674
+ channel: event.channel,
675
+ account: event.accountId,
676
+ conversationType: event.conversation.type,
677
+ command: parsed.name,
678
+ });
679
+ await this.sendCommandNotice(event, `未知命令:/${parsed.name},输入 /help 查看命令。`);
680
+ return;
681
+ }
682
+ // Check #2: a /stop may have arrived while this message was resolving
683
+ // (spec §7) — do not re-wake a stopped agent.
684
+ if (!this.isGenerationCurrent(key, generation))
685
+ return;
686
+ // --- Ordinary message followup --------------------------------------------
687
+ const runId = randomUUID();
688
+ this.logInboundBinaryAvailability(event, binding.sessionId);
689
+ const userMessage = await toHarnessUserMessage(event, {
690
+ includeMetadataPrefix: this.options.config.includeMetadataPrefix,
691
+ saveImage: this.saveImageHook(event, binding.sessionId),
692
+ fileStore: this.fileStoreHook(event, binding.sessionId),
693
+ });
694
+ // Register the reply context keyed by the Harness UserMessage id strictly
695
+ // BEFORE followup.
696
+ this.options.replyContexts.register(userMessage.id, {
697
+ sessionId: binding.sessionId,
698
+ context: {
699
+ conversationType: event.conversation.type,
700
+ senderId: event.sender.id,
701
+ replyToMessageId: event.message.id,
702
+ // Platform reply handles such as DingTalk's per-message sessionWebhook
703
+ // are transient and must travel only with the triggering turn.
704
+ raw: event.raw,
705
+ runId,
706
+ },
707
+ });
708
+ agentRef.followup(userMessage);
709
+ }
710
+ /**
711
+ * /stop FAST PATH (spec §4–§10). Runs OUTSIDE the serial chain:
712
+ * ① bump the generation FIRST (synchronous, before any await) so every
713
+ * queued/stale message on this conversation is invalidated;
714
+ * ② cancel the live agent — preferably by executing the registered /stop
715
+ * command (lifecycle recorded, behavior owned by the handler), with a
716
+ * direct `agent.cancel({ kind: 'user' })` fallback;
717
+ * ③ acknowledge immediately (never wait for `whenIdle`);
718
+ * ④ enqueue a fire-and-forget STOP BARRIER that, after prior chain work
719
+ * converges, re-cancels the LATEST binding's agent (covers the /new race,
720
+ * spec §9).
721
+ */
722
+ async handleImmediateStop(event, key, text) {
723
+ // ① Generation bump must happen before any await (spec §8).
724
+ this.bumpGeneration(key);
725
+ // ② Resolve + cancel.
726
+ const binding = await this.options.bindingStore.get(key);
727
+ if (binding) {
728
+ const agent = this.options.agentManager.getLiveAgent(binding.sessionId);
729
+ if (agent) {
730
+ try {
731
+ const controller = new AbortController();
732
+ // `commands.execute` takes base64 composer images; none accompany
733
+ // an inbound IM stop command.
734
+ const execution = await this.options.ctx.commands.execute(agent, text, [], controller.signal);
735
+ if (execution !== undefined) {
736
+ await this.renderCommandResult(event, execution.result);
737
+ }
738
+ else {
739
+ agent.cancel({ kind: 'user' });
740
+ await this.sendCommandNotice(event, '已停止当前任务。');
741
+ }
742
+ }
743
+ catch {
744
+ agent.cancel({ kind: 'user' });
745
+ await this.sendCommandNotice(event, '已停止当前任务。');
746
+ }
747
+ }
748
+ else {
749
+ // Binding exists but no process-local live agent (cold/resumed
750
+ // elsewhere): nothing to cancel here; the barrier re-checks below.
751
+ await this.sendCommandNotice(event, '已停止当前任务。');
752
+ }
753
+ }
754
+ else {
755
+ // ③ No session: never create one for /stop (spec §39).
756
+ await this.sendCommandNotice(event, '当前没有可停止的任务。');
757
+ }
758
+ // ④ Stop barrier: after existing chain work converges, re-read the LATEST
759
+ // binding and cancel its agent (spec §9).
760
+ void this.enqueueConversation(key, async () => {
761
+ const latestBinding = await this.options.bindingStore.get(key);
762
+ if (!latestBinding)
763
+ return;
764
+ this.options.agentManager.getLiveAgent(latestBinding.sessionId)?.cancel({ kind: 'user' });
765
+ });
766
+ }
767
+ /** Generation helpers (spec §6). */
768
+ generationOf(key) {
769
+ return this.conversationGenerations.get(key) ?? 0;
770
+ }
771
+ bumpGeneration(key) {
772
+ const next = this.generationOf(key) + 1;
773
+ this.conversationGenerations.set(key, next);
774
+ return next;
775
+ }
776
+ isGenerationCurrent(key, generation) {
777
+ return this.generationOf(key) === generation;
778
+ }
779
+ /**
780
+ * Build the converter's per-message image hook: the official Harness
781
+ * attachment seam stays the sole authority for the durable image ref, and
782
+ * — best-effort — the same bytes are MIRRORED into the private channel
783
+ * asset store under the harness attachment id (the model-visible
784
+ * `sha256:…`), so `send_channel_message` can resolve the image for
785
+ * outbound sends (issue #7). A mirror failure never breaks delivery: the
786
+ * ref is already committed and the converter falls back exactly as before.
787
+ */
788
+ saveImageHook(event, sessionId) {
789
+ const commit = this.options.saveImage;
790
+ if (!commit)
791
+ return undefined;
792
+ const mirror = this.options.fileProvider?.storeImage?.bind(this.options.fileProvider);
793
+ if (!mirror)
794
+ return commit;
795
+ return async (input) => {
796
+ const ref = await commit(input);
797
+ try {
798
+ await mirror({
799
+ sessionId,
800
+ channelId: event.channel,
801
+ accountId: event.accountId,
802
+ conversationId: event.conversation.id,
803
+ ...(event.conversation.type ? { conversationType: event.conversation.type } : {}),
804
+ ...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
805
+ messageId: event.message.id,
806
+ }, {
807
+ attachmentId: ref.attachmentId,
808
+ data: input.data,
809
+ mimeType: input.mediaType,
810
+ ...(input.name === undefined ? {} : { name: input.name }),
811
+ });
812
+ }
813
+ catch (error) {
814
+ this.options.logger.warn('[channel-harness] inbound image mirror failed', {
815
+ sessionId,
816
+ attachmentId: ref.attachmentId,
817
+ error: toLoggableError(error),
818
+ });
819
+ }
820
+ return ref;
821
+ };
822
+ }
823
+ /**
824
+ * Build the converter's optional file/audio/video store hook. Absent
825
+ * `fileProvider` -> no hook -> the converter keeps `[file: name]`
826
+ * placeholders (unchanged fallback). The hook binds the current binding's
827
+ * session + event identity so a stored asset is correctly session-ACL'd.
828
+ */
829
+ fileStoreHook(event, sessionId) {
830
+ if (!this.options.fileProvider)
831
+ return undefined;
832
+ const provider = this.options.fileProvider;
833
+ return async (part) => {
834
+ const fields = this.attachmentLogFields(event, sessionId, part);
835
+ try {
836
+ const descriptor = await provider.store({
837
+ sessionId,
838
+ channelId: event.channel,
839
+ accountId: event.accountId,
840
+ conversationId: event.conversation.id,
841
+ ...(event.conversation.type ? { conversationType: event.conversation.type } : {}),
842
+ ...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
843
+ messageId: event.message.id,
844
+ }, part);
845
+ if (!descriptor) {
846
+ this.options.logger.warn('[channel-harness] inbound attachment was not stored', fields);
847
+ return undefined;
848
+ }
849
+ this.options.logger.info('[channel-harness] inbound attachment stored', {
850
+ ...fields,
851
+ attachmentId: descriptor.attachmentId,
852
+ bytes: descriptor.bytes,
853
+ readable: descriptor.readable,
854
+ });
855
+ return descriptor;
856
+ }
857
+ catch (error) {
858
+ this.options.logger.warn('[channel-harness] inbound attachment storage failed', {
859
+ ...fields,
860
+ error: toLoggableError(error),
861
+ });
862
+ return undefined;
863
+ }
864
+ };
865
+ }
866
+ /** Log the adapter-to-asset-store boundary once for every binary inbound part. */
867
+ logInboundBinaryAvailability(event, sessionId) {
868
+ for (const part of event.message.content) {
869
+ if (part.type !== 'file' && part.type !== 'audio' && part.type !== 'video')
870
+ continue;
871
+ if (part.localData?.byteLength)
872
+ continue;
873
+ this.options.logger.warn('[channel-harness] inbound attachment has no local bytes', {
874
+ ...this.attachmentLogFields(event, sessionId, part),
875
+ hasUrl: Boolean(part.url),
876
+ reason: 'adapter did not provide downloaded bytes',
877
+ });
878
+ }
879
+ }
880
+ attachmentLogFields(event, sessionId, part) {
881
+ return {
882
+ channel: event.channel,
883
+ accountId: event.accountId,
884
+ conversationId: event.conversation.id,
885
+ sessionId,
886
+ messageId: event.message.id,
887
+ kind: part.type,
888
+ name: part.type === 'file' ? part.name : undefined,
889
+ mimeType: part.mimeType,
890
+ localBytes: part.localData?.byteLength,
891
+ };
892
+ }
893
+ /**
894
+ * The `commandDeps.startNewSession` implementation. Resolves the
895
+ * conversation from the CURRENT binding of the invoking agent (the session id
896
+ * IS the agent id), then asks the Session factory to mint a NEW session id
897
+ * (never a copy of the old one). The factory also attaches the new session to
898
+ * the same channel Workspace and registers its binding. The OLD agent is NOT
899
+ * disposed here; the bridge's post-command retire handles that. If the
900
+ * factory throws, the old binding stays untouched.
901
+ */
902
+ async startNewSession(agent) {
903
+ const sessionId = String(agent.id);
904
+ const oldBinding = this.options.agentManager.bindingFor(sessionId);
905
+ if (!oldBinding) {
906
+ throw new Error("startNewSession: no binding for session '" + sessionId + "'");
907
+ }
908
+ // Re-resolve through the current routing rules before creating the Session,
909
+ // so /new follows today's overrides rather than the old binding snapshot.
910
+ const route = this.options.agentRouter.resolve({
911
+ channelId: oldBinding.channelId,
912
+ accountId: oldBinding.accountId,
913
+ conversationId: oldBinding.conversationId,
914
+ });
915
+ await this.sessionFactory.create({
916
+ channelId: oldBinding.channelId,
917
+ accountId: oldBinding.accountId,
918
+ conversationId: oldBinding.conversationId,
919
+ conversationType: oldBinding.conversationType,
920
+ ...(oldBinding.senderId ? { senderId: oldBinding.senderId } : {}),
921
+ ...(oldBinding.threadId ? { threadId: oldBinding.threadId } : {}),
922
+ }, route);
923
+ }
924
+ /**
925
+ * Deliver a command-plane notice directly through the channel adapter — never
926
+ * through ReplyRouter and never as an assistant/model message.
927
+ */
928
+ async sendCommandNotice(event, text) {
929
+ const adapter = this.options.getAdapter(event.channel);
930
+ if (!adapter) {
931
+ this.options.logger.warn(`[channel-harness] no adapter for channel '${event.channel}' — could not deliver command notice`);
932
+ return;
933
+ }
934
+ await adapter.send(this.targetForEvent(event), { text });
935
+ }
936
+ /** Render a settled CommandResult to the channel (success/error text). */
937
+ async renderCommandResult(event, result) {
938
+ if (result.kind === 'error') {
939
+ await this.sendCommandNotice(event, result.text);
940
+ return;
941
+ }
942
+ if (result.text) {
943
+ await this.sendCommandNotice(event, result.text);
944
+ }
945
+ }
946
+ /** Build the outbound ChannelTarget from the inbound conversation + message. */
947
+ targetForEvent(event) {
948
+ const target = {
949
+ channelId: event.channel,
950
+ accountId: event.accountId,
951
+ conversationId: event.conversation.id,
952
+ conversationType: event.conversation.type,
953
+ raw: event.raw,
954
+ ...(event.conversation.threadId
955
+ ? { threadId: event.conversation.threadId, replyToMessageId: event.message.id }
956
+ : { replyToMessageId: event.message.id }),
957
+ };
958
+ return target;
959
+ }
960
+ }
961
+ //# sourceMappingURL=bridge.js.map