@ganglion/xacpx 0.23.0-beta.5 → 0.23.0-beta.7

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/cli.js CHANGED
@@ -28499,7 +28499,7 @@ function buildXacpxMcpToolRegistry(input) {
28499
28499
  })
28500
28500
  }, {
28501
28501
  name: "agent_send",
28502
- description: "Send a high-value asynchronous peer message to another agent. ONLY use this when: (1) you have critical information that directly alters the target's current work (e.g. breaking schema change, interface takeover), or (2) you need an unblocking decision only the target can provide. DO NOT send acknowledgments ('Received', 'OK', 'Working on it'), status polling ('Are you done?'), or conversational chatter. One-way notifications require NO reply. Provide replyTo when answering an earlier message. Completion modes: none (one-way message; no completion signal is returned), notify (after the peer turn triggered by this message reaches terminal state, xacpx notifies the sender that the peer completed/failed/cancelled), result (after the peer turn reaches terminal state, xacpx returns the peer's bounded final assistant result to the sender). Note agent_send still returns delivery admission immediately.",
28502
+ description: "Send a high-value asynchronous peer message to another agent. ONLY use this when: (1) you have critical information that directly alters the target's current work (e.g. breaking schema change, interface takeover), or (2) you need an unblocking decision only the target can provide. DO NOT send acknowledgments ('Received', 'OK', 'Working on it'), status polling ('Are you done?'), or conversational chatter. One-way notifications require NO reply. Provide replyTo when answering an earlier message. Completion modes: none (one-way message; no completion signal is returned), notify (after the peer turn triggered by this message reaches terminal state, xacpx notifies the sender that the peer completed/failed/cancelled), result (after the peer turn reaches terminal state, xacpx returns the peer's bounded final assistant result to the sender). Note agent_send still returns delivery admission immediately. mode='interrupt' requests cancellation of the target Agent's current turn and reserves this message as its highest-priority next turn; xacpx waits for the current turn to fully settle before starting the message, and never runs the two turns in parallel. Some runtimes may finish the current turn instead of stopping immediately. Use interrupt only when waiting behind ordinary queued work would materially harm the task. The default/auto mode NEVER selects interrupt, and xacpx never escalates a message to interrupt based on its wording.",
28503
28503
  inputSchema: exports_external.object({
28504
28504
  to: exports_external.string().min(1).optional().describe("Target opaque handle from agent_list"),
28505
28505
  selector: exports_external.object({
@@ -28508,7 +28508,7 @@ function buildXacpxMcpToolRegistry(input) {
28508
28508
  agent: exports_external.string().min(1).optional().describe("Target agent type")
28509
28509
  }).strict().refine((s) => Boolean(s.displayName || s.workspace || s.agent), "Target selector must specify at least one criterion (displayName, workspace, or agent)").optional().describe("Target selector criteria when handle is not known"),
28510
28510
  message: exports_external.string().min(1).describe("Concise, actionable message content"),
28511
- mode: exports_external.enum(["auto", "steer", "queue", "interrupt"]).optional().describe("Delivery mode (default: auto -> queue)"),
28511
+ mode: exports_external.enum(["auto", "steer", "queue", "interrupt"]).optional().describe("Delivery mode (default: auto -> queue). 'interrupt' REQUESTS cancellation of the target's current turn and reserves this message as its highest-priority next turn; xacpx waits for the current turn to fully settle first, and some runtimes may finish it instead of stopping immediately. 'auto' and 'queue' never cancel. 'steer' injects into the target's active turn when the runtime supports it."),
28512
28512
  replyTo: exports_external.string().min(1).optional().describe("Message ID being replied to, if continuing a thread"),
28513
28513
  completion: exports_external.enum(["none", "notify", "result"]).optional().describe("Completion notification mode: 'none' (one-way message; no completion signal is returned), 'notify' (notifies sender when peer turn reaches terminal state), or 'result' (returns peer's bounded final assistant result). Note agent_send still returns delivery admission immediately.")
28514
28514
  }).strict().refine((data) => data.to !== undefined && data.selector === undefined || data.to === undefined && data.selector !== undefined, {
@@ -50667,7 +50667,7 @@ class AgentEndpointRegistry {
50667
50667
  activity: {
50668
50668
  status: isRunning ? "working" : "idle"
50669
50669
  },
50670
- capabilities: queueOnlyCapabilities({ completion: true }),
50670
+ capabilities: queueOnlyCapabilities({ completion: true, interrupt: true }),
50671
50671
  endpointKind: "logical",
50672
50672
  channelId: getChannelIdFromChatKey(session3.alias)
50673
50673
  };
@@ -50718,7 +50718,7 @@ function queueOnlyCapabilities(options) {
50718
50718
  receive: true,
50719
50719
  steer: false,
50720
50720
  queue: true,
50721
- interrupt: false,
50721
+ interrupt: options?.interrupt ?? false,
50722
50722
  conversation: true,
50723
50723
  completion: options?.completion ?? false
50724
50724
  };
@@ -50981,7 +50981,8 @@ var init_agent_message_router = __esm(() => {
50981
50981
  "TARGET_UNAVAILABLE",
50982
50982
  "MESSAGE_QUEUE_FULL",
50983
50983
  "DELIVERY_DENIED",
50984
- "COMPLETION_NOT_SUPPORTED"
50984
+ "COMPLETION_NOT_SUPPORTED",
50985
+ "TARGET_NOT_INTERRUPTIBLE"
50985
50986
  ]);
50986
50987
  AgentMessageRouter = class AgentMessageRouter {
50987
50988
  deps;
@@ -51636,6 +51637,9 @@ var init_agent_message_router = __esm(() => {
51636
51637
  createdAt,
51637
51638
  ...input.replyTo ? { replyTo: input.replyTo } : {}
51638
51639
  };
51640
+ if (message.requestedMode === "interrupt" && target.endpoint.capabilities.interrupt !== true) {
51641
+ throw new AgentMessagingError("TARGET_NOT_INTERRUPTIBLE", "The target does not support explicit interrupt delivery.");
51642
+ }
51639
51643
  if (message.completion !== "none") {
51640
51644
  if (target.endpoint.capabilities.completion !== true) {
51641
51645
  throw new AgentMessagingError("COMPLETION_NOT_SUPPORTED", `Completion mode '${message.completion}' requires a target that advertises completion capability.`);
@@ -52454,10 +52458,11 @@ class LocalAgentMessageDeliveryAdapter {
52454
52458
  source: message.from,
52455
52459
  target: message.to
52456
52460
  };
52457
- const res = await this.deps.deliverLogicalTurn(target.runtime.alias, renderedText, message.id, peerOrigin);
52461
+ const res = await this.deps.deliverLogicalTurn(target.runtime.alias, renderedText, message.id, peerOrigin, message.requestedMode);
52458
52462
  return {
52459
52463
  status: res.status,
52460
- modeUsed: "queue"
52464
+ modeUsed: res.modeUsed ?? "queue",
52465
+ ...res.targetState ? { targetState: res.targetState } : {}
52461
52466
  };
52462
52467
  }
52463
52468
  const session3 = target.runtime.kind === "logical" ? await this.deps.resolveLogicalSession(target.runtime.transportSession) : this.deps.resolveWorkerSession(target.runtime);
@@ -60156,6 +60161,7 @@ class TurnQueue {
60156
60161
  settledRequestIds = new Map;
60157
60162
  static SETTLED_REQUEST_IDS_MAX = 2000;
60158
60163
  queues = new Map;
60164
+ pendingInterrupts = new Map;
60159
60165
  draining = new Set;
60160
60166
  removing = new Set;
60161
60167
  resolveKey(chatKey, sessionAlias, concurrencyKey) {
@@ -60163,7 +60169,7 @@ class TurnQueue {
60163
60169
  return concurrencyKey;
60164
60170
  }
60165
60171
  const directKey = turnKey(chatKey, sessionAlias);
60166
- if (this.inFlight.has(directKey) || this.queues.has(directKey) || this.draining.has(directKey) || this.removing.has(directKey)) {
60172
+ if (this.inFlight.has(directKey) || this.queues.has(directKey) || this.draining.has(directKey) || this.removing.has(directKey) || this.pendingInterrupts.has(directKey)) {
60167
60173
  return directKey;
60168
60174
  }
60169
60175
  const cleanAlias = sessionAlias.includes(":") ? sessionAlias.split(":").pop() : sessionAlias;
@@ -60183,6 +60189,10 @@ class TurnQueue {
60183
60189
  if (k === directKey || k.endsWith(":" + cleanAlias) || k.endsWith(" " + cleanAlias) || k === cleanAlias)
60184
60190
  return k;
60185
60191
  }
60192
+ for (const k of this.pendingInterrupts.keys()) {
60193
+ if (k === directKey || k.endsWith(":" + cleanAlias) || k.endsWith(" " + cleanAlias) || k === cleanAlias)
60194
+ return k;
60195
+ }
60186
60196
  return directKey;
60187
60197
  }
60188
60198
  queueLength(chatKey, sessionAlias, concurrencyKey) {
@@ -60254,6 +60264,99 @@ class TurnQueue {
60254
60264
  this.recordSettledRequestId(params.promptRequestId);
60255
60265
  return { status: "injected" };
60256
60266
  }
60267
+ pendingInterruptCount(chatKey, sessionAlias, concurrencyKey) {
60268
+ return this.pendingInterrupts.has(this.resolveKey(chatKey, sessionAlias, concurrencyKey)) ? 1 : 0;
60269
+ }
60270
+ submitPeerInterrupt(params) {
60271
+ const key = this.resolveKey(params.chatKey, params.sessionAlias, params.concurrencyKey);
60272
+ if (this.removing.has(key)) {
60273
+ return { status: "rejected", reason: "target-unavailable" };
60274
+ }
60275
+ if (params.promptRequestId !== undefined && params.isPeerMessage) {
60276
+ const pendingDup = this.pendingInterrupts.get(key);
60277
+ if (pendingDup?.promptRequestId === params.promptRequestId) {
60278
+ return { status: "queued", modeUsed: "interrupt", queueItemId: pendingDup.id };
60279
+ }
60280
+ if (this.hasSettledRequestId(params.promptRequestId)) {
60281
+ return { status: "injected", modeUsed: "prompt" };
60282
+ }
60283
+ const queuedDup = (this.queues.get(key) ?? []).some((item2) => item2.promptRequestId === params.promptRequestId);
60284
+ const inFlightDup = this.inFlight.get(key)?.promptRequestId === params.promptRequestId;
60285
+ if (queuedDup || inFlightDup) {
60286
+ return { status: "injected", modeUsed: "prompt" };
60287
+ }
60288
+ }
60289
+ const existing = this.inFlight.get(key);
60290
+ const busy = this.draining.has(key) || existing !== undefined;
60291
+ if (!busy) {
60292
+ const idle = this.submitPeerTurn(params);
60293
+ if (idle.status === "rejected")
60294
+ return idle;
60295
+ if (idle.status === "queued") {
60296
+ return { status: "rejected", reason: "queue-full" };
60297
+ }
60298
+ return { status: "injected", modeUsed: "prompt" };
60299
+ }
60300
+ const pending = this.pendingInterrupts.get(key);
60301
+ if (pending) {
60302
+ this.emitPeerInterrupt("rejected_pending", params.chatKey, params.sessionAlias, pending, {
60303
+ requestMessageId: params.peerOrigin?.requestMessageId,
60304
+ promptRequestId: params.promptRequestId,
60305
+ pendingRequestMessageId: pending.peerOrigin?.requestMessageId
60306
+ });
60307
+ return { status: "rejected", reason: "queue-full" };
60308
+ }
60309
+ const id = randomUUID13();
60310
+ const item = {
60311
+ id,
60312
+ text: params.text,
60313
+ enqueuedAt: new Date().toISOString(),
60314
+ senderId: params.senderId,
60315
+ executionContext: {
60316
+ chatKey: params.chatKey,
60317
+ sessionAlias: params.sessionAlias,
60318
+ ...params.boundSessionAlias ? { boundSessionAlias: params.boundSessionAlias } : {}
60319
+ },
60320
+ concurrencyKey: params.concurrencyKey,
60321
+ isPeerMessage: true,
60322
+ allowRestoreArchived: false,
60323
+ ...params.isOwner !== undefined ? { isOwner: params.isOwner } : {},
60324
+ ...params.preserveCoordinatorRoute !== undefined ? { preserveCoordinatorRoute: params.preserveCoordinatorRoute } : {},
60325
+ ...params.accountId !== undefined ? { accountId: params.accountId } : {},
60326
+ ...params.media !== undefined ? { media: params.media } : {},
60327
+ ...params.agentMentions !== undefined ? { agentMentions: params.agentMentions } : {},
60328
+ ...params.promptRequestId !== undefined ? { promptRequestId: params.promptRequestId } : {},
60329
+ ...params.peerOrigin !== undefined ? { peerOrigin: params.peerOrigin } : {},
60330
+ ...params.trustedPeerCompletion !== undefined ? { trustedPeerCompletion: params.trustedPeerCompletion } : {}
60331
+ };
60332
+ this.pendingInterrupts.set(key, item);
60333
+ this.recordSettledRequestId(params.promptRequestId);
60334
+ const predecessorWasAlreadyAborted = existing?.controller.signal.aborted ?? false;
60335
+ this.emitPeerInterrupt("reserved", params.chatKey, params.sessionAlias, item, {
60336
+ predecessorWasAlreadyAborted
60337
+ });
60338
+ if (existing && !predecessorWasAlreadyAborted) {
60339
+ existing.controller.abort();
60340
+ this.emitPeerInterrupt("abort_signalled", params.chatKey, params.sessionAlias, item);
60341
+ }
60342
+ this.emitQueueUpdated(params.chatKey, params.sessionAlias, key);
60343
+ return { status: "queued", modeUsed: "interrupt", queueItemId: id };
60344
+ }
60345
+ emitPeerInterrupt(kind, chatKey, sessionAlias, item, ids) {
60346
+ if (!this.deps.onPeerInterruptEvent)
60347
+ return;
60348
+ try {
60349
+ this.deps.onPeerInterruptEvent({
60350
+ kind,
60351
+ chatKey,
60352
+ sessionAlias,
60353
+ ...ids?.requestMessageId !== undefined ? { requestMessageId: ids.requestMessageId } : item?.peerOrigin?.requestMessageId !== undefined ? { requestMessageId: item.peerOrigin.requestMessageId } : {},
60354
+ ...ids?.promptRequestId !== undefined ? { promptRequestId: ids.promptRequestId } : item?.promptRequestId !== undefined ? { promptRequestId: item.promptRequestId } : {},
60355
+ ...ids?.pendingRequestMessageId !== undefined ? { pendingRequestMessageId: ids.pendingRequestMessageId } : {},
60356
+ ...ids?.predecessorWasAlreadyAborted !== undefined ? { predecessorWasAlreadyAborted: ids.predecessorWasAlreadyAborted } : {}
60357
+ });
60358
+ } catch {}
60359
+ }
60257
60360
  hasSettledRequestId(id) {
60258
60361
  const expiresAt = this.settledRequestIds.get(id);
60259
60362
  if (expiresAt === undefined)
@@ -60293,11 +60396,20 @@ class TurnQueue {
60293
60396
  }
60294
60397
  emitQueueUpdated(chatKey, sessionAlias, queueKey) {
60295
60398
  const key = queueKey ?? this.resolveKey(chatKey, sessionAlias);
60296
- const items = (this.queues.get(key) ?? []).map((q) => ({
60297
- id: q.id,
60298
- textPreview: q.text.length > QUEUE_PREVIEW_MAX ? q.text.slice(0, QUEUE_PREVIEW_MAX) : q.text,
60299
- enqueuedAt: q.enqueuedAt
60300
- }));
60399
+ const interrupt = this.pendingInterrupts.get(key);
60400
+ const items = interrupt ? [{
60401
+ id: interrupt.id,
60402
+ textPreview: interrupt.text.length > QUEUE_PREVIEW_MAX ? interrupt.text.slice(0, QUEUE_PREVIEW_MAX) : interrupt.text,
60403
+ enqueuedAt: interrupt.enqueuedAt,
60404
+ kind: "interrupt"
60405
+ }] : [];
60406
+ for (const q of this.queues.get(key) ?? []) {
60407
+ items.push({
60408
+ id: q.id,
60409
+ textPreview: q.text.length > QUEUE_PREVIEW_MAX ? q.text.slice(0, QUEUE_PREVIEW_MAX) : q.text,
60410
+ enqueuedAt: q.enqueuedAt
60411
+ });
60412
+ }
60301
60413
  this.deps.emitQueueUpdated(chatKey, sessionAlias, items);
60302
60414
  }
60303
60415
  async submit(params) {
@@ -60397,6 +60509,7 @@ class TurnQueue {
60397
60509
  };
60398
60510
  let result;
60399
60511
  try {
60512
+ const turnStarted = params.turnStarted ? params.promptRequestId !== undefined && params.turnStarted.promptRequestId === undefined ? { ...params.turnStarted, promptRequestId: params.promptRequestId } : params.turnStarted : params.promptRequestId !== undefined ? { promptRequestId: params.promptRequestId } : undefined;
60400
60513
  result = await this.deps.runTurn({
60401
60514
  chatKey: params.chatKey,
60402
60515
  sessionAlias: params.sessionAlias,
@@ -60405,7 +60518,7 @@ class TurnQueue {
60405
60518
  senderId: params.senderId,
60406
60519
  isOwner: params.isOwner,
60407
60520
  accountId: params.accountId,
60408
- turnStarted: params.turnStarted,
60521
+ turnStarted,
60409
60522
  media: params.media,
60410
60523
  agentMentions: params.agentMentions,
60411
60524
  allowRestoreArchived: params.allowRestoreArchived,
@@ -60418,7 +60531,7 @@ class TurnQueue {
60418
60531
  } finally {
60419
60532
  if (idleTimer)
60420
60533
  this.clearTimer(idleTimer);
60421
- if ((this.queues.get(key)?.length ?? 0) > 0) {
60534
+ if ((this.queues.get(key)?.length ?? 0) > 0 || this.pendingInterrupts.has(key)) {
60422
60535
  this.draining.add(key);
60423
60536
  }
60424
60537
  const detection = result?.postTurnDetection;
@@ -60435,40 +60548,50 @@ class TurnQueue {
60435
60548
  };
60436
60549
  }
60437
60550
  advanceQueue(key) {
60551
+ const interrupt = this.pendingInterrupts.get(key);
60552
+ if (interrupt) {
60553
+ this.pendingInterrupts.delete(key);
60554
+ this.emitPeerInterrupt("started", interrupt.executionContext.chatKey, interrupt.executionContext.sessionAlias, interrupt);
60555
+ this.drainQueuedPrompt(key, interrupt);
60556
+ return;
60557
+ }
60438
60558
  const q = this.queues.get(key);
60439
60559
  const next = q?.shift();
60440
60560
  if (q && q.length === 0)
60441
60561
  this.queues.delete(key);
60442
60562
  if (next) {
60443
- this.emitQueueUpdated(next.executionContext.chatKey, next.executionContext.sessionAlias, key);
60444
- const concurrencyKey = next.concurrencyKey ?? key;
60445
- const turnStarted = next.isPeerMessage ? { queueItemId: next.id, ...next.promptRequestId !== undefined ? { promptRequestId: next.promptRequestId } : {} } : { prompt: next.text, queueItemId: next.id, ...next.promptRequestId !== undefined ? { promptRequestId: next.promptRequestId } : {} };
60446
- this.submit({
60447
- chatKey: next.executionContext.chatKey,
60448
- sessionAlias: next.executionContext.sessionAlias,
60449
- boundSessionAlias: next.executionContext.boundSessionAlias,
60450
- concurrencyKey,
60451
- text: next.text,
60452
- senderId: next.senderId,
60453
- queueable: true,
60454
- drained: true,
60455
- isPeerMessage: next.isPeerMessage,
60456
- allowRestoreArchived: next.allowRestoreArchived,
60457
- preserveCoordinatorRoute: next.preserveCoordinatorRoute,
60458
- promptRequestId: next.promptRequestId,
60459
- peerOrigin: next.peerOrigin,
60460
- trustedPeerCompletion: next.trustedPeerCompletion,
60461
- ...next.isOwner !== undefined ? { isOwner: next.isOwner } : {},
60462
- ...next.accountId !== undefined ? { accountId: next.accountId } : {},
60463
- ...next.media !== undefined ? { media: next.media } : {},
60464
- ...next.agentMentions !== undefined ? { agentMentions: next.agentMentions } : {},
60465
- turnStarted
60466
- });
60563
+ this.drainQueuedPrompt(key, next);
60467
60564
  } else {
60468
60565
  this.draining.delete(key);
60469
60566
  this.inFlight.delete(key);
60470
60567
  }
60471
60568
  }
60569
+ drainQueuedPrompt(key, next) {
60570
+ this.emitQueueUpdated(next.executionContext.chatKey, next.executionContext.sessionAlias, key);
60571
+ const concurrencyKey = next.concurrencyKey ?? key;
60572
+ const turnStarted = next.isPeerMessage ? { queueItemId: next.id, ...next.promptRequestId !== undefined ? { promptRequestId: next.promptRequestId } : {} } : { prompt: next.text, queueItemId: next.id, ...next.promptRequestId !== undefined ? { promptRequestId: next.promptRequestId } : {} };
60573
+ this.submit({
60574
+ chatKey: next.executionContext.chatKey,
60575
+ sessionAlias: next.executionContext.sessionAlias,
60576
+ boundSessionAlias: next.executionContext.boundSessionAlias,
60577
+ concurrencyKey,
60578
+ text: next.text,
60579
+ senderId: next.senderId,
60580
+ queueable: true,
60581
+ drained: true,
60582
+ isPeerMessage: next.isPeerMessage,
60583
+ allowRestoreArchived: next.allowRestoreArchived,
60584
+ preserveCoordinatorRoute: next.preserveCoordinatorRoute,
60585
+ promptRequestId: next.promptRequestId,
60586
+ peerOrigin: next.peerOrigin,
60587
+ trustedPeerCompletion: next.trustedPeerCompletion,
60588
+ ...next.isOwner !== undefined ? { isOwner: next.isOwner } : {},
60589
+ ...next.accountId !== undefined ? { accountId: next.accountId } : {},
60590
+ ...next.media !== undefined ? { media: next.media } : {},
60591
+ ...next.agentMentions !== undefined ? { agentMentions: next.agentMentions } : {},
60592
+ turnStarted
60593
+ });
60594
+ }
60472
60595
  cancelTurn(chatKey, sessionAlias, concurrencyKey) {
60473
60596
  const key = this.resolveKey(chatKey, sessionAlias, concurrencyKey);
60474
60597
  const entry = this.inFlight.get(key);
@@ -60480,12 +60603,21 @@ class TurnQueue {
60480
60603
  }
60481
60604
  async clearSession(chatKey, sessionAlias, concurrencyKey) {
60482
60605
  const key = this.resolveKey(chatKey, sessionAlias, concurrencyKey);
60606
+ const guardPreexisting = this.removing.has(key);
60607
+ this.removing.add(key);
60483
60608
  const droppedFirst = this.queues.get(key);
60484
60609
  if (droppedFirst) {
60485
60610
  this.queues.delete(key);
60486
60611
  this.emitQueueUpdated(chatKey, sessionAlias, key);
60487
60612
  this.notifyQueuedPeerCancelled(droppedFirst);
60488
60613
  }
60614
+ const droppedInterruptFirst = this.pendingInterrupts.get(key);
60615
+ if (droppedInterruptFirst) {
60616
+ this.pendingInterrupts.delete(key);
60617
+ this.emitPeerInterrupt("cancelled_before_start", droppedInterruptFirst.executionContext.chatKey, droppedInterruptFirst.executionContext.sessionAlias, droppedInterruptFirst);
60618
+ this.notifyQueuedPeerCancelled([droppedInterruptFirst]);
60619
+ this.emitQueueUpdated(chatKey, sessionAlias, key);
60620
+ }
60489
60621
  const entry = this.inFlight.get(key);
60490
60622
  if (entry) {
60491
60623
  entry.controller.abort();
@@ -60496,10 +60628,17 @@ class TurnQueue {
60496
60628
  this.emitQueueUpdated(chatKey, sessionAlias, key);
60497
60629
  this.notifyQueuedPeerCancelled(droppedSecond);
60498
60630
  }
60631
+ const droppedInterruptSecond = this.pendingInterrupts.get(key);
60632
+ if (droppedInterruptSecond) {
60633
+ this.pendingInterrupts.delete(key);
60634
+ this.emitPeerInterrupt("cancelled_before_start", droppedInterruptSecond.executionContext.chatKey, droppedInterruptSecond.executionContext.sessionAlias, droppedInterruptSecond);
60635
+ this.notifyQueuedPeerCancelled([droppedInterruptSecond]);
60636
+ this.emitQueueUpdated(chatKey, sessionAlias, key);
60637
+ }
60499
60638
  }
60500
- const cleared = !this.inFlight.has(key) && !this.draining.has(key);
60501
- if (cleared) {
60502
- this.removing.add(key);
60639
+ const cleared = !this.inFlight.has(key) && !this.draining.has(key) && !this.pendingInterrupts.has(key);
60640
+ if (cleared) {} else if (!guardPreexisting) {
60641
+ this.removing.delete(key);
60503
60642
  }
60504
60643
  return { cleared };
60505
60644
  }
@@ -60508,6 +60647,14 @@ class TurnQueue {
60508
60647
  }
60509
60648
  cancelQueuedItem(chatKey, sessionAlias, itemId, concurrencyKey) {
60510
60649
  const key = this.resolveKey(chatKey, sessionAlias, concurrencyKey);
60650
+ const pendingInterrupt = this.pendingInterrupts.get(key);
60651
+ if (pendingInterrupt?.id === itemId) {
60652
+ this.pendingInterrupts.delete(key);
60653
+ this.emitQueueUpdated(chatKey, sessionAlias, key);
60654
+ this.emitPeerInterrupt("cancelled_before_start", pendingInterrupt.executionContext.chatKey, pendingInterrupt.executionContext.sessionAlias, pendingInterrupt);
60655
+ this.notifyQueuedPeerCancelled([pendingInterrupt]);
60656
+ return { cancelled: true };
60657
+ }
60511
60658
  const q = this.queues.get(key);
60512
60659
  if (!q)
60513
60660
  return { cancelled: false };
@@ -60578,6 +60725,7 @@ class ControlService {
60578
60725
  ...this.deps.onTurnIdleTimeout ? { onIdleTimeout: this.deps.onTurnIdleTimeout } : {},
60579
60726
  ...this.deps.cancelDrainTimeoutMs !== undefined ? { cancelDrainTimeoutMs: this.deps.cancelDrainTimeoutMs } : {},
60580
60727
  ...this.deps.onQueuedPeerCancelled ? { onQueuedPeerCancelled: this.deps.onQueuedPeerCancelled } : {},
60728
+ ...this.deps.onPeerInterruptEvent ? { onPeerInterruptEvent: this.deps.onPeerInterruptEvent } : {},
60581
60729
  emitQueueUpdated: (chatKey, sessionAlias, items) => this.deps.events.emit({
60582
60730
  type: "queue-updated",
60583
60731
  chatKey,
@@ -60865,6 +61013,7 @@ class ControlService {
60865
61013
  return {
60866
61014
  alias: toDisplaySessionAlias(session3.alias),
60867
61015
  agent: session3.agent,
61016
+ driver: session3.driver,
60868
61017
  workspace: session3.workspace,
60869
61018
  transportSession: session3.transportSession,
60870
61019
  running,
@@ -61101,7 +61250,7 @@ class ControlService {
61101
61250
  if (!session3 || session3.archived === true) {
61102
61251
  throw new AgentMessagingError("TARGET_UNAVAILABLE", "The target Agent session is not currently available or is archived.");
61103
61252
  }
61104
- const admission = this.turnQueue.submitPeerTurn({
61253
+ const peerParams = {
61105
61254
  chatKey: input.chatKey,
61106
61255
  sessionAlias: input.sessionAlias,
61107
61256
  boundSessionAlias: internalAlias,
@@ -61113,17 +61262,28 @@ class ControlService {
61113
61262
  allowRestoreArchived: false,
61114
61263
  preserveCoordinatorRoute: true,
61115
61264
  peerOrigin: input.peerOrigin
61116
- });
61117
- if (admission.status === "rejected") {
61118
- if (admission.reason === "queue-full") {
61119
- throw new AgentMessagingError("MESSAGE_QUEUE_FULL", "The target Agent's message queue is full.");
61120
- }
61121
- if (admission.reason === "target-unavailable") {
61122
- throw new AgentMessagingError("TARGET_UNAVAILABLE", "The target Agent session is currently being removed or archived.");
61265
+ };
61266
+ if (input.requestedMode === "interrupt") {
61267
+ const admission2 = this.turnQueue.submitPeerInterrupt(peerParams);
61268
+ if (admission2.status === "rejected") {
61269
+ this.throwPeerAdmissionError(admission2.reason, "interrupt");
61123
61270
  }
61124
- throw new AgentMessagingError("DELIVERY_FAILED", `Peer turn admission rejected: ${admission.reason}`);
61271
+ return admission2.status === "injected" ? { status: "injected", modeUsed: "prompt", targetState: "idle" } : { status: "queued", modeUsed: "interrupt", targetState: "running" };
61272
+ }
61273
+ const admission = this.turnQueue.submitPeerTurn(peerParams);
61274
+ if (admission.status === "rejected") {
61275
+ this.throwPeerAdmissionError(admission.reason, "peer");
61125
61276
  }
61126
- return { status: admission.status };
61277
+ return { status: admission.status, modeUsed: "queue" };
61278
+ }
61279
+ throwPeerAdmissionError(reason, mode) {
61280
+ if (reason === "queue-full") {
61281
+ throw new AgentMessagingError("MESSAGE_QUEUE_FULL", mode === "interrupt" ? "The target Agent already has a pending peer interrupt; only one interrupt may be reserved at a time." : "The target Agent's message queue is full.");
61282
+ }
61283
+ if (reason === "target-unavailable") {
61284
+ throw new AgentMessagingError("TARGET_UNAVAILABLE", "The target Agent session is currently being removed or archived.");
61285
+ }
61286
+ throw new AgentMessagingError("DELIVERY_FAILED", `Peer turn admission rejected: ${reason}`);
61127
61287
  }
61128
61288
  async submitCompletionTurn(input) {
61129
61289
  const session3 = await this.deps.sessions.getSession(input.sourceAlias);
@@ -61234,9 +61394,9 @@ var init_control_service = __esm(() => {
61234
61394
  init_native_session_history();
61235
61395
  init_workspace_fs();
61236
61396
  init_workspace_git();
61397
+ init_turn_support();
61237
61398
  init_session_turn_runner();
61238
61399
  init_turn_queue();
61239
- init_turn_support();
61240
61400
  init_command_timeouts();
61241
61401
  MODEL_SET_SETTLE_BUDGET_MS = 2 * (DEFAULT_MANAGEMENT_COMMAND_TIMEOUT_MS + BRIDGE_REQUEST_TIMEOUT_GRACE_MS);
61242
61402
  });
@@ -62892,7 +63052,7 @@ async function buildApp(paths, deps = {}) {
62892
63052
  return null;
62893
63053
  }
62894
63054
  },
62895
- deliverLogicalTurn: async (alias, renderedText, messageId, peerOrigin) => {
63055
+ deliverLogicalTurn: async (alias, renderedText, messageId, peerOrigin, requestedMode) => {
62896
63056
  if (controlRef) {
62897
63057
  const chatKey = `relay:agent-message:${alias}`;
62898
63058
  return await controlRef.submitPeerTurn({
@@ -62902,10 +63062,11 @@ async function buildApp(paths, deps = {}) {
62902
63062
  text: renderedText,
62903
63063
  senderId: "agent-messaging",
62904
63064
  messageId,
63065
+ requestedMode,
62905
63066
  peerOrigin
62906
63067
  });
62907
63068
  }
62908
- return { status: "queued" };
63069
+ return { status: "queued", modeUsed: "queue" };
62909
63070
  },
62910
63071
  deliverCompletionTurn: async (alias, completion, requestMessageId) => {
62911
63072
  if (controlRef) {
@@ -63090,6 +63251,16 @@ async function buildApp(paths, deps = {}) {
63090
63251
  idleMs
63091
63252
  });
63092
63253
  },
63254
+ onPeerInterruptEvent: (event) => {
63255
+ logger.info(`agent_messaging.peer_interrupt_${event.kind}`, "peer interrupt lane event", {
63256
+ chatKey: event.chatKey,
63257
+ sessionAlias: event.sessionAlias,
63258
+ ...event.requestMessageId !== undefined ? { requestMessageId: event.requestMessageId } : {},
63259
+ ...event.promptRequestId !== undefined ? { promptRequestId: event.promptRequestId } : {},
63260
+ ...event.pendingRequestMessageId !== undefined ? { pendingRequestMessageId: event.pendingRequestMessageId } : {},
63261
+ ...event.predecessorWasAlreadyAborted !== undefined ? { predecessorWasAlreadyAborted: event.predecessorWasAlreadyAborted } : {}
63262
+ });
63263
+ },
63093
63264
  agentMessaging: {
63094
63265
  deliverInbound: async (input) => await agentMessaging.deliverInbound(input),
63095
63266
  deliverInboundCompletion: async (input) => await agentMessaging.deliverInboundCompletion(input),
@@ -14,6 +14,9 @@ export interface QueuedItemInfo {
14
14
  id: string;
15
15
  textPreview: string;
16
16
  enqueuedAt: string;
17
+ /** v0.4: present ONLY for a reserved-but-not-started peer interrupt
18
+ * (snapshot-first item). Additive and backward compatible. */
19
+ kind?: "interrupt";
17
20
  }
18
21
  export type ControlEvent = {
19
22
  type: "turn-output";
@@ -6,15 +6,16 @@ import type { CreateScheduledTaskInput, ScheduledTaskService } from "../schedule
6
6
  import type { ScheduledTaskRecord } from "../scheduled/scheduled-types";
7
7
  import type { CancelTaskInput, OrchestrationService, OrchestrationTaskFilter } from "../orchestration/orchestration-service";
8
8
  import type { OrchestrationTaskRecord } from "../orchestration/orchestration-types";
9
- import type { AgentMessageCompletion } from "../orchestration/agent-messaging-types";
9
+ import type { AgentMessageCompletion, AgentMessageMode } from "../orchestration/agent-messaging-types";
10
10
  import type { ControlEventBus } from "./control-event-bus";
11
11
  import type { AgentCatalogEntry } from "../config/agent-catalog";
12
12
  import { type BrowseDirsResult, type DirListing, type FileContent, type SearchOptions, type SearchResult, type WorkspaceDiff } from "./workspace-fs";
13
13
  import { type GitCheckoutOptions, type GitCommitResult, type GitPushOptions, type GitStatus, type GitWorktreeCreateOptions, type GitWorktreeCreateResult } from "./workspace-git";
14
14
  import type { AgentEndpointView, AgentMessageTraceRecord } from "../orchestration/agent-messaging-types";
15
15
  import type { PromptAttachmentRef } from "@ganglion/xacpx-relay-protocol";
16
- import type { UploadStore } from "./upload-store.js";
17
16
  import { type TurnIdleTimeoutDetail, type PeerTurnOrigin } from "./turn-support";
17
+ import type { PeerInterruptEvent } from "./turn-queue";
18
+ import type { UploadStore } from "./upload-store.js";
18
19
  import type { AppLogger } from "../logging/app-logger";
19
20
  export interface ModelSetRequestOptions {
20
21
  /** Connector-side deadline derived from the Hub request lifetime. */
@@ -23,6 +24,12 @@ export interface ModelSetRequestOptions {
23
24
  export interface ControlSessionInfo {
24
25
  alias: string;
25
26
  agent: string;
27
+ /** The acpx driver backing `agent`, resolved from the agent's config at listing
28
+ * time. Lets the web render the brand icon without an agents-map lookup — which
29
+ * fails for sleeping sessions whose rows live outside the active session list.
30
+ * Optional: mirrors ResolvedSession.driver (custom agents always resolve one,
31
+ * but the type stays tolerant). */
32
+ driver?: string;
26
33
  workspace: string;
27
34
  transportSession: string;
28
35
  running: boolean;
@@ -111,6 +118,7 @@ export interface ControlServiceDeps {
111
118
  peerOrigin: PeerTurnOrigin;
112
119
  promptRequestId?: string;
113
120
  }) => void;
121
+ onPeerInterruptEvent?: (event: PeerInterruptEvent) => void;
114
122
  agentMessaging?: {
115
123
  deliverInbound(input: {
116
124
  sourceNodeId: string;
@@ -394,10 +402,17 @@ export declare class ControlService {
394
402
  text: string;
395
403
  senderId: string;
396
404
  messageId: string;
405
+ /** v0.4: "interrupt" routes through TurnQueue.submitPeerInterrupt (reserve →
406
+ * abort → true-settle → priority drain); every other mode keeps the
407
+ * non-cancelling peer path. */
408
+ requestedMode?: AgentMessageMode;
397
409
  peerOrigin?: PeerTurnOrigin;
398
410
  }): Promise<{
399
411
  status: "injected" | "queued";
412
+ modeUsed: "prompt" | "queue" | "interrupt";
413
+ targetState?: "idle" | "running";
400
414
  }>;
415
+ private throwPeerAdmissionError;
401
416
  submitCompletionTurn(input: {
402
417
  sourceAlias: string;
403
418
  completion: AgentMessageCompletion;
@@ -5,6 +5,10 @@ export interface QueuedItemSnapshot {
5
5
  id: string;
6
6
  textPreview: string;
7
7
  enqueuedAt: string;
8
+ /** v0.4: present ONLY for a reserved-but-not-started peer interrupt
9
+ * (snapshot-first item). Lets the existing per-item cancel surface reach
10
+ * the interrupt slot by id (spec §11.1) without a new RPC. */
11
+ kind?: "interrupt";
8
12
  }
9
13
  export interface TurnQueueDeps {
10
14
  runTurn(req: TurnRequest, signal: AbortSignal, onActivity: () => void): Promise<TurnResult>;
@@ -21,6 +25,20 @@ export interface TurnQueueDeps {
21
25
  peerOrigin: PeerTurnOrigin;
22
26
  promptRequestId?: string;
23
27
  }) => void;
28
+ onPeerInterruptEvent?: (event: PeerInterruptEvent) => void;
29
+ }
30
+ /** v0.4: structured peer-interrupt lifecycle event (spec §17). Ids and lane
31
+ * keys only — never message content. */
32
+ export interface PeerInterruptEvent {
33
+ kind: "reserved" | "abort_signalled" | "started" | "cancelled_before_start" | "rejected_pending";
34
+ chatKey: string;
35
+ sessionAlias: string;
36
+ requestMessageId?: string;
37
+ promptRequestId?: string;
38
+ /** rejected_pending only: requestMessageId of the reservation that occupied
39
+ * the slot (the event's own ids describe the REJECTED request). */
40
+ pendingRequestMessageId?: string;
41
+ predecessorWasAlreadyAborted?: boolean;
24
42
  }
25
43
  export interface SubmitParams {
26
44
  chatKey: string;
@@ -42,6 +60,7 @@ export interface SubmitParams {
42
60
  allowRestoreArchived?: boolean;
43
61
  preserveCoordinatorRoute?: boolean;
44
62
  /** Hub-issued pre-write correlation; stored on the queue item and carried onto the
63
+
45
64
  * drained turn-started so the hub can correlate a queue item back to its
46
65
  * pre-written inbound row (see PromptPayload.promptRequestId). */
47
66
  promptRequestId?: string;
@@ -56,6 +75,23 @@ export type SubmitResult = TurnResult | {
56
75
  queued: true;
57
76
  queueItemId: string;
58
77
  };
78
+ /** v0.4: result of `submitPeerInterrupt`. `queued`+`interrupt` = busy target:
79
+ * the reservation is held and the predecessor cancel has been signalled.
80
+ * `injected`+`prompt` = idle target: ordinary synchronous peer admission with
81
+ * zero cancellations. Rejections reuse the existing reasons ("target-unavailable",
82
+ * "queue-full" — the latter also enforces the one-pending-interrupt slot rule,
83
+ * spec §8/§16). */
84
+ export type PeerInterruptAdmission = {
85
+ status: "injected";
86
+ modeUsed: "prompt";
87
+ } | {
88
+ status: "queued";
89
+ modeUsed: "interrupt";
90
+ queueItemId: string;
91
+ } | {
92
+ status: "rejected";
93
+ reason: string;
94
+ };
59
95
  export declare class TurnQueue {
60
96
  private readonly deps;
61
97
  private readonly setTimer;
@@ -72,6 +108,7 @@ export declare class TurnQueue {
72
108
  private readonly settledRequestIds;
73
109
  private static readonly SETTLED_REQUEST_IDS_MAX;
74
110
  private readonly queues;
111
+ private readonly pendingInterrupts;
75
112
  private readonly draining;
76
113
  private readonly removing;
77
114
  private resolveKey;
@@ -83,6 +120,20 @@ export declare class TurnQueue {
83
120
  status: "rejected";
84
121
  reason: string;
85
122
  };
123
+ /** v0.4 test/observability seam: 1 when a reserved-but-not-started peer
124
+ * interrupt occupies the lane's slot, else 0 (spec §7: at most one). */
125
+ pendingInterruptCount(chatKey: string, sessionAlias: string, concurrencyKey?: string): number;
126
+ /**
127
+ * v0.4 Peer Interrupt Delivery (spec §6.4/§9): atomic reserve → abort →
128
+ * true-settle → priority-drain, owned entirely by this lane. Synchronous:
129
+ * by the time the accepted result returns, the reservation exists in the
130
+ * lane and a live predecessor's AbortController has been signalled. The
131
+ * interrupting turn itself starts only after the predecessor's `settled`
132
+ * resolves and its inFlight entry is gone (drain hand-off in advanceQueue).
133
+ * Router-level cancel();submit() races are impossible by construction here.
134
+ */
135
+ submitPeerInterrupt(params: SubmitParams): PeerInterruptAdmission;
136
+ private emitPeerInterrupt;
86
137
  private hasSettledRequestId;
87
138
  private recordSettledRequestId;
88
139
  /** Terminal cancellation for queued peer items dropped before execution. */
@@ -90,6 +141,7 @@ export declare class TurnQueue {
90
141
  private emitQueueUpdated;
91
142
  submit(params: SubmitParams): Promise<SubmitResult>;
92
143
  private advanceQueue;
144
+ private drainQueuedPrompt;
93
145
  cancelTurn(chatKey: string, sessionAlias: string, concurrencyKey?: string): boolean;
94
146
  /** Tear down all turn state for a session that is being removed or archived: drop every
95
147
  * queued prompt, abort a running turn, and wait (bounded) for it to unwind. The queue is
@@ -104,9 +156,14 @@ export declare class TurnQueue {
104
156
  * in-flight turn and dropped every queued prompt (emitting `queue-updated([])`). The caller
105
157
  * should surface a retry, not present the failure as a no-op.
106
158
  *
107
- * On `cleared: true` it arms a teardown guard (the busy gate now rejects new turns for this
108
- * session) so a scheduled turn cannot cold-start during the caller's transport teardown. The
109
- * caller MUST call `finishClear` once teardown settles (success or failure) to release it. */
159
+ * The teardown guard (`removing`) is armed SYNCHRONOUSLY on ENTRY — before
160
+ * any await — so peer admissions (`submitPeerTurn` / `submitPeerInterrupt`)
161
+ * fail closed `target-unavailable` for the ENTIRE clear lifecycle; a peer
162
+ * interrupt arriving mid-unwind can never be reserved and drained onto the
163
+ * dying session. On `cleared: true` the guard stays armed across the
164
+ * caller's transport teardown; on `cleared: false` it is released so a
165
+ * retry sees a usable lane. The caller MUST call `finishClear` once
166
+ * teardown settles (success or failure) to release the guard. */
110
167
  clearSession(chatKey: string, sessionAlias: string, concurrencyKey?: string): Promise<{
111
168
  cleared: boolean;
112
169
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganglion/xacpx",
3
- "version": "0.23.0-beta.5",
3
+ "version": "0.23.0-beta.7",
4
4
  "description": "随时随地通过聊天频道(微信 / 飞书 / 元宝等)远程控制 `acpx` 上的 Claude Code、Codex 等 Agents。",
5
5
  "keywords": [
6
6
  "acpx",