agent-shell-chat 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/dist/bin/agent-shell.d.ts +15 -0
  4. package/dist/bin/agent-shell.d.ts.map +1 -0
  5. package/dist/bin/agent-shell.js +816 -0
  6. package/dist/bin/agent-shell.js.map +1 -0
  7. package/dist/package.json +54 -0
  8. package/dist/src/acp/agent-manager.d.ts +22 -0
  9. package/dist/src/acp/agent-manager.d.ts.map +1 -0
  10. package/dist/src/acp/agent-manager.js +79 -0
  11. package/dist/src/acp/agent-manager.js.map +1 -0
  12. package/dist/src/acp/client.d.ts +64 -0
  13. package/dist/src/acp/client.d.ts.map +1 -0
  14. package/dist/src/acp/client.js +265 -0
  15. package/dist/src/acp/client.js.map +1 -0
  16. package/dist/src/acp/session.d.ts +81 -0
  17. package/dist/src/acp/session.d.ts.map +1 -0
  18. package/dist/src/acp/session.js +339 -0
  19. package/dist/src/acp/session.js.map +1 -0
  20. package/dist/src/adapter/inbound.d.ts +39 -0
  21. package/dist/src/adapter/inbound.d.ts.map +1 -0
  22. package/dist/src/adapter/inbound.js +264 -0
  23. package/dist/src/adapter/inbound.js.map +1 -0
  24. package/dist/src/bridge.d.ts +115 -0
  25. package/dist/src/bridge.d.ts.map +1 -0
  26. package/dist/src/bridge.js +969 -0
  27. package/dist/src/bridge.js.map +1 -0
  28. package/dist/src/config.d.ts +155 -0
  29. package/dist/src/config.d.ts.map +1 -0
  30. package/dist/src/config.js +265 -0
  31. package/dist/src/config.js.map +1 -0
  32. package/dist/src/index.d.ts +9 -0
  33. package/dist/src/index.d.ts.map +1 -0
  34. package/dist/src/index.js +7 -0
  35. package/dist/src/index.js.map +1 -0
  36. package/dist/src/inject/monitor.d.ts +24 -0
  37. package/dist/src/inject/monitor.d.ts.map +1 -0
  38. package/dist/src/inject/monitor.js +149 -0
  39. package/dist/src/inject/monitor.js.map +1 -0
  40. package/dist/src/inject/queue.d.ts +13 -0
  41. package/dist/src/inject/queue.d.ts.map +1 -0
  42. package/dist/src/inject/queue.js +35 -0
  43. package/dist/src/inject/queue.js.map +1 -0
  44. package/dist/src/inject/types.d.ts +10 -0
  45. package/dist/src/inject/types.d.ts.map +1 -0
  46. package/dist/src/inject/types.js +2 -0
  47. package/dist/src/inject/types.js.map +1 -0
  48. package/dist/src/storage/accounts.d.ts +43 -0
  49. package/dist/src/storage/accounts.d.ts.map +1 -0
  50. package/dist/src/storage/accounts.js +289 -0
  51. package/dist/src/storage/accounts.js.map +1 -0
  52. package/dist/src/storage/runtime.d.ts +23 -0
  53. package/dist/src/storage/runtime.d.ts.map +1 -0
  54. package/dist/src/storage/runtime.js +104 -0
  55. package/dist/src/storage/runtime.js.map +1 -0
  56. package/dist/src/storage/state.d.ts +17 -0
  57. package/dist/src/storage/state.d.ts.map +1 -0
  58. package/dist/src/storage/state.js +78 -0
  59. package/dist/src/storage/state.js.map +1 -0
  60. package/dist/src/telemetry/index.d.ts +33 -0
  61. package/dist/src/telemetry/index.d.ts.map +1 -0
  62. package/dist/src/telemetry/index.js +167 -0
  63. package/dist/src/telemetry/index.js.map +1 -0
  64. package/dist/src/weixin/api.d.ts +50 -0
  65. package/dist/src/weixin/api.d.ts.map +1 -0
  66. package/dist/src/weixin/api.js +90 -0
  67. package/dist/src/weixin/api.js.map +1 -0
  68. package/dist/src/weixin/auth.d.ts +26 -0
  69. package/dist/src/weixin/auth.d.ts.map +1 -0
  70. package/dist/src/weixin/auth.js +103 -0
  71. package/dist/src/weixin/auth.js.map +1 -0
  72. package/dist/src/weixin/media.d.ts +24 -0
  73. package/dist/src/weixin/media.d.ts.map +1 -0
  74. package/dist/src/weixin/media.js +64 -0
  75. package/dist/src/weixin/media.js.map +1 -0
  76. package/dist/src/weixin/monitor.d.ts +16 -0
  77. package/dist/src/weixin/monitor.d.ts.map +1 -0
  78. package/dist/src/weixin/monitor.js +113 -0
  79. package/dist/src/weixin/monitor.js.map +1 -0
  80. package/dist/src/weixin/send.d.ts +28 -0
  81. package/dist/src/weixin/send.d.ts.map +1 -0
  82. package/dist/src/weixin/send.js +162 -0
  83. package/dist/src/weixin/send.js.map +1 -0
  84. package/dist/src/weixin/types.d.ts +149 -0
  85. package/dist/src/weixin/types.d.ts.map +1 -0
  86. package/dist/src/weixin/types.js +33 -0
  87. package/dist/src/weixin/types.js.map +1 -0
  88. package/package.json +54 -0
