mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* chore: ignore local worktrees * docs(channels): design identity and task lifecycle p0 * docs(channels): plan identity and task lifecycle p0 * feat(channels): add identity and task lifecycle metadata * fix(channels): suppress cancelled tool call lifecycle * docs: add channel lifecycle status adapter design * docs: add channel lifecycle status adapter plan * feat(channels): map telegram lifecycle to typing * feat(channels): map weixin lifecycle to typing * fix(channels): reset weixin typing state after failed start * feat(channels): map dingtalk lifecycle to reactions * feat(channels): add feishu card status labels * fix(channels): preserve feishu collapsible status labels * feat(channels): show feishu lifecycle card status * fix(channels): cover loop lifecycle metadata * fix(channels): preserve feishu terminal card status * chore: remove internal task reports from branch * fix(channels): clear late lifecycle status starts * fix(channels): harden lifecycle event edges * fix(channels): clean adapter lifecycle state * fix(channels): finalize cancelled lifecycle before cleanup * fix(feishu): keep cancelled card status visible * docs(channels): add lifecycle status no-op coverage * fix(channels): address lifecycle review suggestions * docs(channels): align lifecycle status documentation * fix(channels): route adapter stop through lifecycle cancel * fix(channels): close lifecycle cancellation races * fix(channels): keep first feishu terminal status * fix(telegram): guard lifecycle typing updates * test(qqbot): cover lifecycle status no-ops * fix(feishu): preserve completed card race status * docs(lifecycle): align status review docs * fix(channels): separate pending cancel state * fix(channels): clean adapter lifecycle status edges * fix(channels): order clear cancellation lifecycle * fix(feishu): preserve user stop status label * fix(channels): suppress loop chunks during pending cancel * test(channels): use active session in cancel regression * fix(channels): harden adapter cancellation tests * fix(channels): sanitize lifecycle tool fields * fix(channels): route shared tool call lifecycle * fix(channels): preserve pending cancel intent * fix(channels): preserve pending cancel intent * fix(telegram): track typing by session * fix(feishu): preserve stop status during finalization * fix(channels): preserve responses after failed cancel * fix(channels): tighten lifecycle cancellation reasons * fix(channels): close lifecycle cancel races and validate identity config Address the outstanding review findings on #6105: - carry a typed reason on ChannelLoopSkippedError and report disabled loops as 'dropped' instead of 'timeout' - treat a turn as committed once delivery starts: /cancel re-checks deliveryStarted after the cancel RPC settles, and neither prompt path lets a late-settling cancel rewrite a delivered turn into cancelled (or follow a /clear cancellation with completed) - tag failed lifecycle events with phase: agent vs delivery - validate identity/memoryScope shape at config parse time instead of throwing an opaque TypeError on the first prompt of every session - guard onPromptStart hooks, pass job.id to loop onPromptStart/End, append the boundary block after operator instructions, gate /who + /status identity lines on configured identity/memoryScope, cache the boundary prompt, and route error logs through lifecycleError() Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(qqbot): keep lifecycle mock in sync * fix(channels): unify cancel state machine and adapter lifecycle edges Address the remaining review findings on #6114: - /cancel now delegates to requestActivePromptCancellation — one cancel state machine for slash command and adapter stop buttons; the helper refuses to claim success once delivery started and sanitizes its logs - share isTerminalTaskLifecycleType from channel-base instead of four hand-rolled terminal checks - DingTalk: only attach reactions for message ids seen inbound (loop job ids no longer trigger doomed emotion API calls), log reaction API failures, and recall reactions when a session dies - Feishu: track userStopped on the card state so every wind-down path renders 已停止生成 after a Stop click, and pass terminal labels through updateCard's statusLabel param at the remaining baked-text sites - Weixin: guard the typing .then() against a racing disconnect - document onTaskLifecycle as the canonical hook (onPromptStart/End are back-compat), fix TS4111 bracket access in the DingTalk test Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(channels): keep failed terminal event when cancel settles post-delivery Self-review follow-up: once delivery started, the catch paths no longer reconcile a pending cancel — a late-resolving cancel RPC used to flip cancelled=true there, suppressing the failed emit while the /cancel handler (seeing deliveryStarted) also declined to emit, leaving a started task with no terminal lifecycle event at all. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(channels): close self-review findings on adapter lifecycle edges - DingTalk: track inbound message ids in a capped insertion-ordered set instead of the TTL-swept dedup map, so a turn queued minutes behind a long predecessor still attaches its reaction - Feishu: reset userStopped when the cancel RPC fails (later wind-down must render the real terminal status), and give handleStop's plain message fallback the same ---/label shape the strip regex expects Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(channels): hold streamed chunks while a cancel is pending Chunks arriving while a /cancel RPC is in flight were pushed straight into the BlockStreamer, which can send a block on a size/paragraph threshold before the cancel resolves — leaking output a successful cancel can never recall. Hold the pending-window chunks instead: replay them (block streaming + text_chunk transcript) when the cancel fails, discard them when it succeeds. onResponseChunk stays live through the window so adapter-accumulated display state has no permanent hole on a failed cancel; adapters gate visible updates on their own stop flags. Also stop passing the loop job id to onPromptStart/onPromptEnd (and the /clear eviction path): the hook contract is inbound platform message ids, and adapters act on them — cards and reactions keyed to a fake id. Lifecycle events still carry job.id for correlation. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(feishu): strip every status-label layout from quoted-reply context Replace the two $-anchored strip regexes in extractCardText with one line-granular status-block filter. The anchored patterns missed four layouts the cards actually render: the two-block truncation card (notice block + label block), terminal labels joined mid-string before a collapsible panel body, the 停止失败,请重试 label (never in the alternation), and label-only stopped cards where the divider leads the text. Tests now assert the real rendered shapes. Also update the adapter-cancellation suppression test to the new hold-and-replay chunk semantics from the base layer. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(feishu): ignore loop job ids in prompt hooks * fix(channels): defer adapter chunks while cancel is pending * fix(feishu): preserve bare status text in quotes * fix(channels): release held chunks on failed cancel * fix(feishu): preserve generated stop fallback status * fix(telegram): clear typing on dead sessions * fix(feishu): share status label definitions --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2802 lines
106 KiB
TypeScript
2802 lines
106 KiB
TypeScript
import { basename, join } from 'node:path';
|
|
import type {
|
|
ChannelConfig,
|
|
ChannelMemoryCallbacks,
|
|
ChannelMemoryTarget,
|
|
ChannelRuntimeIdentity,
|
|
ChannelRuntimeMemoryScope,
|
|
ChannelTaskCancellationReason,
|
|
ChannelTaskLifecycleBase,
|
|
ChannelTaskLifecycleEvent,
|
|
DispatchMode,
|
|
Envelope,
|
|
SanitizedToolCallEvent,
|
|
SessionTarget,
|
|
} from './types.js';
|
|
import { BlockStreamer } from './BlockStreamer.js';
|
|
import { GroupGate } from './GroupGate.js';
|
|
import { GroupHistoryStore } from './group-history-store.js';
|
|
import type { GroupHistoryEntry } from './group-history-store.js';
|
|
import { SenderGate } from './SenderGate.js';
|
|
import { PairingStore } from './PairingStore.js';
|
|
import { SessionRouter } from './SessionRouter.js';
|
|
import { getGlobalQwenDir } from './paths.js';
|
|
import {
|
|
sanitizeSenderName,
|
|
sanitizeQuotedText,
|
|
sanitizePromptText,
|
|
sanitizePromptPath,
|
|
sanitizeLogText,
|
|
} from './sanitize.js';
|
|
import type {
|
|
AvailableCommand,
|
|
ChannelAgentBridge,
|
|
SessionDiedEvent,
|
|
ToolCallEvent,
|
|
} from './ChannelAgentBridge.js';
|
|
import type { ChannelLoop, ChannelLoopInput } from './ChannelLoopStore.js';
|
|
import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js';
|
|
|
|
/**
|
|
* Max time /clear waits for a cancelled in-flight turn to wind down before
|
|
* purging anyway. A wedged ACP child (stuck tool call, not reading stdin, or
|
|
* crashed without closing) can leave active.done unresolved forever; without
|
|
* this bound /clear — and the whole channel — would hang. Safe because the
|
|
* purge runs regardless and the generation is bumped, so a turn that settles
|
|
* later is already invalidated.
|
|
*/
|
|
export const CLEAR_CANCEL_TIMEOUT_MS = 3000;
|
|
const GROUP_HISTORY_CONTEXT_MARKER =
|
|
'[Chat messages since your last reply - for context]';
|
|
const CURRENT_MESSAGE_MARKER = '[Current message - respond to this]';
|
|
const GROUP_HISTORY_ENTRY_TEXT_LIMIT = 1000;
|
|
const GROUP_HISTORY_ENTRY_METADATA_LIMIT = 256;
|
|
const LOOP_CANCEL_GRACE_MS = 5000;
|
|
/** Sentinel message for the loop-prompt timeout rejection; matched by identity below. */
|
|
const LOOP_TIMED_OUT_MESSAGE = 'loop timed out';
|
|
|
|
export interface ChannelBaseOptions {
|
|
router?: SessionRouter;
|
|
proxy?: string;
|
|
channelMemory?: ChannelMemoryCallbacks;
|
|
/**
|
|
* Set when a channel owns a supplied router and should consume bridge
|
|
* events directly.
|
|
*/
|
|
registerBridgeEvents?: boolean;
|
|
groupHistoryPath?: string;
|
|
loopController?: ChannelLoopController;
|
|
}
|
|
|
|
export interface ChannelLoopController {
|
|
create(input: ChannelLoopInput): Promise<ChannelLoop>;
|
|
createForTarget?(
|
|
input: ChannelLoopInput,
|
|
maxEnabledLoops: number,
|
|
): Promise<ChannelLoop | undefined>;
|
|
listForTarget(
|
|
channelName: string,
|
|
target: SessionTarget,
|
|
): Promise<ChannelLoop[]>;
|
|
disable(id: string): Promise<boolean>;
|
|
validateCron(cron: string): void;
|
|
nextFireTime?(job: ChannelLoop): Date;
|
|
}
|
|
|
|
export interface ChannelLoopPromptOptions {
|
|
timeoutMs?: number;
|
|
shouldContinue?: () => Promise<boolean>;
|
|
}
|
|
|
|
/** Handler for a slash command. Return true if handled, false to forward to agent. */
|
|
type CommandHandler = (envelope: Envelope, args: string) => Promise<boolean>;
|
|
type ActivePrompt = {
|
|
cancelled: boolean;
|
|
cancelPending?: boolean;
|
|
cancellationEmitted?: boolean;
|
|
cancelRequested?: Promise<boolean>;
|
|
/** Set once response delivery to the platform has begun; past this point a cancel can no longer suppress the turn's output. */
|
|
deliveryStarted?: boolean;
|
|
/** Set for loop prompts, whose messageId is an internal job id — adapter
|
|
* hooks must not receive it (their contract is platform message ids). */
|
|
loopPrompt?: boolean;
|
|
done: Promise<void>;
|
|
resolve: () => void;
|
|
stopStreaming?: () => void;
|
|
/** The originating turn's chat/message, so a clear-time eviction can run this
|
|
* turn's own onPromptEnd (its finally may settle long after — or never). */
|
|
chatId: string;
|
|
messageId?: string;
|
|
/**
|
|
* Set when /clear's bounded wait times out and evicts this (wedged) turn. /clear
|
|
* has NO replacement turn, so it runs this turn's onPromptEnd at eviction time,
|
|
* and the late-settling finally then skips it (via the clearEvicted guard) so a
|
|
* turn the user started AFTER the clear can't have its working indicator
|
|
* clobbered.
|
|
*/
|
|
clearEvicted?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
|
|
* plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
|
|
* `/compress-fast`, `/git:commit`) parse as commands. Shared by parseCommand and
|
|
* isSlashCommand below so the two classifiers can't drift apart.
|
|
*/
|
|
const COMMAND_TOKEN_CHARS = 'a-zA-Z0-9_:-';
|
|
/** parseCommand: capture the leading `/command` token (+ optional `@botname`) and the rest as args. */
|
|
const PARSE_COMMAND_RE = new RegExp(
|
|
`^\\/([${COMMAND_TOKEN_CHARS}]+)(?:@\\S+)?\\s*(.*)`,
|
|
's',
|
|
);
|
|
/** isSlashCommand: the first whitespace-delimited token alone must be a pure command token. */
|
|
const COMMAND_TOKEN_RE = new RegExp(`^[${COMMAND_TOKEN_CHARS}]+(?:@\\S+)?$`);
|
|
const LOOP_ADD_RE = /^"([^"]+)"\s+(.+)$/su;
|
|
const MAX_LOOP_JOBS_PER_TARGET = 10;
|
|
const MAX_LOOP_PROMPT_CHARS = 4000;
|
|
|
|
/**
|
|
* The command-providing surface of a bridge. AcpBridge runs a single agent and
|
|
* exposes only the global `availableCommands` getter; DaemonChannelBridge keys
|
|
* commands per session and ALSO exposes `getAvailableCommands(sessionId)`. Both
|
|
* members are optional so any bridge type is checked STRUCTURALLY here instead of
|
|
* through a blind `as unknown` cast — a future rename or return-type change then
|
|
* fails to compile rather than breaking at runtime.
|
|
*/
|
|
interface AgentCommandsProvider {
|
|
getAvailableCommands?: (sessionId: string) => AvailableCommand[];
|
|
availableCommands?: AvailableCommand[];
|
|
}
|
|
|
|
function parseLoopAddArgs(
|
|
args: string,
|
|
): { cron: string; prompt: string } | null {
|
|
const match = args.trim().match(LOOP_ADD_RE);
|
|
if (!match) return null;
|
|
const cron = match[1].trim();
|
|
const prompt = match[2].trim();
|
|
return cron && prompt ? { cron, prompt } : null;
|
|
}
|
|
|
|
export abstract class ChannelBase {
|
|
protected config: ChannelConfig;
|
|
protected bridge: ChannelAgentBridge;
|
|
protected groupGate: GroupGate;
|
|
protected gate: SenderGate;
|
|
protected router: SessionRouter;
|
|
protected name: string;
|
|
/** Resolved (defaulted + frozen) identity/scope — adapters should read these, not raw config. */
|
|
protected readonly identity: ChannelRuntimeIdentity;
|
|
protected readonly memoryScope: ChannelRuntimeMemoryScope;
|
|
/** Resolved proxy URL, available to subclasses for adapter-specific clients. */
|
|
protected proxy?: string;
|
|
private readonly channelMemory?: ChannelMemoryCallbacks;
|
|
private groupHistory: GroupHistoryStore;
|
|
private readonly loopController?: ChannelLoopController;
|
|
private instructedSessions: Set<string> = new Set();
|
|
private commands: Map<string, CommandHandler> = new Map();
|
|
/** Per-session promise chain to serialize prompt + send (followup mode). */
|
|
private sessionQueues: Map<string, Promise<void>> = new Map();
|
|
private readonly registerBridgeEvents: boolean;
|
|
/**
|
|
* Per-session generation, bumped by /clear. A queued followup turn captures the
|
|
* generation when it enqueues and bails if /clear bumped it before the turn ran,
|
|
* so a cleared session can't be resurrected by an already-queued prompt.
|
|
*/
|
|
private sessionGenerations: Map<string, number> = new Map();
|
|
|
|
/** Per-session active prompt tracking for dispatch modes. */
|
|
private activePrompts: Map<string, ActivePrompt> = new Map();
|
|
/** Per-session message buffer for collect mode. */
|
|
private collectBuffers: Map<
|
|
string,
|
|
Array<{ text: string; envelope: Envelope }>
|
|
> = new Map();
|
|
private readonly bridgeToolCallListener = (event: ToolCallEvent): void => {
|
|
this.dispatchToolCall(event);
|
|
};
|
|
private readonly bridgeSessionDiedListener = (
|
|
event: SessionDiedEvent,
|
|
): void => {
|
|
this.onSessionDied(event.sessionId);
|
|
};
|
|
|
|
dispatchToolCall(event: ToolCallEvent): void {
|
|
const target = this.router.getTarget(event.sessionId);
|
|
const active = this.activePrompts.get(event.sessionId);
|
|
const chatId = active?.chatId ?? target?.chatId;
|
|
if (!chatId) {
|
|
return;
|
|
}
|
|
if (active && !active.cancelled && !active.cancelPending) {
|
|
// `?? ''`: dispatchToolCall is a public entry point — a third-party bridge
|
|
// omitting a field must not throw out of its emit('toolCall').
|
|
const safeToolCall: SanitizedToolCallEvent = {
|
|
sessionId: event.sessionId,
|
|
toolCallId: event.toolCallId,
|
|
kind: sanitizeLogText(event.kind ?? '', 20),
|
|
title: sanitizeLogText(event.title ?? '', 80),
|
|
status: sanitizeLogText(event.status ?? '', 20),
|
|
};
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(chatId, event.sessionId, active.messageId),
|
|
type: 'tool_call',
|
|
toolCall: safeToolCall,
|
|
});
|
|
}
|
|
this.onToolCall(chatId, event);
|
|
}
|
|
|
|
constructor(
|
|
name: string,
|
|
config: ChannelConfig,
|
|
bridge: ChannelAgentBridge,
|
|
options?: ChannelBaseOptions,
|
|
) {
|
|
this.name = name;
|
|
this.config = config;
|
|
this.bridge = bridge;
|
|
this.proxy = options?.proxy;
|
|
this.identity = Object.freeze(this.resolveIdentity(name, config));
|
|
this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config));
|
|
this.channelMemory = options?.channelMemory;
|
|
this.groupHistory = new GroupHistoryStore(
|
|
options?.groupHistoryPath ??
|
|
join(
|
|
getGlobalQwenDir(),
|
|
'channels',
|
|
`${encodeURIComponent(name)}-group-history.jsonl`,
|
|
),
|
|
);
|
|
this.loopController = options?.loopController;
|
|
|
|
this.groupGate = new GroupGate(config.groupPolicy, config.groups);
|
|
|
|
const pairingStore =
|
|
config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined;
|
|
this.gate = new SenderGate(
|
|
config.senderPolicy,
|
|
config.allowedUsers,
|
|
pairingStore,
|
|
);
|
|
this.router =
|
|
options?.router ||
|
|
new SessionRouter(bridge, config.cwd, config.sessionScope);
|
|
|
|
this.registerSharedCommands();
|
|
|
|
// When running standalone, register bridge listeners directly.
|
|
// In gateway mode, the ChannelManager dispatches events instead.
|
|
this.registerBridgeEvents =
|
|
options?.registerBridgeEvents ?? !options?.router;
|
|
if (this.registerBridgeEvents) {
|
|
this.attachBridgeEvents(bridge);
|
|
}
|
|
}
|
|
|
|
abstract connect(): Promise<void>;
|
|
abstract sendMessage(chatId: string, text: string): Promise<void>;
|
|
abstract disconnect(): void;
|
|
|
|
/**
|
|
* Adapter hook for task lifecycle events — the canonical way to track task
|
|
* state (onPromptStart/onPromptEnd are retained for back-compat). The prompt
|
|
* flow never awaits this hook; an async override's rejection is caught and
|
|
* logged, nothing more.
|
|
*/
|
|
protected onTaskLifecycle(
|
|
_event: ChannelTaskLifecycleEvent,
|
|
): void | Promise<void> {}
|
|
|
|
private emitTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
|
|
try {
|
|
const result = this.onTaskLifecycle(event);
|
|
if (result && typeof result.catch === 'function') {
|
|
result.catch((err: unknown) => {
|
|
this.logTaskLifecycleError(event, err);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
this.logTaskLifecycleError(event, err);
|
|
}
|
|
}
|
|
|
|
private logTaskLifecycleError(
|
|
event: ChannelTaskLifecycleEvent,
|
|
err: unknown,
|
|
): void {
|
|
const channel = sanitizeLogText(this.name, 64);
|
|
const sessionId = sanitizeLogText(event.sessionId, 64);
|
|
const stack =
|
|
err instanceof Error && err.stack
|
|
? ` | ${sanitizeLogText(err.stack, 500)}`
|
|
: '';
|
|
process.stderr.write(
|
|
`[${channel}] onTaskLifecycle threw for ${event.type} session ${sessionId}: ${this.lifecycleError(err)}${stack}\n`,
|
|
);
|
|
}
|
|
|
|
private lifecycleError(err: unknown): string {
|
|
return sanitizeLogText(
|
|
err instanceof Error ? err.message : String(err),
|
|
200,
|
|
);
|
|
}
|
|
|
|
private emitTaskCancellation(
|
|
active: ActivePrompt,
|
|
sessionId: string,
|
|
reason: ChannelTaskCancellationReason,
|
|
): void {
|
|
if (active.cancellationEmitted) {
|
|
return;
|
|
}
|
|
active.cancellationEmitted = true;
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(active.chatId, sessionId, active.messageId),
|
|
type: 'cancelled',
|
|
reason,
|
|
});
|
|
}
|
|
|
|
private resolveIdentity(
|
|
name: string,
|
|
config: ChannelConfig,
|
|
): ChannelRuntimeIdentity {
|
|
return {
|
|
id: config.identity?.id || `channel:${name}`,
|
|
displayName: config.identity?.displayName || name,
|
|
...(config.identity?.description
|
|
? { description: config.identity.description }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
private resolveMemoryScope(
|
|
name: string,
|
|
config: ChannelConfig,
|
|
): ChannelRuntimeMemoryScope {
|
|
return {
|
|
namespace: config.memoryScope?.namespace || `channel:${name}`,
|
|
mode: config.memoryScope?.mode ?? 'metadata-only',
|
|
};
|
|
}
|
|
|
|
/** Built once — identity/memoryScope are frozen at construction. */
|
|
private boundaryPrompt?: string;
|
|
|
|
private channelBoundaryPrompt(): string {
|
|
if (this.boundaryPrompt !== undefined) {
|
|
return this.boundaryPrompt;
|
|
}
|
|
const identityLines = [
|
|
'Channel identity:',
|
|
`- id: ${sanitizeQuotedText(this.identity.id, 128)}`,
|
|
`- display name: ${sanitizeQuotedText(this.identity.displayName, 128)}`,
|
|
...(this.identity.description
|
|
? [
|
|
`- description: ${sanitizeQuotedText(this.identity.description, 256)}`,
|
|
]
|
|
: []),
|
|
];
|
|
const memoryLines = [
|
|
'Memory scope:',
|
|
`- namespace: ${sanitizeQuotedText(this.memoryScope.namespace, 128)}`,
|
|
`- mode: ${this.memoryScope.mode}`,
|
|
'- data from other channels must not be shared.',
|
|
];
|
|
this.boundaryPrompt = [...identityLines, '', ...memoryLines].join('\n');
|
|
return this.boundaryPrompt;
|
|
}
|
|
|
|
private shouldPrependChannelBoundaryPrompt(): boolean {
|
|
return Boolean(this.config.identity || this.config.memoryScope);
|
|
}
|
|
|
|
private lifecycleBase(
|
|
chatId: string,
|
|
sessionId: string,
|
|
messageId?: string,
|
|
): ChannelTaskLifecycleBase {
|
|
return {
|
|
channelName: this.name,
|
|
chatId,
|
|
sessionId,
|
|
...(messageId ? { messageId } : {}),
|
|
identity: this.identity,
|
|
memoryScope: this.memoryScope,
|
|
};
|
|
}
|
|
|
|
supportsProactiveSend(): boolean {
|
|
return false;
|
|
}
|
|
|
|
protected supportsProactiveTarget(target: SessionTarget): boolean {
|
|
return target.threadId === undefined;
|
|
}
|
|
|
|
protected async pushProactive(
|
|
target: SessionTarget,
|
|
text: string,
|
|
): Promise<void> {
|
|
if (target.threadId) {
|
|
throw new Error(
|
|
'Channel does not support proactive loop messages for threaded targets.',
|
|
);
|
|
}
|
|
await this.sendMessage(target.chatId, text);
|
|
}
|
|
|
|
/** Replace the bridge instance (used after crash recovery restart). */
|
|
setBridge(bridge: ChannelAgentBridge): void {
|
|
if (this.registerBridgeEvents) {
|
|
this.detachBridgeEvents(this.bridge);
|
|
}
|
|
this.router.setBridge(bridge);
|
|
this.bridge = bridge;
|
|
if (this.registerBridgeEvents) {
|
|
this.attachBridgeEvents(bridge);
|
|
}
|
|
}
|
|
|
|
async runLoopPrompt(
|
|
job: ChannelLoop,
|
|
options: ChannelLoopPromptOptions = {},
|
|
): Promise<string | undefined> {
|
|
if (!this.supportsProactiveSend()) {
|
|
throw new Error('Channel does not support proactive loop messages.');
|
|
}
|
|
if (this.config.sessionScope === 'single') {
|
|
await this.loopController?.disable(job.id);
|
|
throw new Error(
|
|
'Loop messages are not supported with single session scope.',
|
|
);
|
|
}
|
|
if (job.channelName !== this.name) {
|
|
throw new Error(
|
|
`Loop ${job.id} belongs to ${job.channelName}, not ${this.name}.`,
|
|
);
|
|
}
|
|
if (!this.supportsProactiveTarget(job.target)) {
|
|
throw new Error(
|
|
'Channel does not support proactive loop messages for this chat target.',
|
|
);
|
|
}
|
|
if (!this.isStoredLoopTargetAuthorized(job.target, job.createdBy)) {
|
|
await this.loopController?.disable(job.id);
|
|
throw new Error(`Loop ${job.id} target is no longer authorized.`);
|
|
}
|
|
|
|
const sessionId = await this.router.resolve(
|
|
this.name,
|
|
job.target.senderId,
|
|
job.target.chatId,
|
|
job.target.threadId,
|
|
job.cwd,
|
|
);
|
|
const label = sanitizeQuotedText(job.label || job.id, 80);
|
|
const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
|
|
// Without the delivery-contract sentence the model treats "post X" prompts
|
|
// as an action it must perform itself and goes hunting for send credentials.
|
|
let promptText = `[Loop "${label}" created by ${createdBy}] Scheduled task running unattended: no one is present to answer questions, and your final response is delivered to this chat automatically — do whatever work the task requires, then put the result in your final response instead of trying to deliver it to this chat yourself.\n\n${sanitizePromptText(job.prompt)}`;
|
|
const shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
|
|
|
|
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
const generation = this.sessionGenerations.get(sessionId) ?? 0;
|
|
const current = prev.then(async (): Promise<string | undefined> => {
|
|
if ((this.sessionGenerations.get(sessionId) ?? 0) !== generation) {
|
|
process.stderr.write(
|
|
`[${this.name}] dropped loop ${job.id} for session ${sessionId}: session was cleared before it ran\n`,
|
|
);
|
|
throw new ChannelLoopSkippedError(
|
|
'loop dropped because session was cleared before it ran',
|
|
);
|
|
}
|
|
if (options.shouldContinue && !(await options.shouldContinue())) {
|
|
throw new ChannelLoopSkippedError(
|
|
'loop dropped because it is no longer enabled',
|
|
);
|
|
}
|
|
let shouldClaimSessionContext = false;
|
|
if (shouldPrependSessionContext) {
|
|
const context: string[] = [];
|
|
let sessionContextReady = true;
|
|
if (
|
|
this.channelMemory &&
|
|
this.isSenderAuthorizedForChannelMemory(job.target.senderId) &&
|
|
(!this.isSharedSessionTarget(job.target) ||
|
|
this.config.senderPolicy === 'allowlist')
|
|
) {
|
|
try {
|
|
const memoryText = (
|
|
await this.channelMemory.readChannelMemory({
|
|
channelName: this.name,
|
|
chatId: job.target.chatId,
|
|
threadId: job.target.threadId,
|
|
})
|
|
).trim();
|
|
if (memoryText) {
|
|
context.push(
|
|
`Channel memory for this chat:\n${sanitizePromptText(memoryText)}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`[${this.name}] channel memory read failed for loop ${job.id} chat ${sanitizeLogText(job.target.chatId, 64)}: ${sanitizeLogText(this.channelMemoryErrorMessage(error), 200)}\n`,
|
|
);
|
|
this.instructedSessions.delete(sessionId);
|
|
sessionContextReady = false;
|
|
}
|
|
}
|
|
if (this.config.instructions) {
|
|
context.push(this.config.instructions);
|
|
}
|
|
// Boundary block goes last: recency bias means later instructions win,
|
|
// and the isolation boundary must not be overridable by operator text.
|
|
if (this.shouldPrependChannelBoundaryPrompt()) {
|
|
context.push(this.channelBoundaryPrompt());
|
|
}
|
|
if (context.length > 0) {
|
|
promptText = `${context.join('\n\n')}\n\n${promptText}`;
|
|
}
|
|
if (sessionContextReady) {
|
|
shouldClaimSessionContext = true;
|
|
}
|
|
}
|
|
if ((this.sessionGenerations.get(sessionId) ?? 0) !== generation) {
|
|
process.stderr.write(
|
|
`[${this.name}] dropped loop ${job.id} for session ${sessionId}: session was cleared before it ran\n`,
|
|
);
|
|
throw new ChannelLoopSkippedError(
|
|
'loop dropped because session was cleared before it ran',
|
|
);
|
|
}
|
|
if (shouldClaimSessionContext) {
|
|
this.instructedSessions.add(sessionId);
|
|
}
|
|
|
|
let doneResolve: () => void = () => {};
|
|
const done = new Promise<void>((resolve) => {
|
|
doneResolve = resolve;
|
|
});
|
|
const promptState: ActivePrompt = {
|
|
cancelled: false,
|
|
done,
|
|
resolve: doneResolve,
|
|
chatId: job.target.chatId,
|
|
messageId: job.id,
|
|
loopPrompt: true,
|
|
};
|
|
this.activePrompts.set(sessionId, promptState);
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
type: 'started',
|
|
});
|
|
// Guarded: an adapter indicator failure must not orphan the started
|
|
// event (no terminal) or leak the activePrompts entry.
|
|
// No messageId: the hook contract passes INBOUND platform message ids,
|
|
// and adapters act on them (cards, reactions) — a loop job id would
|
|
// collide. Lifecycle events still carry job.id for correlation.
|
|
try {
|
|
this.onPromptStart(job.target.chatId, sessionId);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] onPromptStart threw in loop ${job.id} for session ${sessionId}: ${this.lifecycleError(err)}\n`,
|
|
);
|
|
}
|
|
|
|
// Same hold-and-replay contract as handleInbound's onChunk: visible
|
|
// sinks stay out of the transcript while a cancel is pending.
|
|
const heldChunks: string[] = [];
|
|
const releaseHeldChunks = () => {
|
|
for (const held of heldChunks.splice(0)) {
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
type: 'text_chunk',
|
|
chunk: held,
|
|
});
|
|
this.onResponseChunk(job.target.chatId, held, sessionId);
|
|
}
|
|
};
|
|
const onChunk = (sid: string, chunk: string) => {
|
|
if (sid !== sessionId || promptState.cancelled) {
|
|
return;
|
|
}
|
|
heldChunks.push(chunk);
|
|
if (!promptState.cancelPending) {
|
|
releaseHeldChunks();
|
|
}
|
|
};
|
|
const promptBridge = this.bridge;
|
|
promptBridge.on('textChunk', onChunk);
|
|
|
|
try {
|
|
const response = await this.runLoopBridgePrompt(
|
|
promptBridge,
|
|
sessionId,
|
|
promptText,
|
|
promptState,
|
|
job.id,
|
|
options.timeoutMs,
|
|
);
|
|
await this.settleCancelRequested(promptState);
|
|
if (promptState.cancelled) {
|
|
throw new ChannelLoopSkippedError(
|
|
'loop cancelled before delivery',
|
|
'cancel_command',
|
|
);
|
|
}
|
|
releaseHeldChunks();
|
|
if (options.shouldContinue && !(await options.shouldContinue())) {
|
|
throw new ChannelLoopSkippedError('loop dropped before delivery');
|
|
}
|
|
if (promptState.cancelled) {
|
|
throw new ChannelLoopSkippedError(
|
|
'loop cancelled before delivery',
|
|
'cancel_command',
|
|
);
|
|
}
|
|
if (response) {
|
|
promptState.deliveryStarted = true;
|
|
await this.pushProactive(job.target, response);
|
|
}
|
|
// Once delivery started the run counts as completed — a cancel settling
|
|
// during/after the send must not convert a delivered run into a skip
|
|
// (a one-shot loop would stay enabled and deliver twice).
|
|
if (!promptState.deliveryStarted) {
|
|
await this.settleCancelRequested(promptState);
|
|
if (promptState.cancelled) {
|
|
throw new ChannelLoopSkippedError(
|
|
'loop cancelled before delivery',
|
|
'cancel_command',
|
|
);
|
|
}
|
|
}
|
|
// /clear can evict mid-delivery and emit its own terminal event; never
|
|
// follow a cancelled event with completed for the same prompt.
|
|
if (!promptState.cancellationEmitted) {
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
type: 'completed',
|
|
});
|
|
}
|
|
return response;
|
|
} catch (err) {
|
|
// Once delivery started, a late-settling cancel must not flip
|
|
// `cancelled` here — it would suppress the failed emit while the
|
|
// /cancel handler (seeing deliveryStarted) declines to emit its own
|
|
// terminal, leaving the task with no terminal event at all.
|
|
if (!promptState.deliveryStarted) {
|
|
await this.settleCancelRequested(promptState);
|
|
}
|
|
if (err instanceof ChannelLoopSkippedError && !promptState.cancelled) {
|
|
this.emitTaskCancellation(promptState, sessionId, err.reason);
|
|
promptState.cancelled = true;
|
|
}
|
|
if (
|
|
!promptState.cancelled &&
|
|
!(err instanceof ChannelLoopSkippedError)
|
|
) {
|
|
releaseHeldChunks();
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
type: 'failed',
|
|
error: this.lifecycleError(err),
|
|
phase: promptState.deliveryStarted ? 'delivery' : 'agent',
|
|
});
|
|
} else if (
|
|
promptState.cancelled &&
|
|
!(err instanceof ChannelLoopSkippedError) &&
|
|
!(err instanceof Error && err.message === LOOP_TIMED_OUT_MESSAGE)
|
|
) {
|
|
const channel = sanitizeLogText(this.name, 64);
|
|
const safeJobId = sanitizeLogText(job.id, 64);
|
|
const safeSessionId = sanitizeLogText(sessionId, 64);
|
|
process.stderr.write(
|
|
`[${channel}] loop ${safeJobId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`,
|
|
);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
promptBridge.off('textChunk', onChunk);
|
|
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
if (!promptState.clearEvicted) {
|
|
try {
|
|
this.onPromptEnd(job.target.chatId, sessionId);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] onPromptEnd threw in loop ${job.id} for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
}
|
|
}
|
|
if (stillCurrent) {
|
|
this.activePrompts.delete(sessionId);
|
|
}
|
|
promptState.resolve();
|
|
const buffer = this.collectBuffers.get(sessionId);
|
|
if (stillCurrent && buffer && buffer.length > 0) {
|
|
this.collectBuffers.delete(sessionId);
|
|
const lost = buffer.length;
|
|
const coalesced = buffer.map((b) => b.text).join('\n\n');
|
|
const lastEnvelope = buffer[buffer.length - 1]!.envelope;
|
|
const syntheticEnvelope: Envelope = {
|
|
...lastEnvelope,
|
|
text: coalesced,
|
|
alreadyPrefixed: true,
|
|
referencedText: undefined,
|
|
attachments: undefined,
|
|
imageBase64: undefined,
|
|
imageMimeType: undefined,
|
|
};
|
|
this.handleInbound(syntheticEnvelope).catch((err) => {
|
|
process.stderr.write(
|
|
`[${this.name}] dropped ${lost} buffered message(s) after loop ${job.id} for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}\n`,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
this.sessionQueues.set(
|
|
sessionId,
|
|
current.then(() => undefined).catch(() => {}),
|
|
);
|
|
return current;
|
|
}
|
|
|
|
private async runLoopBridgePrompt(
|
|
promptBridge: ChannelAgentBridge,
|
|
sessionId: string,
|
|
promptText: string,
|
|
promptState: ActivePrompt,
|
|
jobId: string,
|
|
timeoutMs: number | undefined,
|
|
): Promise<string> {
|
|
const prompt = promptBridge.prompt(sessionId, promptText, {});
|
|
prompt.catch(() => {});
|
|
if (timeoutMs === undefined) {
|
|
return prompt;
|
|
}
|
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
prompt,
|
|
new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new Error(LOOP_TIMED_OUT_MESSAGE));
|
|
}, timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message === LOOP_TIMED_OUT_MESSAGE) {
|
|
promptState.cancelled = true;
|
|
await this.cancelTimedOutLoopPrompt(promptBridge, sessionId, jobId);
|
|
this.emitTaskCancellation(promptState, sessionId, 'timeout');
|
|
}
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
private async cancelTimedOutLoopPrompt(
|
|
promptBridge: ChannelAgentBridge,
|
|
sessionId: string,
|
|
jobId: string,
|
|
): Promise<void> {
|
|
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const cancelled = await Promise.race([
|
|
promptBridge.cancelSession(sessionId).then(() => true),
|
|
new Promise<boolean>((resolve) => {
|
|
graceTimer = setTimeout(() => resolve(false), LOOP_CANCEL_GRACE_MS);
|
|
graceTimer.unref?.();
|
|
}),
|
|
]);
|
|
if (!cancelled) {
|
|
this.router.removeSessionId(sessionId);
|
|
this.instructedSessions.delete(sessionId);
|
|
process.stderr.write(
|
|
`[${this.name}] retired timed out loop ${jobId} session ${sessionId} after cancel did not settle\n`,
|
|
);
|
|
}
|
|
} catch (cancelErr) {
|
|
process.stderr.write(
|
|
`[${this.name}] cancelSession failed for timed out loop ${jobId} in session ${sessionId}: ${
|
|
cancelErr instanceof Error ? cancelErr.message : cancelErr
|
|
}\n`,
|
|
);
|
|
} finally {
|
|
clearTimeout(graceTimer);
|
|
}
|
|
}
|
|
|
|
protected requestActivePromptCancellation(
|
|
sessionId: string,
|
|
reason: 'cancel_command' | 'clear' | 'steer' = 'cancel_command',
|
|
): Promise<boolean> {
|
|
const active = this.activePrompts.get(sessionId);
|
|
if (!active) {
|
|
return this.bridge.cancelSession(sessionId).then(
|
|
() => true,
|
|
(err) => {
|
|
this.logCancelSessionFailure(sessionId, err);
|
|
return false;
|
|
},
|
|
);
|
|
}
|
|
if (active.deliveryStarted) {
|
|
return Promise.resolve(false);
|
|
}
|
|
const cancelRequested =
|
|
active.cancelRequested ??
|
|
this.bridge.cancelSession(sessionId).then(
|
|
() => true,
|
|
(err) => {
|
|
this.logCancelSessionFailure(sessionId, err);
|
|
active.cancelRequested = undefined;
|
|
return false;
|
|
},
|
|
);
|
|
active.cancelRequested = cancelRequested;
|
|
active.cancelPending = true;
|
|
return cancelRequested
|
|
.finally(() => {
|
|
active.cancelPending = false;
|
|
})
|
|
.then((cancelSucceeded) => {
|
|
// Re-check after the await: while the cancel RPC was in flight the
|
|
// turn may have started delivery, or ended on its own (uncancelled) —
|
|
// claiming success then would emit a spurious cancelled event for a
|
|
// response the user received. A turn that ended already-cancelled
|
|
// (the abort landed) still counts as a successful cancel.
|
|
const turnEnded = this.activePrompts.get(sessionId) !== active;
|
|
if (
|
|
!cancelSucceeded ||
|
|
active.deliveryStarted ||
|
|
(turnEnded && !active.cancelled)
|
|
) {
|
|
return false;
|
|
}
|
|
active.cancelled = true;
|
|
this.stopActiveStreaming(active, sessionId, reason);
|
|
this.collectBuffers.delete(sessionId);
|
|
this.emitTaskCancellation(active, sessionId, reason);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private logCancelSessionFailure(sessionId: string, err: unknown): void {
|
|
process.stderr.write(
|
|
`[${sanitizeLogText(this.name, 64)}] cancelSession failed for session=${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`,
|
|
);
|
|
}
|
|
|
|
private async settleCancelRequested(active: ActivePrompt): Promise<void> {
|
|
if (!active.cancelRequested || active.cancelled) {
|
|
return;
|
|
}
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const cancelled = await Promise.race([
|
|
active.cancelRequested,
|
|
new Promise<boolean>((resolve) => {
|
|
timer = setTimeout(() => resolve(false), CLEAR_CANCEL_TIMEOUT_MS);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
if (cancelled) {
|
|
active.cancelled = true;
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
onToolCall(_chatId: string, _event: ToolCallEvent): void {}
|
|
|
|
onSessionDied(sessionId: string): void {
|
|
this.router.removeSessionId(sessionId);
|
|
this.instructedSessions.delete(sessionId);
|
|
}
|
|
|
|
private attachBridgeEvents(bridge: ChannelAgentBridge): void {
|
|
bridge.on('toolCall', this.bridgeToolCallListener);
|
|
bridge.on('sessionDied', this.bridgeSessionDiedListener);
|
|
}
|
|
|
|
private detachBridgeEvents(bridge: ChannelAgentBridge): void {
|
|
bridge.off('toolCall', this.bridgeToolCallListener);
|
|
bridge.off('sessionDied', this.bridgeSessionDiedListener);
|
|
}
|
|
|
|
/**
|
|
* Called when a prompt actually begins processing (inside the session queue).
|
|
* Override to show a platform-specific working indicator (e.g., typing, reaction).
|
|
* Not called for buffered messages (collect mode) or gated/blocked messages.
|
|
*/
|
|
protected onPromptStart(
|
|
_chatId: string,
|
|
_sessionId: string,
|
|
_messageId?: string,
|
|
): void {}
|
|
|
|
/**
|
|
* Called when a prompt finishes (response sent or cancelled).
|
|
* Override to hide the working indicator.
|
|
*/
|
|
protected onPromptEnd(
|
|
_chatId: string,
|
|
_sessionId: string,
|
|
_messageId?: string,
|
|
): void {}
|
|
|
|
/**
|
|
* Called for each text chunk as the agent streams its response.
|
|
* Override to implement progressive display (e.g., updating an AI card in-place).
|
|
* Default: no-op (chunks are collected internally and delivered via onResponseComplete).
|
|
*/
|
|
protected onResponseChunk(
|
|
_chatId: string,
|
|
_chunk: string,
|
|
_sessionId: string,
|
|
): void {}
|
|
|
|
/**
|
|
* Called when the agent's full response is ready.
|
|
* Override to customize delivery (e.g., finalize an AI card).
|
|
* Default: calls sendMessage() with the full response text.
|
|
*/
|
|
protected async onResponseComplete(
|
|
chatId: string,
|
|
fullText: string,
|
|
_sessionId: string,
|
|
): Promise<void> {
|
|
await this.sendMessage(chatId, fullText);
|
|
}
|
|
|
|
/**
|
|
* Register a slash command handler. Subclasses can call this to add
|
|
* platform-specific commands (e.g., /start for Telegram).
|
|
* Overrides shared commands if the same name is registered.
|
|
*/
|
|
protected registerCommand(name: string, handler: CommandHandler): void {
|
|
this.commands.set(name.toLowerCase(), handler);
|
|
}
|
|
|
|
protected registerCancelCommand(name = 'cancel'): void {
|
|
this.registerCommand(name, async (envelope) => {
|
|
// /cancel aborts an in-flight turn — destructive in a shared session, where
|
|
// it would otherwise let any member kill another user's running turn. Gate it
|
|
// to authorized senders like /clear (auth gate only — no confirm step). A
|
|
// non-shared (1:1) session is always authorized, so behavior is unchanged.
|
|
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Only authorized members can cancel requests in this shared session.',
|
|
);
|
|
return true;
|
|
}
|
|
const activeSessionId = this.findActiveSessionId(envelope);
|
|
if (!activeSessionId) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'No request is currently running.',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const active = this.activePrompts.get(activeSessionId);
|
|
if (!active) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'No request is currently running.',
|
|
);
|
|
return true;
|
|
}
|
|
// Single cancel state machine: adapter stop buttons and /cancel share
|
|
// requestActivePromptCancellation so the two paths cannot drift.
|
|
const cancelSucceeded = await this.requestActivePromptCancellation(
|
|
activeSessionId,
|
|
'cancel_command',
|
|
);
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
cancelSucceeded
|
|
? 'Cancelled current request.'
|
|
: 'Failed to cancel current request.',
|
|
);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/** Register shared slash commands. Called from constructor. */
|
|
private registerSharedCommands(): void {
|
|
const doClear = async (envelope: Envelope): Promise<void> => {
|
|
const removedIds = this.router.removeSession(
|
|
this.name,
|
|
envelope.senderId,
|
|
envelope.chatId,
|
|
envelope.threadId,
|
|
);
|
|
this.clearPendingGroupHistory(envelope);
|
|
if (removedIds.length > 0) {
|
|
for (const id of removedIds) {
|
|
// Audit: clearing a SHARED session wipes the conversation for every
|
|
// participant, so record who triggered it (sanitized display name +
|
|
// stable senderId) and which session — mirrors the file's stderr audit
|
|
// style. A 1:1 DM clear only touches the caller, so it isn't logged.
|
|
if (this.isSharedSession(envelope)) {
|
|
const who = sanitizeSenderName(
|
|
envelope.senderName || envelope.senderId || 'unknown',
|
|
);
|
|
process.stderr.write(
|
|
`[${this.name}] shared session ${id} cleared by ${who} (sender ${envelope.senderId})\n`,
|
|
);
|
|
}
|
|
// Bump the generation up-front (before any await) so a followup turn
|
|
// already queued onto this session sees a stale generation and bails
|
|
// instead of running bridge.prompt() against the cleared session.
|
|
this.sessionGenerations.set(
|
|
id,
|
|
(this.sessionGenerations.get(id) ?? 0) + 1,
|
|
);
|
|
// Cancel an in-flight turn (and drop its buffered follow-ups) before
|
|
// purging, so a running prompt can't deliver a stale response into —
|
|
// or resurrect via collect-drain — the just-cleared session.
|
|
const active = this.activePrompts.get(id);
|
|
this.collectBuffers.delete(id);
|
|
if (active) {
|
|
// Bounded cancel + wind-down wait; purge regardless of the result.
|
|
const settled = await this.cancelAndAwaitActive(active, id);
|
|
if (!settled) {
|
|
// Wedged: the turn never wound down within the bound. Surface it —
|
|
// otherwise a zombie bridge.prompt() lingers in the child with zero
|
|
// observability ("/clear worked" but a turn is still pinned).
|
|
// Include the originating chat/message (sanitized — platform IDs can
|
|
// be attacker-influenced) so oncall can correlate the wedged turn. Both
|
|
// are read defensively (fallback / omitted) so a partial entry can't
|
|
// crash /clear, the recovery path.
|
|
const wedgedChat = active.chatId
|
|
? sanitizeLogText(active.chatId, 64)
|
|
: 'unknown';
|
|
const wedgedMessage = active.messageId
|
|
? `, message ${sanitizeLogText(active.messageId, 64)}`
|
|
: '';
|
|
process.stderr.write(
|
|
`[${this.name}] /clear abandoned a wedged turn for session ${id} (chat ${wedgedChat}${wedgedMessage}): it did not wind down within ${CLEAR_CANCEL_TIMEOUT_MS}ms\n`,
|
|
);
|
|
// The wedged turn's finally may run much later (or never), so clean
|
|
// up its OWN platform indicator now, while no replacement exists yet.
|
|
// Mark it clearEvicted FIRST so the late finally skips onPromptEnd — a
|
|
// turn the user starts after this /clear owns the chat indicator by
|
|
// then, and re-running cleanup would clobber it.
|
|
active.clearEvicted = true;
|
|
// onPromptEnd runs adapter cleanup (platform API calls that can throw).
|
|
// Swallow + audit any throw: an uncaught one would abort the purge
|
|
// below, leaving this turn in activePrompts so its late finally sees it
|
|
// as still-current (`stillCurrent || !clearEvicted`) and re-runs
|
|
// onPromptEnd anyway. Letting the purge proceed makes the turn
|
|
// non-current, so the clearEvicted guard then skips correctly.
|
|
try {
|
|
this.onPromptEnd(
|
|
active.chatId,
|
|
id,
|
|
active.loopPrompt ? undefined : active.messageId,
|
|
);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] onPromptEnd threw during /clear eviction for session ${id}: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// Purge every per-session map (all keyed by sessionId) so a
|
|
// long-running gateway doesn't leak dead entries after /clear.
|
|
this.instructedSessions.delete(id);
|
|
// The queue's tail resolves only after every turn queued before this
|
|
// /clear has dequeued and bailed on the bumped generation. Capture it
|
|
// before deletion so we can reclaim sessionGenerations[id] once it
|
|
// drains — otherwise the bumped entry leaks for the gateway's lifetime.
|
|
const drained = this.sessionQueues.get(id);
|
|
const bumpedGeneration = this.sessionGenerations.get(id);
|
|
this.sessionQueues.delete(id);
|
|
this.activePrompts.delete(id);
|
|
if (drained) {
|
|
// Deferred, never awaited: a wedged turn that never drains must not
|
|
// block /clear (the entry just lingers, as before). The guards skip
|
|
// reclamation if a newer turn re-queued onto this id or another
|
|
// /clear re-bumped it, so an entry a queued turn still needs is never
|
|
// deleted out from under it.
|
|
void drained.then(() => {
|
|
if (
|
|
!this.sessionQueues.has(id) &&
|
|
this.sessionGenerations.get(id) === bumpedGeneration
|
|
) {
|
|
this.sessionGenerations.delete(id);
|
|
}
|
|
});
|
|
} else {
|
|
// Nothing was ever queued for this session, so no turn can read the
|
|
// bumped value — reclaim it immediately.
|
|
this.sessionGenerations.delete(id);
|
|
}
|
|
}
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Session cleared. The next message starts a fresh conversation.',
|
|
);
|
|
} else {
|
|
await this.sendMessage(envelope.chatId, 'No active session to clear.');
|
|
}
|
|
};
|
|
|
|
// For a shared session, clearing it affects everyone who shares it: restrict
|
|
// it to authorized senders (config.allowedUsers, when set) and require an
|
|
// explicit "confirm". DMs on per-user/thread scope and per-user groups clear
|
|
// directly — there /clear only touches the caller's own session.
|
|
const clearHandler: CommandHandler = async (envelope, args) => {
|
|
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Only authorized members can clear this shared session.',
|
|
);
|
|
return true;
|
|
}
|
|
if (this.isSharedSession(envelope) && args.toLowerCase() !== 'confirm') {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'This clears the shared session for everyone who shares it. Re-send with "confirm" (e.g. /clear confirm) to proceed.',
|
|
);
|
|
return true;
|
|
}
|
|
await doClear(envelope);
|
|
return true;
|
|
};
|
|
|
|
this.registerCommand('clear', clearHandler);
|
|
this.registerCommand('reset', clearHandler);
|
|
this.registerCommand('new', clearHandler);
|
|
|
|
// Read-only: report the current (possibly group-shared) session and workspace.
|
|
// For a shared session, gate it to authorized senders like /clear — /who
|
|
// leaks the workspace basename, so non-members shouldn't see it either.
|
|
this.registerCommand('who', async (envelope) => {
|
|
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Only authorized members can view this shared session.',
|
|
);
|
|
return true;
|
|
}
|
|
const active = this.router.hasSession(
|
|
this.name,
|
|
envelope.senderId,
|
|
envelope.chatId,
|
|
envelope.threadId,
|
|
);
|
|
// `single` collapses EVERY DM and group to one `__single__` session, so it
|
|
// is shared channel-wide regardless of where the /who came from — report
|
|
// that explicitly (a group `single` session understates its blast radius as
|
|
// "shared by this group"). Other scopes keep their existing wording.
|
|
const scopeNote =
|
|
this.config.sessionScope === 'single'
|
|
? ' (shared channel-wide)'
|
|
: this.isSharedSession(envelope)
|
|
? envelope.isGroup
|
|
? ' (shared by this group)'
|
|
: ''
|
|
: envelope.isGroup
|
|
? ' (private to you)'
|
|
: '';
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
[
|
|
`Channel: ${this.name}`,
|
|
// Identity/memory lines only for channels that opted in — keep
|
|
// unconfigured channels' output unchanged.
|
|
...(this.shouldPrependChannelBoundaryPrompt()
|
|
? [
|
|
`Identity: ${sanitizeQuotedText(this.identity.displayName, 128)}`,
|
|
`Memory: ${sanitizeQuotedText(this.memoryScope.namespace, 128)}`,
|
|
]
|
|
: []),
|
|
// Only the basename — don't leak the absolute cwd to group members.
|
|
`Workspace: ${basename(this.config.cwd)}`,
|
|
`Session: ${active ? 'active' : 'none'}${scopeNote}`,
|
|
].join('\n'),
|
|
);
|
|
return true;
|
|
});
|
|
|
|
this.registerCommand('remember-channel', async (envelope, args) => {
|
|
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
return true;
|
|
}
|
|
if (envelope.isGroup) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Channel memory cannot be changed in group chats.',
|
|
);
|
|
return true;
|
|
}
|
|
if (args.trim() === '') {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Usage: /remember-channel <text>',
|
|
);
|
|
return true;
|
|
}
|
|
const channelMemory = await this.getChannelMemory(envelope);
|
|
if (!channelMemory) {
|
|
return true;
|
|
}
|
|
try {
|
|
await channelMemory.appendChannelMemory(
|
|
this.channelMemoryTarget(envelope),
|
|
args.trim(),
|
|
);
|
|
} catch (error) {
|
|
const message = this.channelMemoryErrorMessage(error);
|
|
this.logChannelMemoryError('save', envelope, message);
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`,
|
|
);
|
|
return true;
|
|
}
|
|
this.invalidateSessionContext(envelope);
|
|
await this.sendMessage(envelope.chatId, 'Channel memory updated.');
|
|
return true;
|
|
});
|
|
|
|
this.registerCommand('channel-memory', async (envelope) => {
|
|
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
return true;
|
|
}
|
|
if (envelope.isGroup) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Channel memory cannot be shown in group chats.',
|
|
);
|
|
return true;
|
|
}
|
|
const channelMemory = await this.getChannelMemory(envelope);
|
|
if (!channelMemory) {
|
|
return true;
|
|
}
|
|
let text: string;
|
|
try {
|
|
text = (
|
|
await channelMemory.readChannelMemory(
|
|
this.channelMemoryTarget(envelope),
|
|
)
|
|
).trim();
|
|
} catch (error) {
|
|
const message = this.channelMemoryErrorMessage(error);
|
|
this.logChannelMemoryError('read', envelope, message);
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`,
|
|
);
|
|
return true;
|
|
}
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
text === '' ? 'No channel memory saved.' : sanitizePromptText(text),
|
|
);
|
|
return true;
|
|
});
|
|
|
|
this.registerCommand('forget-channel', async (envelope, args) => {
|
|
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
return true;
|
|
}
|
|
if (envelope.isGroup) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Channel memory cannot be changed in group chats.',
|
|
);
|
|
return true;
|
|
}
|
|
if (args.toLowerCase() !== 'confirm') {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'This clears channel memory for this chat. Re-send with "confirm" (e.g. /forget-channel confirm) to proceed.',
|
|
);
|
|
return true;
|
|
}
|
|
const channelMemory = await this.getChannelMemory(envelope);
|
|
if (!channelMemory) {
|
|
return true;
|
|
}
|
|
let result: { changed: boolean };
|
|
try {
|
|
result = await channelMemory.clearChannelMemory(
|
|
this.channelMemoryTarget(envelope),
|
|
);
|
|
} catch (error) {
|
|
const message = this.channelMemoryErrorMessage(error);
|
|
this.logChannelMemoryError('clear', envelope, message);
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`,
|
|
);
|
|
return true;
|
|
}
|
|
this.invalidateSessionContext(envelope);
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
result.changed ? 'Channel memory cleared.' : 'No channel memory saved.',
|
|
);
|
|
return true;
|
|
});
|
|
|
|
this.registerCommand('help', async (envelope) => {
|
|
const lines = [
|
|
'Commands:',
|
|
'/help — Show this help',
|
|
this.isSharedSession(envelope)
|
|
? '/clear confirm — Clear the shared session (aliases: /reset, /new)'
|
|
: '/clear — Clear your session (aliases: /reset, /new)',
|
|
'/who — Show current session & workspace',
|
|
'/status — Show session info',
|
|
'/remember-channel <text> — Save memory for this chat',
|
|
'/channel-memory — Show memory for this chat',
|
|
'/forget-channel confirm — Clear memory for this chat',
|
|
];
|
|
|
|
// Platform-specific commands (registered by adapters, not shared ones)
|
|
const sharedCmds = new Set([
|
|
'help',
|
|
'clear',
|
|
'reset',
|
|
'new',
|
|
'who',
|
|
'status',
|
|
'remember-channel',
|
|
'channel-memory',
|
|
'forget-channel',
|
|
]);
|
|
const platformCmds = [...this.commands.keys()].filter(
|
|
(c) => !sharedCmds.has(c),
|
|
);
|
|
if (platformCmds.length > 0) {
|
|
for (const cmd of platformCmds) {
|
|
lines.push(`/${cmd}`);
|
|
}
|
|
}
|
|
|
|
const sessionId = this.router.getSession(
|
|
this.name,
|
|
envelope.senderId,
|
|
envelope.chatId,
|
|
envelope.threadId,
|
|
);
|
|
const agentCommands = sessionId
|
|
? this.getAgentCommandsForSession(sessionId)
|
|
: this.bridge.availableCommands;
|
|
if (agentCommands.length > 0) {
|
|
lines.push('', 'Agent commands (forwarded to Qwen Code):');
|
|
for (const cmd of agentCommands) {
|
|
lines.push(`/${cmd.name} — ${cmd.description}`);
|
|
}
|
|
}
|
|
|
|
lines.push('', 'Send any text to chat with the agent.');
|
|
await this.sendMessage(envelope.chatId, lines.join('\n'));
|
|
return true;
|
|
});
|
|
|
|
this.registerCommand('status', async (envelope) => {
|
|
// For a shared session, gate it to authorized senders like /who — /status
|
|
// reports session & access state, so non-members shouldn't read it either.
|
|
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Only authorized members can view this shared session.',
|
|
);
|
|
return true;
|
|
}
|
|
const hasSession = this.router.hasSession(
|
|
this.name,
|
|
envelope.senderId,
|
|
envelope.chatId,
|
|
envelope.threadId,
|
|
);
|
|
const policy = this.config.senderPolicy;
|
|
const lines = [
|
|
`Session: ${hasSession ? 'active' : 'none'}`,
|
|
`Access: ${policy}`,
|
|
`Channel: ${this.name}`,
|
|
...(this.shouldPrependChannelBoundaryPrompt()
|
|
? [
|
|
`Identity: ${sanitizeQuotedText(this.identity.id, 128)}`,
|
|
`Memory: ${this.memoryScope.mode}`,
|
|
]
|
|
: []),
|
|
];
|
|
await this.sendMessage(envelope.chatId, lines.join('\n'));
|
|
return true;
|
|
});
|
|
|
|
this.registerCommand('loop', async (envelope, args) =>
|
|
this.handleLoopCommand(envelope, args),
|
|
);
|
|
}
|
|
|
|
private async handleLoopCommand(
|
|
envelope: Envelope,
|
|
args: string,
|
|
): Promise<boolean> {
|
|
if (!this.loopController) {
|
|
await this.sendMessage(envelope.chatId, 'Loops are not available.');
|
|
return true;
|
|
}
|
|
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Only authorized members can use loops in this shared session.',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const [subcommand = '', ...rest] = args.trim().split(/\s+/u);
|
|
switch (subcommand.toLowerCase()) {
|
|
case 'add':
|
|
return this.handleLoopAdd(envelope, rest.join(' '));
|
|
case 'list':
|
|
return this.handleLoopList(envelope);
|
|
case 'inspect':
|
|
return this.handleLoopInspect(envelope, rest[0]);
|
|
case 'cancel':
|
|
return this.handleLoopCancel(envelope, rest[0]);
|
|
default:
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>',
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private async handleLoopAdd(
|
|
envelope: Envelope,
|
|
args: string,
|
|
): Promise<boolean> {
|
|
if (!this.loopController) return true;
|
|
if (!this.supportsProactiveSend()) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'This channel does not support proactive loop messages.',
|
|
);
|
|
return true;
|
|
}
|
|
if (this.config.sessionScope === 'single') {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Loops are not supported when sessionScope is single.',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const parsed = parseLoopAddArgs(args);
|
|
if (!parsed) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Usage: /loop add "<cron>" <prompt>',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
this.loopController.validateCron(parsed.cron);
|
|
} catch (err) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const target = this.loopTargetFromEnvelope(envelope);
|
|
if (!this.supportsProactiveTarget(target)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'This channel does not support proactive loop messages for this chat target.',
|
|
);
|
|
return true;
|
|
}
|
|
const prompt = sanitizePromptText(parsed.prompt.trim());
|
|
if (Array.from(prompt).length > MAX_LOOP_PROMPT_CHARS) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`Loop prompt is too long; keep it under ${MAX_LOOP_PROMPT_CHARS} characters.`,
|
|
);
|
|
return true;
|
|
}
|
|
const input: ChannelLoopInput = {
|
|
channelName: this.name,
|
|
target,
|
|
cwd: this.config.cwd,
|
|
cron: parsed.cron,
|
|
prompt,
|
|
label: truncateLoopLabel(prompt),
|
|
recurring: true,
|
|
createdBy: sanitizeSenderName(
|
|
envelope.senderName || envelope.senderId || 'unknown',
|
|
),
|
|
};
|
|
let job: ChannelLoop | undefined;
|
|
if (this.loopController.createForTarget) {
|
|
job = await this.loopController.createForTarget(
|
|
input,
|
|
MAX_LOOP_JOBS_PER_TARGET,
|
|
);
|
|
} else {
|
|
const existingJobs = await this.loopController.listForTarget(
|
|
this.name,
|
|
target,
|
|
);
|
|
if (
|
|
existingJobs.filter((existingJob) => existingJob.enabled).length <
|
|
MAX_LOOP_JOBS_PER_TARGET
|
|
) {
|
|
job = await this.loopController.create(input);
|
|
}
|
|
}
|
|
if (!job) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`Too many loops for this chat. Cancel an existing loop before adding another.`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
await this.sendMessage(envelope.chatId, `Loop ${job.id}: ${job.cron}`);
|
|
return true;
|
|
}
|
|
|
|
private async handleLoopList(envelope: Envelope): Promise<boolean> {
|
|
if (!this.loopController) return true;
|
|
const jobs = await this.loopController.listForTarget(
|
|
this.name,
|
|
this.loopTargetFromEnvelope(envelope),
|
|
);
|
|
if (jobs.length === 0) {
|
|
await this.sendMessage(envelope.chatId, 'No loops.');
|
|
return true;
|
|
}
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
jobs.map((job) => this.formatLoopListLine(job)).join('\n'),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
private async handleLoopInspect(
|
|
envelope: Envelope,
|
|
id: string | undefined,
|
|
): Promise<boolean> {
|
|
if (!this.loopController) return true;
|
|
if (!id) {
|
|
await this.sendMessage(envelope.chatId, 'Usage: /loop inspect <id>');
|
|
return true;
|
|
}
|
|
const jobs = await this.loopController.listForTarget(
|
|
this.name,
|
|
this.loopTargetFromEnvelope(envelope),
|
|
);
|
|
const job = jobs.find((candidate) => candidate.id === id);
|
|
if (!job) {
|
|
await this.sendMessage(envelope.chatId, `No loop ${id}.`);
|
|
return true;
|
|
}
|
|
|
|
const lines = [
|
|
`Loop ${job.id}`,
|
|
`Status: ${job.enabled ? 'enabled' : 'disabled'}, last=${this.lastLoopStatus(job)}`,
|
|
`Cron: ${job.cron}`,
|
|
`Next: ${this.formatNextFireTime(job)}`,
|
|
`Runs: ${job.runCount}`,
|
|
`Created by: ${job.createdBy}`,
|
|
`Created: ${job.createdAt}`,
|
|
];
|
|
if (job.lastFinishedAt) {
|
|
lines.push(`Last finished: ${job.lastFinishedAt}`);
|
|
}
|
|
if (job.lastError) {
|
|
lines.push(`Last error: ${job.lastError}`);
|
|
}
|
|
if (job.lastResultPreview) {
|
|
lines.push(`Last result: ${job.lastResultPreview}`);
|
|
}
|
|
lines.push(`Prompt: ${job.prompt}`);
|
|
await this.sendMessage(envelope.chatId, lines.join('\n'));
|
|
return true;
|
|
}
|
|
|
|
private formatLoopListLine(job: ChannelLoop): string {
|
|
const fields = [
|
|
job.id,
|
|
job.cron,
|
|
job.enabled ? 'enabled' : 'disabled',
|
|
`last=${this.lastLoopStatus(job)}`,
|
|
`next=${this.formatNextFireTime(job)}`,
|
|
`runs=${job.runCount}`,
|
|
];
|
|
if (job.label) fields.push(job.label);
|
|
return fields.join(' ');
|
|
}
|
|
|
|
private lastLoopStatus(job: ChannelLoop): string {
|
|
if (job.runningSince) return 'running';
|
|
return job.lastStatus ?? 'never';
|
|
}
|
|
|
|
private formatNextFireTime(job: ChannelLoop): string {
|
|
try {
|
|
return this.loopController?.nextFireTime?.(job).toISOString() ?? 'n/a';
|
|
} catch {
|
|
return 'invalid cron';
|
|
}
|
|
}
|
|
|
|
private async handleLoopCancel(
|
|
envelope: Envelope,
|
|
id: string | undefined,
|
|
): Promise<boolean> {
|
|
if (!this.loopController) return true;
|
|
if (!id) {
|
|
await this.sendMessage(envelope.chatId, 'Usage: /loop cancel <id>');
|
|
return true;
|
|
}
|
|
const jobs = await this.loopController.listForTarget(
|
|
this.name,
|
|
this.loopTargetFromEnvelope(envelope),
|
|
);
|
|
const match = jobs.find((job) => job.id === id);
|
|
const disabled = match ? await this.loopController.disable(id) : false;
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
disabled ? `Cancelled loop ${id}.` : `No loop ${id}.`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
private loopTargetFromEnvelope(envelope: Envelope): SessionTarget {
|
|
return {
|
|
channelName: this.name,
|
|
senderId: envelope.senderId,
|
|
chatId: envelope.chatId,
|
|
threadId: envelope.threadId,
|
|
isGroup: envelope.isGroup === true,
|
|
};
|
|
}
|
|
|
|
private isStoredLoopTargetAuthorized(
|
|
target: SessionTarget,
|
|
senderName: string,
|
|
): boolean {
|
|
if (target.isGroup === undefined) {
|
|
return false;
|
|
}
|
|
const envelope: Envelope = {
|
|
channelName: this.name,
|
|
senderId: target.senderId,
|
|
senderName,
|
|
chatId: target.chatId,
|
|
text: '',
|
|
threadId: target.threadId,
|
|
isGroup: target.isGroup === true,
|
|
isMentioned: true,
|
|
isReplyToBot: true,
|
|
};
|
|
return (
|
|
this.groupGate.check(envelope).allowed &&
|
|
this.gate.isAllowed(target.senderId) &&
|
|
this.isAuthorizedForSharedSession(envelope)
|
|
);
|
|
}
|
|
|
|
/** Check if a message text matches a registered local command. */
|
|
protected isLocalCommand(text: string): boolean {
|
|
const parsed = this.parseCommand(text);
|
|
return parsed !== null && this.commands.has(parsed.command);
|
|
}
|
|
|
|
private findActiveSessionId(envelope: Envelope): string | undefined {
|
|
const sessionId = this.router.getSession(
|
|
this.name,
|
|
envelope.senderId,
|
|
envelope.chatId,
|
|
envelope.threadId,
|
|
);
|
|
return sessionId && this.activePrompts.has(sessionId)
|
|
? sessionId
|
|
: undefined;
|
|
}
|
|
|
|
private channelMemoryTarget(envelope: Envelope): ChannelMemoryTarget {
|
|
return {
|
|
channelName: this.name,
|
|
chatId: envelope.chatId,
|
|
threadId: envelope.threadId,
|
|
};
|
|
}
|
|
|
|
private invalidateSessionContext(envelope: Envelope): void {
|
|
const sessionId = this.router.getSession(
|
|
this.name,
|
|
envelope.senderId,
|
|
envelope.chatId,
|
|
envelope.threadId,
|
|
);
|
|
if (sessionId) {
|
|
this.instructedSessions.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
private dropQueuedTurnIfStale(
|
|
sessionId: string,
|
|
generation: number,
|
|
envelope: Envelope,
|
|
): boolean {
|
|
if ((this.sessionGenerations.get(sessionId) ?? 0) === generation) {
|
|
return false;
|
|
}
|
|
|
|
// Surface the drop — otherwise an unanswered queued message vanishes
|
|
// silently, making "my message was never answered" undiagnosable.
|
|
// envelope.text is attacker-controlled, so neutralize it with the shared
|
|
// log sanitizer: it renders newlines visibly and strips the C0/DEL controls
|
|
// PLUS PROMPT_UNSAFE_INVISIBLES — the C1 block (notably NEL U+0085, a line
|
|
// break that could forge an extra [channel] log line), the Unicode line/
|
|
// paragraph separators U+2028/U+2029, and the bidi overrides — any of which
|
|
// would otherwise inject, overwrite, or reorder an operator's audit line.
|
|
// Same helper as the QQ audit log, so the defense can't drift between sites.
|
|
const loggedText = sanitizeLogText(envelope.text, 80);
|
|
process.stderr.write(
|
|
`[${this.name}] dropped queued turn from ${envelope.senderId} for session ${sessionId}: session was cleared before it ran (text: ${loggedText})\n`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
private isAuthorizedForChannelMemory(envelope: Envelope): boolean {
|
|
return this.isSenderAuthorizedForChannelMemory(envelope.senderId);
|
|
}
|
|
|
|
private isSenderAuthorizedForChannelMemory(senderId: string): boolean {
|
|
return (
|
|
this.config.allowedUsers.length > 0 &&
|
|
this.config.allowedUsers.includes(senderId)
|
|
);
|
|
}
|
|
|
|
private async ensureChannelMemoryAuthorized(
|
|
envelope: Envelope,
|
|
): Promise<boolean> {
|
|
if (!this.isAuthorizedForChannelMemory(envelope)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Only authorized members can manage channel memory.',
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async getChannelMemory(
|
|
envelope: Envelope,
|
|
): Promise<ChannelMemoryCallbacks | undefined> {
|
|
if (!this.channelMemory) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Channel memory is not configured for this channel.',
|
|
);
|
|
return undefined;
|
|
}
|
|
return this.channelMemory;
|
|
}
|
|
|
|
private channelMemoryErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
private channelMemoryUserErrorMessage(): string {
|
|
return 'An error occurred while accessing channel memory.';
|
|
}
|
|
|
|
private logChannelMemoryError(
|
|
action: 'save' | 'read' | 'clear',
|
|
envelope: Envelope,
|
|
message: string,
|
|
): void {
|
|
process.stderr.write(
|
|
`[${this.name}] channel memory ${action} failed for sender=${sanitizeLogText(
|
|
envelope.senderId,
|
|
80,
|
|
)} chat=${sanitizeLogText(envelope.chatId, 80)} thread=${sanitizeLogText(
|
|
envelope.threadId ?? '',
|
|
80,
|
|
)}: ${sanitizeLogText(message, 200)}\n`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the resolved session is SHARED across senders. `single` collapses
|
|
* the whole channel to one `__single__` session for EVERY sender — group OR
|
|
* DM — so it is ALWAYS shared (even a DM maps to `__single__`). `thread` is
|
|
* shared only in a group (a DM maps to the lone caller's own chat). `user` is
|
|
* per-sender, never shared. Drives both the destructive-/clear confirm gate
|
|
* and the host-shell (`!`) gate.
|
|
*/
|
|
private isSharedSession(envelope: Envelope): boolean {
|
|
return this.isSharedSessionTarget(envelope);
|
|
}
|
|
|
|
private isSharedSessionTarget(target: { isGroup?: boolean }): boolean {
|
|
return (
|
|
this.config.sessionScope === 'single' ||
|
|
(target.isGroup === true && this.config.sessionScope === 'thread')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether `envelope.senderId` may act on the resolved session's destructive or
|
|
* workspace-leaking commands (/clear, /who). A SHARED session with a non-empty
|
|
* allowedUsers list is restricted to those members; a per-user session, or one
|
|
* with no allowlist, is unrestricted. Shared verbatim by /clear and /who so the
|
|
* gate can't drift; each caller sends its own rejection wording.
|
|
*/
|
|
private isAuthorizedForSharedSession(envelope: Envelope): boolean {
|
|
if (!this.isSharedSession(envelope)) return true;
|
|
const authorized = this.config.allowedUsers;
|
|
return authorized.length === 0 || authorized.includes(envelope.senderId);
|
|
}
|
|
|
|
private stopActiveStreaming(
|
|
active: ActivePrompt,
|
|
sessionId: string,
|
|
reason: string,
|
|
): void {
|
|
try {
|
|
active.stopStreaming?.();
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] stopStreaming threw during ${reason} for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel the active turn and wait (bounded) for it to wind down. Stops the
|
|
* BlockStreamer so buffered text can't leak via the idle timer, then fires a
|
|
* best-effort cancelSession (NOT awaited — a wedged child/daemon can leave the
|
|
* request pending forever). Returns true if active.done settled first, false
|
|
* if the CLEAR_CANCEL_TIMEOUT_MS bound won (the turn never wound down). Used by
|
|
* /clear, which genuinely EVICTS the session and so must proceed even when the
|
|
* turn is wedged. Steer no longer uses this: it best-effort cancels then chains
|
|
* the new turn behind the old one (see handleInbound), so it never needs to
|
|
* proceed past a still-active turn.
|
|
*/
|
|
private async cancelAndAwaitActive(
|
|
active: ActivePrompt,
|
|
sessionId: string,
|
|
): Promise<boolean> {
|
|
active.cancelled = true;
|
|
this.stopActiveStreaming(active, sessionId, 'cancel');
|
|
// Fire-and-forget, but LOG the IPC failure: a swallowed reason leaves a
|
|
// wedged turn undiagnosable (operator sees only the wind-down timeout below
|
|
// with no cause).
|
|
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
process.stderr.write(
|
|
`[${this.name}] cancelSession failed for session=${sessionId} (clear/await): ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
});
|
|
this.emitTaskCancellation(active, sessionId, 'clear');
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const settled = await Promise.race([
|
|
active.done.then(() => true),
|
|
new Promise<boolean>((resolve) => {
|
|
timer = setTimeout(() => resolve(false), CLEAR_CANCEL_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
clearTimeout(timer);
|
|
return settled;
|
|
}
|
|
|
|
/**
|
|
* Parse a slash command from message text.
|
|
* Returns { command, raw, args } or null if not a slash command. `command` is
|
|
* lowercased for case-insensitive LOCAL dispatch (registerCommand lowercases the
|
|
* names it stores); `raw` keeps the typed case so agent-command matching can be
|
|
* CASE-SENSITIVE, mirroring the CLI's parseSlashCommand (`cmd.name === part`).
|
|
*/
|
|
private parseCommand(
|
|
text: string,
|
|
): { command: string; raw: string; args: string } | null {
|
|
// Trim first so a leading-whitespace slash command (common from IME /
|
|
// copy-paste, e.g. " /help") parses, and so this agrees with isSlashCommand
|
|
// (which already trims). Otherwise isSlashCommand suppresses the [sender] tag
|
|
// while parseCommand returns null, leaking the command to the agent unattributed.
|
|
const trimmed = text.trim();
|
|
if (!trimmed.startsWith('/')) return null;
|
|
// Handle /command@botname format (Telegram groups). The token allows `-` and
|
|
// `:` so hyphenated and namespaced agent commands (e.g. /compress-fast,
|
|
// /git:commit) still parse as commands rather than being treated as text
|
|
// (charset shared with isSlashCommand via PARSE_COMMAND_RE).
|
|
const match = trimmed.match(PARSE_COMMAND_RE);
|
|
if (!match) return null;
|
|
return {
|
|
command: match[1].toLowerCase(),
|
|
raw: match[1],
|
|
args: match[2].trim(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether `text` is a real slash command rather than prose that merely starts
|
|
* with `/`. A command's first whitespace-delimited token must match
|
|
* parseCommand()'s charset — `[a-zA-Z0-9_:-]+`, plus an optional `@botname`
|
|
* suffix — and not be a `//` line comment or `/*` block comment. Slash-prefixed
|
|
* paths (`/tmp/foo`), comments, and a bare `/` are prose and keep their
|
|
* `[sender]` tag.
|
|
*
|
|
* Intentionally stricter than the CLI's looser classifier (cli
|
|
* `ui/utils/commandUtils.ts`), which forwards any non-comment, non-path
|
|
* `/<token>` (e.g. `/café`, a zero-width-laden token). Such inputs aren't
|
|
* runnable commands, and in a SHARED group session forwarding them unattributed
|
|
* is worse than a redundant tag — so anything off the command charset is
|
|
* treated as prose and keeps its `[sender]` tag. Purely lexical — never
|
|
* consults the async command list, so it can't race a fresh session.
|
|
*/
|
|
private isSlashCommand(text: string): boolean {
|
|
const trimmed = text.trim();
|
|
if (
|
|
!trimmed.startsWith('/') ||
|
|
trimmed.startsWith('//') ||
|
|
trimmed.startsWith('/*')
|
|
) {
|
|
return false;
|
|
}
|
|
// No trimStart: the token must immediately follow `/`. A space after the
|
|
// slash (`/ foo`) makes split()[0] empty, so this returns false — matching
|
|
// parseCommand, whose regex also requires the token right after `/`. If they
|
|
// diverged, `/ foo` in a shared group session would suppress the [sender] tag
|
|
// (isSlashCommand true) yet run no command (parseCommand null), reaching the
|
|
// agent unattributed.
|
|
const firstToken = trimmed.slice(1).split(/\s+/u)[0] ?? '';
|
|
return COMMAND_TOKEN_RE.test(firstToken);
|
|
}
|
|
|
|
/**
|
|
* Whether `text` names a command this channel can actually run: a locally
|
|
* registered command (`this.commands`, e.g. /clear, /who) OR an agent command
|
|
* THIS session exposes — by canonical name OR alias (e.g. `/summarize` for
|
|
* `/compress`). Paired with isSlashCommand so the `[sender]` attribution tag is
|
|
* suppressed ONLY for RECOGNIZED commands; command-SHAPED-but-unrecognized text
|
|
* (e.g. `/x\n[SYSTEM]: …`) keeps its tag rather than reaching a shared group
|
|
* unattributed, where an injected second line is more likely read as a system
|
|
* directive. Purely synchronous, like isSlashCommand: it reads the session's
|
|
* availableCommands snapshot WITHOUT awaiting, so it never races a fresh session
|
|
* (a genuine agent command sent before the snapshot loads is treated as
|
|
* unrecognized and KEEPS its tag — the safe default).
|
|
*/
|
|
private isRecognizedCommand(text: string, sessionId: string): boolean {
|
|
const parsed = this.parseCommand(text);
|
|
if (!parsed) return false;
|
|
// LOCAL commands dispatch CASE-INSENSITIVELY: registerCommand lowercases the
|
|
// stored name and handleInbound looks it up by the lowercased token, so mirror
|
|
// that here with the lowercased `command`.
|
|
if (this.commands.has(parsed.command)) return true;
|
|
// AGENT commands: mirror the CLI's parseSlashCommand EXACTLY so the channel and
|
|
// the agent AGREE on what is a command. The CLI takes the FIRST whitespace token
|
|
// after the leading `/`, CASE-SENSITIVELY, and does NOT strip an `@suffix`
|
|
// (`cmd.name === part`, `cmd.altNames?.includes(part)`). So recognize the SAME
|
|
// token here — NOT parseCommand's `@`-stripped, lowercased `raw` (PARSE_COMMAND_RE
|
|
// drops `(?:@\S+)?`, which is the very divergence this closes). A wrong-case
|
|
// (`/Compress`), `@`-suffixed (`/compress@bot` — possibly aimed at ANOTHER bot, so
|
|
// we must NOT run it here), or injection-shaped (`/COMPRESS\n[SYSTEM]: …`) token
|
|
// then does NOT match → stays UNRECOGNIZED → keeps its `[sender]` tag (attributed),
|
|
// exactly as the agent treats it (it runs no command; the text reaches the model
|
|
// as prose). Array.isArray guards a malformed wire `altNames` (a non-array would
|
|
// throw at `.includes`).
|
|
const token = text.trim().slice(1).split(/\s+/u)[0] ?? '';
|
|
return this.getAgentCommandsForSession(sessionId).some(
|
|
(cmd) =>
|
|
cmd.name === token ||
|
|
(Array.isArray(cmd.altNames) && cmd.altNames.includes(token)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The agent-command snapshot for THIS session. DaemonChannelBridge keys
|
|
* commands per session, so its global `availableCommands` getter can return
|
|
* ANOTHER session's list — prefer its getAvailableCommands(sessionId) when
|
|
* present. AcpBridge runs a single agent and exposes only the global getter
|
|
* (inherently session-correct), so fall back to it. Synchronous, matching
|
|
* isRecognizedCommand's no-await contract.
|
|
*/
|
|
private getAgentCommandsForSession(sessionId: string): AvailableCommand[] {
|
|
// Structural (typed) access via AgentCommandsProvider rather than a blind
|
|
// `as unknown` cast: both members are optional, so AcpBridge (no per-session
|
|
// getter) is assignable while a rename/return-type change is still type-checked.
|
|
const bridge: AgentCommandsProvider = this.bridge;
|
|
if (typeof bridge.getAvailableCommands === 'function') {
|
|
return bridge.getAvailableCommands(sessionId) ?? [];
|
|
}
|
|
return bridge.availableCommands ?? [];
|
|
}
|
|
|
|
private groupHistoryKey(envelope: Envelope): string {
|
|
return JSON.stringify([
|
|
this.name,
|
|
envelope.chatId,
|
|
envelope.threadId ?? null,
|
|
]);
|
|
}
|
|
|
|
private groupHistoryLimit(envelope: Envelope): number {
|
|
if (!envelope.isGroup) {
|
|
return 0;
|
|
}
|
|
const groupCfg = this.config.groups[envelope.chatId];
|
|
const wildcardGroupCfg = this.config.groups['*'];
|
|
const configured =
|
|
groupCfg?.groupHistoryLimit ??
|
|
wildcardGroupCfg?.groupHistoryLimit ??
|
|
this.config.groupHistoryLimit ??
|
|
0;
|
|
if (!Number.isFinite(configured) || configured <= 0) {
|
|
return 0;
|
|
}
|
|
return Math.floor(configured);
|
|
}
|
|
|
|
private recordPendingGroupHistory(envelope: Envelope): void {
|
|
const limit = this.groupHistoryLimit(envelope);
|
|
if (limit <= 0 || envelope.text.trim().length === 0) {
|
|
return;
|
|
}
|
|
const senderId = truncateGroupHistoryField(envelope.senderId);
|
|
if (!this.gate.isAllowed(senderId)) {
|
|
return;
|
|
}
|
|
|
|
const entry: GroupHistoryEntry = {
|
|
senderId,
|
|
senderName: truncateGroupHistoryField(envelope.senderName),
|
|
text: envelope.text.slice(0, GROUP_HISTORY_ENTRY_TEXT_LIMIT),
|
|
messageId:
|
|
envelope.messageId === undefined
|
|
? undefined
|
|
: truncateGroupHistoryField(envelope.messageId),
|
|
timestamp: Date.now(),
|
|
};
|
|
try {
|
|
this.groupHistory.record(this.groupHistoryKey(envelope), entry, limit);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] failed to record group history for chat ${sanitizeLogText(envelope.chatId, 64)}: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private drainPendingGroupHistory(envelope: Envelope): GroupHistoryEntry[] {
|
|
const limit = this.groupHistoryLimit(envelope);
|
|
if (limit <= 0) {
|
|
return [];
|
|
}
|
|
try {
|
|
return this.groupHistory.drain(this.groupHistoryKey(envelope), limit);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] failed to drain group history for chat ${sanitizeLogText(envelope.chatId, 64)}: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private clearPendingGroupHistory(envelope: Envelope): void {
|
|
if (!envelope.isGroup && this.config.sessionScope !== 'single') {
|
|
return;
|
|
}
|
|
try {
|
|
if (this.config.sessionScope === 'single') {
|
|
this.groupHistory.clearAll();
|
|
} else {
|
|
this.groupHistory.clear(this.groupHistoryKey(envelope));
|
|
}
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] failed to clear group history for chat ${sanitizeLogText(envelope.chatId, 64)}: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private prependGroupHistoryContext(
|
|
promptText: string,
|
|
entries: GroupHistoryEntry[],
|
|
): string {
|
|
if (entries.length === 0) {
|
|
return promptText;
|
|
}
|
|
|
|
const lines = entries.filter((entry) =>
|
|
this.gate.isAllowed(entry.senderId),
|
|
);
|
|
if (lines.length === 0) {
|
|
return promptText;
|
|
}
|
|
|
|
const formatted = lines.map((entry) => {
|
|
const who = sanitizeSenderName(entry.senderName || entry.senderId);
|
|
const text = sanitizeQuotedText(
|
|
entry.text,
|
|
GROUP_HISTORY_ENTRY_TEXT_LIMIT,
|
|
);
|
|
return `- [${who}] ${text}`;
|
|
});
|
|
|
|
return `${GROUP_HISTORY_CONTEXT_MARKER}\n${formatted.join('\n')}\n\n${CURRENT_MESSAGE_MARKER}\n${promptText}`;
|
|
}
|
|
|
|
async handleInbound(envelope: Envelope): Promise<void> {
|
|
// 1. Group gate: policy + allowlist + mention gating
|
|
const groupResult = this.groupGate.check(envelope);
|
|
if (!groupResult.allowed) {
|
|
if (groupResult.reason === 'mention_required') {
|
|
this.recordPendingGroupHistory(envelope);
|
|
}
|
|
return; // silently drop — no pairing, no reply
|
|
}
|
|
|
|
// 2. Sender gate: allowlist / pairing / open
|
|
const result = this.gate.check(envelope.senderId, envelope.senderName);
|
|
if (!result.allowed) {
|
|
if (result.pairingCode !== undefined) {
|
|
await this.onPairingRequired(envelope.chatId, result.pairingCode);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 3. Slash command handling — before session/agent routing
|
|
const parsed = this.parseCommand(envelope.text);
|
|
if (parsed) {
|
|
const handler = this.commands.get(parsed.command);
|
|
if (handler) {
|
|
const handled = await handler(envelope, parsed.args);
|
|
if (handled) return;
|
|
}
|
|
// Unrecognized commands fall through to the agent
|
|
}
|
|
|
|
// 3.5. Bang (!) shell command — refuse outside a private 1:1 chat BEFORE
|
|
// resolving a session, so a refused command never creates or persists one.
|
|
// Phase 0 has no per-sender trust model (the [sender] marker is NOT a trust
|
|
// boundary). Any group is multi-operator — even a user-scope group, which is
|
|
// NOT a "shared session" — so an allowed member could `!rm -rf /` the host.
|
|
const bangText = envelope.text.trimStart();
|
|
if (bangText.startsWith('!')) {
|
|
if (envelope.isGroup || this.isSharedSession(envelope)) {
|
|
// Audit a blocked host-shell attempt — a group/shared member trying `!`
|
|
// is security-relevant, so surface it to operators. Sanitize the display
|
|
// name (attacker-controlled) and do NOT echo the command payload.
|
|
const who = sanitizeSenderName(
|
|
envelope.senderName || envelope.senderId || 'unknown',
|
|
);
|
|
process.stderr.write(
|
|
`[${this.name}] blocked ! shell command from ${who} (sender ${envelope.senderId}) in chat ${sanitizeLogText(envelope.chatId, 64)}\n`,
|
|
);
|
|
}
|
|
if (envelope.isGroup) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Shell commands (`!`) are disabled in group chats.',
|
|
);
|
|
return;
|
|
}
|
|
// A single-scope DM collapses every DM to one channel-wide session, so it
|
|
// is multi-operator too despite not being a group.
|
|
if (this.isSharedSession(envelope)) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
'Shell commands (`!`) are disabled in shared sessions.',
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const sessionId = await this.router.resolve(
|
|
this.name,
|
|
envelope.senderId,
|
|
envelope.chatId,
|
|
envelope.threadId,
|
|
this.config.cwd,
|
|
);
|
|
|
|
// Bang (!) execution — a private 1:1 session has a single operator, so
|
|
// direct shell execution stays allowed. Group/shared contexts were refused
|
|
// above, before the session was resolved.
|
|
if (bangText.startsWith('!')) {
|
|
const cmd = bangText.slice(1).trim();
|
|
const bridgeShellCommand = this.bridge.shellCommand;
|
|
if (cmd && bridgeShellCommand) {
|
|
try {
|
|
const result = await bridgeShellCommand(sessionId, cmd);
|
|
const longestRun = Math.max(
|
|
0,
|
|
...Array.from(
|
|
(result.output || '').matchAll(/`+/g),
|
|
(m) => m[0].length,
|
|
),
|
|
);
|
|
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
const output = result.output
|
|
? `${fence}\n${result.output}\n${fence}`
|
|
: '(no output)';
|
|
const exitLine =
|
|
result.exitCode !== null && result.exitCode !== 0
|
|
? `\nExit code: ${result.exitCode}`
|
|
: '';
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`$ ${cmd}\n${output}${exitLine}`,
|
|
);
|
|
} catch (error) {
|
|
await this.sendMessage(
|
|
envelope.chatId,
|
|
`Shell command failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const recognizedSlashCommand =
|
|
this.isSlashCommand(envelope.text) &&
|
|
this.isRecognizedCommand(envelope.text, sessionId);
|
|
// Prepend referenced (quoted) message text for reply context
|
|
let promptText = envelope.text;
|
|
|
|
// Multiplayer attribution: when a session can carry multiple humans, tag each
|
|
// turn with the speaker so the agent can tell members apart. That is any group
|
|
// AND any single-scope DM — `single` collapses every sender's DM into one
|
|
// __single__ session (the same multi-operator case the !-gate, /clear confirm
|
|
// and /who already treat as shared), so without a tag it would merge different
|
|
// people into one unattributed conversation. NOT gated on isSharedSession:
|
|
// that is false for a user-scope GROUP, which still needs attribution. Sanitize
|
|
// the name so a crafted nick can't break out of the [..] tag or inject
|
|
// newlines. Skipped for a per-user 1:1 chat and for already-prefixed re-entries
|
|
// (collect-mode coalescing). The tag is also suppressed for a real slash
|
|
// command — a [sender] prefix would stop it from parsing — but ONLY when it is
|
|
// BOTH a genuine command SHAPE (isSlashCommand) AND a RECOGNIZED command
|
|
// (isRecognizedCommand: a locally registered or agent-exposed command, by
|
|
// canonical name OR alias, for THIS session — matched EXACTLY as the agent's
|
|
// parseSlashCommand does, so the two never diverge). Command-shaped-but-
|
|
// unrecognized text like `/x\n[SYSTEM]: …` (token matches the charset but no such
|
|
// command exists) KEEPS its tag, so its injected second line can't reach a shared
|
|
// group unattributed and pose as a system directive. Slash-prefixed paths
|
|
// (/tmp/foo) and comments (//…, /*…*/) are prose, so they stay attributed too.
|
|
// Both checks are synchronous (no await), so this never races the async command
|
|
// list — see isRecognizedCommand for the no-await tradeoff.
|
|
if (
|
|
(envelope.isGroup || this.config.sessionScope === 'single') &&
|
|
!envelope.alreadyPrefixed &&
|
|
!recognizedSlashCommand
|
|
) {
|
|
const who = sanitizeSenderName(
|
|
envelope.senderName || envelope.senderId || 'unknown',
|
|
);
|
|
promptText = `[${who}] ${sanitizePromptText(promptText)}`;
|
|
}
|
|
|
|
if (envelope.referencedText) {
|
|
// Quoted text is attacker-controlled. sanitizeQuotedText strips C0/DEL
|
|
// controls, Unicode line/paragraph separators (U+2028/U+2029) and bidi
|
|
// overrides, and the wrapper's own `"[]` delimiters, then caps length -
|
|
// so a crafted quote can't inject newlines/instructions, close the
|
|
// [Replying to: "..."] wrapper, flip text direction, or balloon the prompt.
|
|
const quoted = sanitizeQuotedText(envelope.referencedText, 500);
|
|
promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
|
|
}
|
|
|
|
// Resolve attachments: extract image for bridge, append file paths to text
|
|
let imageBase64 = envelope.imageBase64;
|
|
let imageMimeType = envelope.imageMimeType;
|
|
if (envelope.attachments?.length) {
|
|
const filePaths: string[] = [];
|
|
for (const att of envelope.attachments) {
|
|
if (att.type === 'image' && att.data && !imageBase64) {
|
|
imageBase64 = att.data;
|
|
imageMimeType = att.mimeType;
|
|
} else if (att.filePath) {
|
|
const label = att.type === 'file' ? 'file' : att.type;
|
|
// The filename is attacker-supplied (e.g. DingTalk), so neutralize both
|
|
// the human-readable label and the on-disk path as they enter the
|
|
// prompt. They need DIFFERENT rules: the quoted fileName label is just
|
|
// prose, so sanitizeQuotedText (which also strips `"[]`) is fine — but
|
|
// the rendered filePath must stay byte-resolvable. Brackets, quotes and
|
|
// spaces are VALID, common path chars (e.g. `app/[slug]/page.tsx`), so
|
|
// stripping them would advertise a path that doesn't exist on disk and
|
|
// break the agent's read-file tool. sanitizePromptPath preserves them
|
|
// and removes ONLY what could break/reorder the `saved to:` line
|
|
// (CR/LF, C0/DEL, Unicode line/para separators, bidi overrides).
|
|
const name = att.fileName
|
|
? ` "${sanitizeQuotedText(att.fileName, 128)}"`
|
|
: '';
|
|
const renderedPath = sanitizePromptPath(att.filePath);
|
|
filePaths.push(
|
|
`User sent a ${label}${name}. It has been saved to: ${renderedPath}`,
|
|
);
|
|
}
|
|
}
|
|
if (filePaths.length > 0) {
|
|
promptText = promptText + '\n\n' + filePaths.join('\n');
|
|
}
|
|
}
|
|
|
|
// Resolve dispatch mode: per-group override → channel config → default
|
|
const groupCfg = envelope.isGroup
|
|
? this.config.groups[envelope.chatId] || this.config.groups['*']
|
|
: undefined;
|
|
const mode: DispatchMode =
|
|
groupCfg?.dispatchMode || this.config.dispatchMode || 'steer';
|
|
|
|
const active = this.activePrompts.get(sessionId);
|
|
|
|
// Diagnostic watchdog for a steered turn that chains behind a wedged
|
|
// predecessor. Chain-and-wait (option a) means a hung predecessor bridge.prompt()
|
|
// silently deadlocks this session with no log; this surfaces that. Armed only in
|
|
// the steer branch, disarmed as the first statement of the chained `.then()` once
|
|
// the predecessor's tail resolves. Diagnostic-only — it does NOT touch the
|
|
// chain-and-wait concurrency invariant.
|
|
let steerWatchdog: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
if (active) {
|
|
// A prompt is already running for this session
|
|
switch (mode) {
|
|
case 'collect': {
|
|
// Buffer the message; it will be coalesced when the active prompt finishes
|
|
let buffer = this.collectBuffers.get(sessionId);
|
|
if (!buffer) {
|
|
buffer = [];
|
|
this.collectBuffers.set(sessionId, buffer);
|
|
}
|
|
buffer.push({ text: promptText, envelope });
|
|
return;
|
|
}
|
|
case 'steer': {
|
|
// Authorization gate (mirrors /cancel): steer = cancel-running +
|
|
// send-new, so without this an UNAUTHORIZED member of a shared session —
|
|
// already blocked from /cancel — could abort another user's running turn
|
|
// just by sending any normal message, defeating the /cancel restriction.
|
|
// If not authorized, break out of the steer case: the message is NOT
|
|
// dropped — it falls through to normal queuing (chains onto the session
|
|
// queue tail and runs AFTER the active turn) without cancelling it.
|
|
// isAuthorizedForSharedSession returns true for 1:1/non-shared sessions
|
|
// and for authorized members, so their steer-cancel is unchanged. Audit
|
|
// the silent steer→queue downgrade (like the /cancel, /clear, /who, /status
|
|
// gates surface theirs) so an operator can see WHY a member's messages
|
|
// queue instead of steering. Operator-level only — a normal message from an
|
|
// unauthorized member shouldn't get a per-message user-facing rejection.
|
|
// senderId is a stable platform id, not user-controlled display text.
|
|
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
process.stderr.write(
|
|
`[${this.name}] steer denied for ${envelope.senderId} in shared session (chat=${sanitizeLogText(envelope.chatId, 64)}); queuing instead\n`,
|
|
);
|
|
break;
|
|
}
|
|
// Best-effort cancel the running turn so it winds down sooner, then fall
|
|
// through to CHAIN this new turn onto the session queue tail (see `prev`
|
|
// below). The new turn therefore runs ONLY AFTER the old turn's finally
|
|
// has actually run — onChunk detached, activePrompts cleared, indicator
|
|
// released — so it never executes concurrently with the turn it
|
|
// supersedes.
|
|
//
|
|
// We deliberately do NOT race a bounded wait and then proceed with a
|
|
// replacement bridge.prompt() while the old turn is still active: both
|
|
// bridges key active-prompt tracking AND streamed chunks by sessionId
|
|
// alone, so a concurrent replacement on one session is bridge-unsafe —
|
|
// DaemonChannelBridge.prompt() rejects while the prior prompt is still
|
|
// active (the replacement is silently dropped), and the abandoned turn's
|
|
// late chunks mix into the replacement's stream (duplicated/stale
|
|
// output). So a genuinely wedged turn makes its successor WAIT rather
|
|
// than be force-interrupted. Turn-scoped cancellation/routing (a new
|
|
// turn that runs without waiting for a wedged predecessor) is the
|
|
// deferred fix — it needs an API change across every adapter and is out
|
|
// of scope for this phase (wenshao option (b)).
|
|
const firstCancellation = !active.cancelled;
|
|
active.cancelled = true;
|
|
if (firstCancellation) {
|
|
process.stderr.write(
|
|
`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`,
|
|
);
|
|
this.stopActiveStreaming(active, sessionId, 'steer');
|
|
// Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
|
|
// best-effort cancel that fails isn't silently invisible to operators.
|
|
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
process.stderr.write(
|
|
`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
});
|
|
// Emitted before the bridge cancel settles: steer supersedes the
|
|
// turn at the channel level (cancelled is already set above), so
|
|
// the event reflects that intent, not the bridge RPC outcome.
|
|
this.emitTaskCancellation(active, sessionId, 'steer');
|
|
}
|
|
// Diagnostic watchdog: if the predecessor turn is STILL the active prompt
|
|
// after the wind-down bound, this steered turn is wedged behind a hung
|
|
// bridge.prompt() — surface it (the chained `.then()` clears it once the
|
|
// predecessor settles). This only LOGS; it does not start a replacement or
|
|
// change concurrency. /clear is the recovery path. unref so a pending timer
|
|
// never keeps the process alive.
|
|
steerWatchdog = setTimeout(() => {
|
|
if (this.activePrompts.get(sessionId) === active) {
|
|
process.stderr.write(
|
|
`[${this.name}] steer queued behind active turn for session ${sessionId}: still waiting after ${CLEAR_CANCEL_TIMEOUT_MS}ms (use /clear to recover)\n`,
|
|
);
|
|
}
|
|
}, CLEAR_CANCEL_TIMEOUT_MS);
|
|
steerWatchdog.unref?.();
|
|
// Prepend a cancellation note so the agent understands context.
|
|
promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`;
|
|
break;
|
|
}
|
|
case 'followup': {
|
|
// Chain onto the session queue (existing sequential behavior)
|
|
break;
|
|
}
|
|
default: {
|
|
// Exhaustive check — should never happen
|
|
const _exhaustive: never = mode;
|
|
throw new Error(`Unknown dispatch mode: ${_exhaustive}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
let shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
|
|
if (shouldPrependSessionContext) {
|
|
this.instructedSessions.add(sessionId);
|
|
}
|
|
|
|
// Run the prompt with per-session serialization. followup AND steer both chain
|
|
// onto the existing queue tail; steer additionally best-effort cancelled the
|
|
// running turn above so the tail resolves sooner. Chaining (rather than seeding
|
|
// a fresh Promise.resolve()) is what guarantees this turn never runs while the
|
|
// turn it supersedes is still active — see the steer branch above.
|
|
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
// Snapshot the session generation at enqueue time to guard against a /clear
|
|
// racing this turn. There is no await between reading `active` above and this
|
|
// snapshot, so the capture is atomic with the enqueue; if /clear bumps the
|
|
// generation before this turn dequeues, the session we captured is gone — bail
|
|
// (at the dequeue guard below) rather than resurrect it.
|
|
const generation = this.sessionGenerations.get(sessionId) ?? 0;
|
|
const useBlockStreaming = this.config.blockStreaming === 'on';
|
|
const current = prev.then(async () => {
|
|
// Disarm the steer watchdog: the predecessor's tail has resolved, so this
|
|
// chained turn is no longer wedged behind it. No-op when unarmed (the timer is
|
|
// only set on the steer path).
|
|
clearTimeout(steerWatchdog);
|
|
// A /clear (or reset/new) while we were queued bumps the generation; the
|
|
// captured session is cleared, so don't run the prompt against it.
|
|
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
return;
|
|
}
|
|
if (
|
|
!shouldPrependSessionContext &&
|
|
!this.instructedSessions.has(sessionId)
|
|
) {
|
|
shouldPrependSessionContext = true;
|
|
this.instructedSessions.add(sessionId);
|
|
}
|
|
const sessionContext: string[] = [];
|
|
if (shouldPrependSessionContext) {
|
|
let memoryText: string | undefined;
|
|
if (
|
|
this.channelMemory &&
|
|
this.isAuthorizedForChannelMemory(envelope) &&
|
|
(!this.isSharedSession(envelope) ||
|
|
this.config.senderPolicy === 'allowlist')
|
|
) {
|
|
try {
|
|
memoryText = (
|
|
await this.channelMemory.readChannelMemory(
|
|
this.channelMemoryTarget(envelope),
|
|
)
|
|
)?.trim();
|
|
} catch (error) {
|
|
this.logChannelMemoryError(
|
|
'read',
|
|
envelope,
|
|
this.channelMemoryErrorMessage(error),
|
|
);
|
|
this.instructedSessions.delete(sessionId);
|
|
}
|
|
}
|
|
if (memoryText) {
|
|
sessionContext.push(
|
|
`Channel memory for this chat:\n${sanitizePromptText(memoryText)}`,
|
|
);
|
|
}
|
|
if (this.config.instructions) {
|
|
sessionContext.push(this.config.instructions);
|
|
}
|
|
// Boundary block goes last: recency bias means later instructions win,
|
|
// and the isolation boundary must not be overridable by operator text.
|
|
if (this.shouldPrependChannelBoundaryPrompt()) {
|
|
sessionContext.push(this.channelBoundaryPrompt());
|
|
}
|
|
}
|
|
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
return;
|
|
}
|
|
const groupHistoryEntries = recognizedSlashCommand
|
|
? []
|
|
: this.drainPendingGroupHistory(envelope);
|
|
let promptToSend = this.prependGroupHistoryContext(
|
|
promptText,
|
|
groupHistoryEntries,
|
|
);
|
|
if (sessionContext.length > 0) {
|
|
promptToSend = `${sessionContext.join('\n\n')}\n\n${promptToSend}`;
|
|
}
|
|
// Register this prompt as active
|
|
let doneResolve: () => void = () => {};
|
|
const done = new Promise<void>((r) => {
|
|
doneResolve = r;
|
|
});
|
|
const promptState: ActivePrompt = {
|
|
cancelled: false,
|
|
done,
|
|
resolve: doneResolve,
|
|
chatId: envelope.chatId,
|
|
messageId: envelope.messageId,
|
|
};
|
|
// This turn is now the single owner of the session's active-prompt slot.
|
|
// (Steer no longer hands a still-active session to a replacement; only
|
|
// /clear evicts, and it gives the next turn a fresh session.)
|
|
this.activePrompts.set(sessionId, promptState);
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
type: 'started',
|
|
});
|
|
|
|
// Guarded: an adapter indicator failure must not orphan the started
|
|
// event (no terminal) or leak the activePrompts entry.
|
|
try {
|
|
this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] onPromptStart threw for session ${sessionId}: ${this.lifecycleError(err)}\n`,
|
|
);
|
|
}
|
|
|
|
const streamer = useBlockStreaming
|
|
? new BlockStreamer({
|
|
minChars: this.config.blockStreamingChunk?.minChars ?? 400,
|
|
maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000,
|
|
idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500,
|
|
send: (text) => this.sendMessage(envelope.chatId, text),
|
|
})
|
|
: null;
|
|
promptState.stopStreaming = () => streamer?.stop();
|
|
|
|
// Chunks arriving while a cancel is PENDING are held here: pushing them
|
|
// to any visible sink could send output the cancel can't recall. On a
|
|
// failed cancel they're replayed; on success, discarded.
|
|
const heldChunks: string[] = [];
|
|
const releaseHeldChunks = () => {
|
|
for (const held of heldChunks.splice(0)) {
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(
|
|
envelope.chatId,
|
|
sessionId,
|
|
envelope.messageId,
|
|
),
|
|
type: 'text_chunk',
|
|
chunk: held,
|
|
});
|
|
this.onResponseChunk(envelope.chatId, held, sessionId);
|
|
streamer?.push(held);
|
|
}
|
|
};
|
|
const onChunk = (sid: string, chunk: string) => {
|
|
if (sid !== sessionId || promptState.cancelled) {
|
|
return;
|
|
}
|
|
heldChunks.push(chunk);
|
|
if (!promptState.cancelPending) {
|
|
releaseHeldChunks();
|
|
}
|
|
};
|
|
const promptBridge = this.bridge;
|
|
promptBridge.on('textChunk', onChunk);
|
|
|
|
try {
|
|
const response = await promptBridge.prompt(sessionId, promptToSend, {
|
|
imageBase64,
|
|
imageMimeType,
|
|
});
|
|
|
|
await this.settleCancelRequested(promptState);
|
|
if (!promptState.cancelled) {
|
|
releaseHeldChunks();
|
|
}
|
|
|
|
// If cancelled, skip sending the response
|
|
if (!promptState.cancelled && response) {
|
|
promptState.deliveryStarted = true;
|
|
if (streamer) {
|
|
await streamer.flush();
|
|
} else {
|
|
await this.onResponseComplete(envelope.chatId, response, sessionId);
|
|
}
|
|
}
|
|
// Once delivery started the turn's outcome is fixed — don't let a
|
|
// cancel settling during the send rewrite completed into cancelled.
|
|
if (!promptState.deliveryStarted) {
|
|
await this.settleCancelRequested(promptState);
|
|
}
|
|
if (!promptState.cancelled && !promptState.cancellationEmitted) {
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(
|
|
envelope.chatId,
|
|
sessionId,
|
|
envelope.messageId,
|
|
),
|
|
type: 'completed',
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Mirror the try path: once delivery started, a late-settling cancel
|
|
// must not suppress the failed emit (the /cancel handler declines to
|
|
// emit its own terminal once deliveryStarted is set).
|
|
if (!promptState.deliveryStarted) {
|
|
await this.settleCancelRequested(promptState);
|
|
}
|
|
if (!promptState.cancelled) {
|
|
releaseHeldChunks();
|
|
this.emitTaskLifecycle({
|
|
...this.lifecycleBase(
|
|
envelope.chatId,
|
|
sessionId,
|
|
envelope.messageId,
|
|
),
|
|
type: 'failed',
|
|
error: this.lifecycleError(err),
|
|
phase: promptState.deliveryStarted ? 'delivery' : 'agent',
|
|
});
|
|
} else {
|
|
const channel = sanitizeLogText(this.name, 64);
|
|
const safeSessionId = sanitizeLogText(sessionId, 64);
|
|
const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
|
|
process.stderr.write(
|
|
`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`,
|
|
);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
promptBridge.off('textChunk', onChunk);
|
|
streamer?.stop();
|
|
// Identity guard: a turn that wedged past /clear's bounded wait gets
|
|
// EVICTED — /clear gives up on active.done, deletes activePrompts, and a
|
|
// turn the user starts AFTER the clear can re-seed activePrompts (and own
|
|
// the collect buffer) for this session. When the wedged bridge.prompt
|
|
// finally settles and runs this finally, touching session-visible state
|
|
// would clobber that live later turn — ending the working indicator it
|
|
// re-seeded or draining a buffer it owns. So only touch session-scoped
|
|
// state when the entry is still ours. (Steer no longer evicts: it cancels
|
|
// and waits, so a steered turn is always stillCurrent when it completes.)
|
|
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
// onPromptEnd runs platform cleanup (clear the typing interval, recall the
|
|
// working reaction, finalize the card). Run it UNLESS this turn was a
|
|
// /clear eviction (clearEvicted): /clear already ran this turn's onPromptEnd
|
|
// at clear-time, and a turn the user started after the clear may now own the
|
|
// chat-scoped indicator, so re-running cleanup here would clobber it.
|
|
// Invariant: clearEvicted is set ONLY by /clear's eviction, which then
|
|
// UNCONDITIONALLY deletes activePrompts[sessionId] (its try/catch around the
|
|
// clear-time onPromptEnd guarantees the purge runs even if that throws), and
|
|
// no turn ever re-inserts THIS promptState object — so clearEvicted ⟹ NOT
|
|
// stillCurrent. Hence `stillCurrent || !clearEvicted` reduces to
|
|
// `!clearEvicted` (the `stillCurrent && clearEvicted` case is unreachable).
|
|
// Steer no longer evicts (it chains and waits), so a steered turn is always
|
|
// stillCurrent on completion.
|
|
if (!promptState.clearEvicted) {
|
|
// onPromptEnd runs platform-adapter cleanup (clear the typing interval,
|
|
// recall the working reaction, finalize the card) — network/IO that CAN
|
|
// throw. Guard it like the /clear-eviction path above: an uncaught throw
|
|
// here would skip activePrompts.delete (session leak), promptState.resolve
|
|
// (active.done never settles → a later /clear falsely logs "abandoned a
|
|
// wedged turn" for a turn that completed), and the collect-buffer drain
|
|
// (lost messages) — and the rejected queue-chain promise, swallowed by the
|
|
// tail .catch(() => {}), would silently drop every later turn this session.
|
|
try {
|
|
this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[${this.name}] onPromptEnd threw in finally for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
}
|
|
}
|
|
if (stillCurrent) {
|
|
this.activePrompts.delete(sessionId);
|
|
}
|
|
// Signal any /clear waiter racing our done that we're done — even a
|
|
// /clear-evicted wedged turn must release it (its bounded wait already
|
|
// timed out). (Steer no longer waits on done; it chains on the queue tail.)
|
|
promptState.resolve();
|
|
|
|
// Drain collect buffer if any messages accumulated — but only while we're
|
|
// still the active turn, so a /clear-evicted wedged turn whose bridge.prompt
|
|
// settles late can't drain a buffer a later turn now owns. (Belt-and-
|
|
// suspenders: /clear already deletes the buffer on eviction, so this guard
|
|
// is defensive — but it keeps the invariant "only the current turn drains".)
|
|
const buffer = this.collectBuffers.get(sessionId);
|
|
if (stillCurrent && buffer && buffer.length > 0) {
|
|
this.collectBuffers.delete(sessionId);
|
|
const lost = buffer.length;
|
|
const coalesced = buffer.map((b) => b.text).join('\n\n');
|
|
const lastEnvelope = buffer[buffer.length - 1]!.envelope;
|
|
// Re-enter handleInbound with the coalesced message
|
|
const syntheticEnvelope: Envelope = {
|
|
...lastEnvelope,
|
|
text: coalesced,
|
|
// Coalesced text already carries each message's [sender] prefix.
|
|
alreadyPrefixed: true,
|
|
// Clear attachments/references — already resolved in original text
|
|
referencedText: undefined,
|
|
attachments: undefined,
|
|
imageBase64: undefined,
|
|
imageMimeType: undefined,
|
|
};
|
|
// Queue the coalesced prompt (don't await to avoid deadlock on the queue).
|
|
// Surface a drain failure instead of silently losing buffered turns.
|
|
this.handleInbound(syntheticEnvelope).catch((err) => {
|
|
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`,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
this.sessionQueues.set(
|
|
sessionId,
|
|
current.catch(() => {}),
|
|
);
|
|
await current;
|
|
}
|
|
|
|
protected async onPairingRequired(
|
|
chatId: string,
|
|
code: string | null,
|
|
): Promise<void> {
|
|
if (code) {
|
|
await this.sendMessage(
|
|
chatId,
|
|
`Your pairing code is: ${code}\n\nAsk the bot operator to approve you with:\n qwen channel pairing approve ${this.name} ${code}`,
|
|
);
|
|
} else {
|
|
await this.sendMessage(
|
|
chatId,
|
|
'Too many pending pairing requests. Please try again later.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function truncateGroupHistoryField(value: string): string {
|
|
return value.slice(0, GROUP_HISTORY_ENTRY_METADATA_LIMIT);
|
|
}
|
|
|
|
function truncateLoopLabel(prompt: string): string {
|
|
const chars = Array.from(prompt);
|
|
return chars.length > 60 ? `${chars.slice(0, 57).join('')}...` : prompt;
|
|
}
|