@qwen-code/channel-qqbot 0.26.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,14 @@ export declare class QQChannel extends ChannelBase {
65
65
  private inboundReplyContext;
66
66
  /** msg_seq counter per user messageId, for multi-block streaming. */
67
67
  private msgSeqMap;
68
+ /**
69
+ * Anchored sends currently in flight, keyed by the msgId whose msg_seq
70
+ * counter they hold (refcounted: two sessions may send under one msgId).
71
+ * Covers sends that own no streamState entry — the terminal send in
72
+ * onResponseComplete and the stale-entry branch — which flushingSessions
73
+ * (session-keyed) cannot see.
74
+ */
75
+ private inFlightMsgSeqSends;
68
76
  /** Periodic cleanup timer for expired replyMsgId entries. */
69
77
  private replyMsgIdCleanupTimer;
70
78
  /** 5-minute TTL for replyMsgId entries and seenMessages dedup. */
@@ -75,8 +83,18 @@ export declare class QQChannel extends ChannelBase {
75
83
  private maxFlushRetries;
76
84
  /** Retry delay for subsequent attempts (backoff beyond first retry). */
77
85
  private static readonly IDLE_FLUSH_BACKOFF_MS;
86
+ /**
87
+ * Foreground ceiling for the completion-path final segment when
88
+ * maxFlushRetries is the documented "unlimited" (<= 0). The fire-and-forget
89
+ * flush paths may retry forever because no caller awaits them, but a
90
+ * completion send is awaited inside the turn's try, so its readiness to give
91
+ * up decides when that turn's finally runs and the session lock is released.
92
+ */
93
+ private static readonly FINAL_SEGMENT_ROUTE_ATTEMPTS;
78
94
  /** Max buffer length before forcing an immediate flush. */
79
95
  private static readonly MAX_BUFFER_LENGTH;
96
+ /** Purge records kept in the rescue file, oldest dropped first. */
97
+ private static readonly MAX_PURGE_RECORDS;
80
98
  /** Per-group bot OPENID map for multi-group support. */
81
99
  private botOpenIdByGroup;
82
100
  /** Dedup set for unexpected senderOpenId format warnings (key: `${chatId}:${senderOpenId}`). */
@@ -122,6 +140,29 @@ export declare class QQChannel extends ChannelBase {
122
140
  * - flushedSessions tracks already-sent sessions to skip final fullText
123
141
  */
124
142
  private streamState;
143
+ /**
144
+ * Per-session reply msgId anchor, kept across idle-flush buffer windows.
145
+ * Set deterministically in onPromptStart from the triggering message's
146
+ * id (the same event.id setReplyMsgId stores) and released when the
147
+ * response completes, fails permanently, or the session dies. Entries
148
+ * carry a timestamp so readers (onResponseChunk / onResponseComplete)
149
+ * can drop anchors past the 5-minute TTL and fall back to the active
150
+ * send path — a slow turn must never keep sending chunks with an
151
+ * expired msg_id. Because it outlives individual streamState entries, a
152
+ * concurrent message in the same chat that overwrites the chat-level
153
+ * replyMsgId entry mid-stream cannot re-parent this session's later
154
+ * chunks onto its own msg_id (see PR #6457 review).
155
+ */
156
+ private sessionReplyMsgId;
157
+ /**
158
+ * Monotonic turn counter per session, bumped on every onPromptStart.
159
+ * streamState entries carry the turn they were created in; when a new
160
+ * prompt starts on a session whose previous turn left a streamState entry
161
+ * behind (deferred send still settling), onResponseChunk uses this counter
162
+ * to detect and drop the stale entry so the new turn's chunks cannot be
163
+ * appended to the old turn's buffer or delivered under its msgId.
164
+ */
165
+ private turnCounter;
125
166
  private flushingSessions;
126
167
  private pendingStreamDelete;
127
168
  private _reconnectId;
@@ -139,6 +180,56 @@ export declare class QQChannel extends ChannelBase {
139
180
  * finally, even on error/cancel), independent of streaming config.
140
181
  */
141
182
  private activePromptSessions;
183
+ /**
184
+ * Side buffer for chunks arriving while a previous turn's deferred flush
185
+ * chain still owns the session's streamState entry (the stale parked
186
+ * early-return in onResponseChunk). Each entry records the turn that owns
187
+ * its text, so a teardown can tell a live turn's stashed HEAD — which must
188
+ * still be delivered — from a superseded turn's leftovers, which are
189
+ * discarded with a log. The chunks are prepended to the owning turn's next
190
+ * state entry once the chain settles and frees the entry, so the HEAD of a
191
+ * reply is never silently dropped during the chain's settle window (up to
192
+ * ~10s under rate-limit backoff). A teardown may only drop an entry whose
193
+ * turn is not the live turn. The consumption sites are onResponseChunk
194
+ * (the next turn's first chunk folds the stash in), onResponseComplete (the
195
+ * owning turn's completion prepends the sealed `pre`), and onPromptEnd's two
196
+ * branches: a cancelled turn merges the stash into its residual buffer, or —
197
+ * when a parked chain can still deliver it — hands it to
198
+ * deliverCancelledStash instead.
199
+ * `pre` seals the portion accumulated before the most recent
200
+ * responseBoundary: the bridge accumulates every textChunk and only a
201
+ * boundary clears that collection, so at completion only that sealed
202
+ * portion is missing from fullText — the post-boundary remainder is already
203
+ * there and must not be prepended a second time. It is absent until a
204
+ * boundary fires for the entry. Entries are cleared when the session dies
205
+ * or the channel disconnects.
206
+ */
207
+ private streamOrphanBuffer;
208
+ /**
209
+ * The turn whose onResponseComplete has already run, per session. The
210
+ * completion that prepends a turn's stash cannot run a second time, so a
211
+ * stash tagged with that turn has no guaranteed consumer, and
212
+ * handOffSealedPre delivers its text directly instead of writing it. Dropped
213
+ * when a new turn starts, on session death, and on disconnect; onPromptEnd's
214
+ * terminal teardown drops it unless a chain still holds the session's flush
215
+ * marker, and a flush chain's terminal settle drops it when it still owns the
216
+ * generation (deleteTurnGenerationIfOwned). A teardown that finds a live
217
+ * marker keeps the record so that chain's handOffSealedPre can still read it;
218
+ * because that teardown also clears the marker, the chain's own .finally
219
+ * returns on its ownership check and the record is instead dropped by the
220
+ * next onPromptStart, onSessionDied, or disconnect.
221
+ */
222
+ private completedTurns;
223
+ /**
224
+ * Bridge-side `responseBoundary` observer. ChannelBase's own listener returns
225
+ * early while a cancel is pending, so onResponseBoundary can miss a boundary
226
+ * even though the bridge still cleared its chunk collection — the stash's
227
+ * `pre` would then never be sealed and the diverted head would be absent from
228
+ * both state.buffer and fullText. Observing the bridge event directly (the
229
+ * same emit the bridge's clearChunks listens to) is ungated.
230
+ */
231
+ private _bridgeBoundarySeal;
232
+ private bridgeBoundarySealAttached;
142
233
  private readonly qqStatePath;
143
234
  /**
144
235
  * Path to the global sessions.json managed by start.ts.
@@ -147,8 +238,42 @@ export declare class QQChannel extends ChannelBase {
147
238
  private readonly globalSessionsPath;
148
239
  /** Backup of sessions.json so conversations survive daemon restarts. */
149
240
  private readonly sessionsBackupPath;
241
+ /**
242
+ * Append-only rescue log of the routes the orphan purge deletes, written
243
+ * immediately before the first deletion when `purgeLegacySessions` is on, so
244
+ * an operator can restore a legacy conversation by hand.
245
+ */
246
+ private readonly sessionsPurgedPath;
150
247
  constructor(name: string, config: ChannelConfig & Record<string, unknown>, bridge: ChannelAgentBridge, options?: ChannelBaseOptions);
151
248
  private handleCronTextChunk;
249
+ /**
250
+ * Flush `entry`'s accumulated cron text through the delivery ladder: one
251
+ * send plus its two bounded retries. A successful attempt deletes the
252
+ * entry when its buffer is empty, or re-arms the normal flush when a chunk
253
+ * arrived while the send was in flight.
254
+ */
255
+ private flushCronEntry;
256
+ /**
257
+ * Terminal failure of a cron flush. Text that arrived while the failed send
258
+ * was in flight was never attempted, so it must not be dropped with the
259
+ * entry: re-arm the normal flush for that residual, exactly like the success
260
+ * tail. An entry that no longer owns the session was already replaced, so its
261
+ * successor's text is left alone, and an entry with an empty residual has
262
+ * nothing left to deliver — both go.
263
+ */
264
+ private dropCronEntryOrRearm;
265
+ /**
266
+ * Tail of a successful cron send. If the entry still owns the session and
267
+ * a chunk arrived while the send was in flight, arm the normal flush for
268
+ * that residual; otherwise drop the entry once its buffer is empty.
269
+ */
270
+ private settleCronFlushSuccess;
271
+ /**
272
+ * Arm the normal 2s idle flush (immediate at the buffer length limit) for
273
+ * `entry`, unless one is already armed. The callback re-checks that the
274
+ * entry still owns the session before flushing.
275
+ */
276
+ private armCronFlush;
152
277
  /**
153
278
  * Public gate for external cron/scheduler integration.
154
279
  * Wraps a cron message flow to activate `_inCronFlow` so that
@@ -167,13 +292,24 @@ export declare class QQChannel extends ChannelBase {
167
292
  handleInbound(envelope: Envelope): Promise<void>;
168
293
  sendMessage(chatId: string, text: string): Promise<void>;
169
294
  protected sendThreadMessage(chatId: string, _threadId: string | undefined, text: string, sourceLabel?: string): Promise<void>;
295
+ /**
296
+ * The reply context a session's next response would carry: the inbound
297
+ * message the turn is answering, resolved through the turn-scoped
298
+ * activePrompts entry. Read by sendResponseMessage and captured once by
299
+ * deliverCancelledStash, which re-attempts a delivery after that entry may
300
+ * already belong to a successor turn.
301
+ */
302
+ private resolveResponseReplyContext;
170
303
  protected sendResponseMessage(chatId: string, text: string, sessionId: string, sourceLabel?: string): Promise<void>;
171
304
  private sendMessageWithReplyContext;
172
305
  /**
173
306
  * Resolve API routing: handles disposed check, token refresh, chatId validation,
174
- * sandbox detection, and C2C/group path selection. Returns null if any guard fails.
307
+ * sandbox detection, and C2C/group path selection. When a guard fails it
308
+ * returns `{ block }` naming whether a retry could help, instead of a route.
175
309
  */
176
310
  private resolveRoute;
311
+ /** The chat type recorded for a chat, if any (see resolveRoute). */
312
+ private chatTypeFor;
177
313
  disconnect(): void;
178
314
  /**
179
315
  * QQ Bot API V2 does not provide a typing indicator endpoint, but these
@@ -181,9 +317,68 @@ export declare class QQChannel extends ChannelBase {
181
317
  * discriminator (see activePromptSessions). ChannelBase always pairs the
182
318
  * two calls per prompt turn (onPromptEnd runs in the prompt path's
183
319
  * finally, even on error/cancel).
320
+ *
321
+ * Set the per-session reply anchor deterministically from the triggering
322
+ * message's id (ChannelBase passes envelope.messageId, which for QQ is
323
+ * event.id — the same value setReplyMsgId stores in the chat-level entry).
324
+ * This replaces the previous opportunistic capture on the first chunk,
325
+ * which had a blind spot: a slow model turn could pass the chat entry's
326
+ * 5-minute TTL before its first chunk, and the capture would then pick up
327
+ * the msgId of a NEWER message in the same chat (another user's turn).
328
+ * Anchoring at prompt start is immune — the anchor always refers to the
329
+ * message that actually triggered this session's turn.
330
+ *
331
+ * Proactive turns (loop/webhook/cron) have no triggering message; their
332
+ * replies must go out as active messages, so clear any stale anchor.
184
333
  */
185
- protected onPromptStart(_chatId: string, sessionId: string, _messageId?: string): void;
186
- protected onPromptEnd(_chatId: string, sessionId: string, _messageId?: string): void;
334
+ protected onPromptStart(_chatId: string, sessionId: string, messageId?: string): void;
335
+ /**
336
+ * ChannelBase invokes this unconditionally in a finally block — including
337
+ * when the prompt was cancelled, in which case onResponseComplete is
338
+ * skipped. That makes it the one reliable release point for per-session
339
+ * streaming state: without it a cancelled turn would leave its reply
340
+ * anchor in sessionReplyMsgId (and the next prompt on the same session
341
+ * would overwrite it only at its own start, leaking the stale entry's
342
+ * msg_seq orphan meanwhile).
343
+ *
344
+ * Deferred completions are exempt: when onResponseComplete parked the
345
+ * session in pendingStreamDelete, a final flush is still in flight and
346
+ * its promise chain owns the streamState lifecycle (it re-flushes the
347
+ * residual buffer, then releases the anchor). Clearing state here would
348
+ * trip the .then() identity guard and silently drop the residual buffer.
349
+ */
350
+ protected onPromptEnd(chatId: string, sessionId: string, _messageId?: string): void;
351
+ /**
352
+ * Deliver a cancelled turn's stashed HEAD on its OWN anchor. The session is
353
+ * parked in pendingStreamDelete while a predecessor turn's deferred chain
354
+ * settles, so ChannelBase skips onResponseComplete and onPromptEnd's park
355
+ * early-return would otherwise leave this turn's stash for the next turn to
356
+ * discard as superseded. Fire-and-forget — onPromptEnd is sync — and never
357
+ * rejects, so a failed delivery cannot become an unhandled rejection. The
358
+ * park flag and the predecessor's streamState entry/buffer are deliberately
359
+ * untouched: that chain still owns them and must settle and release its own
360
+ * anchor, and this text must not go out under the predecessor's msgId.
361
+ *
362
+ * This is the only delivery for a stash that onPromptEnd already deleted from
363
+ * streamOrphanBuffer (or a sealed head handOffSealedPre cleared from
364
+ * sealedPre), so a transient failure must not drop the text. RATE_LIMITED is
365
+ * the one code the flush path classifies as transient, and no copy survives
366
+ * for a later turn, so the send is re-attempted here under the same
367
+ * maxFlushRetries bound flushAndTrack uses (<= 0 means unlimited). A
368
+ * re-attempt is not routed through streamState/idleFlush: the turn is over,
369
+ * so a state entry for it would be dropped as stale by the next prompt, and
370
+ * the entry-less in-flight guard below would be lost. Every attempt
371
+ * reuses the anchor, the attribution label and the reply context captured
372
+ * before the first one.
373
+ *
374
+ * A send also fails to reach the wire without throwing when its route cannot
375
+ * be resolved; sendMessageWithReplyContext reports that as transient (token
376
+ * refresh failure) or permanent (disposed / unusable chatId / no chat type),
377
+ * and the second class is dropped with a log immediately rather than spun on
378
+ * the backoff forever. Permanent DeliveryErrors and exhaustion drop with a
379
+ * log, as the other paths do.
380
+ */
381
+ private deliverCancelledStash;
187
382
  protected onResponseChunk(chatId: string, chunk: string, sessionId: string, segment?: ChannelOutputSegmentContext): void;
188
383
  private idleFlush;
189
384
  /**
@@ -192,10 +387,233 @@ export declare class QQChannel extends ChannelBase {
192
387
  * on failure) logic to eliminate duplication.
193
388
  */
194
389
  private flushAndTrack;
390
+ /**
391
+ * Drop the session's turn-generation records — the turn counter and the
392
+ * completion record completedTurns — only when they still belong to the
393
+ * settling turn. A successor turn that started while this chain was in flight
394
+ * bumped the counter and cleared the completion record already (see
395
+ * onPromptStart); deleting them here would reset the successor's stale-state
396
+ * detection to 0 and let a stale completion record alias onto its turn.
397
+ *
398
+ * The counter is kept while this session still has a prompt in flight. A
399
+ * settle can run in the microtask window between onResponseComplete parking
400
+ * the session and ChannelBase's onPromptEnd (which clears the active marker
401
+ * first), and onPromptEnd then recognises the turn's own re-stashed sealed
402
+ * head by comparing the stash's turn against this counter — as does the
403
+ * parked-stash service before its early return. Deleting the counter here
404
+ * makes that gate read 0 and discard the head as superseded: the reply's
405
+ * opening reaches no wire, no buffer and no stash while the turn reports
406
+ * completed. The completion record still goes: it is turn-scoped, and a
407
+ * later prompt reusing the number must not alias it. onPromptEnd,
408
+ * onPromptStart and onSessionDied all still delete the counter once the
409
+ * prompt is over, so it stays bounded by the live sessions and can never be
410
+ * inherited by a successor turn.
411
+ */
412
+ private deleteTurnGenerationIfOwned;
195
413
  onToolCall(_chatId: string, event: ToolCallEvent): void;
196
414
  protected onResponseBoundary(_chatId: string, sessionId: string): void;
197
415
  protected onResponseComplete(chatId: string, fullText: string, sessionId: string, segment?: ChannelOutputSegmentContext): Promise<void>;
416
+ /**
417
+ * A stash's sealed head has no other copy — the bridge cleared it from its
418
+ * collection at the boundary — so a delivery that fails after the entry was
419
+ * consumed must report the loss (length and error) instead of dropping it
420
+ * silently. Delete-before-send is deliberate: it stops a chunk arriving
421
+ * mid-send from draining the same stash twice.
422
+ */
423
+ private logLostSealedHead;
424
+ /**
425
+ * Act on the route class a completion-path send reports. A blocked send never
426
+ * reached the wire but throws nothing, so without this the final segment is
427
+ * indistinguishable from a delivered one: the sealed head's loss log is
428
+ * bypassed and the turn is reported delivered. A transient block (token
429
+ * refresh failure) can clear on a later attempt, so it is re-attempted; a
430
+ * permanent block, or exhaustion, reports the sealed head this path consumed
431
+ * instead of dropping it silently. Exhaustion is bounded by maxFlushRetries
432
+ * when that is positive, and by FINAL_SEGMENT_ROUTE_ATTEMPTS when
433
+ * maxFlushRetries is the documented "unlimited" (<= 0): this send is awaited
434
+ * inside the turn, so unlike the fire-and-forget flush paths it cannot be
435
+ * allowed to retry forever — the turn's finally (and with it the session's
436
+ * turn lock) would never run. The caller holds the msg_seq counter across the
437
+ * whole call and releases the anchor only after it returns, so a re-attempt
438
+ * cannot restart at seq 1 (QQ dedupes on msg_id + msg_seq).
439
+ */
440
+ private sendFinalSegmentChecked;
198
441
  private streamBufferLimit;
442
+ /**
443
+ * Build a fresh streamState entry for a session's first chunk, resolving
444
+ * the session's reply anchor (TTL-checked) and the segment's reply context.
445
+ * Shared by onResponseChunk's fresh-entry path and onPromptEnd's delivery
446
+ * of a cancelled turn's stashed head.
447
+ */
448
+ private createStreamState;
449
+ /**
450
+ * Report the tail a capped divert episode accumulated after its first
451
+ * overflow line. See the cap branch in onResponseChunk: the first drop is
452
+ * logged immediately so the loss is visible, later chunks only count, and
453
+ * this emits the cumulative total once per episode when the stash leaves
454
+ * the side buffer. `capDropped` counts characters, not fields: the sealed
455
+ * pre's own trim is a subset of the text trim and is not charged again.
456
+ */
457
+ private reportOrphanStashCap;
458
+ /**
459
+ * Drop a side-buffer entry that cannot be delivered (it belongs to a
460
+ * superseded turn, or a fresh turn is starting and can never own it) and
461
+ * log the discarded text so the loss is observable.
462
+ */
463
+ private dropOrphanStash;
464
+ /**
465
+ * The seal a failed payload needs when a boundary cleared the bridge's
466
+ * collection while the send was in flight: the payload's whole text is now
467
+ * absent from fullText, so it is sealed outright rather than only by the seal
468
+ * the send carried, which may cover just a prefix. `residual` is the text the
469
+ * boundary stripped from the live turn, captured by the boundary hooks ('' =
470
+ * nothing), and is appended after the payload — no string comparison against
471
+ * the carried seal, so a residual whose text repeats it still counts.
472
+ */
473
+ private sealClearedPayload;
474
+ /**
475
+ * Put a transiently-failed payload back on `current` in the order the seal
476
+ * states it in.
477
+ *
478
+ * With no stash merged during this flight the payload predates whatever
479
+ * accumulated while it was in flight — chunks sent to state.buffer by the
480
+ * bridge's fallback path, or a residual that arrived mid-send — so the plain
481
+ * prepend (`payload + buffer`) is correct and is what the non-widened case
482
+ * has always done.
483
+ *
484
+ * After onPromptEnd merged a stash while this send was flight, the buffer is
485
+ * `[stashed head][chunks that arrived since]`: the merge prepends the head,
486
+ * and every later writer appends. That head is OLDER than the failed payload
487
+ * (it is the seal's leading segment — see handOffSealedPre, "the older
488
+ * first"), so the payload belongs BETWEEN the two portions and the
489
+ * reconstruction is `head + payload + newer`. Reconstructing it is the whole
490
+ * reason `staleStashPrefix` exists: the failed payload's text may repeat
491
+ * either portion of the buffer, the newer text may itself begin with the
492
+ * payload's text, and the payload may even be empty, so no boundary can be
493
+ * found by comparing text. A `startsWith`/dedup test against the buffer would
494
+ * strip the newer text's own leading characters — dropping real reply text —
495
+ * so the split uses the recorded length alone and never compares strings.
496
+ * The marker is consumed here — the payload is folded in and the next flight
497
+ * must not reorder against it again (flushAndTrack also resets it).
498
+ *
499
+ * A boundary that sealed a residual of its own while the send was in flight
500
+ * does not change that order: captureBoundaryClear records the residual and
501
+ * marks the re-seal, but the residual is only the text that accumulated
502
+ * since the boundary — it never contains the in-flight payload, and it does
503
+ * not know that the head prepended in front of it is older. A plain prepend
504
+ * would therefore read `head + payload + newer` as `payload + head + newer`.
505
+ * The reconstruction above stays the same; resealOnRebuffer's boundary arm
506
+ * extends the widened seal to match it.
507
+ */
508
+ private rebufferAfterStashMerge;
509
+ /**
510
+ * The seal owed for `payload` when a boundary cleared the bridge's collection
511
+ * while its send was in flight. onPromptEnd's stash merge may have widened the
512
+ * seal during this flight; the undelivered window is then the reconstruction
513
+ * rebufferAfterStashMerge builds (the OLDER stashed head, then this payload,
514
+ * then whatever accumulated since), read lazily so the non-widened path never
515
+ * consumes the stash marker. Otherwise the plain payload-plus-residual seal
516
+ * stands. Shared by the transient re-buffer arms (resealOnRebuffer) and the
517
+ * permanent handoff: all describe the same window, so they must not drift.
518
+ */
519
+ private sealForClearedWindow;
520
+ /**
521
+ * Re-seal a failed payload that a transient re-buffer puts back on `current`:
522
+ * a boundary that cleared the bridge's collection while the send was in
523
+ * flight removed the payload's whole text from fullText, so the entire
524
+ * payload is sealed, plus the residual the boundary captured (flagged
525
+ * deterministically, not by comparing seal text). Only a chain that still
526
+ * owns the live turn may write the seal — a superseded entry's payload is
527
+ * abandoned with its failure and must not be injected into a successor's
528
+ * reply. `carriedSeal` is the backstop for a re-seal whose boundary marker
529
+ * was missed, but it is skipped when the seal was widened during this flight
530
+ * (sealWidenedInFlight — onPromptEnd's stash merge already folded the carried
531
+ * head in as the widened seal's suffix, so prepending it again would seal it
532
+ * twice). Shared by the parked and non-parked re-buffer arms, and by the
533
+ * permanent handoff through sealForClearedWindow; they must stay identical
534
+ * because they encode the same rule.
535
+ */
536
+ private resealOnRebuffer;
537
+ /**
538
+ * Carry a widened, boundary-cleared re-buffer into the seal the NEXT flight
539
+ * captures, so the knowledge survives the per-flight resets and a permanent
540
+ * failure on a retry re-stashes the whole window instead of the stale
541
+ * residual.
542
+ *
543
+ * resealOnRebuffer writes exactly this for a live turn, but its live-turn
544
+ * gate skips a superseded entry — whose re-buffer would otherwise keep the
545
+ * residual the boundary overwrote ('AC') while `current.buffer` already holds
546
+ * the reconstruction ('APC'). The retry flight then starts with `sealedPre`
547
+ * 'AC' and, because flushAndTrack resets sealWidenedInFlight /
548
+ * boundaryClearedInFlight at every flight start, its permanent arm can no
549
+ * longer rebuild the window: the payload 'P' is on no wire and in no seal and
550
+ * the entry is deleted. Recording the reconstruction on the entry hands the
551
+ * retry flight the one value it needs: its success path still clears it
552
+ * without delivering, and only a give-up re-stashes it (or delivers it), the
553
+ * same handoff the widened permanent arm performs.
554
+ *
555
+ * Gated on the boundary marker AND the widening, not on the buffer alone: a
556
+ * widened re-buffer with NO boundary leaves the newer text in the bridge's
557
+ * collection, so welding it into the seal would deliver it twice. The value
558
+ * is the reconstruction `rebufferAfterStashMerge` just wrote to
559
+ * `current.buffer` — no seal/buffer text comparison anywhere.
560
+ */
561
+ private carryWidenedWindowIntoSeal;
562
+ /**
563
+ * Whether a settling flush chain still owns the session: its entry is the
564
+ * live streamState entry, or the chain still holds the flush marker. The
565
+ * marker is cleared only by `.finally()` after the settle arms, so this stays
566
+ * true while those arms hand state off; a session torn down by onSessionDied
567
+ * (or replaced outright) owns neither, and its chain must not write shared
568
+ * per-session state.
569
+ */
570
+ private ownsSession;
571
+ /**
572
+ * Whether this entry is still the turn that owns the session's counter. A
573
+ * superseded entry's text is abandoned with its failure and must not be
574
+ * sealed into a successor's reply.
575
+ */
576
+ private ownsLiveTurn;
577
+ /**
578
+ * Hand a doomed entry's sealed pre-boundary head to whatever can still
579
+ * deliver it, before the entry carrying the only copy is dropped. The
580
+ * boundary cleared the bridge's chunk collection, so this text is absent
581
+ * from fullText: dropping it truncates the reply's opening with no log.
582
+ *
583
+ * A later settle of this turn (onResponseComplete has not run yet), or a
584
+ * successor turn that owns the counter now, can still prepend it — re-stash,
585
+ * merging into an existing stash. A fresh entry is tagged with the turn that
586
+ * owns the counter, never the doomed one: every consumer gates on the entry
587
+ * turn matching the live turn, so a doomed tag is dropped as superseded by
588
+ * the successor's first chunk and the head is lost anyway. The merge branch
589
+ * keeps the existing entry's turn, which a stash always carries as the turn
590
+ * that wrote it. `pre` carries the sealed head alone and is never widened to
591
+ * the whole dropped buffer: the post-boundary text is still in fullText and
592
+ * prepending it again would deliver it twice.
593
+ *
594
+ * Otherwise the turn is already over — completion returned and parked the
595
+ * session, or the caller is the park-consuming exhaustion branch, which
596
+ * clears the flag before reaching here — and this turn still owns the
597
+ * counter, so the block that drops the entry also drops the counter and a
598
+ * stash would be discarded as superseded. Deliver the sealed head on this
599
+ * turn's own anchor instead — the same path onPromptEnd already uses for a
600
+ * parked cancelled turn's HEAD. The same delivery is taken when the turn
601
+ * being tagged has already run its completion (see completedTurns): its
602
+ * completion is the reader that would prepend the stash, it will not run
603
+ * again, and a stash it cannot consume is dropped by the next onPromptStart.
604
+ *
605
+ * `turnIsOver` defaults to the park flag, which is still armed at the four
606
+ * sites that reach here while it is; the pending-exhaustion branch has
607
+ * already consumed it, so it passes the fact explicitly.
608
+ *
609
+ * `carriedSeal` is the seal a permanently-failed send captured when it
610
+ * started (flushAndTrack). A later boundary can re-seal the same state's
611
+ * residual while that send is in flight, overwriting `sealedPre`; the failed
612
+ * send carried the older head, which then has no other copy. Both are
613
+ * recovered here — the older first, since the seal always covers a later
614
+ * buffer window.
615
+ */
616
+ private handOffSealedPre;
199
617
  onSessionDied(sessionId: string): void;
200
618
  private serializeQQState;
201
619
  /** Debounced state persistence with atomic write. */
@@ -211,6 +629,41 @@ export declare class QQChannel extends ChannelBase {
211
629
  * No-op if not attached or if cron is disabled.
212
630
  */
213
631
  private detachCronHandler;
632
+ /**
633
+ * Attach the ungated `responseBoundary` observer to the current bridge.
634
+ * No-op if already attached.
635
+ */
636
+ private attachBridgeBoundarySeal;
637
+ /** Detach the boundary observer. No-op if not attached. */
638
+ private detachBridgeBoundarySeal;
639
+ /**
640
+ * Seal the pre-boundary portion of a stashed head. Called from both the
641
+ * adapter hook and the bridge's own boundary event: ChannelBase suppresses
642
+ * the hook while a cancel is pending, but the bridge clears its chunk
643
+ * collection on every boundary it emits, so only the ungated observer can
644
+ * guarantee `pre` is set before the turn completes.
645
+ */
646
+ private sealOrphanStash;
647
+ /**
648
+ * A boundary clears the bridge's chunk collection. Seal the live turn's
649
+ * buffer-resident prefix for the completion path, and record on the
650
+ * entry whose flush is in flight what its re-buffer must re-seal: the payload
651
+ * the boundary stripped from fullText, always, plus that residual when the
652
+ * flushed entry is the live turn's own. Called by the adapter hook and by the
653
+ * ungated bridge observer — the observer is the only signal when ChannelBase
654
+ * suppresses the hook (cancel pending) — and both run on a normal boundary,
655
+ * where the assignments are idempotent. Keyed off the in-flight marker rather
656
+ * than streamState, so a successor turn replacing the entry cannot hide the
657
+ * chain whose payload is still in the air.
658
+ */
659
+ private captureBoundaryClear;
660
+ /**
661
+ * The residual text a boundary captured for this entry, or '' when it
662
+ * recorded none. The text lives in `sealedPre`, written by
663
+ * captureBoundaryClear in the same call that sets the marker, so it is always
664
+ * a real residual and never the seal the failed payload carried.
665
+ */
666
+ private capturedResidual;
214
667
  /** Flush pending state writes immediately (called on disconnect). */
215
668
  private flushQQState;
216
669
  /**
@@ -240,6 +693,123 @@ export declare class QQChannel extends ChannelBase {
240
693
  * Keep this while old persisted files may still exist.
241
694
  */
242
695
  private fixRestoredSessions;
696
+ /**
697
+ * Purge orphaned session mappings left over from older scope eras:
698
+ * `<channel>:__single__` keys (single-scope era, see PR #6457) and this
699
+ * channel's own three-part user-scope keys.
700
+ *
701
+ * `<channel>:__single__` is the live routing key under an explicit 'single'
702
+ * sessionScope, so it is an orphan only under any other scope. There it can
703
+ * never be routed to again and is dead weight in the router maps and the
704
+ * persisted sessions file — and worse, restore re-attaches it via
705
+ * bridge.loadSession on every restart, silently resetting continuity.
706
+ *
707
+ * User-scope keys (`<channel>:<sender>:<chat>`) are unroutable under every
708
+ * scope other than 'user' (including 'single'): SessionRouter's
709
+ * thread/chat_thread routing key is `<channel>:<chatId>` and its 'single'
710
+ * key is `<channel>:__single__`, while resolve() is a bare map lookup with
711
+ * no shape fallback. This channel's own user-scope entries are therefore
712
+ * purged under every recognized non-'user' scope; an unrecognized scope
713
+ * fails closed (see knownScope below). A sibling channel's are left alone,
714
+ * decided by target ownership, never by key prefix. The purge runs after
715
+ * restore, so on the first boot those keys are still re-attached once by
716
+ * bridge.loadSession and then released here; afterwards they are gone from
717
+ * the persisted file.
718
+ *
719
+ * The destructive half is opt-in via `purgeLegacySessions` (default false):
720
+ * the default scope changing is not the operator asking to delete persisted
721
+ * conversations. With it off, the doomed routes are only counted and
722
+ * reported, and nothing is released. With it on, before the first deletion
723
+ * the doomed routes are appended to `<name>-sessions-purged.json` in the
724
+ * channel state directory so an operator can restore a legacy conversation
725
+ * by hand; if that write fails, nothing is deleted. Each record carries the
726
+ * route's `cwd` when the router's persisted route store still has it, so a
727
+ * restored route resolves to the same workspace. The file accumulates purge
728
+ * records and is never read back automatically.
729
+ *
730
+ * Runs AFTER restoreSessions(): SessionRouter exposes no public API to drop
731
+ * persisted entries before restore (readPersistedEntries/deleteByKey are
732
+ * private), and rewriting the persist file from here would be fragile and
733
+ * race-prone. The orphan is therefore re-attached by bridge.loadSession
734
+ * during restore and then released here — both the router mapping and the
735
+ * daemon-side session (see bridge.discardSession below) are torn down.
736
+ */
737
+ private purgeSingleScopeOrphans;
738
+ /**
739
+ * Resolve a session's reply anchor, returning both the raw entry and the
740
+ * msgId only while the entry is still inside REPLY_MSG_ID_TTL_MS. Callers
741
+ * that just need a usable anchor take `.msgId`; callers that must tell an
742
+ * absent anchor from an expired one (the final-segment fallback logs the
743
+ * expired case) branch on `.entry`. One site, so the freshness rule cannot
744
+ * drift between the flush, cancelled-stash, completion and create paths.
745
+ */
746
+ private resolveSessionReplyAnchor;
747
+ /**
748
+ * Release a session's reply anchor, cascading to msgSeqMap when the
749
+ * anchored msgId is no longer referenced by any live session or by the
750
+ * chat's current replyMsgId entry. Idempotent — safe to call on sessions
751
+ * that never held an anchor. This is the single release path for
752
+ * sessionReplyMsgId so every release point (flush completion, permanent
753
+ * delivery failure, retry exhaustion, onResponseComplete, onSessionDied,
754
+ * onPromptEnd, group removal) also cleans up msg_seq counters whose msgId
755
+ * can never be used again — otherwise they accumulate forever in memory
756
+ * and in the persisted QQ state.
757
+ *
758
+ * When expectedMsgId is given, the map entry is only removed if it still
759
+ * points at that msgId. A deferred send chain may settle after the next
760
+ * prompt on the same session has already overwritten the anchor with a new
761
+ * msgId; releasing by sessionId alone would delete the *successor's* anchor
762
+ * (the exact defect this parameter guards). The msgSeqMap cascade still
763
+ * runs for expectedMsgId so the superseded turn's counter cannot orphan.
764
+ */
765
+ private releaseSessionReplyAnchor;
766
+ /**
767
+ * Whether a msgId's msg_seq counter is still needed: another session is
768
+ * anchored to it, the chat-level replyMsgId entry still points at it, a
769
+ * streamState entry holds it with a pending residual or an in-flight flush,
770
+ * an anchored send under it is in flight (including the entry-less sends
771
+ * flushingSessions cannot see), or the replyContextByMessageId routing map
772
+ * still names it. The single predicate for every msgSeqMap reclamation site,
773
+ * so the release veto and the TTL/teardown reclaimers cannot disagree and
774
+ * drop a counter under a live send. It errs toward retaining: a counter that
775
+ * survives too long only makes a later send under the same msgId start at a
776
+ * higher msg_seq, which QQ accepts, while reclaiming it early loses the tail
777
+ * (QQ dedupes on msg_id + msg_seq).
778
+ */
779
+ private isMsgSeqStillInUse;
780
+ /**
781
+ * Drop a msgId's msg_seq counter once no holder remains. Derived,
782
+ * not decided per call site: a release site that vetoes reclamation while a
783
+ * holder still exists never gets a second chance once that holder later
784
+ * disappears, so every site routes through isMsgSeqStillInUse and the sweep
785
+ * re-checks the counter map itself. Returns whether the counter was dropped;
786
+ * the drop is persisted so a cold restart cannot resurrect it.
787
+ */
788
+ private reclaimMsgSeq;
789
+ /**
790
+ * Safety net that reclaims a msg_seq counter once its last holder is gone:
791
+ * the iteration domain is the counter map itself, so a
792
+ * counter whose only *naming* entry (session anchor, chat anchor,
793
+ * replyContextByMessageId routing entry, stream entry) was already deleted
794
+ * is still visited. It cannot make orphaning impossible — a counter can
795
+ * outlive its last holder between two 60s ticks — but it guarantees the
796
+ * counter is dropped on the next tick instead of leaking forever. Deleting
797
+ * from a Map while iterating its keys is safe. Returns whether anything was
798
+ * reclaimed.
799
+ */
800
+ private reclaimOrphanMsgSeqCounters;
801
+ /** Mark an anchored send under msgId in flight for the release guard. */
802
+ private beginMsgSeqSend;
803
+ /** Clear the matching beginMsgSeqSend once the send has settled. */
804
+ private endMsgSeqSend;
805
+ /** Whether any live session is still anchored to this msgId. */
806
+ private isMsgIdAnchoredBySession;
807
+ /**
808
+ * Set replyMsgId for a chat, replacing any previous entry. The previous
809
+ * msgId's msg_seq counter is deliberately NOT dropped here (see the NOTE
810
+ * below): it is left to the TTL sweep, which reclaims it only once no
811
+ * holder remains.
812
+ */
243
813
  private setReplyMsgId;
244
814
  private deleteReplyContext;
245
815
  /**