@qwen-code/channel-base 0.22.2-preview.1 → 0.22.3

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.
@@ -8,6 +8,7 @@ import { GroupHistoryStore } from './group-history-store.js';
8
8
  import { SenderGate } from './SenderGate.js';
9
9
  import { PairingStore } from './PairingStore.js';
10
10
  import { SessionRouter } from './SessionRouter.js';
11
+ import { NamedSessionManager, } from './named-session-manager.js';
11
12
  import { getGlobalQwenDir } from './paths.js';
12
13
  import { sanitizeSenderName, sanitizeQuotedText, sanitizePromptText, sanitizePromptPath, sanitizeLogText, sanitizeDisplayText, truncateCodePoints, PROMPT_UNSAFE_INVISIBLES, } from './sanitize.js';
13
14
  import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js';
@@ -122,6 +123,7 @@ export class ChannelBase {
122
123
  groupPairingNotified = new Map();
123
124
  loopController;
124
125
  observedContacts;
126
+ namedSessions;
125
127
  observedContactEnvelopes = new WeakSet();
126
128
  instructedSessions = new Set();
127
129
  unattendedMemorySessions = new Set();
@@ -130,6 +132,8 @@ export class ChannelBase {
130
132
  commands = new Map();
131
133
  /** Per-session promise chain to serialize prompt + send (followup mode). */
132
134
  sessionQueues = new Map();
135
+ queuedTurns = new Map();
136
+ namedTurnBindings = new WeakMap();
133
137
  registerBridgeEvents;
134
138
  bridgeRecovery;
135
139
  /**
@@ -208,7 +212,16 @@ export class ChannelBase {
208
212
  await this.pushProactive(target, text);
209
213
  return;
210
214
  }
211
- await this.sendResponseMessage(target.chatId, text, sessionId);
215
+ await this.deliverBackgroundReply(target.chatId, text, sessionId);
216
+ }
217
+ /**
218
+ * Fallback delivery of a background response when proactive send is
219
+ * unavailable. Adapters whose turn replies bypass sendResponseMessage (to
220
+ * stay out of turn-scoped streaming state, for example) override only this
221
+ * step instead of re-implementing the whole dispatch flow.
222
+ */
223
+ async deliverBackgroundReply(chatId, text, sessionId) {
224
+ await this.sendResponseMessage(chatId, text, sessionId);
212
225
  }
213
226
  async dispatchPermissionRequest(event) {
214
227
  const target = this.permissionTargetForEvent(event);
@@ -471,6 +484,21 @@ export class ChannelBase {
471
484
  this.router =
472
485
  options?.router ||
473
486
  new SessionRouter(bridge, config.cwd, config.sessionScope);
487
+ if (config.multiSession) {
488
+ if (config.sessionScope !== 'user') {
489
+ throw new Error(`Channel "${name}" requires sessionScope "user" when multiSession is enabled.`);
490
+ }
491
+ if (!options?.stateDir) {
492
+ throw new Error(`Channel "${name}" multiSession is available only in daemon-managed mode.`);
493
+ }
494
+ this.namedSessions = new NamedSessionManager({
495
+ channelName: name,
496
+ cwd: config.cwd,
497
+ filePath: join(options.stateDir, 'named-sessions.json'),
498
+ router: this.router,
499
+ isBusy: (sessionId) => this.isNamedSessionBusy(sessionId),
500
+ });
501
+ }
474
502
  this.registerSharedCommands();
475
503
  if (this.loopController) {
476
504
  bridge.registerChannelLoopToolHandler?.(this.channelLoopToolHandler);
@@ -893,8 +921,11 @@ export class ChannelBase {
893
921
  imageBase64: undefined,
894
922
  imageMimeType: undefined,
895
923
  };
924
+ if (this.namedSessions) {
925
+ this.bindNamedTurn(syntheticEnvelope, sessionId);
926
+ }
896
927
  this.markPreflighted(syntheticEnvelope);
897
- this.processInbound(syntheticEnvelope).catch((err) => {
928
+ void this.processPreflightedInbound(syntheticEnvelope).catch((err) => {
898
929
  process.stderr.write(`[${this.name}] dropped ${lost} buffered message(s) after ${taskLabel} for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${err instanceof Error ? err.message : String(err)}\n`);
899
930
  });
900
931
  }
@@ -1630,7 +1661,9 @@ export class ChannelBase {
1630
1661
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can cancel requests in this shared session.');
1631
1662
  return true;
1632
1663
  }
1633
- const activeSessionId = this.findActiveSessionId(envelope);
1664
+ const activeSessionId = this.namedSessions
1665
+ ? await this.findNamedActiveSessionId(envelope)
1666
+ : this.findActiveSessionId(envelope);
1634
1667
  if (!activeSessionId) {
1635
1668
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No request is currently running.');
1636
1669
  return true;
@@ -1900,10 +1933,221 @@ export class ChannelBase {
1900
1933
  : 'Permission request is no longer pending.');
1901
1934
  return true;
1902
1935
  }
1936
+ namedSessionOwner(envelope) {
1937
+ return {
1938
+ senderId: envelope.senderId,
1939
+ chatId: envelope.chatId,
1940
+ ...(envelope.threadId !== undefined
1941
+ ? { threadId: envelope.threadId }
1942
+ : {}),
1943
+ ...(envelope.isGroup !== undefined ? { isGroup: envelope.isGroup } : {}),
1944
+ };
1945
+ }
1946
+ async currentSessionId(envelope) {
1947
+ if (this.namedSessions) {
1948
+ return (await this.namedSessions.current(this.namedSessionOwner(envelope)))?.sessionId;
1949
+ }
1950
+ return this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
1951
+ }
1952
+ reserveQueuedTurn(sessionId) {
1953
+ this.queuedTurns.set(sessionId, (this.queuedTurns.get(sessionId) ?? 0) + 1);
1954
+ }
1955
+ releaseQueuedTurn(sessionId) {
1956
+ const remaining = (this.queuedTurns.get(sessionId) ?? 1) - 1;
1957
+ if (remaining === 0) {
1958
+ this.queuedTurns.delete(sessionId);
1959
+ }
1960
+ else {
1961
+ this.queuedTurns.set(sessionId, remaining);
1962
+ }
1963
+ }
1964
+ bindNamedTurn(envelope, sessionId) {
1965
+ const binding = {
1966
+ sessionId,
1967
+ generation: this.sessionGenerations.get(sessionId) ?? 0,
1968
+ claimed: false,
1969
+ released: false,
1970
+ };
1971
+ this.namedTurnBindings.set(envelope, binding);
1972
+ this.reserveQueuedTurn(sessionId);
1973
+ return binding;
1974
+ }
1975
+ releaseNamedTurnBinding(binding) {
1976
+ if (binding.sessionId === null || binding.claimed || binding.released) {
1977
+ return;
1978
+ }
1979
+ binding.released = true;
1980
+ this.releaseQueuedTurn(binding.sessionId);
1981
+ }
1982
+ finishNamedTurnBinding(envelope) {
1983
+ const binding = this.namedTurnBindings.get(envelope);
1984
+ if (!binding)
1985
+ return;
1986
+ this.releaseNamedTurnBinding(binding);
1987
+ this.namedTurnBindings.delete(envelope);
1988
+ }
1989
+ bypassesNamedTurnBinding(envelope) {
1990
+ const parsed = this.parseCommand(envelope.text);
1991
+ if (parsed && this.commands.has(parsed.command))
1992
+ return true;
1993
+ const bangText = envelope.text.trimStart();
1994
+ return (bangText.startsWith('!') &&
1995
+ (envelope.isGroup || this.isSharedSession(envelope)));
1996
+ }
1997
+ async prepareNamedTurnBinding(envelope) {
1998
+ const namedSessions = this.namedSessions;
1999
+ if (!namedSessions || this.namedTurnBindings.has(envelope))
2000
+ return true;
2001
+ if (this.bypassesNamedTurnBinding(envelope))
2002
+ return true;
2003
+ try {
2004
+ const sessionId = await namedSessions.resolve(this.namedSessionOwner(envelope), undefined, (resolvedSessionId) => {
2005
+ const binding = this.bindNamedTurn(envelope, resolvedSessionId);
2006
+ return () => this.releaseNamedTurnBinding(binding);
2007
+ });
2008
+ if (!sessionId && !this.namedTurnBindings.has(envelope)) {
2009
+ this.namedTurnBindings.set(envelope, {
2010
+ sessionId: null,
2011
+ generation: 0,
2012
+ claimed: false,
2013
+ released: true,
2014
+ });
2015
+ }
2016
+ return true;
2017
+ }
2018
+ catch (error) {
2019
+ this.finishNamedTurnBinding(envelope);
2020
+ await this.sendNamedSessionError(envelope, error);
2021
+ return false;
2022
+ }
2023
+ }
2024
+ isNamedSessionBusy(sessionId) {
2025
+ if ((this.queuedTurns.get(sessionId) ?? 0) > 0)
2026
+ return true;
2027
+ if (this.activePrompts.has(sessionId))
2028
+ return true;
2029
+ for (const permission of this.pendingPermissions.values()) {
2030
+ if (permission.sessionId === sessionId)
2031
+ return true;
2032
+ }
2033
+ try {
2034
+ return (this.bridge
2035
+ .listSessions?.()
2036
+ .some((session) => session.sessionId === sessionId && session.hasActivePrompt) ?? false);
2037
+ }
2038
+ catch {
2039
+ return true;
2040
+ }
2041
+ }
2042
+ async sendNamedSessionError(envelope, error) {
2043
+ const message = error instanceof Error
2044
+ ? sanitizeDisplayText(error.message, 500)
2045
+ : 'Named-session operation failed.';
2046
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, message || 'Named-session operation failed.');
2047
+ }
2048
+ async handleNamedSessionsCommand(envelope, args) {
2049
+ const namedSessions = this.namedSessions;
2050
+ if (!namedSessions)
2051
+ return false;
2052
+ const normalized = args.trim().toLowerCase();
2053
+ if (normalized !== '' && normalized !== 'all') {
2054
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /sessions [all]');
2055
+ return true;
2056
+ }
2057
+ try {
2058
+ const tasks = await namedSessions.list(this.namedSessionOwner(envelope), normalized === 'all');
2059
+ if (tasks.length === 0) {
2060
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, normalized === 'all' ? 'No named tasks.' : 'No open named tasks.');
2061
+ return true;
2062
+ }
2063
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [
2064
+ normalized === 'all' ? 'Tasks:' : 'Open tasks:',
2065
+ ...tasks.map((task) => `${task.active ? '*' : '-'} ${task.name} (${task.status}, ${task.isolation})`),
2066
+ ].join('\n'));
2067
+ }
2068
+ catch (error) {
2069
+ await this.sendNamedSessionError(envelope, error);
2070
+ }
2071
+ return true;
2072
+ }
2073
+ async handleNamedSessionCommand(envelope, args) {
2074
+ const namedSessions = this.namedSessions;
2075
+ if (!namedSessions)
2076
+ return false;
2077
+ const parts = args.trim().split(/\s+/u).filter(Boolean);
2078
+ const subcommand = parts.shift()?.toLowerCase() ?? '';
2079
+ const owner = this.namedSessionOwner(envelope);
2080
+ try {
2081
+ switch (subcommand) {
2082
+ case 'current': {
2083
+ if (parts.length > 0)
2084
+ break;
2085
+ const current = await namedSessions.current(owner);
2086
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, current
2087
+ ? `Current task: ${current.name} (${current.isolation})`
2088
+ : 'No task is currently selected. Use /session new <name> or /session use <name>.');
2089
+ return true;
2090
+ }
2091
+ case 'new': {
2092
+ if (parts.includes('--worktree')) {
2093
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Worktree tasks are not available in Part 2 yet. Create a shared task with /session new <name>.');
2094
+ return true;
2095
+ }
2096
+ if (parts.length !== 1)
2097
+ break;
2098
+ const created = await namedSessions.create(owner, parts[0]);
2099
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Created and selected task "${created.name}" (shared workspace).`);
2100
+ return true;
2101
+ }
2102
+ case 'use': {
2103
+ if (parts.length !== 1)
2104
+ break;
2105
+ const selected = await namedSessions.use(owner, parts[0]);
2106
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Selected task "${selected.name}" (${selected.isolation} workspace).`);
2107
+ return true;
2108
+ }
2109
+ case 'close': {
2110
+ if (parts.length !== 1)
2111
+ break;
2112
+ const result = await namedSessions.close(owner, parts[0]);
2113
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.active
2114
+ ? `Closed task "${result.closed.name}". Selected "${result.active.name}".`
2115
+ : `Closed task "${result.closed.name}". No task is selected.`);
2116
+ return true;
2117
+ }
2118
+ case 'cancel':
2119
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Named task cancellation is not available in Part 2 yet. Wait for the selected task to finish before switching; Telegram users can use /cancel.');
2120
+ return true;
2121
+ default:
2122
+ break;
2123
+ }
2124
+ }
2125
+ catch (error) {
2126
+ await this.sendNamedSessionError(envelope, error);
2127
+ return true;
2128
+ }
2129
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /session current | /session new <name> | /session use <name> | /session close <name> | /session cancel [<name>]');
2130
+ return true;
2131
+ }
1903
2132
  /** Register shared slash commands. Called from constructor. */
