@qwen-code/channel-base 0.22.3 → 0.23.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.
- package/README.md +16 -10
- package/dist/AcpBridge.d.ts +5 -3
- package/dist/AcpBridge.d.ts.map +1 -1
- package/dist/AcpBridge.js +63 -3
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +17 -3
- package/dist/ChannelAgentBridge.d.ts.map +1 -1
- package/dist/ChannelAgentBridge.js +1 -0
- package/dist/ChannelAgentBridge.js.map +1 -1
- package/dist/ChannelBase.d.ts +27 -5
- package/dist/ChannelBase.d.ts.map +1 -1
- package/dist/ChannelBase.js +510 -57
- package/dist/ChannelBase.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +16 -2
- package/dist/DaemonChannelBridge.d.ts.map +1 -1
- package/dist/DaemonChannelBridge.js +12 -1
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SessionRouter.d.ts +1 -0
- package/dist/SessionRouter.d.ts.map +1 -1
- package/dist/SessionRouter.js +12 -10
- package/dist/SessionRouter.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/named-session-manager.d.ts +14 -2
- package/dist/named-session-manager.d.ts.map +1 -1
- package/dist/named-session-manager.js +89 -26
- package/dist/named-session-manager.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -82,6 +82,8 @@ const LOOP_ADD_RE = /^"([^"]+)"\s+(.+)$/su;
|
|
|
82
82
|
const MAX_LOOP_JOBS_PER_TARGET = 10;
|
|
83
83
|
const MAX_LOOP_PROMPT_CHARS = 4000;
|
|
84
84
|
const MAX_DISPLAY_PROJECTION_CHARS = 8000;
|
|
85
|
+
// Mirrors BTW_MAX_INPUT_LENGTH in core without adding core to channel-base.
|
|
86
|
+
const CHANNEL_BTW_MAX_INPUT_LENGTH = 4096;
|
|
85
87
|
function parseLoopAddArgs(args) {
|
|
86
88
|
const match = args.trim().match(LOOP_ADD_RE);
|
|
87
89
|
if (!match)
|
|
@@ -134,6 +136,7 @@ export class ChannelBase {
|
|
|
134
136
|
sessionQueues = new Map();
|
|
135
137
|
queuedTurns = new Map();
|
|
136
138
|
namedTurnBindings = new WeakMap();
|
|
139
|
+
inboundErrorSourceLabels = new WeakMap();
|
|
137
140
|
registerBridgeEvents;
|
|
138
141
|
bridgeRecovery;
|
|
139
142
|
/**
|
|
@@ -146,6 +149,7 @@ export class ChannelBase {
|
|
|
146
149
|
pendingChannelMemoryMutationDeliveries = new Map();
|
|
147
150
|
/** Per-session active prompt tracking for dispatch modes. */
|
|
148
151
|
activePrompts = new Map();
|
|
152
|
+
activeBtw = new Map();
|
|
149
153
|
/** Per-session message buffer for collect mode. */
|
|
150
154
|
collectBuffers = new Map();
|
|
151
155
|
preflightedEnvelopes = new WeakSet();
|
|
@@ -202,17 +206,46 @@ export class ChannelBase {
|
|
|
202
206
|
this.onToolCall(chatId, event);
|
|
203
207
|
}
|
|
204
208
|
async dispatchBackgroundResponse(sessionId, text) {
|
|
205
|
-
|
|
209
|
+
let target = this.router.getTarget(sessionId);
|
|
206
210
|
if (!target ||
|
|
207
211
|
target.channelName !== this.name ||
|
|
208
212
|
text.trim().length === 0) {
|
|
209
213
|
return;
|
|
210
214
|
}
|
|
215
|
+
let sourceLabel;
|
|
216
|
+
if (this.namedSessions) {
|
|
217
|
+
const presentation = await this.namedSessions.resolvePresentation(sessionId);
|
|
218
|
+
const currentTarget = this.router.getTarget(sessionId);
|
|
219
|
+
const currentPresentation = this.namedSessions.presentation(sessionId);
|
|
220
|
+
if (!presentation ||
|
|
221
|
+
presentation.status !== 'open' ||
|
|
222
|
+
!currentTarget ||
|
|
223
|
+
!this.router.isSessionLive(sessionId) ||
|
|
224
|
+
!currentPresentation ||
|
|
225
|
+
currentPresentation.status !== 'open' ||
|
|
226
|
+
currentPresentation.taskName !== presentation.taskName ||
|
|
227
|
+
!this.sameTaskOwner(target, currentTarget) ||
|
|
228
|
+
!this.sameTaskOwner(currentTarget, currentPresentation.target)) {
|
|
229
|
+
throw new Error('Named background response ownership is unavailable.');
|
|
230
|
+
}
|
|
231
|
+
target = currentTarget;
|
|
232
|
+
sourceLabel = this.createSourceLabel(presentation, target);
|
|
233
|
+
}
|
|
211
234
|
if (this.supportsProactiveSend() && this.supportsProactiveTarget(target)) {
|
|
212
|
-
|
|
235
|
+
if (sourceLabel) {
|
|
236
|
+
await this.pushProactive(target, text, sourceLabel);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await this.pushProactive(target, text);
|
|
240
|
+
}
|
|
213
241
|
return;
|
|
214
242
|
}
|
|
215
|
-
|
|
243
|
+
if (sourceLabel) {
|
|
244
|
+
await this.deliverBackgroundReply(target.chatId, text, sessionId, sourceLabel);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
await this.deliverBackgroundReply(target.chatId, text, sessionId);
|
|
248
|
+
}
|
|
216
249
|
}
|
|
217
250
|
/**
|
|
218
251
|
* Fallback delivery of a background response when proactive send is
|
|
@@ -220,8 +253,132 @@ export class ChannelBase {
|
|
|
220
253
|
* stay out of turn-scoped streaming state, for example) override only this
|
|
221
254
|
* step instead of re-implementing the whole dispatch flow.
|
|
222
255
|
*/
|
|
223
|
-
async deliverBackgroundReply(chatId, text, sessionId) {
|
|
224
|
-
await this.sendResponseMessage(chatId, text, sessionId);
|
|
256
|
+
async deliverBackgroundReply(chatId, text, sessionId, sourceLabel) {
|
|
257
|
+
await this.sendResponseMessage(chatId, text, sessionId, sourceLabel);
|
|
258
|
+
}
|
|
259
|
+
async handleBtw(envelope, sessionId, question, sourceLabel) {
|
|
260
|
+
const target = this.router.getTarget(sessionId);
|
|
261
|
+
if (!target || target.channelName !== this.name) {
|
|
262
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Could not resolve the current task for /btw.', sourceLabel);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const running = this.activeBtw.get(sessionId);
|
|
266
|
+
if (running) {
|
|
267
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `BTW #${running.id} is still running for this task.`, sourceLabel);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const reference = this.namedSessions?.presentation(sessionId);
|
|
271
|
+
const request = {
|
|
272
|
+
id: randomUUID().slice(0, 8),
|
|
273
|
+
bridge: this.bridge,
|
|
274
|
+
controller: new AbortController(),
|
|
275
|
+
target: { ...target },
|
|
276
|
+
chatId: envelope.chatId,
|
|
277
|
+
...(envelope.threadId ? { threadId: envelope.threadId } : {}),
|
|
278
|
+
...(sourceLabel ? { sourceLabel } : {}),
|
|
279
|
+
...(reference?.status === 'open' ? { taskName: reference.taskName } : {}),
|
|
280
|
+
};
|
|
281
|
+
this.activeBtw.set(sessionId, request);
|
|
282
|
+
try {
|
|
283
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `BTW #${request.id} received. The main task will continue.`, sourceLabel);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (this.activeBtw.get(sessionId) === request) {
|
|
287
|
+
this.cancelBtw(sessionId);
|
|
288
|
+
}
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
if (!this.isBtwCurrent(sessionId, request)) {
|
|
292
|
+
if (this.activeBtw.get(sessionId) === request) {
|
|
293
|
+
this.cancelBtw(sessionId);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
void this.deliverBtw(sessionId, question, request).catch((error) => {
|
|
298
|
+
process.stderr.write(`[${this.name}] BTW delivery failed for session ${sanitizeLogText(sessionId, 128)}: ${this.lifecycleError(error)}\n`);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async deliverBtw(sessionId, question, request) {
|
|
302
|
+
let message;
|
|
303
|
+
try {
|
|
304
|
+
let result;
|
|
305
|
+
try {
|
|
306
|
+
result = await request.bridge.btw(sessionId, question, request.controller.signal);
|
|
307
|
+
if (result.sessionId !== sessionId) {
|
|
308
|
+
throw new Error('BTW response session did not match the request');
|
|
309
|
+
}
|
|
310
|
+
const answer = result.answer?.trim();
|
|
311
|
+
message = answer
|
|
312
|
+
? `BTW #${request.id}\n\n${answer}`
|
|
313
|
+
: `BTW #${request.id}\n\nNo answer is available from the current conversation context.`;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (request.controller.signal.aborted)
|
|
317
|
+
return;
|
|
318
|
+
process.stderr.write(`[${this.name}] BTW request failed for session ${sanitizeLogText(sessionId, 128)}: ${this.lifecycleError(error)}\n`);
|
|
319
|
+
message = `BTW #${request.id} failed. Please try again.`;
|
|
320
|
+
}
|
|
321
|
+
if (!this.isBtwCurrent(sessionId, request))
|
|
322
|
+
return;
|
|
323
|
+
try {
|
|
324
|
+
await this.sendThreadMessage(request.chatId, request.threadId, message, request.sourceLabel);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
try {
|
|
328
|
+
await this.sendThreadMessage(request.chatId, request.threadId, `BTW #${request.id} failed. Please try again.`, request.sourceLabel);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// Best effort only; the original delivery failure is logged by the caller.
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
if (this.activeBtw.get(sessionId) === request) {
|
|
338
|
+
this.activeBtw.delete(sessionId);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
isBtwCurrent(sessionId, request) {
|
|
343
|
+
if (request.controller.signal.aborted ||
|
|
344
|
+
this.activeBtw.get(sessionId) !== request ||
|
|
345
|
+
this.bridge !== request.bridge ||
|
|
346
|
+
!this.router.isSessionLive(sessionId)) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
const currentTarget = this.router.getTarget(sessionId);
|
|
350
|
+
// Compare owner + thread only: SessionRouter.promoteTargetToGroup flips
|
|
351
|
+
// the live target's isGroup whenever any group envelope or loop/webhook
|
|
352
|
+
// target resolves the same routing key, which changes neither the
|
|
353
|
+
// conversation nor the delivery destination, so it must not void an
|
|
354
|
+
// acknowledged answer. The named-task branch applies the same tolerance to
|
|
355
|
+
// the registry's creation-time snapshot, which keeps the pre-promotion
|
|
356
|
+
// isGroup value.
|
|
357
|
+
if (!currentTarget ||
|
|
358
|
+
!this.sameTaskOwner(request.target, currentTarget) ||
|
|
359
|
+
request.target.threadId !== currentTarget.threadId) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (!request.taskName)
|
|
363
|
+
return true;
|
|
364
|
+
const reference = this.namedSessions?.presentation(sessionId);
|
|
365
|
+
return (reference?.status === 'open' &&
|
|
366
|
+
reference.taskName === request.taskName &&
|
|
367
|
+
this.sameTaskOwner(currentTarget, reference.target) &&
|
|
368
|
+
currentTarget.threadId === reference.target.threadId);
|
|
369
|
+
}
|
|
370
|
+
cancelBtw(sessionId) {
|
|
371
|
+
const request = this.activeBtw.get(sessionId);
|
|
372
|
+
if (!request)
|
|
373
|
+
return;
|
|
374
|
+
this.activeBtw.delete(sessionId);
|
|
375
|
+
request.controller.abort();
|
|
376
|
+
}
|
|
377
|
+
cancelAllBtw() {
|
|
378
|
+
const requests = Array.from(this.activeBtw.values());
|
|
379
|
+
this.activeBtw.clear();
|
|
380
|
+
for (const request of requests)
|
|
381
|
+
request.controller.abort();
|
|
225
382
|
}
|
|
226
383
|
async dispatchPermissionRequest(event) {
|
|
227
384
|
const target = this.permissionTargetForEvent(event);
|
|
@@ -236,12 +393,70 @@ export class ChannelBase {
|
|
|
236
393
|
}
|
|
237
394
|
return;
|
|
238
395
|
}
|
|
396
|
+
let sourceLabel;
|
|
397
|
+
let taskName;
|
|
398
|
+
if (this.namedSessions) {
|
|
399
|
+
let presentation;
|
|
400
|
+
try {
|
|
401
|
+
presentation = await this.namedSessions.resolvePresentation(event.sessionId);
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
try {
|
|
405
|
+
await this.bridge.respondToPermission?.(event.requestId, {
|
|
406
|
+
outcome: { outcome: 'cancelled' },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
catch (respondErr) {
|
|
410
|
+
process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
|
|
411
|
+
}
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
const currentTarget = this.permissionTargetForEvent(event);
|
|
415
|
+
const currentPresentation = this.namedSessions.presentation(event.sessionId);
|
|
416
|
+
if (!presentation ||
|
|
417
|
+
presentation.status !== 'open' ||
|
|
418
|
+
!currentTarget ||
|
|
419
|
+
!this.router.isSessionLive(event.sessionId) ||
|
|
420
|
+
!currentPresentation ||
|
|
421
|
+
currentPresentation.status !== 'open' ||
|
|
422
|
+
currentPresentation.taskName !== presentation.taskName ||
|
|
423
|
+
!this.sameTaskOwner(target, currentTarget) ||
|
|
424
|
+
!this.sameTaskOwner(currentTarget, currentPresentation.target)) {
|
|
425
|
+
try {
|
|
426
|
+
await this.bridge.respondToPermission?.(event.requestId, {
|
|
427
|
+
outcome: { outcome: 'cancelled' },
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
catch (respondErr) {
|
|
431
|
+
process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const active = this.activePrompts.get(event.sessionId);
|
|
436
|
+
sourceLabel = active
|
|
437
|
+
? active.sourceLabel
|
|
438
|
+
: this.createSourceLabel(presentation, target);
|
|
439
|
+
if (!sourceLabel) {
|
|
440
|
+
try {
|
|
441
|
+
await this.bridge.respondToPermission?.(event.requestId, {
|
|
442
|
+
outcome: { outcome: 'cancelled' },
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch (respondErr) {
|
|
446
|
+
process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
taskName = presentation.taskName;
|
|
451
|
+
}
|
|
239
452
|
this.removePendingPermission(event.requestId);
|
|
240
453
|
const pending = {
|
|
241
454
|
requestId: event.requestId,
|
|
242
455
|
sessionId: event.sessionId,
|
|
243
456
|
target,
|
|
244
457
|
request: event.request,
|
|
458
|
+
...(sourceLabel ? { sourceLabel } : {}),
|
|
459
|
+
...(taskName ? { taskName } : {}),
|
|
245
460
|
settlementListeners: new Set(),
|
|
246
461
|
};
|
|
247
462
|
this.pendingPermissions.set(event.requestId, pending);
|
|
@@ -258,10 +473,10 @@ export class ChannelBase {
|
|
|
258
473
|
if (target.threadId !== undefined &&
|
|
259
474
|
this.supportsProactiveSend() &&
|
|
260
475
|
this.supportsProactiveTarget(target)) {
|
|
261
|
-
await this.pushProactive(target, text);
|
|
476
|
+
await this.pushProactive(target, text, pending.sourceLabel);
|
|
262
477
|
}
|
|
263
478
|
else {
|
|
264
|
-
await this.sendThreadMessage(target.chatId, target.threadId, text);
|
|
479
|
+
await this.sendThreadMessage(target.chatId, target.threadId, text, pending.sourceLabel);
|
|
265
480
|
}
|
|
266
481
|
}
|
|
267
482
|
catch (err) {
|
|
@@ -296,6 +511,7 @@ export class ChannelBase {
|
|
|
296
511
|
runId: active.runId,
|
|
297
512
|
owner: active.owner,
|
|
298
513
|
target: pending.target,
|
|
514
|
+
...(pending.sourceLabel ? { sourceLabel: pending.sourceLabel } : {}),
|
|
299
515
|
...(precedingSegment
|
|
300
516
|
? { precedingSegmentId: precedingSegment.segmentId }
|
|
301
517
|
: {}),
|
|
@@ -516,8 +732,8 @@ export class ChannelBase {
|
|
|
516
732
|
* on a specific issue/PR. The default falls through to sendMessage(chatId,
|
|
517
733
|
* text), ignoring threadId — existing IM adapters are behaviorally unchanged.
|
|
518
734
|
*/
|
|
519
|
-
async sendThreadMessage(chatId, _threadId, text) {
|
|
520
|
-
await this.sendMessage(chatId, text);
|
|
735
|
+
async sendThreadMessage(chatId, _threadId, text, sourceLabel) {
|
|
736
|
+
await this.sendMessage(chatId, this.formatAttributedText(text, sourceLabel));
|
|
521
737
|
}
|
|
522
738
|
/**
|
|
523
739
|
* Adapter hook for task lifecycle events — the canonical way to track task
|
|
@@ -673,6 +889,7 @@ export class ChannelBase {
|
|
|
673
889
|
segmentId,
|
|
674
890
|
owner: active.owner,
|
|
675
891
|
target: resolvedTarget,
|
|
892
|
+
...(active.sourceLabel ? { sourceLabel: active.sourceLabel } : {}),
|
|
676
893
|
...(active.messageId ? { messageId: active.messageId } : {}),
|
|
677
894
|
};
|
|
678
895
|
}
|
|
@@ -714,11 +931,11 @@ export class ChannelBase {
|
|
|
714
931
|
supportsProactiveWebhookTarget(target) {
|
|
715
932
|
return this.supportsProactiveTarget(target);
|
|
716
933
|
}
|
|
717
|
-
async pushProactive(target, text) {
|
|
934
|
+
async pushProactive(target, text, sourceLabel) {
|
|
718
935
|
if (target.threadId) {
|
|
719
936
|
throw new Error('Channel does not support proactive loop messages for threaded targets.');
|
|
720
937
|
}
|
|
721
|
-
await this.sendThreadMessage(target.chatId, target.threadId, text);
|
|
938
|
+
await this.sendThreadMessage(target.chatId, target.threadId, text, sourceLabel);
|
|
722
939
|
}
|
|
723
940
|
async pushProactiveDelivery(target, text) {
|
|
724
941
|
try {
|
|
@@ -931,6 +1148,7 @@ export class ChannelBase {
|
|
|
931
1148
|
}
|
|
932
1149
|
/** Replace the bridge instance (used after crash recovery restart). */
|
|
933
1150
|
setBridge(bridge) {
|
|
1151
|
+
this.cancelAllBtw();
|
|
934
1152
|
if (this.registerBridgeEvents) {
|
|
935
1153
|
this.detachBridgeEvents(this.bridge);
|
|
936
1154
|
}
|
|
@@ -1427,6 +1645,7 @@ export class ChannelBase {
|
|
|
1427
1645
|
}),
|
|
1428
1646
|
]);
|
|
1429
1647
|
if (!cancelled) {
|
|
1648
|
+
this.cancelBtw(sessionId);
|
|
1430
1649
|
this.router.removeSessionId(sessionId);
|
|
1431
1650
|
this.instructedSessions.delete(sessionId);
|
|
1432
1651
|
this.unattendedMemorySessions.delete(sessionId);
|
|
@@ -1559,6 +1778,7 @@ export class ChannelBase {
|
|
|
1559
1778
|
}
|
|
1560
1779
|
onToolCall(_chatId, _event) { }
|
|
1561
1780
|
onSessionDied(sessionId) {
|
|
1781
|
+
this.cancelBtw(sessionId);
|
|
1562
1782
|
this.router.handleSessionDied(sessionId);
|
|
1563
1783
|
this.instructedSessions.delete(sessionId);
|
|
1564
1784
|
this.unattendedMemorySessions.delete(sessionId);
|
|
@@ -1608,11 +1828,11 @@ export class ChannelBase {
|
|
|
1608
1828
|
* Override to clear adapter-owned streaming buffers.
|
|
1609
1829
|
*/
|
|
1610
1830
|
onResponseBoundary(_chatId, _sessionId) { }
|
|
1611
|
-
async sendResponseMessage(chatId, text, sessionId) {
|
|
1831
|
+
async sendResponseMessage(chatId, text, sessionId, sourceLabel) {
|
|
1612
1832
|
const active = this.activePrompts.get(sessionId);
|
|
1613
1833
|
const target = this.router.getTarget(sessionId);
|
|
1614
1834
|
const threadId = active?.threadId ?? target?.threadId;
|
|
1615
|
-
await this.sendThreadMessage(chatId, threadId, text);
|
|
1835
|
+
await this.sendThreadMessage(chatId, threadId, text, sourceLabel ?? active?.sourceLabel);
|
|
1616
1836
|
}
|
|
1617
1837
|
/**
|
|
1618
1838
|
* Adapter hook for delivery-only metadata. The response delivery path can read
|
|
@@ -1627,6 +1847,12 @@ export class ChannelBase {
|
|
|
1627
1847
|
getResponseMetadata(sessionId) {
|
|
1628
1848
|
return this.activePrompts.get(sessionId)?.metadata;
|
|
1629
1849
|
}
|
|
1850
|
+
getResponseSourceLabel(sessionId) {
|
|
1851
|
+
return this.activePrompts.get(sessionId)?.sourceLabel;
|
|
1852
|
+
}
|
|
1853
|
+
getInboundErrorSourceLabel(envelope) {
|
|
1854
|
+
return this.inboundErrorSourceLabels.get(envelope);
|
|
1855
|
+
}
|
|
1630
1856
|
/**
|
|
1631
1857
|
* Returns the active prompt's response thread while it remains available for
|
|
1632
1858
|
* adapter delivery. Falls back to the session target after prompt cleanup.
|
|
@@ -1640,8 +1866,8 @@ export class ChannelBase {
|
|
|
1640
1866
|
* Override to customize delivery (e.g., finalize an AI card).
|
|
1641
1867
|
* Default: sends the full response text.
|
|
1642
1868
|
*/
|
|
1643
|
-
async onResponseComplete(chatId, fullText, sessionId,
|
|
1644
|
-
await this.sendResponseMessage(chatId, fullText, sessionId);
|
|
1869
|
+
async onResponseComplete(chatId, fullText, sessionId, segment) {
|
|
1870
|
+
await this.sendResponseMessage(chatId, fullText, sessionId, segment?.sourceLabel);
|
|
1645
1871
|
}
|
|
1646
1872
|
/**
|
|
1647
1873
|
* Register a slash command handler. Subclasses can call this to add
|
|
@@ -1762,7 +1988,7 @@ export class ChannelBase {
|
|
|
1762
1988
|
}
|
|
1763
1989
|
return 'resolved_outside_presenter';
|
|
1764
1990
|
}
|
|
1765
|
-
pendingPermissionForEnvelope(envelope, args) {
|
|
1991
|
+
pendingPermissionForEnvelope(envelope, args, selectedSessionId) {
|
|
1766
1992
|
const trimmed = args.trim();
|
|
1767
1993
|
if (trimmed) {
|
|
1768
1994
|
const explicit = this.pendingPermissions.get(trimmed);
|
|
@@ -1779,6 +2005,8 @@ export class ChannelBase {
|
|
|
1779
2005
|
const matching = requestIds
|
|
1780
2006
|
.map((id) => this.pendingPermissions.get(id))
|
|
1781
2007
|
.filter((pending) => pending !== undefined &&
|
|
2008
|
+
(selectedSessionId === undefined ||
|
|
2009
|
+
pending.sessionId === selectedSessionId) &&
|
|
1782
2010
|
this.canEnvelopeAnswerPendingPermission(envelope, pending));
|
|
1783
2011
|
if (matching.length === 0) {
|
|
1784
2012
|
return { kind: 'none', explicit: false };
|
|
@@ -1801,29 +2029,91 @@ export class ChannelBase {
|
|
|
1801
2029
|
}
|
|
1802
2030
|
formatPermissionRequest(pending) {
|
|
1803
2031
|
const { toolCall } = pending.request;
|
|
1804
|
-
const
|
|
2032
|
+
const parameters = this.permissionParameterSummary(toolCall);
|
|
2033
|
+
const approveLabel = this.permissionOptionLabel(this.approvalOption(pending), 'allow once');
|
|
1805
2034
|
const alwaysOption = this.approvalAlwaysOption(pending);
|
|
2035
|
+
const denyLabel = this.permissionOptionLabel(this.denialOption(pending), 'deny');
|
|
2036
|
+
const requestSuffix = pending.taskName ? ` ${pending.requestId}` : '';
|
|
2037
|
+
const replyPadding = pending.taskName
|
|
2038
|
+
? { approve: ' ', always: ' ', deny: ' ' }
|
|
2039
|
+
: { approve: ' ', always: ' ', deny: ' ' };
|
|
1806
2040
|
const replies = [
|
|
1807
|
-
|
|
1808
|
-
...(alwaysOption
|
|
1809
|
-
|
|
2041
|
+
`/approve${requestSuffix}${replyPadding.approve}${approveLabel}`,
|
|
2042
|
+
...(alwaysOption
|
|
2043
|
+
? [
|
|
2044
|
+
`/approve-always${requestSuffix}${replyPadding.always}${alwaysOption.label}`,
|
|
2045
|
+
]
|
|
2046
|
+
: []),
|
|
2047
|
+
`/deny${requestSuffix}${replyPadding.deny}${denyLabel}`,
|
|
1810
2048
|
];
|
|
1811
|
-
|
|
2049
|
+
return [
|
|
1812
2050
|
'Permission required to run a tool',
|
|
2051
|
+
...(pending.taskName ? [`Request: ${pending.requestId}`] : []),
|
|
1813
2052
|
'',
|
|
1814
|
-
|
|
1815
|
-
|
|
2053
|
+
`Tool: ${this.permissionToolName(toolCall)}`,
|
|
2054
|
+
`Action: ${this.permissionTitle(toolCall)}`,
|
|
2055
|
+
...(parameters ? [`Parameters: ${parameters}`] : []),
|
|
1816
2056
|
'',
|
|
1817
2057
|
'Reply with:',
|
|
1818
2058
|
...replies,
|
|
1819
|
-
];
|
|
1820
|
-
return lines.join('\n');
|
|
2059
|
+
].join('\n');
|
|
1821
2060
|
}
|
|
1822
|
-
|
|
2061
|
+
permissionTitle(toolCall) {
|
|
2062
|
+
const rawTitle = typeof toolCall.title === 'string' ? toolCall.title : undefined;
|
|
2063
|
+
return sanitizeQuotedText(rawTitle || '', 160).trim() || 'Tool use';
|
|
2064
|
+
}
|
|
2065
|
+
permissionToolName(toolCall) {
|
|
2066
|
+
const rawToolCall = toolCall;
|
|
2067
|
+
const meta = isRecord(rawToolCall['_meta'])
|
|
2068
|
+
? rawToolCall['_meta']
|
|
2069
|
+
: undefined;
|
|
2070
|
+
for (const candidate of [meta?.['toolName'], rawToolCall['kind']]) {
|
|
2071
|
+
if (typeof candidate !== 'string')
|
|
2072
|
+
continue;
|
|
2073
|
+
const name = sanitizeQuotedText(candidate, 120).trim();
|
|
2074
|
+
if (name)
|
|
2075
|
+
return name;
|
|
2076
|
+
}
|
|
2077
|
+
return 'unknown';
|
|
2078
|
+
}
|
|
2079
|
+
permissionParameterSummary(toolCall) {
|
|
2080
|
+
const rawToolCall = toolCall;
|
|
2081
|
+
const rawInput = isRecord(rawToolCall['rawInput'])
|
|
2082
|
+
? rawToolCall['rawInput']
|
|
2083
|
+
: undefined;
|
|
2084
|
+
if (!rawInput)
|
|
2085
|
+
return undefined;
|
|
2086
|
+
const entries = Object.entries(rawInput);
|
|
2087
|
+
if (entries.length === 0)
|
|
2088
|
+
return undefined;
|
|
2089
|
+
const visible = entries.slice(0, 4).map(([key, value]) => {
|
|
2090
|
+
const safeKey = sanitizeQuotedText(key, 48).trim() || 'unknown';
|
|
2091
|
+
if (Array.isArray(value)) {
|
|
2092
|
+
return `${safeKey} (${value.length} ${value.length === 1 ? 'item' : 'items'})`;
|
|
2093
|
+
}
|
|
2094
|
+
if (isRecord(value)) {
|
|
2095
|
+
return `${safeKey} (object)`;
|
|
2096
|
+
}
|
|
2097
|
+
return safeKey;
|
|
2098
|
+
});
|
|
2099
|
+
if (entries.length > visible.length) {
|
|
2100
|
+
visible.push(`+${entries.length - visible.length} more`);
|
|
2101
|
+
}
|
|
2102
|
+
return visible.join(', ');
|
|
2103
|
+
}
|
|
2104
|
+
permissionOptionLabel(option, fallback) {
|
|
2105
|
+
const rawLabel = typeof option?.name === 'string' ? option.name : '';
|
|
2106
|
+
const label = sanitizeQuotedText(rawLabel, 160).trim();
|
|
2107
|
+
return label || fallback;
|
|
2108
|
+
}
|
|
2109
|
+
approvalOption(pending) {
|
|
1823
2110
|
const options = pending.request.options;
|
|
1824
|
-
return (options.find((option) => option.kind === 'allow_once')
|
|
2111
|
+
return (options.find((option) => option.kind === 'allow_once') ??
|
|
1825
2112
|
options.find((option) => option.optionId === 'proceed_once' &&
|
|
1826
|
-
option.kind === undefined)
|
|
2113
|
+
option.kind === undefined));
|
|
2114
|
+
}
|
|
2115
|
+
approvalOptionId(pending) {
|
|
2116
|
+
return this.approvalOption(pending)?.optionId;
|
|
1827
2117
|
}
|
|
1828
2118
|
approvalAlwaysOption(pending) {
|
|
1829
2119
|
const options = pending.request.options.filter((option) => option.kind === 'allow_always');
|
|
@@ -1835,7 +2125,7 @@ export class ChannelBase {
|
|
|
1835
2125
|
}
|
|
1836
2126
|
return {
|
|
1837
2127
|
optionId: option.optionId,
|
|
1838
|
-
label: this.approvalAlwaysLabel(option),
|
|
2128
|
+
label: this.permissionOptionLabel(option, this.approvalAlwaysLabel(option)),
|
|
1839
2129
|
};
|
|
1840
2130
|
}
|
|
1841
2131
|
findScopedAlwaysOption(options, scope) {
|
|
@@ -1851,29 +2141,45 @@ export class ChannelBase {
|
|
|
1851
2141
|
return 'always allow';
|
|
1852
2142
|
}
|
|
1853
2143
|
denialResponse(pending) {
|
|
1854
|
-
const option =
|
|
1855
|
-
pending.request.options.find((candidate) => candidate.optionId === 'cancel' &&
|
|
1856
|
-
candidate.kind === undefined);
|
|
2144
|
+
const option = this.denialOption(pending);
|
|
1857
2145
|
if (option) {
|
|
1858
2146
|
return { outcome: { outcome: 'selected', optionId: option.optionId } };
|
|
1859
2147
|
}
|
|
1860
2148
|
return { outcome: { outcome: 'cancelled' } };
|
|
1861
2149
|
}
|
|
2150
|
+
denialOption(pending) {
|
|
2151
|
+
return (pending.request.options.find((candidate) => candidate.kind === 'reject_once') ??
|
|
2152
|
+
pending.request.options.find((candidate) => candidate.optionId === 'cancel' &&
|
|
2153
|
+
candidate.kind === undefined));
|
|
2154
|
+
}
|
|
1862
2155
|
async handlePermissionResponseCommand(envelope, args, decision) {
|
|
1863
2156
|
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
1864
2157
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can answer permission requests in this shared session.');
|
|
1865
2158
|
return true;
|
|
1866
2159
|
}
|
|
1867
|
-
const
|
|
2160
|
+
const namedSessions = this.namedSessions;
|
|
2161
|
+
const bareNamedCommand = namedSessions !== undefined && args.trim() === '';
|
|
2162
|
+
let selectedTask;
|
|
2163
|
+
if (bareNamedCommand) {
|
|
2164
|
+
try {
|
|
2165
|
+
selectedTask = await namedSessions.current(this.namedSessionOwner(envelope));
|
|
2166
|
+
}
|
|
2167
|
+
catch (error) {
|
|
2168
|
+
await this.sendNamedSessionError(envelope, error);
|
|
2169
|
+
return true;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
const lookup = this.pendingPermissionForEnvelope(envelope, args, bareNamedCommand ? (selectedTask?.sessionId ?? null) : undefined);
|
|
1868
2173
|
if (lookup.kind === 'ambiguous') {
|
|
1869
2174
|
const requestList = lookup.requestIds
|
|
1870
2175
|
.slice(0, 6)
|
|
1871
2176
|
.map((id) => {
|
|
1872
2177
|
const pending = this.pendingPermissions.get(id);
|
|
1873
2178
|
const title = pending
|
|
1874
|
-
? `: ${
|
|
2179
|
+
? `: ${this.permissionTitle(pending.request.toolCall)}`
|
|
1875
2180
|
: '';
|
|
1876
|
-
|
|
2181
|
+
const task = pending?.taskName ? `Task ${pending.taskName} — ` : '';
|
|
2182
|
+
return `- ${task}${sanitizeQuotedText(id, 128)}${title}`;
|
|
1877
2183
|
})
|
|
1878
2184
|
.join('\n');
|
|
1879
2185
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`);
|
|
@@ -1882,16 +2188,20 @@ export class ChannelBase {
|
|
|
1882
2188
|
if (lookup.kind === 'none') {
|
|
1883
2189
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, lookup.explicit
|
|
1884
2190
|
? 'No pending permission request with that id for this chat.'
|
|
1885
|
-
:
|
|
2191
|
+
: selectedTask
|
|
2192
|
+
? `No pending permission request for selected task "${selectedTask.name}". Use an explicit request ID to answer another task.`
|
|
2193
|
+
: this.namedSessions
|
|
2194
|
+
? 'No task is currently selected. Use an explicit request ID to answer a named task.'
|
|
2195
|
+
: 'No pending permission request for this chat.');
|
|
1886
2196
|
return true;
|
|
1887
2197
|
}
|
|
2198
|
+
const { pending } = lookup;
|
|
1888
2199
|
if (!this.bridge.respondToPermission) {
|
|
1889
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Permission relay is not available for this session.');
|
|
2200
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Permission relay is not available for this session.', pending.sourceLabel);
|
|
1890
2201
|
return true;
|
|
1891
2202
|
}
|
|
1892
|
-
const { pending } = lookup;
|
|
1893
2203
|
if (pending.userInputPresented && decision !== 'deny') {
|
|
1894
|
-
await this.
|
|
2204
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Submit this question through its interactive card, or use /deny [request-id] to cancel it.', pending.sourceLabel);
|
|
1895
2205
|
return true;
|
|
1896
2206
|
}
|
|
1897
2207
|
const response = (() => {
|
|
@@ -1908,7 +2218,7 @@ export class ChannelBase {
|
|
|
1908
2218
|
if (!response) {
|
|
1909
2219
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, decision === 'approve-always'
|
|
1910
2220
|
? 'This permission request has no always-allow option.'
|
|
1911
|
-
: 'This permission request has no approvable option.');
|
|
2221
|
+
: 'This permission request has no approvable option.', pending.sourceLabel);
|
|
1912
2222
|
return true;
|
|
1913
2223
|
}
|
|
1914
2224
|
let accepted;
|
|
@@ -1920,7 +2230,7 @@ export class ChannelBase {
|
|
|
1920
2230
|
catch (err) {
|
|
1921
2231
|
this.removePendingPermission(pending.requestId);
|
|
1922
2232
|
process.stderr.write(`[${this.name}] permission response failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
|
|
1923
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Failed to answer the permission request.');
|
|
2233
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Failed to answer the permission request.', pending.sourceLabel);
|
|
1924
2234
|
return true;
|
|
1925
2235
|
}
|
|
1926
2236
|
this.removePendingPermission(pending.requestId);
|
|
@@ -1930,7 +2240,7 @@ export class ChannelBase {
|
|
|
1930
2240
|
: decision === 'approve-always'
|
|
1931
2241
|
? 'Permission approved always.'
|
|
1932
2242
|
: 'Permission denied.'
|
|
1933
|
-
: 'Permission request is no longer pending.');
|
|
2243
|
+
: 'Permission request is no longer pending.', pending.sourceLabel);
|
|
1934
2244
|
return true;
|
|
1935
2245
|
}
|
|
1936
2246
|
namedSessionOwner(envelope) {
|
|
@@ -2001,7 +2311,7 @@ export class ChannelBase {
|
|
|
2001
2311
|
if (this.bypassesNamedTurnBinding(envelope))
|
|
2002
2312
|
return true;
|
|
2003
2313
|
try {
|
|
2004
|
-
const sessionId = await namedSessions.resolve(this.namedSessionOwner(envelope),
|
|
2314
|
+
const sessionId = await namedSessions.resolve(this.namedSessionOwner(envelope), (resolvedSessionId) => {
|
|
2005
2315
|
const binding = this.bindNamedTurn(envelope, resolvedSessionId);
|
|
2006
2316
|
return () => this.releaseNamedTurnBinding(binding);
|
|
2007
2317
|
});
|
|
@@ -2090,7 +2400,7 @@ export class ChannelBase {
|
|
|
2090
2400
|
}
|
|
2091
2401
|
case 'new': {
|
|
2092
2402
|
if (parts.includes('--worktree')) {
|
|
2093
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Worktree tasks are
|
|
2403
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Worktree tasks are planned for Part 4. Create a shared task with /session new <name>.');
|
|
2094
2404
|
return true;
|
|
2095
2405
|
}
|
|
2096
2406
|
if (parts.length !== 1)
|
|
@@ -2109,15 +2419,42 @@ export class ChannelBase {
|
|
|
2109
2419
|
case 'close': {
|
|
2110
2420
|
if (parts.length !== 1)
|
|
2111
2421
|
break;
|
|
2422
|
+
const closing = await namedSessions.lookup(owner, parts[0]);
|
|
2112
2423
|
const result = await namedSessions.close(owner, parts[0]);
|
|
2424
|
+
if (closing)
|
|
2425
|
+
this.cancelBtw(closing.sessionId);
|
|
2113
2426
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.active
|
|
2114
2427
|
? `Closed task "${result.closed.name}". Selected "${result.active.name}".`
|
|
2115
2428
|
: `Closed task "${result.closed.name}". No task is selected.`);
|
|
2116
2429
|
return true;
|
|
2117
2430
|
}
|
|
2118
|
-
case 'cancel':
|
|
2119
|
-
|
|
2431
|
+
case 'cancel': {
|
|
2432
|
+
if (parts.length > 1)
|
|
2433
|
+
break;
|
|
2434
|
+
const taskName = parts[0];
|
|
2435
|
+
const task = taskName
|
|
2436
|
+
? await namedSessions.lookup(owner, taskName)
|
|
2437
|
+
: await namedSessions.current(owner);
|
|
2438
|
+
if (!task) {
|
|
2439
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, taskName
|
|
2440
|
+
? `Task "${sanitizeQuotedText(taskName, 32)}" was not found.`
|
|
2441
|
+
: 'No task is currently selected.');
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
if (task.status !== 'open') {
|
|
2445
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Task "${task.name}" is closed.`);
|
|
2446
|
+
return true;
|
|
2447
|
+
}
|
|
2448
|
+
if (!this.activePrompts.has(task.sessionId)) {
|
|
2449
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No request is currently running for task "${task.name}".`);
|
|
2450
|
+
return true;
|
|
2451
|
+
}
|
|
2452
|
+
const cancelled = await this.requestActivePromptCancellation(task.sessionId, 'cancel_command');
|
|
2453
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, cancelled
|
|
2454
|
+
? `Cancelled task "${task.name}".`
|
|
2455
|
+
: `Failed to cancel task "${task.name}".`);
|
|
2120
2456
|
return true;
|
|
2457
|
+
}
|
|
2121
2458
|
default:
|
|
2122
2459
|
break;
|
|
2123
2460
|
}
|
|
@@ -2151,6 +2488,7 @@ export class ChannelBase {
|
|
|
2151
2488
|
this.clearPendingGroupHistory(envelope);
|
|
2152
2489
|
if (removedIds.length > 0) {
|
|
2153
2490
|
for (const id of removedIds) {
|
|
2491
|
+
this.cancelBtw(id);
|
|
2154
2492
|
// Audit: clearing a SHARED session wipes the conversation for every
|
|
2155
2493
|
// participant, so record who triggered it (sanitized display name +
|
|
2156
2494
|
// stable senderId) and which session — mirrors the file's stderr audit
|
|
@@ -2266,6 +2604,7 @@ export class ChannelBase {
|
|
|
2266
2604
|
this.registerCommand('clear', clearHandler);
|
|
2267
2605
|
this.registerCommand('reset', clearHandler);
|
|
2268
2606
|
this.registerCommand('new', clearHandler);
|
|
2607
|
+
this.registerCommand('btw', () => Promise.resolve(false));
|
|
2269
2608
|
if (this.namedSessions) {
|
|
2270
2609
|
this.registerCommand('sessions', (envelope, args) => this.handleNamedSessionsCommand(envelope, args));
|
|
2271
2610
|
this.registerCommand('session', (envelope, args) => this.handleNamedSessionCommand(envelope, args));
|
|
@@ -2325,10 +2664,15 @@ export class ChannelBase {
|
|
|
2325
2664
|
'/approve [request-id] — Approve a pending permission request',
|
|
2326
2665
|
'/approve-always [request-id] — Always approve a pending permission request',
|
|
2327
2666
|
'/deny [request-id] — Deny a pending permission request',
|
|
2667
|
+
...(this.bridge.btw
|
|
2668
|
+
? [
|
|
2669
|
+
'/btw <question> — Ask a side question without interrupting the current task',
|
|
2670
|
+
]
|
|
2671
|
+
: []),
|
|
2328
2672
|
...(this.namedSessions
|
|
2329
2673
|
? [
|
|
2330
2674
|
'/sessions [all] — List your named tasks',
|
|
2331
|
-
'/session current|new|use|close — Manage your named tasks',
|
|
2675
|
+
'/session current|new|use|close|cancel — Manage your named tasks',
|
|
2332
2676
|
]
|
|
2333
2677
|
: []),
|
|
2334
2678
|
];
|
|
@@ -2341,6 +2685,7 @@ export class ChannelBase {
|
|
|
2341
2685
|
'approve',
|
|
2342
2686
|
'approve-always',
|
|
2343
2687
|
'deny',
|
|
2688
|
+
'btw',
|
|
2344
2689
|
'remember-channel',
|
|
2345
2690
|
'channel-memory',
|
|
2346
2691
|
'forget-channel',
|
|
@@ -2356,9 +2701,13 @@ export class ChannelBase {
|
|
|
2356
2701
|
}
|
|
2357
2702
|
}
|
|
2358
2703
|
const sessionId = await this.currentSessionId(envelope);
|
|
2359
|
-
const agentCommands = sessionId
|
|
2704
|
+
const agentCommands = (sessionId
|
|
2360
2705
|
? this.getAgentCommandsForSession(sessionId)
|
|
2361
|
-
: this.bridge.availableCommands
|
|
2706
|
+
: this.bridge.availableCommands).filter((command) => !this.commands.has(command.name) ||
|
|
2707
|
+
// `btw` is registered unconditionally but only handled locally when
|
|
2708
|
+
// the bridge supports it. Without that capability the agent's entry
|
|
2709
|
+
// is the working one, so it must stay listed.
|
|
2710
|
+
(command.name === 'btw' && !this.bridge.btw));
|
|
2362
2711
|
if (agentCommands.length > 0) {
|
|
2363
2712
|
lines.push('', 'Agent commands (forwarded to Qwen Code):');
|
|
2364
2713
|
for (const cmd of agentCommands) {
|
|
@@ -3758,6 +4107,67 @@ export class ChannelBase {
|
|
|
3758
4107
|
return undefined;
|
|
3759
4108
|
}
|
|
3760
4109
|
}
|
|
4110
|
+
formatAttributedText(text, sourceLabel) {
|
|
4111
|
+
if (!sourceLabel || text.trim().length === 0)
|
|
4112
|
+
return text;
|
|
4113
|
+
return `${sourceLabel} ${text}`;
|
|
4114
|
+
}
|
|
4115
|
+
formatMarkdownAttributedText(text, sourceLabel) {
|
|
4116
|
+
const escapedLabel = sourceLabel?.replace(/([\\`*_[\]{}()#+\-.!|>~])/gu, '\\$1');
|
|
4117
|
+
if (!escapedLabel || text.trim().length === 0)
|
|
4118
|
+
return text;
|
|
4119
|
+
return `${escapedLabel}\n${text}`;
|
|
4120
|
+
}
|
|
4121
|
+
sameTaskOwner(a, b) {
|
|
4122
|
+
return (a.channelName === b.channelName &&
|
|
4123
|
+
a.chatId === b.chatId &&
|
|
4124
|
+
a.senderId === b.senderId);
|
|
4125
|
+
}
|
|
4126
|
+
createSourceLabel(reference, target, active) {
|
|
4127
|
+
const activeName = active?.senderName
|
|
4128
|
+
? this.sanitizeSourceSender(active.senderName)
|
|
4129
|
+
: undefined;
|
|
4130
|
+
const senderLabel = (activeName && activeName !== 'unknown' ? activeName : undefined) ??
|
|
4131
|
+
this.observedSenderLabel(target.chatId, target.senderId) ??
|
|
4132
|
+
this.sanitizeSourceSender(target.senderId || 'unknown');
|
|
4133
|
+
const isGroup = active?.isGroup ?? target.isGroup ?? true;
|
|
4134
|
+
return isGroup
|
|
4135
|
+
? `[${senderLabel} · ${reference.taskName}]`
|
|
4136
|
+
: `[${reference.taskName}]`;
|
|
4137
|
+
}
|
|
4138
|
+
observedSenderLabel(chatId, senderId) {
|
|
4139
|
+
const graph = this.persistedObservedContacts();
|
|
4140
|
+
if (!graph)
|
|
4141
|
+
return undefined;
|
|
4142
|
+
const sources = [
|
|
4143
|
+
graph.groups
|
|
4144
|
+
.filter((group) => group.id === chatId)
|
|
4145
|
+
.flatMap((group) => group.users)
|
|
4146
|
+
.filter((user) => user.id === senderId),
|
|
4147
|
+
graph.users.filter((user) => user.id === senderId),
|
|
4148
|
+
];
|
|
4149
|
+
for (const source of sources) {
|
|
4150
|
+
const candidates = source.sort((a, b) => b.lastObservedAt.localeCompare(a.lastObservedAt));
|
|
4151
|
+
for (const candidate of candidates) {
|
|
4152
|
+
const label = this.sanitizeSourceSender(candidate.label);
|
|
4153
|
+
if (label !== 'unknown')
|
|
4154
|
+
return label;
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
return undefined;
|
|
4158
|
+
}
|
|
4159
|
+
sanitizeSourceSender(value) {
|
|
4160
|
+
return sanitizeSenderName(value).replace(/\s+/gu, ' ').trim() || 'unknown';
|
|
4161
|
+
}
|
|
4162
|
+
sourceLabelForTurn(sessionId, envelope) {
|
|
4163
|
+
const reference = this.namedSessions?.presentation(sessionId);
|
|
4164
|
+
if (!reference || reference.status !== 'open')
|
|
4165
|
+
return undefined;
|
|
4166
|
+
return this.createSourceLabel(reference, reference.target, {
|
|
4167
|
+
isGroup: envelope.isGroup,
|
|
4168
|
+
senderName: envelope.senderName,
|
|
4169
|
+
});
|
|
4170
|
+
}
|
|
3761
4171
|
markPreflighted(envelope) {
|
|
3762
4172
|
this.preflightedEnvelopes.add(envelope);
|
|
3763
4173
|
}
|
|
@@ -3794,12 +4204,16 @@ export class ChannelBase {
|
|
|
3794
4204
|
// control chars cannot reach the session-bus echo, recorded transcript,
|
|
3795
4205
|
// or session previews.
|
|
3796
4206
|
const displayText = sanitizeDisplayText(envelope.displayText ?? envelope.text, MAX_DISPLAY_PROJECTION_CHARS);
|
|
3797
|
-
|
|
4207
|
+
const parsed = this.parseCommand(envelope.text);
|
|
4208
|
+
let memoryIntent = parsed?.command === 'btw'
|
|
4209
|
+
? null
|
|
4210
|
+
: parseChannelMemoryIntent(envelope.text);
|
|
3798
4211
|
let memoryIntentFromClassifier = false;
|
|
3799
4212
|
if (memoryIntent?.kind === 'update' || memoryIntent?.kind === 'remove') {
|
|
3800
4213
|
this.deletePendingChannelMemoryMutation(envelope);
|
|
3801
4214
|
}
|
|
3802
4215
|
if (!memoryIntent &&
|
|
4216
|
+
parsed?.command !== 'btw' &&
|
|
3803
4217
|
this.shouldClassifyChannelMemoryIntent(envelope.text)) {
|
|
3804
4218
|
memoryIntent = await this.classifyChannelMemoryIntent(envelope);
|
|
3805
4219
|
memoryIntentFromClassifier = memoryIntent !== null;
|
|
@@ -3821,7 +4235,7 @@ export class ChannelBase {
|
|
|
3821
4235
|
}
|
|
3822
4236
|
}
|
|
3823
4237
|
// 3. Slash command handling — before session/agent routing
|
|
3824
|
-
|
|
4238
|
+
let btwQuestion;
|
|
3825
4239
|
if (parsed) {
|
|
3826
4240
|
const handler = this.commands.get(parsed.command);
|
|
3827
4241
|
if (handler) {
|
|
@@ -3830,6 +4244,29 @@ export class ChannelBase {
|
|
|
3830
4244
|
return;
|
|
3831
4245
|
}
|
|
3832
4246
|
// Unrecognized commands fall through to the agent
|
|
4247
|
+
// Intercept /btw only where the bridge can answer it out of band. With no
|
|
4248
|
+
// btw capability this is not a locally handled command at all: it falls
|
|
4249
|
+
// through to the agent, which serves /btw as its own slash command, the
|
|
4250
|
+
// same path it took before this interception existed.
|
|
4251
|
+
if (parsed.command === 'btw' && this.bridge.btw) {
|
|
4252
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
4253
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can use /btw in this shared session.');
|
|
4254
|
+
return;
|
|
4255
|
+
}
|
|
4256
|
+
btwQuestion = parsed.args.trim();
|
|
4257
|
+
if (!btwQuestion) {
|
|
4258
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /btw <question>');
|
|
4259
|
+
return;
|
|
4260
|
+
}
|
|
4261
|
+
if (btwQuestion.length > CHANNEL_BTW_MAX_INPUT_LENGTH) {
|
|
4262
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `BTW questions are limited to ${CHANNEL_BTW_MAX_INPUT_LENGTH} characters.`);
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
4265
|
+
if (envelope.imageBase64 || envelope.attachments?.length) {
|
|
4266
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, '/btw supports text-only questions.');
|
|
4267
|
+
return;
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
3833
4270
|
}
|
|
3834
4271
|
// 3.5. Bang (!) shell command — refuse outside a private 1:1 chat BEFORE
|
|
3835
4272
|
// resolving a session, so a refused command never creates or persists one.
|
|
@@ -3870,20 +4307,21 @@ export class ChannelBase {
|
|
|
3870
4307
|
return;
|
|
3871
4308
|
}
|
|
3872
4309
|
try {
|
|
3873
|
-
|
|
4310
|
+
const resumed = await this.namedSessions.resumeReserved(this.namedSessionOwner(envelope), namedTurn.sessionId);
|
|
4311
|
+
sessionId = resumed ? namedTurn.sessionId : undefined;
|
|
3874
4312
|
}
|
|
3875
4313
|
catch (error) {
|
|
3876
4314
|
await this.sendNamedSessionError(envelope, error);
|
|
3877
4315
|
return;
|
|
3878
4316
|
}
|
|
3879
4317
|
if (!sessionId) {
|
|
3880
|
-
process.stderr.write(`[${this.name}] dropped collected turn from ${envelope.senderId} for session ${namedTurn.sessionId}:
|
|
4318
|
+
process.stderr.write(`[${this.name}] dropped collected turn from ${envelope.senderId} for session ${namedTurn.sessionId}: reserved task is no longer available\n`);
|
|
3881
4319
|
return;
|
|
3882
4320
|
}
|
|
3883
4321
|
}
|
|
3884
4322
|
else if (this.namedSessions) {
|
|
3885
4323
|
try {
|
|
3886
|
-
sessionId = await this.namedSessions.resolve(this.namedSessionOwner(envelope),
|
|
4324
|
+
sessionId = await this.namedSessions.resolve(this.namedSessionOwner(envelope), (resolvedSessionId) => {
|
|
3887
4325
|
const binding = this.bindNamedTurn(envelope, resolvedSessionId);
|
|
3888
4326
|
namedTurn = binding;
|
|
3889
4327
|
return () => this.releaseNamedTurnBinding(binding);
|
|
@@ -3901,6 +4339,17 @@ export class ChannelBase {
|
|
|
3901
4339
|
else {
|
|
3902
4340
|
sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup);
|
|
3903
4341
|
}
|
|
4342
|
+
const sourceLabel = this.namedSessions
|
|
4343
|
+
? this.sourceLabelForTurn(sessionId, envelope)
|
|
4344
|
+
: undefined;
|
|
4345
|
+
if (this.namedSessions && !sourceLabel) {
|
|
4346
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Could not identify the selected task. Use /sessions, select it again, and retry.');
|
|
4347
|
+
return;
|
|
4348
|
+
}
|
|
4349
|
+
if (btwQuestion !== undefined) {
|
|
4350
|
+
await this.handleBtw(envelope, sessionId, btwQuestion, sourceLabel);
|
|
4351
|
+
return;
|
|
4352
|
+
}
|
|
3904
4353
|
// Bang (!) execution — a private 1:1 session has a single operator, so
|
|
3905
4354
|
// direct shell execution stays allowed. Group/shared contexts were refused
|
|
3906
4355
|
// above, before the session was resolved.
|
|
@@ -3918,10 +4367,10 @@ export class ChannelBase {
|
|
|
3918
4367
|
const exitLine = result.exitCode !== null && result.exitCode !== 0
|
|
3919
4368
|
? `\nExit code: ${result.exitCode}`
|
|
3920
4369
|
: '';
|
|
3921
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}
|
|
4370
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`, sourceLabel);
|
|
3922
4371
|
}
|
|
3923
4372
|
catch (error) {
|
|
3924
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}
|
|
4373
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, sourceLabel);
|
|
3925
4374
|
}
|
|
3926
4375
|
return;
|
|
3927
4376
|
}
|
|
@@ -4268,6 +4717,7 @@ export class ChannelBase {
|
|
|
4268
4717
|
senderId: envelope.senderId,
|
|
4269
4718
|
senderName: envelope.senderName,
|
|
4270
4719
|
metadata: envelope.metadata,
|
|
4720
|
+
sourceLabel,
|
|
4271
4721
|
};
|
|
4272
4722
|
// This turn is now the single owner of the session's active-prompt slot.
|
|
4273
4723
|
// (Steer no longer hands a still-active session to a replacement; only
|
|
@@ -4290,7 +4740,7 @@ export class ChannelBase {
|
|
|
4290
4740
|
minChars: this.config.blockStreamingChunk?.minChars ?? 400,
|
|
4291
4741
|
maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000,
|
|
4292
4742
|
idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500,
|
|
4293
|
-
send: (text) => this.sendResponseMessage(envelope.chatId, text, sessionId),
|
|
4743
|
+
send: (text) => this.sendResponseMessage(envelope.chatId, text, sessionId, sourceLabel),
|
|
4294
4744
|
})
|
|
4295
4745
|
: null;
|
|
4296
4746
|
promptState.stopStreaming = () => streamer?.stop();
|
|
@@ -4408,6 +4858,9 @@ export class ChannelBase {
|
|
|
4408
4858
|
if (promptState.cancelled) {
|
|
4409
4859
|
return;
|
|
4410
4860
|
}
|
|
4861
|
+
if (sourceLabel) {
|
|
4862
|
+
this.inboundErrorSourceLabels.set(envelope, sourceLabel);
|
|
4863
|
+
}
|
|
4411
4864
|
throw err;
|
|
4412
4865
|
}
|
|
4413
4866
|
finally {
|