@@ -0,0 +1,969 @@
1
+ /**
2
+ * AgentShellBridge β€” the main orchestrator.
3
+ *
4
+ * Connects WeChat's iLink long-poll to ACP agent subprocesses.
5
+ * One bridge = one WeChat bot account β†’ many users β†’ many agent sessions.
6
+ */
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { login, loadToken } from "./weixin/auth.js";
11
+ import { startMonitor } from "./weixin/monitor.js";
12
+ import { sendImageMessage, sendFileMessage, sendTextMessage, splitText } from "./weixin/send.js";
13
+ import { sendTyping, getConfig } from "./weixin/api.js";
14
+ import { TypingStatus, MessageType } from "./weixin/types.js";
15
+ import { SessionManager } from "./acp/session.js";
16
+ import { weixinMessageToPrompt } from "./adapter/inbound.js";
17
+ import { BRIDGE_COMMANDS, resolveCommandAliases, resolveCommandNames } from "./config.js";
18
+ import { InjectionMonitor } from "./inject/monitor.js";
19
+ import { resolveUserTarget, updateLastActiveUser } from "./storage/state.js";
20
+ import { trackEvent, trackException, hashUserId } from "./telemetry/index.js";
21
+ const ACP_CONFIG_COMMAND = BRIDGE_COMMANDS.acpConfig;
22
+ const ACP_CANCEL_COMMAND = BRIDGE_COMMANDS.acpCancel;
23
+ const BUFFER_START_COMMAND = BRIDGE_COMMANDS.promptStart;
24
+ const BUFFER_DONE_COMMAND = BRIDGE_COMMANDS.promptDone;
25
+ const TEXT_CHUNK_LIMIT = 4000;
26
+ const BUFFER_TTL_MS = 10 * 60 * 1000; // 10 minutes
27
+ const BUFFER_MAX_BLOCKS = 50;
28
+ const SEGMENT_SEND_MAX_ATTEMPTS = 3;
29
+ const SEGMENT_SEND_RETRY_BASE_MS = 300;
30
+ /**
31
+ * Minimum spacing between two consecutive outbound text messages to the
32
+ * same user. Each reply segment is an independent iLink API call with no
33
+ * ordering hint, and WeChat appears to order back-to-back bot messages by
34
+ * server-receive time. Without spacing, near-simultaneous sends can race
35
+ * and be delivered to the user out of order (see issue #38). A short delay
36
+ * separates their server-side timestamps and preserves order.
37
+ */
38
+ const REPLY_SEND_SPACING_MS = 150;
39
+ export class AgentShellBridge {
40
+ config;
41
+ abortController = new AbortController();
42
+ sessionManager = null;
43
+ injectionMonitor = null;
44
+ tokenData = null;
45
+ stateUpdate = Promise.resolve();
46
+ // Per-user typing ticket cache
47
+ typingTickets = new Map();
48
+ // Timestamp (ms) at which the last text message was issued to each user,
49
+ // used to pace consecutive sends so they don't race and arrive reordered.
50
+ lastSendAt = new Map();
51
+ // Per-user promise chain serializing replies so concurrent sendReply calls
52
+ // (e.g. a command reply racing an active session flush) cannot interleave
53
+ // their segments and arrive out of order (issue #38).
54
+ sendChains = new Map();
55
+ // Per-user message buffer for /acp-prompt-start.../acp-prompt-done multi-part compose
56
+ messageBuffers = new Map();
57
+ // Per-user expiry timers for buffer cleanup
58
+ bufferTimers = new Map();
59
+ // Users currently flushing their buffer (between /done and enqueue).
60
+ // Maps userId to a promise that resolves when the flush completes, so
61
+ // messages arriving during the flush wait for the buffered prompt to
62
+ // enqueue first, preserving turn order.
63
+ bufferFlushing = new Map();
64
+ log;
65
+ constructor(config, log) {
66
+ this.config = config;
67
+ this.log = log ?? ((msg) => console.log(`[agent-shell] ${msg}`));
68
+ }
69
+ async start(opts) {
70
+ const { forceLogin, renderQrUrl } = opts ?? {};
71
+ // 1. Login or load token
72
+ if (!forceLogin) {
73
+ this.tokenData = loadToken(this.config.storage.dir);
74
+ if (this.tokenData) {
75
+ trackEvent("token.reused");
76
+ }
77
+ }
78
+ if (!this.tokenData) {
79
+ const loginStart = Date.now();
80
+ try {
81
+ this.tokenData = await login({
82
+ baseUrl: this.config.wechat.baseUrl,
83
+ botType: this.config.wechat.botType,
84
+ storageDir: this.config.storage.dir,
85
+ log: this.log,
86
+ renderQrUrl,
87
+ });
88
+ trackEvent("login.success", {
89
+ forced: !!forceLogin,
90
+ durationMs: Date.now() - loginStart,
91
+ });
92
+ }
93
+ catch (err) {
94
+ trackException(err, "auth");
95
+ trackEvent("login.failure", {
96
+ forced: !!forceLogin,
97
+ durationMs: Date.now() - loginStart,
98
+ errorType: err instanceof Error ? err.name : "Unknown",
99
+ });
100
+ throw err;
101
+ }
102
+ }
103
+ else {
104
+ this.log(`Loaded saved token (Bot: ${this.tokenData.accountId}, saved at ${this.tokenData.savedAt})`);
105
+ this.log(`Use --login to force re-login`);
106
+ }
107
+ // 2. Create SessionManager
108
+ this.sessionManager = new SessionManager({
109
+ agentCommand: this.config.agent.command,
110
+ agentArgs: this.config.agent.args,
111
+ agentCwd: this.config.agent.cwd,
112
+ agentEnv: this.config.agent.env,
113
+ agentPreset: this.config.agent.preset ?? "raw",
114
+ agentSystemPrompt: this.config.agent.systemPrompt,
115
+ idleTimeoutMs: this.config.session.idleTimeoutMs,
116
+ maxConcurrentUsers: this.config.session.maxConcurrentUsers,
117
+ showThoughts: this.config.agent.showThoughts,
118
+ showDiffs: this.config.agent.showDiffs ?? false,
119
+ log: this.log,
120
+ onReply: (userId, contextToken, text) => this.sendReply(userId, contextToken, text),
121
+ sendTyping: (userId, contextToken) => this.sendTypingIndicator(userId, contextToken),
122
+ });
123
+ this.sessionManager.start();
124
+ if (this.config.storage.injectDir && this.config.storage.stateFile) {
125
+ this.injectionMonitor = new InjectionMonitor({
126
+ injectDir: this.config.storage.injectDir,
127
+ log: this.log,
128
+ onMessage: (job) => this.enqueueInjectedMessage(job),
129
+ });
130
+ await this.injectionMonitor.start();
131
+ this.log(`Injection queue: ${this.config.storage.injectDir}`);
132
+ }
133
+ // 3. Start monitor loop
134
+ this.log("Starting message polling...");
135
+ await startMonitor({
136
+ baseUrl: this.tokenData.baseUrl,
137
+ token: this.tokenData.token,
138
+ storageDir: this.config.storage.dir,
139
+ abortSignal: this.abortController.signal,
140
+ log: this.log,
141
+ onMessage: (msg) => this.handleMessage(msg),
142
+ });
143
+ }
144
+ async stop() {
145
+ this.log("Stopping bridge...");
146
+ this.abortController.abort();
147
+ await this.injectionMonitor?.stop();
148
+ await this.sessionManager?.stop();
149
+ await this.stateUpdate.catch((err) => {
150
+ this.log(`Failed to flush state before stop: ${String(err)}`);
151
+ trackException(sanitizeStateError(err), "state");
152
+ });
153
+ this.log("Bridge stopped");
154
+ }
155
+ handleMessage(msg) {
156
+ // Only process user messages (not bot's own messages)
157
+ if (msg.message_type !== MessageType.USER)
158
+ return;
159
+ // Skip group messages (v1: direct only)
160
+ if (msg.group_id)
161
+ return;
162
+ const userId = msg.from_user_id;
163
+ const contextToken = msg.context_token;
164
+ if (!userId || !contextToken)
165
+ return;
166
+ this.log(`Message from ${userId}: ${this.previewMessage(msg)}`);
167
+ this.rememberActiveUser(userId, contextToken);
168
+ trackEvent("message.received", {
169
+ userIdHash: hashUserId(userId),
170
+ kind: this.messageKind(msg),
171
+ }, hashUserId(userId));
172
+ const acpConfigCommand = this.extractAcpConfigCommand(msg);
173
+ if (acpConfigCommand) {
174
+ this.handleAcpConfigCommand(acpConfigCommand, userId, contextToken).catch((err) => {
175
+ this.log(`Failed to handle ACP config command from ${userId}: ${String(err)}`);
176
+ trackException(err, "command", hashUserId(userId));
177
+ });
178
+ return;
179
+ }
180
+ const acpCancelCommand = this.extractAcpCancelCommand(msg);
181
+ if (acpCancelCommand) {
182
+ this.handleAcpCancelCommand(acpCancelCommand, userId, contextToken).catch((err) => {
183
+ this.log(`Failed to handle ACP cancel command from ${userId}: ${String(err)}`);
184
+ trackException(err, "command", hashUserId(userId));
185
+ });
186
+ return;
187
+ }
188
+ // /acp-prompt-start β€” enter buffering mode
189
+ if (this.isBufferStartCommand(msg)) {
190
+ this.handleBufferStart(userId, contextToken);
191
+ return;
192
+ }
193
+ // /acp-prompt-done β€” flush buffer and send to agent
194
+ if (this.isBufferDoneCommand(msg)) {
195
+ this.handleBufferDone(userId, contextToken).catch((err) => {
196
+ this.log(`Failed to flush message buffer for ${userId}: ${String(err)}`);
197
+ trackException(err, "buffer", hashUserId(userId));
198
+ });
199
+ return;
200
+ }
201
+ // If user is in buffering mode, append to buffer instead of enqueuing
202
+ if (this.messageBuffers.has(userId)) {
203
+ this.appendToBuffer(msg, userId, contextToken);
204
+ return;
205
+ }
206
+ // Convert and enqueue β€” fire-and-forget (don't block the poll loop)
207
+ const waitForFlush = this.bufferFlushing.get(userId);
208
+ const enqueue = waitForFlush
209
+ ? waitForFlush.then(() => this.enqueueMessage(msg, userId, contextToken))
210
+ : this.enqueueMessage(msg, userId, contextToken);
211
+ enqueue.catch((err) => {
212
+ this.log(`Failed to enqueue message from ${userId}: ${String(err)}`);
213
+ trackException(err, "enqueue", hashUserId(userId));
214
+ });
215
+ }
216
+ async enqueueMessage(msg, userId, contextToken) {
217
+ const prompt = await weixinMessageToPrompt(msg, this.config.wechat.cdnBaseUrl, this.log, this.config.storage.inboxDir);
218
+ await this.sessionManager.enqueue(userId, { prompt, contextToken });
219
+ }
220
+ async enqueueInjectedMessage(job) {
221
+ if (!this.sessionManager || !this.config.storage.stateFile) {
222
+ throw new Error("Bridge is not ready to process injected messages");
223
+ }
224
+ const target = await resolveUserTarget(this.config.storage.stateFile, job.target, job.contextToken);
225
+ const prompt = [{ type: "text", text: job.text }];
226
+ this.log(`[inject] enqueue ${job.id} for ${target.userId}`);
227
+ trackEvent("message.injected", {
228
+ userIdHash: hashUserId(target.userId),
229
+ targetKind: job.target === "last-active-user" ? "last-active-user" : "explicit",
230
+ }, hashUserId(target.userId));
231
+ await this.sessionManager.enqueueAndWait(target.userId, {
232
+ prompt,
233
+ contextToken: target.contextToken,
234
+ });
235
+ }
236
+ async handleAcpConfigCommand(command, userId, contextToken) {
237
+ const args = command.trim().split(/\s+/);
238
+ if (args.length === 1) {
239
+ const configOptions = this.sessionManager?.getSessionConfigOptions(userId);
240
+ trackEvent("command.acp_config.view", {
241
+ userIdHash: hashUserId(userId),
242
+ hasSession: !!configOptions,
243
+ optionCount: configOptions?.length ?? 0,
244
+ }, hashUserId(userId));
245
+ await this.sendReply(userId, contextToken, this.formatAcpConfigList(userId));
246
+ return;
247
+ }
248
+ if (args[1] === "set") {
249
+ if (args.length < 4) {
250
+ await this.sendReply(userId, contextToken, this.formatAcpConfigUsage("Missing configId or value."));
251
+ return;
252
+ }
253
+ const configId = args[2];
254
+ const rawValue = args.slice(3).join(" ");
255
+ try {
256
+ const resolved = this.resolveAcpConfigValue(userId, configId, rawValue);
257
+ await this.sessionManager.setSessionConfigOption(userId, configId, resolved.rawValue);
258
+ const optionType = this.sessionManager
259
+ .getSessionConfigOptions(userId)
260
+ ?.find((o) => o.id === configId)?.type;
261
+ trackEvent("command.acp_config.set", {
262
+ userIdHash: hashUserId(userId),
263
+ configId,
264
+ optionType: optionType ?? "unknown",
265
+ optionValue: resolved.displayValue,
266
+ }, hashUserId(userId));
267
+ await this.sendReply(userId, contextToken, `βœ… Updated ACP config: ${configId} = ${resolved.displayValue}\n\n${this.formatAcpConfigList(userId)}`);
268
+ }
269
+ catch (err) {
270
+ await this.sendReply(userId, contextToken, this.formatAcpConfigUsage(err instanceof Error ? err.message : String(err)));
271
+ }
272
+ return;
273
+ }
274
+ await this.sendReply(userId, contextToken, this.formatAcpConfigUsage(`Unknown subcommand: ${args[1]}`));
275
+ }
276
+ async handleAcpCancelCommand(command, userId, contextToken) {
277
+ const args = command.trim().split(/\s+/);
278
+ const sub = args[1]?.toLowerCase();
279
+ if (sub && sub !== "all") {
280
+ await this.sendReply(userId, contextToken, this.formatAcpCancelUsage(`Unknown subcommand: ${args[1]}`));
281
+ return;
282
+ }
283
+ if (!this.sessionManager) {
284
+ await this.sendReply(userId, contextToken, this.formatAcpCancelUsage("Bridge is not ready yet."));
285
+ return;
286
+ }
287
+ const drainQueue = sub === "all";
288
+ const result = await this.sessionManager.cancelCurrent(userId, { drainQueue });
289
+ trackEvent("command.acp_cancel", {
290
+ userIdHash: hashUserId(userId),
291
+ drainQueue,
292
+ cancelledTurn: result.cancelledTurn,
293
+ droppedQueueCount: result.droppedQueueCount,
294
+ }, hashUserId(userId));
295
+ await this.sendReply(userId, contextToken, this.formatAcpCancelResult(result, drainQueue));
296
+ }
297
+ formatAcpCancelResult(result, drainQueue) {
298
+ const lines = [];
299
+ if (result.cancelledTurn) {
300
+ lines.push("πŸ›‘ Cancel signal sent. The current ACP turn will stop shortly.");
301
+ }
302
+ else {
303
+ lines.push("ℹ️ No active ACP turn to cancel.");
304
+ }
305
+ if (drainQueue && result.droppedQueueCount > 0) {
306
+ lines.push(`Dropped ${result.droppedQueueCount} queued message(s).`);
307
+ }
308
+ lines.push("");
309
+ lines.push("πŸ’‘ **Usage**");
310
+ lines.push(` β€’ Cancel current turn: ${ACP_CANCEL_COMMAND}${this.aliasHint(ACP_CANCEL_COMMAND)}`);
311
+ lines.push(` β€’ Cancel + drop queued msgs: ${ACP_CANCEL_COMMAND} all`);
312
+ return lines.join("\n");
313
+ }
314
+ formatAcpCancelUsage(error) {
315
+ const lines = [];
316
+ if (error) {
317
+ lines.push(`⚠️ ${error}`);
318
+ lines.push("");
319
+ }
320
+ lines.push("πŸ’‘ **Usage**");
321
+ lines.push(` β€’ Cancel current turn: ${ACP_CANCEL_COMMAND}${this.aliasHint(ACP_CANCEL_COMMAND)}`);
322
+ lines.push(` β€’ Cancel + drop queued msgs: ${ACP_CANCEL_COMMAND} all`);
323
+ return lines.join("\n");
324
+ }
325
+ isBufferStartCommand(msg) {
326
+ return this.extractBridgeCommand(msg, BUFFER_START_COMMAND) !== null;
327
+ }
328
+ isBufferDoneCommand(msg) {
329
+ return this.extractBridgeCommand(msg, BUFFER_DONE_COMMAND) !== null;
330
+ }
331
+ handleBufferStart(userId, contextToken) {
332
+ if (this.messageBuffers.has(userId)) {
333
+ const buffer = this.messageBuffers.get(userId);
334
+ this.sendReply(userId, contextToken, `πŸ“ Already in buffering mode (${buffer.blocks.length} block(s) collected). Keep sending, then ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)} to submit.`).catch((err) => {
335
+ this.log(`Failed to send buffer active notice to ${userId}: ${String(err)}`);
336
+ });
337
+ return;
338
+ }
339
+ this.messageBuffers.set(userId, { blocks: [], contextToken, pending: Promise.resolve(), lastUpdatedAt: Date.now() });
340
+ this.resetBufferTimer(userId);
341
+ this.log(`Buffer started for ${userId}`);
342
+ trackEvent("command.buffer_start", { userIdHash: hashUserId(userId) }, hashUserId(userId));
343
+ this.sendReply(userId, contextToken, `πŸ“ Buffering mode started. Send your messages (text, images, files), then send ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)} to submit them all at once.`).catch((err) => {
344
+ this.log(`Failed to send buffer start confirmation to ${userId}: ${String(err)}`);
345
+ });
346
+ }
347
+ handleBufferDone(userId, contextToken) {
348
+ const buffer = this.messageBuffers.get(userId);
349
+ if (!buffer) {
350
+ return this.sendReply(userId, contextToken, `⚠️ Nothing buffered. Send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} first, then send messages before ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)}.`);
351
+ }
352
+ // Remove from map immediately so new messages during the await
353
+ // are not appended to a stale buffer.
354
+ const pending = buffer.pending;
355
+ this.messageBuffers.delete(userId);
356
+ this.clearBufferTimer(userId);
357
+ // Register a flushing promise so messages arriving during the await
358
+ // queue behind the buffered prompt, preserving turn order.
359
+ const flushPromise = this.doFlush(userId, contextToken, buffer, pending);
360
+ this.bufferFlushing.set(userId, flushPromise);
361
+ flushPromise.finally(() => {
362
+ // Only clear if this is still our flush (not a newer one)
363
+ if (this.bufferFlushing.get(userId) === flushPromise) {
364
+ this.bufferFlushing.delete(userId);
365
+ }
366
+ });
367
+ return flushPromise;
368
+ }
369
+ async doFlush(userId, contextToken, buffer, pending) {
370
+ // Wait for any in-flight appends to finish before reading
371
+ try {
372
+ await pending;
373
+ }
374
+ catch {
375
+ // A prior append failed (e.g. image download error). The chain
376
+ // already logged/tracked the error. Clear the buffer so the user
377
+ // can start fresh.
378
+ await this.sendReply(userId, contextToken, `⚠️ A buffered message failed to process. Buffer cleared. Please send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} to try again.`);
379
+ return;
380
+ }
381
+ // Check expiry
382
+ if (Date.now() - buffer.lastUpdatedAt > BUFFER_TTL_MS) {
383
+ await this.sendReply(userId, contextToken, `⚠️ Buffer expired (10 min without activity). Please send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} to start over.`);
384
+ return;
385
+ }
386
+ if (buffer.blocks.length === 0) {
387
+ await this.sendReply(userId, contextToken, `⚠️ Buffer is empty. Send some messages before ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)}.`);
388
+ return;
389
+ }
390
+ this.log(`Buffer flushed for ${userId}: ${buffer.blocks.length} block(s)`);
391
+ trackEvent("command.buffer_done", {
392
+ userIdHash: hashUserId(userId),
393
+ blockCount: buffer.blocks.length,
394
+ }, hashUserId(userId));
395
+ await this.sessionManager.enqueue(userId, {
396
+ prompt: buffer.blocks,
397
+ contextToken: buffer.contextToken,
398
+ });
399
+ }
400
+ appendToBuffer(msg, userId, contextToken) {
401
+ const buffer = this.messageBuffers.get(userId);
402
+ if (!buffer)
403
+ return;
404
+ // Chain the async conversion so /acp-prompt-done waits for all in-flight appends
405
+ buffer.pending = buffer.pending
406
+ .then(async () => {
407
+ // Re-check buffer still exists (could have been flushed or expired)
408
+ if (!this.messageBuffers.has(userId))
409
+ return;
410
+ // Check TTL
411
+ if (Date.now() - buffer.lastUpdatedAt > BUFFER_TTL_MS) {
412
+ this.messageBuffers.delete(userId);
413
+ this.log(`Buffer expired for ${userId}`);
414
+ await this.sendReply(userId, contextToken, `⚠️ Buffering timed out (10 min without activity). Please send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} again.`);
415
+ return;
416
+ }
417
+ // Check block limit
418
+ if (buffer.blocks.length >= BUFFER_MAX_BLOCKS) {
419
+ await this.sendReply(userId, contextToken, `⚠️ Buffer is full (${BUFFER_MAX_BLOCKS} blocks max). Send ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)} to submit what you have.`);
420
+ return;
421
+ }
422
+ const prompt = await weixinMessageToPrompt(msg, this.config.wechat.cdnBaseUrl, this.log, this.config.storage.inboxDir);
423
+ buffer.blocks.push(...prompt);
424
+ buffer.contextToken = contextToken;
425
+ buffer.lastUpdatedAt = Date.now();
426
+ this.resetBufferTimer(userId);
427
+ this.log(`Buffered message from ${userId}, now ${buffer.blocks.length} block(s)`);
428
+ });
429
+ buffer.pending.catch((err) => {
430
+ this.log(`Failed to buffer message from ${userId}: ${String(err)}`);
431
+ trackException(err, "buffer", hashUserId(userId));
432
+ });
433
+ }
434
+ resetBufferTimer(userId) {
435
+ this.clearBufferTimer(userId);
436
+ this.bufferTimers.set(userId, setTimeout(() => {
437
+ const buffer = this.messageBuffers.get(userId);
438
+ if (!buffer)
439
+ return;
440
+ this.messageBuffers.delete(userId);
441
+ this.bufferTimers.delete(userId);
442
+ this.log(`Buffer expired (timer) for ${userId}`);
443
+ }, BUFFER_TTL_MS));
444
+ }
445
+ clearBufferTimer(userId) {
446
+ const timer = this.bufferTimers.get(userId);
447
+ if (timer) {
448
+ clearTimeout(timer);
449
+ this.bufferTimers.delete(userId);
450
+ }
451
+ }
452
+ rememberActiveUser(userId, contextToken) {
453
+ if (!this.config.storage.stateFile)
454
+ return;
455
+ this.stateUpdate = this.stateUpdate
456
+ .catch(() => { })
457
+ .then(() => updateLastActiveUser(this.config.storage.stateFile, userId, contextToken));
458
+ this.stateUpdate.catch((err) => {
459
+ this.log(`Failed to persist last active user: ${String(err)}`);
460
+ trackException(sanitizeStateError(err), "state", hashUserId(userId));
461
+ });
462
+ }
463
+ async sendReply(userId, contextToken, text) {
464
+ // Serialize all replies to the same user behind a per-user promise chain so
465
+ // that segments from separate sendReply calls cannot interleave (issue #38).
466
+ // The stored link swallows errors so one failed reply doesn't break the
467
+ // chain for the next caller, while the returned promise still propagates.
468
+ const previous = this.sendChains.get(userId) ?? Promise.resolve();
469
+ const current = previous
470
+ .catch(() => { })
471
+ .then(() => this.deliverReply(userId, contextToken, text));
472
+ this.sendChains.set(userId, current.catch(() => { }));
473
+ return current;
474
+ }
475
+ async deliverReply(userId, contextToken, text) {
476
+ const segments = splitText(text, TEXT_CHUNK_LIMIT);
477
+ const startedAt = Date.now();
478
+ let segmentsSent = 0;
479
+ let anyFailed = false;
480
+ for (const segment of segments) {
481
+ // Generate one stable idempotency key per segment *before* the retry
482
+ // loop so that all attempts for the same segment reuse the same
483
+ // client_id. The iLink gateway de-duplicates by client_id, so a retry
484
+ // after a transient hard error (connection reset, 5xx) will not produce
485
+ // a duplicate message even if the first attempt was already received.
486
+ const segmentClientId = `agent-shell-${crypto.randomUUID()}`;
487
+ let sent = false;
488
+ for (let attempt = 1; attempt <= SEGMENT_SEND_MAX_ATTEMPTS; attempt++) {
489
+ try {
490
+ await this.paceConsecutiveSend(userId);
491
+ await sendTextMessage(userId, segment, {
492
+ baseUrl: this.tokenData.baseUrl,
493
+ token: this.tokenData.token,
494
+ contextToken,
495
+ }, segmentClientId);
496
+ sent = true;
497
+ break;
498
+ }
499
+ catch (err) {
500
+ trackException(err, "reply.segment", hashUserId(userId));
501
+ if (attempt < SEGMENT_SEND_MAX_ATTEMPTS) {
502
+ await new Promise((r) => setTimeout(r, SEGMENT_SEND_RETRY_BASE_MS * attempt));
503
+ }
504
+ }
505
+ }
506
+ if (sent) {
507
+ segmentsSent++;
508
+ }
509
+ else {
510
+ // Log the drop but continue β€” a single failed segment must not
511
+ // prevent the remaining segments from being delivered.
512
+ anyFailed = true;
513
+ }
514
+ }
515
+ if (anyFailed) {
516
+ trackException(new Error(`deliverReply: ${segments.length - segmentsSent}/${segments.length} segment(s) failed to send after retries`), "reply", hashUserId(userId));
517
+ }
518
+ // Auto-send media files referenced in reply text (e.g. markdown image links)
519
+ if (this.config.agent.cwd) {
520
+ await this.tryAutoSendMedia(userId, contextToken, text);
521
+ }
522
+ trackEvent("reply.sent", {
523
+ userIdHash: hashUserId(userId),
524
+ segments: segments.length,
525
+ segmentsSent,
526
+ chars: text.length,
527
+ durationMs: Date.now() - startedAt,
528
+ }, hashUserId(userId));
529
+ // Cancel typing indicator after reply is sent
530
+ this.cancelTypingIndicator(userId, contextToken).catch(() => { });
531
+ }
532
+ /**
533
+ * Wait, if necessary, so that consecutive text messages to the same user
534
+ * are issued at least {@link REPLY_SEND_SPACING_MS} apart. This spaces
535
+ * out their server-receive timestamps so WeChat preserves the order the
536
+ * bridge sent them in, instead of racing and delivering them reversed
537
+ * (issue #38). Sends to different users are tracked independently and do
538
+ * not delay each other.
539
+ */
540
+ async paceConsecutiveSend(userId) {
541
+ const last = this.lastSendAt.get(userId);
542
+ const now = Date.now();
543
+ if (last !== undefined) {
544
+ const wait = REPLY_SEND_SPACING_MS - (now - last);
545
+ if (wait > 0) {
546
+ await new Promise((resolve) => setTimeout(resolve, wait));
547
+ }
548
+ }
549
+ this.lastSendAt.set(userId, Date.now());
550
+ }
551
+ async cancelTypingIndicator(userId, contextToken) {
552
+ const ticket = await this.getTypingTicket(userId, contextToken);
553
+ if (!ticket)
554
+ return;
555
+ await sendTyping({
556
+ baseUrl: this.tokenData.baseUrl,
557
+ token: this.tokenData.token,
558
+ body: {
559
+ ilink_user_id: userId,
560
+ typing_ticket: ticket,
561
+ status: TypingStatus.CANCEL,
562
+ },
563
+ });
564
+ }
565
+ async sendTypingIndicator(userId, contextToken) {
566
+ try {
567
+ const ticket = await this.getTypingTicket(userId, contextToken);
568
+ if (!ticket)
569
+ return;
570
+ await sendTyping({
571
+ baseUrl: this.tokenData.baseUrl,
572
+ token: this.tokenData.token,
573
+ body: {
574
+ ilink_user_id: userId,
575
+ typing_ticket: ticket,
576
+ status: TypingStatus.TYPING,
577
+ },
578
+ });
579
+ }
580
+ catch {
581
+ // Typing is best-effort
582
+ }
583
+ }
584
+ async getTypingTicket(userId, contextToken) {
585
+ const cached = this.typingTickets.get(userId);
586
+ if (cached && cached.expiresAt > Date.now())
587
+ return cached.ticket;
588
+ try {
589
+ const resp = await getConfig({
590
+ baseUrl: this.tokenData.baseUrl,
591
+ token: this.tokenData.token,
592
+ ilinkUserId: userId,
593
+ contextToken,
594
+ });
595
+ if (resp.typing_ticket) {
596
+ this.typingTickets.set(userId, {
597
+ ticket: resp.typing_ticket,
598
+ expiresAt: Date.now() + 24 * 60 * 60_000, // 24h cache
599
+ });
600
+ return resp.typing_ticket;
601
+ }
602
+ }
603
+ catch {
604
+ // Not critical
605
+ }
606
+ return null;
607
+ }
608
+ previewMessage(msg) {
609
+ const items = msg.item_list ?? [];
610
+ for (const item of items) {
611
+ if (item.type === 1 && item.text_item?.text) {
612
+ const text = item.text_item.text;
613
+ return text.length > 50 ? text.substring(0, 50) + "..." : text;
614
+ }
615
+ if (item.type === 2)
616
+ return "[image]";
617
+ if (item.type === 3)
618
+ return item.voice_item?.text ? `[voice] ${item.voice_item.text.substring(0, 30)}` : "[voice]";
619
+ if (item.type === 4)
620
+ return `[file] ${item.file_item?.file_name ?? ""}`;
621
+ if (item.type === 5)
622
+ return "[video]";
623
+ }
624
+ return "[empty]";
625
+ }
626
+ messageKind(msg) {
627
+ const items = msg.item_list ?? [];
628
+ for (const item of items) {
629
+ if (item.type === 1)
630
+ return "text";
631
+ if (item.type === 2)
632
+ return "image";
633
+ if (item.type === 3)
634
+ return "voice";
635
+ if (item.type === 4)
636
+ return "file";
637
+ if (item.type === 5)
638
+ return "video";
639
+ }
640
+ return "empty";
641
+ }
642
+ extractAcpConfigCommand(msg) {
643
+ return this.extractBridgeCommand(msg, ACP_CONFIG_COMMAND);
644
+ }
645
+ extractAcpCancelCommand(msg) {
646
+ return this.extractBridgeCommand(msg, ACP_CANCEL_COMMAND);
647
+ }
648
+ extractBridgeCommand(msg, canonical) {
649
+ const items = msg.item_list ?? [];
650
+ if (items.length !== 1)
651
+ return null;
652
+ const item = items[0];
653
+ if (item?.type !== 1 || !item.text_item?.text)
654
+ return null;
655
+ const text = item.text_item.text.trim();
656
+ const names = resolveCommandNames(canonical, this.config.commandAliases);
657
+ for (const name of names) {
658
+ // Exact match β†’ normalize to the canonical command with no arguments.
659
+ // This is the only matching mode for bare-phrase aliases (no leading
660
+ // "/"), e.g. a voice-transcribed "ε–ζΆˆ", which must match the whole
661
+ // message to avoid false positives.
662
+ if (text === name)
663
+ return canonical;
664
+ // Slash-prefixed names (the canonical command and "/"-style aliases)
665
+ // also support trailing arguments. Replace the matched name with the
666
+ // canonical command so handlers always see a single, stable token.
667
+ if (name.startsWith("/") && text.startsWith(`${name} `)) {
668
+ return canonical + text.slice(name.length);
669
+ }
670
+ }
671
+ return null;
672
+ }
673
+ /**
674
+ * Render a usage hint suffix listing any configured aliases for a
675
+ * canonical command, e.g. " (aliases: /cancel, /ε–ζΆˆ)". Returns an
676
+ * empty string when no aliases are configured.
677
+ */
678
+ aliasHint(canonical) {
679
+ const aliases = resolveCommandAliases(canonical, this.config.commandAliases);
680
+ return aliases.length > 0 ? ` (aliases: ${aliases.join(", ")})` : "";
681
+ }
682
+ formatAcpConfigList(userId) {
683
+ const configOptions = this.sessionManager?.getSessionConfigOptions(userId);
684
+ if (!configOptions) {
685
+ return this.formatAcpConfigUsage("No active ACP session for this chat yet. Send a normal message first.");
686
+ }
687
+ if (configOptions.length === 0) {
688
+ return this.formatAcpConfigUsage("The current ACP agent does not expose any configurable session options.");
689
+ }
690
+ const lines = [];
691
+ lines.push("βš™οΈ **ACP Session Config**");
692
+ lines.push("━━━━━━━━━━━━━━━━");
693
+ for (const option of configOptions) {
694
+ lines.push("");
695
+ lines.push(`πŸ“Œ **${option.name}** (id: \`${option.id}\`)`);
696
+ lines.push(` β€’ Current: ${this.describeCurrentConfigValue(option)}`);
697
+ if (option.type === "select") {
698
+ lines.push(` β€’ Options: ${this.listConfigOptionChoices(option).join(" | ")}`);
699
+ }
700
+ else if (option.type === "boolean") {
701
+ lines.push(` β€’ Options: true | false`);
702
+ }
703
+ }
704
+ lines.push("");
705
+ lines.push("━━━━━━━━━━━━━━━━");
706
+ lines.push("πŸ’‘ **Usage**");
707
+ lines.push(` β€’ View: ${ACP_CONFIG_COMMAND}${this.aliasHint(ACP_CONFIG_COMMAND)}`);
708
+ lines.push(` β€’ Update: ${ACP_CONFIG_COMMAND} set <configId> <value>`);
709
+ return lines.join("\n");
710
+ }
711
+ formatAcpConfigUsage(error) {
712
+ const lines = [];
713
+ if (error) {
714
+ lines.push(`⚠️ ${error}`);
715
+ lines.push("");
716
+ }
717
+ lines.push("πŸ’‘ **Usage**");
718
+ lines.push(` β€’ View: ${ACP_CONFIG_COMMAND}${this.aliasHint(ACP_CONFIG_COMMAND)}`);
719
+ lines.push(` β€’ Update: ${ACP_CONFIG_COMMAND} set <configId> <value>`);
720
+ return lines.join("\n");
721
+ }
722
+ describeCurrentConfigValue(option) {
723
+ if (option.type === "boolean") {
724
+ return option.currentValue ? "true" : "false";
725
+ }
726
+ const current = this.findConfigOptionChoice(option, option.currentValue);
727
+ return current ? this.describeConfigChoice(current) : option.currentValue;
728
+ }
729
+ listConfigOptionChoices(option) {
730
+ if (option.type !== "select")
731
+ return [];
732
+ return this.flattenSelectOptions(option.options).map((choice) => this.describeConfigChoice(choice));
733
+ }
734
+ resolveAcpConfigValue(userId, configId, rawValue) {
735
+ const configOptions = this.sessionManager?.getSessionConfigOptions(userId);
736
+ if (!configOptions) {
737
+ throw new Error("No active ACP session for this chat yet. Send a normal message first.");
738
+ }
739
+ const option = configOptions.find((candidate) => candidate.id === configId);
740
+ if (!option) {
741
+ throw new Error(`Unknown ACP config option: ${configId}`);
742
+ }
743
+ if (option.type === "boolean") {
744
+ const normalized = rawValue.trim().toLowerCase();
745
+ if (["true", "on", "1", "yes"].includes(normalized)) {
746
+ return { rawValue: true, displayValue: "true" };
747
+ }
748
+ if (["false", "off", "0", "no"].includes(normalized)) {
749
+ return { rawValue: false, displayValue: "false" };
750
+ }
751
+ throw new Error(`Invalid boolean value for ${configId}: ${rawValue}`);
752
+ }
753
+ const candidates = this.flattenSelectOptions(option.options).filter((choice) => this.configChoiceAliases(choice).has(rawValue.trim().toLowerCase()));
754
+ if (candidates.length === 0) {
755
+ throw new Error(`Invalid value for ${configId}: ${rawValue}. Options: ${this.listConfigOptionChoices(option).join(", ")}`);
756
+ }
757
+ if (candidates.length > 1) {
758
+ throw new Error(`Ambiguous value for ${configId}: ${rawValue}`);
759
+ }
760
+ const match = candidates[0];
761
+ return {
762
+ rawValue: match.value,
763
+ displayValue: this.describeConfigChoice(match),
764
+ };
765
+ }
766
+ flattenSelectOptions(options) {
767
+ if (options.length === 0)
768
+ return [];
769
+ const first = options[0];
770
+ if (first && "value" in first) {
771
+ return options;
772
+ }
773
+ return options.flatMap((group) => group.options);
774
+ }
775
+ findConfigOptionChoice(option, rawValue) {
776
+ return this.flattenSelectOptions(option.options).find((choice) => choice.value === rawValue);
777
+ }
778
+ configChoiceAliases(choice) {
779
+ const aliases = new Set();
780
+ aliases.add(choice.value.toLowerCase());
781
+ aliases.add(choice.name.toLowerCase());
782
+ const compactName = choice.name.toLowerCase().replace(/\s+/g, "-");
783
+ aliases.add(compactName);
784
+ const tail = this.extractConfigValueTail(choice.value);
785
+ if (tail)
786
+ aliases.add(tail.toLowerCase());
787
+ return aliases;
788
+ }
789
+ describeConfigChoice(choice) {
790
+ const tail = this.extractConfigValueTail(choice.value);
791
+ if (tail && tail.toLowerCase() !== choice.name.toLowerCase()) {
792
+ return tail;
793
+ }
794
+ return choice.value;
795
+ }
796
+ extractConfigValueTail(value) {
797
+ const hashIndex = value.lastIndexOf("#");
798
+ if (hashIndex >= 0 && hashIndex < value.length - 1) {
799
+ return value.slice(hashIndex + 1);
800
+ }
801
+ const slashIndex = value.lastIndexOf("/");
802
+ if (slashIndex >= 0 && slashIndex < value.length - 1) {
803
+ return value.slice(slashIndex + 1);
804
+ }
805
+ return value;
806
+ }
807
+ // ── Auto-send media (images / files) referenced in agent reply ────────
808
+ /**
809
+ * Scan the reply text for file-path references (markdown links and
810
+ * backtick-enclosed paths), resolve them inside the agent workspace,
811
+ * and send the corresponding files as WeChat media messages.
812
+ *
813
+ * Modeled after codex-wechat's `sendAssistantAttachmentsForReply()` and
814
+ * `extractAutoSendFilePathsFromReply()`.
815
+ */
816
+ async tryAutoSendMedia(userId, contextToken, text) {
817
+ const mode = this.config.agent.autoSendMedia ?? "all";
818
+ if (mode === "off")
819
+ return;
820
+ const cwd = path.resolve(this.config.agent.cwd);
821
+ const candidates = mode === "tagged"
822
+ ? this.extractTaggedFilePaths(text)
823
+ : this.extractFilePathsFromReply(text);
824
+ for (const relPath of candidates) {
825
+ const fullPath = path.resolve(cwd, relPath);
826
+ // Security: must stay within the agent workspace
827
+ if (!fullPath.startsWith(cwd + path.sep) && fullPath !== cwd) {
828
+ continue;
829
+ }
830
+ let buf;
831
+ try {
832
+ buf = await fs.readFile(fullPath);
833
+ }
834
+ catch {
835
+ continue;
836
+ }
837
+ const ext = path.extname(fullPath).toLowerCase();
838
+ const imageExts = new Set([
839
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg",
840
+ ]);
841
+ const isImage = imageExts.has(ext);
842
+ const opts = {
843
+ baseUrl: this.tokenData.baseUrl,
844
+ token: this.tokenData.token,
845
+ contextToken,
846
+ cdnBaseUrl: this.config.wechat.cdnBaseUrl,
847
+ };
848
+ try {
849
+ if (isImage) {
850
+ await sendImageMessage(userId, buf, opts);
851
+ }
852
+ else {
853
+ await sendFileMessage(userId, path.basename(fullPath), buf, opts);
854
+ }
855
+ this.log(`Auto-sent media: ${path.relative(cwd, fullPath)} (${buf.length} bytes)`);
856
+ }
857
+ catch (err) {
858
+ this.log(`Failed to auto-send media ${path.relative(cwd, fullPath)}: ${String(err)}`);
859
+ }
860
+ }
861
+ }
862
+ /**
863
+ * Extract file-path candidates from reply text.
864
+ *
865
+ * Supports two patterns (matching codex-wechat):
866
+ * - Markdown links: `![alt](path)` or `[text](path)`
867
+ * - Backtick-enclosed paths: `` `path` ``
868
+ */
869
+ extractFilePathsFromReply(text) {
870
+ const candidates = [];
871
+ const seen = new Set();
872
+ // Markdown image / link references
873
+ const mdLinkRe = /!?\[[^\]]*\]\(([^)\n]+)\)/g;
874
+ let m;
875
+ while ((m = mdLinkRe.exec(text)) !== null) {
876
+ const raw = (m[1] ?? "").trim();
877
+ if (raw) {
878
+ const cleaned = this.stripPathDecorations(raw);
879
+ if (cleaned && !seen.has(cleaned)) {
880
+ seen.add(cleaned);
881
+ candidates.push(cleaned);
882
+ }
883
+ }
884
+ }
885
+ // Backtick-enclosed paths
886
+ const btRe = /`([^`\n]+)`/g;
887
+ while ((m = btRe.exec(text)) !== null) {
888
+ const raw = (m[1] ?? "").trim();
889
+ if (raw) {
890
+ const cleaned = this.stripPathDecorations(raw);
891
+ if (cleaned && !seen.has(cleaned)) {
892
+ seen.add(cleaned);
893
+ candidates.push(cleaned);
894
+ }
895
+ }
896
+ }
897
+ return candidates;
898
+ }
899
+ /**
900
+ * Extract file-path candidates tagged with `@send:` marker.
901
+ *
902
+ * Supports patterns:
903
+ * - `@send:path/to/file` β€” explicit send marker
904
+ * - `πŸ“Žpath/to/file` β€” emoji send marker
905
+ *
906
+ * Only these explicitly tagged paths are returned; regular file
907
+ * references in code discussions are ignored.
908
+ */
909
+ extractTaggedFilePaths(text) {
910
+ const candidates = [];
911
+ const seen = new Set();
912
+ // @send: marker
913
+ const sendRe = /@send:(\S+)/g;
914
+ let m;
915
+ while ((m = sendRe.exec(text)) !== null) {
916
+ const raw = (m[1] ?? "").trim();
917
+ if (raw && !seen.has(raw)) {
918
+ seen.add(raw);
919
+ candidates.push(raw);
920
+ }
921
+ }
922
+ // πŸ“Ž emoji marker
923
+ const emojiRe = /πŸ“Ž(\S+)/g;
924
+ while ((m = emojiRe.exec(text)) !== null) {
925
+ const raw = (m[1] ?? "").trim();
926
+ if (raw && !seen.has(raw)) {
927
+ seen.add(raw);
928
+ candidates.push(raw);
929
+ }
930
+ }
931
+ return candidates;
932
+ }
933
+ /**
934
+ * Strip decorations from a raw path candidate so it resolves cleanly:
935
+ * - `< >` wrapping (auto-linked bare paths on some platforms)
936
+ * - `#L…` line references from editor / code-review links
937
+ * - `file.ext:line:col` suffixes
938
+ */
939
+ stripPathDecorations(candidate) {
940
+ let value = candidate.trim();
941
+ if (!value)
942
+ return "";
943
+ // Strip <...> wrapping
944
+ if (value.startsWith("<") && value.endsWith(">")) {
945
+ value = value.slice(1, -1).trim();
946
+ }
947
+ // Strip #L... line anchor
948
+ const hashIdx = value.indexOf("#L");
949
+ if (hashIdx >= 0) {
950
+ value = value.slice(0, hashIdx).trim();
951
+ }
952
+ // Strip :line or :line:col suffix on filenames
953
+ const lineSuffixMatch = value.match(/^(.*\.[A-Za-z0-9_-]+):\d+(?::\d+)?$/);
954
+ if (lineSuffixMatch) {
955
+ value = lineSuffixMatch[1];
956
+ }
957
+ return value;
958
+ }
959
+ }
960
+ function sanitizeStateError(err) {
961
+ const code = typeof err === "object" && err !== null && "code" in err
962
+ ? String(err.code)
963
+ : "";
964
+ const sanitized = new Error(code ? `State persistence failed (${code})` : "State persistence failed");
965
+ sanitized.name = err instanceof Error ? err.name : "Error";
966
+ sanitized.stack = undefined;
967
+ return sanitized;
968
+ }
969
+ //# sourceMappingURL=bridge.js.map