1904
2133
  registerSharedCommands() {
1905
2134
  const doClear = async (envelope) => {
1906
- const removedIds = this.router.removeSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2135
+ let resetTaskName;
2136
+ let removedIds;
2137
+ if (this.namedSessions) {
2138
+ try {
2139
+ const reset = await this.namedSessions.reset(this.namedSessionOwner(envelope));
2140
+ resetTaskName = reset?.name;
2141
+ removedIds = reset ? [reset.previousSessionId] : [];
2142
+ }
2143
+ catch (error) {
2144
+ await this.sendNamedSessionError(envelope, error);
2145
+ return;
2146
+ }
2147
+ }
2148
+ else {
2149
+ removedIds = this.router.removeSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2150
+ }
1907
2151
  this.clearPendingGroupHistory(envelope);
1908
2152
  if (removedIds.length > 0) {
1909
2153
  for (const id of removedIds) {
@@ -1995,7 +2239,9 @@ export class ChannelBase {
1995
2239
  }
1996
2240
  this.discardRetiredSession(this.bridge, id, 'cleared');
1997
2241
  }
1998
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Session cleared. The next message starts a fresh conversation.');
2242
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, resetTaskName
2243
+ ? `Task "${resetTaskName}" reset with a fresh conversation.`
2244
+ : 'Session cleared. The next message starts a fresh conversation.');
1999
2245
  }
2000
2246
  else {
2001
2247
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No active session to clear.');
@@ -2020,6 +2266,10 @@ export class ChannelBase {
2020
2266
  this.registerCommand('clear', clearHandler);
2021
2267
  this.registerCommand('reset', clearHandler);
2022
2268
  this.registerCommand('new', clearHandler);
2269
+ if (this.namedSessions) {
2270
+ this.registerCommand('sessions', (envelope, args) => this.handleNamedSessionsCommand(envelope, args));
2271
+ this.registerCommand('session', (envelope, args) => this.handleNamedSessionCommand(envelope, args));
2272
+ }
2023
2273
  this.registerCommand('approve', (envelope, args) => this.handlePermissionResponseCommand(envelope, args, 'approve'));
2024
2274
  this.registerCommand('approve-always', (envelope, args) => this.handlePermissionResponseCommand(envelope, args, 'approve-always'));
2025
2275
  this.registerCommand('deny', (envelope, args) => this.handlePermissionResponseCommand(envelope, args, 'deny'));
@@ -2031,7 +2281,9 @@ export class ChannelBase {
2031
2281
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can view this shared session.');
2032
2282
  return true;
2033
2283
  }
2034
- const active = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2284
+ const active = this.namedSessions
2285
+ ? Boolean(await this.currentSessionId(envelope))
2286
+ : this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2035
2287
  // `single` collapses EVERY DM and group to one `__single__` session, so it
2036
2288
  // is shared channel-wide regardless of where the /who came from — report
2037
2289
  // that explicitly (a group `single` session understates its blast radius as
@@ -2073,6 +2325,12 @@ export class ChannelBase {
2073
2325
  '/approve [request-id] — Approve a pending permission request',
2074
2326
  '/approve-always [request-id] — Always approve a pending permission request',
2075
2327
  '/deny [request-id] — Deny a pending permission request',
2328
+ ...(this.namedSessions
2329
+ ? [
2330
+ '/sessions [all] — List your named tasks',
2331
+ '/session current|new|use|close — Manage your named tasks',
2332
+ ]
2333
+ : []),
2076
2334
  ];
2077
2335
  // Platform-specific commands (registered by adapters, not shared ones)
2078
2336
  const sharedCmds = new Set([
@@ -2088,6 +2346,8 @@ export class ChannelBase {
2088
2346
  'forget-channel',
2089
2347
  'who',
2090
2348
  'status',
2349
+ 'sessions',
2350
+ 'session',
2091
2351
  ]);
2092
2352
  const platformCmds = [...this.commands.keys()].filter((c) => !sharedCmds.has(c));
2093
2353
  if (platformCmds.length > 0) {
@@ -2095,7 +2355,7 @@ export class ChannelBase {
2095
2355
  lines.push(`/${cmd}`);
2096
2356
  }
2097
2357
  }
2098
- const sessionId = this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2358
+ const sessionId = await this.currentSessionId(envelope);
2099
2359
  const agentCommands = sessionId
2100
2360
  ? this.getAgentCommandsForSession(sessionId)
2101
2361
  : this.bridge.availableCommands;
@@ -2116,7 +2376,9 @@ export class ChannelBase {
2116
2376
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can view this shared session.');
2117
2377
  return true;
2118
2378
  }
2119
- const hasSession = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2379
+ const hasSession = this.namedSessions
2380
+ ? Boolean(await this.currentSessionId(envelope))
2381
+ : this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2120
2382
  const policy = this.config.senderPolicy;
2121
2383
  const lines = [
2122
2384
  `Session: ${hasSession ? 'active' : 'none'}`,
@@ -2466,6 +2728,12 @@ export class ChannelBase {
2466
2728
  ? sessionId
2467
2729
  : undefined;
2468
2730
  }
2731
+ async findNamedActiveSessionId(envelope) {
2732
+ const sessionId = await this.currentSessionId(envelope);
2733
+ return sessionId && this.activePrompts.has(sessionId)
2734
+ ? sessionId
2735
+ : undefined;
2736
+ }
2469
2737
  channelMemoryTarget(envelope) {
2470
2738
  return {
2471
2739
  channelName: this.name,
@@ -3302,9 +3570,14 @@ export class ChannelBase {
3302
3570
  });
3303
3571
  return `${GROUP_HISTORY_CONTEXT_MARKER}\n${formatted.join('\n')}\n\n${CURRENT_MESSAGE_MARKER}\n${promptText}`;
3304
3572
  }
3305
- preflightInbound(envelope) {
3306
- const groupResult = this.groupGate.check(envelope);
3307
- if (!groupResult.allowed) {
3573
+ preflightInbound(envelope, options = {}) {
3574
+ const groupResult = this.groupGate.check(envelope, {
3575
+ createPairingRequest: !options.deferPairingRequests,
3576
+ });
3577
+ const deferredGroupPairing = options.deferPairingRequests === true &&
3578
+ groupResult.reason === 'pairing_trigger_required' &&
3579
+ (envelope.isMentioned || envelope.isReplyToBot);
3580
+ if (!groupResult.allowed && !deferredGroupPairing) {
3308
3581
  if (groupResult.pairing !== undefined) {
3309
3582
  this.logPreflightRejected('group_pairing_required');
3310
3583
  return this.onGroupPairingRequired(envelope.chatId, groupResult.pairing, envelope.threadId)
@@ -3335,6 +3608,12 @@ export class ChannelBase {
3335
3608
  this.markPreflighted(envelope);
3336
3609
  return true;
3337
3610
  }
3611
+ if (options.deferPairingRequests === true &&
3612
+ this.config.senderPolicy === 'pairing' &&
3613
+ !this.gate.isAllowed(envelope.senderId)) {
3614
+ this.markPreflighted(envelope);
3615
+ return true;
3616
+ }
3338
3617
  const result = this.gate.check(envelope.senderId, envelope.senderName);
3339
3618
  if (!result.allowed) {
3340
3619
  if (result.pairing !== undefined) {
@@ -3370,7 +3649,55 @@ export class ChannelBase {
3370
3649
  const preflight = this.preflightInbound(envelope);
3371
3650
  if (!(isPromiseLike(preflight) ? await preflight : preflight))
3372
3651
  return;
3373
- await this.processInbound(envelope);
3652
+ await this.processPreflightedInbound(envelope);
3653
+ }
3654
+ async prepareThenHandleInbound(envelope, prepare, preflightOptions = {}) {
3655
+ const preflight = this.preflightInbound(envelope, preflightOptions);
3656
+ if (!(isPromiseLike(preflight) ? await preflight : preflight))
3657
+ return;
3658
+ if (this.namedSessions) {
3659
+ const result = await this.namedSessions.resolveAfterPreparation(this.namedSessionOwner(envelope), prepare, () => !this.bypassesNamedTurnBinding(envelope), (sessionId) => {
3660
+ const binding = this.bindNamedTurn(envelope, sessionId);
3661
+ return () => this.releaseNamedTurnBinding(binding);
3662
+ });
3663
+ if (result.status === 'aborted')
3664
+ return;
3665
+ if (result.status === 'resolve_error') {
3666
+ this.finishNamedTurnBinding(envelope);
3667
+ await this.sendNamedSessionError(envelope, result.error);
3668
+ return;
3669
+ }
3670
+ if (result.status === 'resolved' &&
3671
+ !result.sessionId &&
3672
+ !this.namedTurnBindings.has(envelope)) {
3673
+ this.namedTurnBindings.set(envelope, {
3674
+ sessionId: null,
3675
+ generation: 0,
3676
+ claimed: false,
3677
+ released: true,
3678
+ });
3679
+ }
3680
+ }
3681
+ else if ((await prepare()) === false) {
3682
+ return;
3683
+ }
3684
+ try {
3685
+ await this.handleInbound(envelope);
3686
+ }
3687
+ finally {
3688
+ this.finishNamedTurnBinding(envelope);
3689
+ }
3690
+ }
3691
+ async processPreflightedInbound(envelope, process = () => this.processInbound(envelope)) {
3692
+ if (this.namedSessions && !(await this.prepareNamedTurnBinding(envelope))) {
3693
+ return;
3694
+ }
3695
+ try {
3696
+ await process();
3697
+ }
3698
+ finally {
3699
+ this.finishNamedTurnBinding(envelope);
3700
+ }
3374
3701
  }
3375
3702
  async recordObservedContact(envelope) {
3376
3703
  if (!this.observedContacts)
@@ -3532,7 +3859,48 @@ export class ChannelBase {
3532
3859
  // Preprocessing above can await memory/command hooks; recovery may have
3533
3860
  // started since the entry check. Recheck immediately before session routing.
3534
3861
  await this.waitForBridgeRecovery();
3535
- const sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup);
3862
+ let sessionId;
3863
+ let namedTurn = this.namedTurnBindings.get(envelope);
3864
+ if (this.namedSessions && namedTurn) {
3865
+ if (namedTurn.sessionId === null) {
3866
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No task was selected when this message was received. Select a task and send it again.');
3867
+ return;
3868
+ }
3869
+ if (this.dropQueuedTurnIfStale(namedTurn.sessionId, namedTurn.generation, envelope)) {
3870
+ return;
3871
+ }
3872
+ try {
3873
+ sessionId = await this.namedSessions.resolve(this.namedSessionOwner(envelope), namedTurn.sessionId);
3874
+ }
3875
+ catch (error) {
3876
+ await this.sendNamedSessionError(envelope, error);
3877
+ return;
3878
+ }
3879
+ if (!sessionId) {
3880
+ process.stderr.write(`[${this.name}] dropped collected turn from ${envelope.senderId} for session ${namedTurn.sessionId}: selected task changed before it ran\n`);
3881
+ return;
3882
+ }
3883
+ }
3884
+ else if (this.namedSessions) {
3885
+ try {
3886
+ sessionId = await this.namedSessions.resolve(this.namedSessionOwner(envelope), undefined, (resolvedSessionId) => {
3887
+ const binding = this.bindNamedTurn(envelope, resolvedSessionId);
3888
+ namedTurn = binding;
3889
+ return () => this.releaseNamedTurnBinding(binding);
3890
+ });
3891
+ }
3892
+ catch (error) {
3893
+ await this.sendNamedSessionError(envelope, error);
3894
+ return;
3895
+ }
3896
+ if (!sessionId) {
3897
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No task is currently selected. Use /session new <name> or /session use <name>.');
3898
+ return;
3899
+ }
3900
+ }
3901
+ else {
3902
+ sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup);
3903
+ }
3536
3904
  // Bang (!) execution — a private 1:1 session has a single operator, so
3537
3905
  // direct shell execution stays allowed. Group/shared contexts were refused
3538
3906
  // above, before the session was resolved.
@@ -3616,15 +3984,22 @@ export class ChannelBase {
3616
3984
  const quoted = sanitizeQuotedText(envelope.referencedText, 500);
3617
3985
  promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
3618
3986
  }
3619
- // Resolve attachments: extract image for bridge, append file paths to text
3987
+ // Resolve attachments: extract images for bridge, append file paths to text
3620
3988
  let imageBase64 = envelope.imageBase64;
3621
3989
  let imageMimeType = envelope.imageMimeType;
3990
+ const images = [];
3991
+ if (imageBase64 && imageMimeType) {
3992
+ images.push({ data: imageBase64, mimeType: imageMimeType });
3993
+ }
3622
3994
  if (envelope.attachments?.length) {
3623
3995
  const filePaths = [];
3624
3996
  for (const att of envelope.attachments) {
3625
- if (att.type === 'image' && att.data && !imageBase64) {
3626
- imageBase64 = att.data;
3627
- imageMimeType = att.mimeType;
3997
+ if (att.type === 'image' && att.data) {
3998
+ images.push({ data: att.data, mimeType: att.mimeType });
3999
+ if (!imageBase64) {
4000
+ imageBase64 = att.data;
4001
+ imageMimeType = att.mimeType;
4002
+ }
3628
4003
  }
3629
4004
  else if (att.filePath) {
3630
4005
  const label = att.type === 'file' ? 'file' : att.type;
@@ -3784,13 +4159,17 @@ export class ChannelBase {
3784
4159
  // a fresh Promise.resolve()) is what guarantees this turn never runs while the
3785
4160
  // turn it supersedes is still active — see the steer branch above.
3786
4161
  const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
3787
- // Snapshot the session generation at enqueue time to guard against a /clear
3788
- // racing this turn. There is no await between reading `active` above and this
3789
- // snapshot, so the capture is atomic with the enqueue; if /clear bumps the
3790
- // generation before this turn dequeues, the session we captured is gone — bail
3791
- // (at the dequeue guard below) rather than resurrect it.
3792
- const generation = this.sessionGenerations.get(sessionId) ?? 0;
4162
+ // Fresh turns snapshot the generation at enqueue time. A collected re-entry
4163
+ // keeps the generation captured when its buffer drained, so /clear cannot
4164
+ // resurrect it while preprocessing runs before this queue.
4165
+ const generation = namedTurn?.generation ?? this.sessionGenerations.get(sessionId) ?? 0;
3793
4166
  const useBlockStreaming = this.config.blockStreaming === 'on';
4167
+ if (namedTurn) {
4168
+ namedTurn.claimed = true;
4169
+ }
4170
+ else {
4171
+ this.reserveQueuedTurn(sessionId);
4172
+ }
3794
4173
  const current = prev.then(async () => {
3795
4174
  // Disarm the steer watchdog: the predecessor's tail has resolved, so this
3796
4175
  // chained turn is no longer wedged behind it. No-op when unarmed (the timer is
@@ -3962,6 +4341,7 @@ export class ChannelBase {
3962
4341
  promptBridge.on('responseBoundary', onResponseBoundary);
3963
4342
  try {
3964
4343
  const response = await promptBridge.prompt(sessionId, promptToSend, {
4344
+ ...(images.length > 0 ? { images } : {}),
3965
4345
  imageBase64,
3966
4346
  imageMimeType,
3967
4347
  displayText,
@@ -4033,7 +4413,13 @@ export class ChannelBase {
4033
4413
  finally {
4034
4414
  promptBridge.off('textChunk', onChunk);
4035
4415
  promptBridge.off('responseBoundary', onResponseBoundary);
4036
- streamer?.stop();
4416
+ if (streamer) {
4417
+ streamer.stop();
4418
+ // Queued block sends belong to this turn: let them land before
4419
+ // onPromptEnd settles turn-scoped adapter state, or a send racing
4420
+ // the settle can recreate discarded state and leak unredacted text.
4421
+ await streamer.drain();
4422
+ }
4037
4423
  // Identity guard: a turn that wedged past /clear's bounded wait gets
4038
4424
  // EVICTED — /clear gives up on active.done, deletes activePrompts, and a
4039
4425
  // turn the user starts AFTER the clear can re-seed activePrompts (and own
@@ -4085,42 +4471,14 @@ export class ChannelBase {
4085
4471
  // settles late can't drain a buffer a later turn now owns. (Belt-and-
4086
4472
  // suspenders: /clear already deletes the buffer on eviction, so this guard
4087
4473
  // is defensive — but it keeps the invariant "only the current turn drains".)
4088
- const buffer = this.collectBuffers.get(sessionId);
4089
- if (stillCurrent && buffer && buffer.length > 0) {
4090
- this.collectBuffers.delete(sessionId);
4091
- const lost = buffer.length;
4092
- const coalesced = buffer.map((b) => b.text).join('\n\n');
4093
- const coalescedDisplayText = buffer
4094
- .map((b) => b.displayText)
4095
- .join('\n\n');
4096
- const lastEnvelope = buffer[buffer.length - 1].envelope;
4097
- this.notifyPromptBufferDrained(lastEnvelope.chatId, sessionId, buffer);
4098
- // Re-enter handleInbound with the coalesced message
4099
- const syntheticEnvelope = {
4100
- ...lastEnvelope,
4101
- text: coalesced,
4102
- displayText: coalescedDisplayText,
4103
- // Coalesced text already carries each message's [sender] prefix.
4104
- alreadyPrefixed: true,
4105
- // Clear attachments/references — already resolved in original text
4106
- referencedText: undefined,
4107
- mentionedMemberIds: undefined,
4108
- attachments: undefined,
4109
- metadata: undefined,
4110
- imageBase64: undefined,
4111
- imageMimeType: undefined,
4112
- };
4113
- this.markPreflighted(syntheticEnvelope);
4114
- // Queue the coalesced prompt (don't await to avoid deadlock on the queue).
4115
- // Surface a drain failure instead of silently losing buffered turns.
4116
- this.processInbound(syntheticEnvelope).catch((err) => {
4117
- process.stderr.write(`[${this.name}] dropped ${lost} buffered message(s) on collect re-entry for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${err instanceof Error ? err.message : String(err)}\n`);
4118
- });
4119
- }
4474
+ this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, 'prompt completion');
4120
4475
  }
4121
4476
  });
4122
- this.sessionQueues.set(sessionId, current.catch(() => { }));
4123
- await current;
4477
+ const tracked = current.finally(() => {
4478
+ this.releaseQueuedTurn(sessionId);
4479
+ });
4480
+ this.sessionQueues.set(sessionId, tracked.catch(() => { }));
4481
+ await tracked;
4124
4482
  }
4125
4483
  pairingRejectionMessage(rejected) {
4126
4484
  return rejected === 'sender_pending'