@qwen-code/channel-base 0.23.1-preview.1 → 0.23.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.
- package/dist/AcpBridge.d.ts +1 -0
- package/dist/AcpBridge.d.ts.map +1 -1
- package/dist/AcpBridge.js +48 -9
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +29 -1
- package/dist/ChannelAgentBridge.d.ts.map +1 -1
- package/dist/ChannelAgentBridge.js +33 -0
- package/dist/ChannelAgentBridge.js.map +1 -1
- package/dist/ChannelBase.d.ts +43 -3
- package/dist/ChannelBase.d.ts.map +1 -1
- package/dist/ChannelBase.js +136 -17
- package/dist/ChannelBase.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +18 -0
- package/dist/DaemonChannelBridge.d.ts.map +1 -1
- package/dist/DaemonChannelBridge.js +60 -6
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SessionRouter.d.ts +39 -2
- package/dist/SessionRouter.d.ts.map +1 -1
- package/dist/SessionRouter.js +275 -11
- package/dist/SessionRouter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/named-session-manager.d.ts +38 -1
- package/dist/named-session-manager.d.ts.map +1 -1
- package/dist/named-session-manager.js +168 -24
- package/dist/named-session-manager.js.map +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -7,8 +7,8 @@ import { DmGate } from './DmGate.js';
|
|
|
7
7
|
import { GroupHistoryStore } from './group-history-store.js';
|
|
8
8
|
import { SenderGate } from './SenderGate.js';
|
|
9
9
|
import { PairingStore } from './PairingStore.js';
|
|
10
|
-
import { SessionRouter } from './SessionRouter.js';
|
|
11
|
-
import { NamedSessionManager, } from './named-session-manager.js';
|
|
10
|
+
import { SessionRouter, readDaemonHttpErrorCode } from './SessionRouter.js';
|
|
11
|
+
import { NamedSessionManager, NamedSessionTaskError, } from './named-session-manager.js';
|
|
12
12
|
import { getGlobalQwenDir } from './paths.js';
|
|
13
13
|
import { sanitizeSenderName, sanitizeQuotedText, sanitizePromptText, sanitizePromptPath, sanitizeLogText, sanitizeDisplayText, truncateCodePoints, PROMPT_UNSAFE_INVISIBLES, } from './sanitize.js';
|
|
14
14
|
import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js';
|
|
@@ -160,14 +160,17 @@ export class ChannelBase {
|
|
|
160
160
|
bridgeToolCallListener = (event) => {
|
|
161
161
|
this.dispatchToolCall(event);
|
|
162
162
|
};
|
|
163
|
-
bridgeBackgroundResponseListener = (sessionId, text) => {
|
|
164
|
-
void this.dispatchBackgroundResponse(sessionId, text).catch((err) => {
|
|
163
|
+
bridgeBackgroundResponseListener = (sessionId, text, context) => {
|
|
164
|
+
void this.dispatchBackgroundResponse(sessionId, text, context).catch((err) => {
|
|
165
165
|
process.stderr.write(`[${this.name}] background response delivery failed for session ${sanitizeLogText(sessionId, 128)}: ${this.lifecycleError(err)}\n`);
|
|
166
166
|
});
|
|
167
167
|
};
|
|
168
168
|
bridgeSessionDiedListener = (event) => {
|
|
169
169
|
this.onSessionDied(event.sessionId);
|
|
170
170
|
};
|
|
171
|
+
bridgeDisconnectedListener = () => {
|
|
172
|
+
this.onBridgeDisconnected();
|
|
173
|
+
};
|
|
171
174
|
bridgePermissionRequestListener = (event) => {
|
|
172
175
|
void this.dispatchPermissionRequest(event).catch((err) => {
|
|
173
176
|
process.stderr.write(`[${this.name}] permission relay failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(err)}\n`);
|
|
@@ -209,13 +212,19 @@ export class ChannelBase {
|
|
|
209
212
|
}
|
|
210
213
|
this.onToolCall(chatId, event);
|
|
211
214
|
}
|
|
212
|
-
async dispatchBackgroundResponse(sessionId, text) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
async dispatchBackgroundResponse(sessionId, text, _context) {
|
|
216
|
+
if (text.trim().length === 0)
|
|
217
|
+
return;
|
|
218
|
+
const delivery = await this.resolveBackgroundResponseDelivery(sessionId);
|
|
219
|
+
if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
|
|
217
220
|
return;
|
|
218
221
|
}
|
|
222
|
+
await this.deliverBackgroundResponseToTarget(sessionId, text, delivery);
|
|
223
|
+
}
|
|
224
|
+
async resolveBackgroundResponseDelivery(sessionId) {
|
|
225
|
+
let target = this.router.getTarget(sessionId);
|
|
226
|
+
if (!target || target.channelName !== this.name)
|
|
227
|
+
return undefined;
|
|
219
228
|
let sourceLabel;
|
|
220
229
|
if (this.namedSessions) {
|
|
221
230
|
const presentation = await this.namedSessions.resolvePresentation(sessionId);
|
|
@@ -235,6 +244,10 @@ export class ChannelBase {
|
|
|
235
244
|
target = currentTarget;
|
|
236
245
|
sourceLabel = this.createSourceLabel(presentation, target);
|
|
237
246
|
}
|
|
247
|
+
return { target, sourceLabel };
|
|
248
|
+
}
|
|
249
|
+
async deliverBackgroundResponseToTarget(sessionId, text, delivery) {
|
|
250
|
+
const { target, sourceLabel } = delivery;
|
|
238
251
|
if (this.supportsProactiveSend() && this.supportsProactiveTarget(target)) {
|
|
239
252
|
if (sourceLabel) {
|
|
240
253
|
await this.pushProactive(target, text, sourceLabel);
|
|
@@ -1658,6 +1671,7 @@ export class ChannelBase {
|
|
|
1658
1671
|
]);
|
|
1659
1672
|
if (!cancelled) {
|
|
1660
1673
|
this.cancelBtw(sessionId);
|
|
1674
|
+
this.onSessionRetiring(sessionId);
|
|
1661
1675
|
this.router.removeSessionId(sessionId);
|
|
1662
1676
|
this.instructedSessions.delete(sessionId);
|
|
1663
1677
|
this.unattendedMemorySessions.delete(sessionId);
|
|
@@ -1796,10 +1810,19 @@ export class ChannelBase {
|
|
|
1796
1810
|
this.unattendedMemorySessions.delete(sessionId);
|
|
1797
1811
|
this.removePendingPermissionsForSession(sessionId);
|
|
1798
1812
|
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Called when the standalone ACP bridge process exits. Its in-flight turns
|
|
1815
|
+
* never settle, but crash recovery restores the sessions on a fresh bridge,
|
|
1816
|
+
* so overrides must clear only turn-scoped transient state (never session
|
|
1817
|
+
* routing) and must not fabricate terminal outcomes for interrupted turns.
|
|
1818
|
+
*/
|
|
1819
|
+
onBridgeDisconnected() { }
|
|
1820
|
+
onSessionRetiring(_sessionId) { }
|
|
1799
1821
|
attachBridgeEvents(bridge) {
|
|
1800
1822
|
bridge.on('toolCall', this.bridgeToolCallListener);
|
|
1801
1823
|
bridge.on('backgroundResponse', this.bridgeBackgroundResponseListener);
|
|
1802
1824
|
bridge.on('sessionDied', this.bridgeSessionDiedListener);
|
|
1825
|
+
bridge.on('disconnected', this.bridgeDisconnectedListener);
|
|
1803
1826
|
bridge.on('permissionRequest', this.bridgePermissionRequestListener);
|
|
1804
1827
|
bridge.on('permissionResolved', this.bridgePermissionResolvedListener);
|
|
1805
1828
|
}
|
|
@@ -1807,6 +1830,7 @@ export class ChannelBase {
|
|
|
1807
1830
|
bridge.off('toolCall', this.bridgeToolCallListener);
|
|
1808
1831
|
bridge.off('backgroundResponse', this.bridgeBackgroundResponseListener);
|
|
1809
1832
|
bridge.off('sessionDied', this.bridgeSessionDiedListener);
|
|
1833
|
+
bridge.off('disconnected', this.bridgeDisconnectedListener);
|
|
1810
1834
|
bridge.off('permissionRequest', this.bridgePermissionRequestListener);
|
|
1811
1835
|
bridge.off('permissionResolved', this.bridgePermissionResolvedListener);
|
|
1812
1836
|
}
|
|
@@ -2307,6 +2331,21 @@ export class ChannelBase {
|
|
|
2307
2331
|
binding.released = true;
|
|
2308
2332
|
this.releaseQueuedTurn(binding.sessionId);
|
|
2309
2333
|
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Follow a task onto the session its reload healed to (a superseded
|
|
2336
|
+
* redirect). The queue reservation and the staleness generation are keyed
|
|
2337
|
+
* by session id, so both must move with the binding: leaving them behind
|
|
2338
|
+
* keeps the stale id "busy" forever and drops the turn on a generation the
|
|
2339
|
+
* replacement never had.
|
|
2340
|
+
*/
|
|
2341
|
+
moveNamedTurnBinding(binding, sessionId) {
|
|
2342
|
+
if (binding.sessionId !== null) {
|
|
2343
|
+
this.releaseQueuedTurn(binding.sessionId);
|
|
2344
|
+
}
|
|
2345
|
+
binding.sessionId = sessionId;
|
|
2346
|
+
binding.generation = this.sessionGenerations.get(sessionId) ?? 0;
|
|
2347
|
+
this.reserveQueuedTurn(sessionId);
|
|
2348
|
+
}
|
|
2310
2349
|
finishNamedTurnBinding(envelope) {
|
|
2311
2350
|
const binding = this.namedTurnBindings.get(envelope);
|
|
2312
2351
|
if (!binding)
|
|
@@ -2367,13 +2406,67 @@ export class ChannelBase {
|
|
|
2367
2406
|
return true;
|
|
2368
2407
|
}
|
|
2369
2408
|
}
|
|
2409
|
+
/**
|
|
2410
|
+
* The actionable recovery message for a worktree task whose ownership state
|
|
2411
|
+
* the daemon refused to restore. Both codes come from the load/resume route
|
|
2412
|
+
* — the reset route resumes an interrupted transfer itself and reports a
|
|
2413
|
+
* broken marker as invalid state — so they surface on selection and on a
|
|
2414
|
+
* message, wrapped in the manager's generic load failure.
|
|
2415
|
+
*
|
|
2416
|
+
* Clearing always acts on the *selected* task, so pointing the user at a
|
|
2417
|
+
* clear is only safe when the task that failed is the selected one. Aimed at
|
|
2418
|
+
* any other task it would run a full ownership transfer against a healthy
|
|
2419
|
+
* one and destroy that conversation, so the alternative points at the close
|
|
2420
|
+
* that works on the broken task without loading it. Both name that task.
|
|
2421
|
+
*/
|
|
2422
|
+
async worktreeRecoveryMessage(envelope, error) {
|
|
2423
|
+
const code = readDaemonHttpErrorCode(error);
|
|
2424
|
+
if (code !== 'worktree_reset_interrupted' &&
|
|
2425
|
+
code !== 'worktree_marker_missing') {
|
|
2426
|
+
return undefined;
|
|
2427
|
+
}
|
|
2428
|
+
const interrupted = code === 'worktree_reset_interrupted';
|
|
2429
|
+
const failedTaskName = error instanceof NamedSessionTaskError ? error.taskName : undefined;
|
|
2430
|
+
// This message bypasses the wrapper's sanitizeDisplayText, so bound the one
|
|
2431
|
+
// piece of interpolated text it adds.
|
|
2432
|
+
const safeTaskName = failedTaskName === undefined
|
|
2433
|
+
? undefined
|
|
2434
|
+
: sanitizeDisplayText(failedTaskName, 32);
|
|
2435
|
+
const subject = safeTaskName === undefined ? 'The task' : `Task "${safeTaskName}"`;
|
|
2436
|
+
const problem = interrupted
|
|
2437
|
+
? 'was interrupted while being reset'
|
|
2438
|
+
: 'cannot verify its worktree because its ownership marker is missing';
|
|
2439
|
+
if (safeTaskName !== undefined &&
|
|
2440
|
+
failedTaskName === (await this.selectedTaskName(envelope))) {
|
|
2441
|
+
return `${subject} ${problem}. Its files were not changed. ${interrupted
|
|
2442
|
+
? 'Clear the task again to finish the reset.'
|
|
2443
|
+
: 'Clear the task to restart it in the same worktree, or close it.'}`;
|
|
2444
|
+
}
|
|
2445
|
+
const remedy = safeTaskName === undefined
|
|
2446
|
+
? 'select this task first or close it'
|
|
2447
|
+
: `select this task first or close it with ${this.prefixedCommand(`/session close ${safeTaskName}`)}`;
|
|
2448
|
+
return `${subject} ${problem}. Its files were not changed. Clearing now would reset the selected task instead, so ${remedy}.`;
|
|
2449
|
+
}
|
|
2450
|
+
async selectedTaskName(envelope) {
|
|
2451
|
+
const namedSessions = this.namedSessions;
|
|
2452
|
+
if (!namedSessions)
|
|
2453
|
+
return undefined;
|
|
2454
|
+
try {
|
|
2455
|
+
return (await namedSessions.current(this.namedSessionOwner(envelope)))
|
|
2456
|
+
?.name;
|
|
2457
|
+
}
|
|
2458
|
+
catch {
|
|
2459
|
+
return undefined;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2370
2462
|
async sendNamedSessionError(envelope, error) {
|
|
2371
2463
|
if (error instanceof Error && error.cause !== undefined) {
|
|
2372
2464
|
process.stderr.write(`[${sanitizeLogText(this.name, 64)}] named-session operation failed: ${this.lifecycleError(error)} | cause: ${this.lifecycleError(error.cause)}\n`);
|
|
2373
2465
|
}
|
|
2374
|
-
const message = error
|
|
2375
|
-
|
|
2376
|
-
|
|
2466
|
+
const message = (await this.worktreeRecoveryMessage(envelope, error)) ??
|
|
2467
|
+
(error instanceof Error
|
|
2468
|
+
? sanitizeDisplayText(error.message, 500)
|
|
2469
|
+
: 'Named-session operation failed.');
|
|
2377
2470
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, message || 'Named-session operation failed.');
|
|
2378
2471
|
}
|
|
2379
2472
|
async handleNamedSessionsCommand(envelope, args) {
|
|
@@ -2424,7 +2517,8 @@ export class ChannelBase {
|
|
|
2424
2517
|
? 'worktree'
|
|
2425
2518
|
: 'shared';
|
|
2426
2519
|
if ((isolation === 'shared' && parts.length !== 1) ||
|
|
2427
|
-
(isolation === 'worktree' && parts.length !== 2)
|
|
2520
|
+
(isolation === 'worktree' && parts.length !== 2) ||
|
|
2521
|
+
parts[0]?.startsWith('-')) {
|
|
2428
2522
|
break;
|
|
2429
2523
|
}
|
|
2430
2524
|
const created = await namedSessions.create(owner, parts[0], isolation);
|
|
@@ -2443,8 +2537,10 @@ export class ChannelBase {
|
|
|
2443
2537
|
break;
|
|
2444
2538
|
const closing = await namedSessions.lookup(owner, parts[0]);
|
|
2445
2539
|
const result = await namedSessions.close(owner, parts[0]);
|
|
2446
|
-
if (closing)
|
|
2540
|
+
if (closing) {
|
|
2447
2541
|
this.cancelBtw(closing.sessionId);
|
|
2542
|
+
this.onSessionRetiring(closing.sessionId);
|
|
2543
|
+
}
|
|
2448
2544
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.active
|
|
2449
2545
|
? `Closed task "${result.closed.name}". Selected "${result.active.name}".`
|
|
2450
2546
|
: `Closed task "${result.closed.name}". No task is selected.`);
|
|
@@ -2492,14 +2588,31 @@ export class ChannelBase {
|
|
|
2492
2588
|
registerSharedCommands() {
|
|
2493
2589
|
const doClear = async (envelope) => {
|
|
2494
2590
|
let resetTaskName;
|
|
2591
|
+
let resetWorktreeKept = false;
|
|
2495
2592
|
let removedIds;
|
|
2593
|
+
const retiringSessionId = this.namedSessions
|
|
2594
|
+
? undefined
|
|
2595
|
+
: this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
|
|
2596
|
+
if (retiringSessionId)
|
|
2597
|
+
this.onSessionRetiring(retiringSessionId);
|
|
2496
2598
|
if (this.namedSessions) {
|
|
2497
2599
|
try {
|
|
2498
2600
|
const reset = await this.namedSessions.reset(this.namedSessionOwner(envelope));
|
|
2499
2601
|
resetTaskName = reset?.name;
|
|
2602
|
+
resetWorktreeKept = reset?.worktreeKept ?? false;
|
|
2500
2603
|
removedIds = reset ? [reset.previousSessionId] : [];
|
|
2501
2604
|
}
|
|
2502
2605
|
catch (error) {
|
|
2606
|
+
// The reset route's busy signal has its own wording; every other
|
|
2607
|
+
// typed failure goes through the shared named-session surface.
|
|
2608
|
+
const code = readDaemonHttpErrorCode(error);
|
|
2609
|
+
if (code !== undefined) {
|
|
2610
|
+
process.stderr.write(`[${sanitizeLogText(this.name, 64)}] worktree reset failed (${sanitizeLogText(code, 64)}): ${this.lifecycleError(error)}\n`);
|
|
2611
|
+
}
|
|
2612
|
+
if (code === 'worktree_reset_active') {
|
|
2613
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Task is busy. Wait for the running prompt to finish (or cancel it), then try again.');
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2503
2616
|
await this.sendNamedSessionError(envelope, error);
|
|
2504
2617
|
return;
|
|
2505
2618
|
}
|
|
@@ -2510,6 +2623,8 @@ export class ChannelBase {
|
|
|
2510
2623
|
this.clearPendingGroupHistory(envelope);
|
|
2511
2624
|
if (removedIds.length > 0) {
|
|
2512
2625
|
for (const id of removedIds) {
|
|
2626
|
+
if (id !== retiringSessionId)
|
|
2627
|
+
this.onSessionRetiring(id);
|
|
2513
2628
|
this.cancelBtw(id);
|
|
2514
2629
|
// Audit: clearing a SHARED session wipes the conversation for every
|
|
2515
2630
|
// participant, so record who triggered it (sanitized display name +
|
|
@@ -2600,7 +2715,9 @@ export class ChannelBase {
|
|
|
2600
2715
|
this.discardRetiredSession(this.bridge, id, 'cleared');
|
|
2601
2716
|
}
|
|
2602
2717
|
await this.sendThreadMessage(envelope.chatId, envelope.threadId, resetTaskName
|
|
2603
|
-
?
|
|
2718
|
+
? resetWorktreeKept
|
|
2719
|
+
? `Task "${resetTaskName}" reset with a fresh conversation; its worktree and files were kept.`
|
|
2720
|
+
: `Task "${resetTaskName}" reset with a fresh conversation.`
|
|
2604
2721
|
: 'Session cleared. The next message starts a fresh conversation.');
|
|
2605
2722
|
}
|
|
2606
2723
|
else {
|
|
@@ -4370,8 +4487,10 @@ export class ChannelBase {
|
|
|
4370
4487
|
return;
|
|
4371
4488
|
}
|
|
4372
4489
|
try {
|
|
4373
|
-
|
|
4374
|
-
sessionId
|
|
4490
|
+
sessionId = await this.namedSessions.resumeReserved(this.namedSessionOwner(envelope), namedTurn.sessionId);
|
|
4491
|
+
if (sessionId && sessionId !== namedTurn.sessionId) {
|
|
4492
|
+
this.moveNamedTurnBinding(namedTurn, sessionId);
|
|
4493
|
+
}
|
|
4375
4494
|
}
|
|
4376
4495
|
catch (error) {
|
|
4377
4496
|
await this.sendNamedSessionError(envelope, error);
|