@qwen-code/channel-base 0.22.2 → 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.
- package/README.md +15 -9
- package/dist/AcpBridge.d.ts.map +1 -1
- package/dist/AcpBridge.js +4 -4
- package/dist/AcpBridge.js.map +1 -1
- package/dist/BlockStreamer.d.ts +6 -1
- package/dist/BlockStreamer.d.ts.map +1 -1
- package/dist/BlockStreamer.js +9 -1
- package/dist/BlockStreamer.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +17 -0
- package/dist/ChannelAgentBridge.d.ts.map +1 -1
- package/dist/ChannelAgentBridge.js +30 -0
- package/dist/ChannelAgentBridge.js.map +1 -1
- package/dist/ChannelBase.d.ts +30 -1
- package/dist/ChannelBase.d.ts.map +1 -1
- package/dist/ChannelBase.js +416 -58
- package/dist/ChannelBase.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +11 -3
- package/dist/DaemonChannelBridge.d.ts.map +1 -1
- package/dist/DaemonChannelBridge.js +243 -64
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SessionRouter.d.ts +11 -0
- package/dist/SessionRouter.d.ts.map +1 -1
- package/dist/SessionRouter.js +150 -1
- package/dist/SessionRouter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- 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 +89 -0
- package/dist/named-session-manager.d.ts.map +1 -0
- package/dist/named-session-manager.js +695 -0
- package/dist/named-session-manager.js.map +1 -0
- 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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
3626
|
-
|
|
3627
|
-
|
|
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
|
-
//
|
|
3788
|
-
//
|
|
3789
|
-
//
|
|
3790
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4123
|
-
|
|
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'
|