@ganglion/xacpx 0.23.0-beta.4 → 0.23.0-beta.6

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.
@@ -3529,7 +3529,7 @@ function buildToolUseEvent(update, driver) {
3529
3529
  const content = update.content;
3530
3530
  const rawOutput = update.rawOutput ?? claudeToolResponse;
3531
3531
  const locations = update.locations;
3532
- const machineToolName = (isClaudeDriver ? update._meta?.claudeCode?.toolName?.trim() : "") || (driver === "qoder" ? update._meta?.qoder?.toolName?.trim() : "") || (driver === "codex" ? codexMcpMachineToolName(update.rawInput) : "") || (driver === "cursor" ? machineToolNameFromCursorInput(update.rawInput) : "") || undefined;
3532
+ const machineToolName = (isClaudeDriver ? update._meta?.claudeCode?.toolName?.trim() : "") || (driver === "qoder" ? update._meta?.qoder?.toolName?.trim() : "") || (driver === "codex" ? codexMcpMachineToolName(update.rawInput) : "") || (driver === "cursor" ? machineToolNameFromCursorInput(update.rawInput) : "") || (driver === "omp" ? ompXdevMachineToolName(update.rawInput, update.rawOutput) : "") || undefined;
3533
3533
  const parentToolCallId = claudeMeta?.parentToolUseId?.trim() || update.parentToolCallId?.trim();
3534
3534
  const isSubagent = claudeMeta?.toolName === "Agent" || driver === "qoder" && update._meta?.qoder?.toolName === "Agent" || driver === "kimi" && isKimiSubagentInput(rawInput) || driver === "codex" && isCodexSubagentMeta(update._meta?.codex?.subagent) || driver === "cursor" && isCursorSubagentInput(rawInput, title);
3535
3535
  return {
@@ -3558,6 +3558,23 @@ function codexMcpMachineToolName(rawInput) {
3558
3558
  return "";
3559
3559
  return tool.trim();
3560
3560
  }
3561
+ function ompXdevMachineToolName(rawInput, rawOutput) {
3562
+ const details = isRecord2(rawOutput) ? rec2(rawOutput.details) : undefined;
3563
+ const xdev = details ? rec2(details.xdev) : undefined;
3564
+ if (typeof xdev?.tool === "string" && xdev.tool.trim().length > 0) {
3565
+ return xdev.tool.trim();
3566
+ }
3567
+ const path2 = isRecord2(rawInput) && typeof rawInput.path === "string" ? rawInput.path.trim() : "";
3568
+ if (path2.toLowerCase().startsWith("xd://")) {
3569
+ const name = path2.slice(5).split(/[/?#]/)[0] ?? "";
3570
+ if (name.length > 0)
3571
+ return name;
3572
+ }
3573
+ return "";
3574
+ }
3575
+ function rec2(v) {
3576
+ return isRecord2(v) ? v : undefined;
3577
+ }
3561
3578
  function machineToolNameFromCursorInput(rawInput) {
3562
3579
  if (!isRecord2(rawInput))
3563
3580
  return;
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);
@@ -56318,7 +56323,7 @@ function buildToolUseEvent(update, driver) {
56318
56323
  const content = update.content;
56319
56324
  const rawOutput = update.rawOutput ?? claudeToolResponse;
56320
56325
  const locations = update.locations;
56321
- const machineToolName = (isClaudeDriver ? update._meta?.claudeCode?.toolName?.trim() : "") || (driver === "qoder" ? update._meta?.qoder?.toolName?.trim() : "") || (driver === "codex" ? codexMcpMachineToolName(update.rawInput) : "") || (driver === "cursor" ? machineToolNameFromCursorInput(update.rawInput) : "") || undefined;
56326
+ const machineToolName = (isClaudeDriver ? update._meta?.claudeCode?.toolName?.trim() : "") || (driver === "qoder" ? update._meta?.qoder?.toolName?.trim() : "") || (driver === "codex" ? codexMcpMachineToolName(update.rawInput) : "") || (driver === "cursor" ? machineToolNameFromCursorInput(update.rawInput) : "") || (driver === "omp" ? ompXdevMachineToolName(update.rawInput, update.rawOutput) : "") || undefined;
56322
56327
  const parentToolCallId = claudeMeta?.parentToolUseId?.trim() || update.parentToolCallId?.trim();
56323
56328
  const isSubagent = claudeMeta?.toolName === "Agent" || driver === "qoder" && update._meta?.qoder?.toolName === "Agent" || driver === "kimi" && isKimiSubagentInput(rawInput) || driver === "codex" && isCodexSubagentMeta(update._meta?.codex?.subagent) || driver === "cursor" && isCursorSubagentInput(rawInput, title);
56324
56329
  return {
@@ -56347,6 +56352,23 @@ function codexMcpMachineToolName(rawInput) {
56347
56352
  return "";
56348
56353
  return tool.trim();
56349
56354
  }
56355
+ function ompXdevMachineToolName(rawInput, rawOutput) {
56356
+ const details = isRecord5(rawOutput) ? rec2(rawOutput.details) : undefined;
56357
+ const xdev = details ? rec2(details.xdev) : undefined;
56358
+ if (typeof xdev?.tool === "string" && xdev.tool.trim().length > 0) {
56359
+ return xdev.tool.trim();
56360
+ }
56361
+ const path14 = isRecord5(rawInput) && typeof rawInput.path === "string" ? rawInput.path.trim() : "";
56362
+ if (path14.toLowerCase().startsWith("xd://")) {
56363
+ const name = path14.slice(5).split(/[/?#]/)[0] ?? "";
56364
+ if (name.length > 0)
56365
+ return name;
56366
+ }
56367
+ return "";
56368
+ }
56369
+ function rec2(v) {
56370
+ return isRecord5(v) ? v : undefined;
56371
+ }
56350
56372
  function machineToolNameFromCursorInput(rawInput) {
56351
56373
  if (!isRecord5(rawInput))
56352
56374
  return;
@@ -60139,6 +60161,7 @@ class TurnQueue {
60139
60161
  settledRequestIds = new Map;
60140
60162
  static SETTLED_REQUEST_IDS_MAX = 2000;
60141
60163
  queues = new Map;
60164
+ pendingInterrupts = new Map;
60142
60165
  draining = new Set;
60143
60166
  removing = new Set;
60144
60167
  resolveKey(chatKey, sessionAlias, concurrencyKey) {
@@ -60146,7 +60169,7 @@ class TurnQueue {
60146
60169
  return concurrencyKey;
60147
60170
  }
60148
60171
  const directKey = turnKey(chatKey, sessionAlias);
60149
- 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)) {
60150
60173
  return directKey;
60151
60174
  }
60152
60175
  const cleanAlias = sessionAlias.includes(":") ? sessionAlias.split(":").pop() : sessionAlias;
@@ -60166,6 +60189,10 @@ class TurnQueue {
60166
60189
  if (k === directKey || k.endsWith(":" + cleanAlias) || k.endsWith(" " + cleanAlias) || k === cleanAlias)
60167
60190
  return k;
60168
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
+ }
60169
60196
  return directKey;
60170
60197
  }
60171
60198
  queueLength(chatKey, sessionAlias, concurrencyKey) {
@@ -60237,6 +60264,99 @@ class TurnQueue {
60237
60264
  this.recordSettledRequestId(params.promptRequestId);
60238
60265
  return { status: "injected" };
60239
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
+ }
60240
60360
  hasSettledRequestId(id) {
60241
60361
  const expiresAt = this.settledRequestIds.get(id);
60242
60362
  if (expiresAt === undefined)
@@ -60276,11 +60396,20 @@ class TurnQueue {
60276
60396
  }
60277
60397
  emitQueueUpdated(chatKey, sessionAlias, queueKey) {
60278
60398
  const key = queueKey ?? this.resolveKey(chatKey, sessionAlias);
60279
- const items = (this.queues.get(key) ?? []).map((q) => ({
60280
- id: q.id,
60281
- textPreview: q.text.length > QUEUE_PREVIEW_MAX ? q.text.slice(0, QUEUE_PREVIEW_MAX) : q.text,
60282
- enqueuedAt: q.enqueuedAt
60283
- }));
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
+ }
60284
60413
  this.deps.emitQueueUpdated(chatKey, sessionAlias, items);
60285
60414
  }
60286
60415
  async submit(params) {
@@ -60401,7 +60530,7 @@ class TurnQueue {
60401
60530
  } finally {
60402
60531
  if (idleTimer)
60403
60532
  this.clearTimer(idleTimer);
60404
- if ((this.queues.get(key)?.length ?? 0) > 0) {
60533
+ if ((this.queues.get(key)?.length ?? 0) > 0 || this.pendingInterrupts.has(key)) {
60405
60534
  this.draining.add(key);
60406
60535
  }
60407
60536
  const detection = result?.postTurnDetection;
@@ -60418,40 +60547,50 @@ class TurnQueue {
60418
60547
  };
60419
60548
  }
60420
60549
  advanceQueue(key) {
60550
+ const interrupt = this.pendingInterrupts.get(key);
60551
+ if (interrupt) {
60552
+ this.pendingInterrupts.delete(key);
60553
+ this.emitPeerInterrupt("started", interrupt.executionContext.chatKey, interrupt.executionContext.sessionAlias, interrupt);
60554
+ this.drainQueuedPrompt(key, interrupt);
60555
+ return;
60556
+ }
60421
60557
  const q = this.queues.get(key);
60422
60558
  const next = q?.shift();
60423
60559
  if (q && q.length === 0)
60424
60560
  this.queues.delete(key);
60425
60561
  if (next) {
60426
- this.emitQueueUpdated(next.executionContext.chatKey, next.executionContext.sessionAlias, key);
60427
- const concurrencyKey = next.concurrencyKey ?? key;
60428
- 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 } : {} };
60429
- this.submit({
60430
- chatKey: next.executionContext.chatKey,
60431
- sessionAlias: next.executionContext.sessionAlias,
60432
- boundSessionAlias: next.executionContext.boundSessionAlias,
60433
- concurrencyKey,
60434
- text: next.text,
60435
- senderId: next.senderId,
60436
- queueable: true,
60437
- drained: true,
60438
- isPeerMessage: next.isPeerMessage,
60439
- allowRestoreArchived: next.allowRestoreArchived,
60440
- preserveCoordinatorRoute: next.preserveCoordinatorRoute,
60441
- promptRequestId: next.promptRequestId,
60442
- peerOrigin: next.peerOrigin,
60443
- trustedPeerCompletion: next.trustedPeerCompletion,
60444
- ...next.isOwner !== undefined ? { isOwner: next.isOwner } : {},
60445
- ...next.accountId !== undefined ? { accountId: next.accountId } : {},
60446
- ...next.media !== undefined ? { media: next.media } : {},
60447
- ...next.agentMentions !== undefined ? { agentMentions: next.agentMentions } : {},
60448
- turnStarted
60449
- });
60562
+ this.drainQueuedPrompt(key, next);
60450
60563
  } else {
60451
60564
  this.draining.delete(key);
60452
60565
  this.inFlight.delete(key);
60453
60566
  }
60454
60567
  }
60568
+ drainQueuedPrompt(key, next) {
60569
+ this.emitQueueUpdated(next.executionContext.chatKey, next.executionContext.sessionAlias, key);
60570
+ const concurrencyKey = next.concurrencyKey ?? key;
60571
+ 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 } : {} };
60572
+ this.submit({
60573
+ chatKey: next.executionContext.chatKey,
60574
+ sessionAlias: next.executionContext.sessionAlias,
60575
+ boundSessionAlias: next.executionContext.boundSessionAlias,
60576
+ concurrencyKey,
60577
+ text: next.text,
60578
+ senderId: next.senderId,
60579
+ queueable: true,
60580
+ drained: true,
60581
+ isPeerMessage: next.isPeerMessage,
60582
+ allowRestoreArchived: next.allowRestoreArchived,
60583
+ preserveCoordinatorRoute: next.preserveCoordinatorRoute,
60584
+ promptRequestId: next.promptRequestId,
60585
+ peerOrigin: next.peerOrigin,
60586
+ trustedPeerCompletion: next.trustedPeerCompletion,
60587
+ ...next.isOwner !== undefined ? { isOwner: next.isOwner } : {},
60588
+ ...next.accountId !== undefined ? { accountId: next.accountId } : {},
60589
+ ...next.media !== undefined ? { media: next.media } : {},
60590
+ ...next.agentMentions !== undefined ? { agentMentions: next.agentMentions } : {},
60591
+ turnStarted
60592
+ });
60593
+ }
60455
60594
  cancelTurn(chatKey, sessionAlias, concurrencyKey) {
60456
60595
  const key = this.resolveKey(chatKey, sessionAlias, concurrencyKey);
60457
60596
  const entry = this.inFlight.get(key);
@@ -60463,12 +60602,21 @@ class TurnQueue {
60463
60602
  }
60464
60603
  async clearSession(chatKey, sessionAlias, concurrencyKey) {
60465
60604
  const key = this.resolveKey(chatKey, sessionAlias, concurrencyKey);
60605
+ const guardPreexisting = this.removing.has(key);
60606
+ this.removing.add(key);
60466
60607
  const droppedFirst = this.queues.get(key);
60467
60608
  if (droppedFirst) {
60468
60609
  this.queues.delete(key);
60469
60610
  this.emitQueueUpdated(chatKey, sessionAlias, key);
60470
60611
  this.notifyQueuedPeerCancelled(droppedFirst);
60471
60612
  }
60613
+ const droppedInterruptFirst = this.pendingInterrupts.get(key);
60614
+ if (droppedInterruptFirst) {
60615
+ this.pendingInterrupts.delete(key);
60616
+ this.emitPeerInterrupt("cancelled_before_start", droppedInterruptFirst.executionContext.chatKey, droppedInterruptFirst.executionContext.sessionAlias, droppedInterruptFirst);
60617
+ this.notifyQueuedPeerCancelled([droppedInterruptFirst]);
60618
+ this.emitQueueUpdated(chatKey, sessionAlias, key);
60619
+ }
60472
60620
  const entry = this.inFlight.get(key);
60473
60621
  if (entry) {
60474
60622
  entry.controller.abort();
@@ -60479,10 +60627,17 @@ class TurnQueue {
60479
60627
  this.emitQueueUpdated(chatKey, sessionAlias, key);
60480
60628
  this.notifyQueuedPeerCancelled(droppedSecond);
60481
60629
  }
60630
+ const droppedInterruptSecond = this.pendingInterrupts.get(key);
60631
+ if (droppedInterruptSecond) {
60632
+ this.pendingInterrupts.delete(key);
60633
+ this.emitPeerInterrupt("cancelled_before_start", droppedInterruptSecond.executionContext.chatKey, droppedInterruptSecond.executionContext.sessionAlias, droppedInterruptSecond);
60634
+ this.notifyQueuedPeerCancelled([droppedInterruptSecond]);
60635
+ this.emitQueueUpdated(chatKey, sessionAlias, key);
60636
+ }
60482
60637
  }
60483
- const cleared = !this.inFlight.has(key) && !this.draining.has(key);
60484
- if (cleared) {
60485
- this.removing.add(key);
60638
+ const cleared = !this.inFlight.has(key) && !this.draining.has(key) && !this.pendingInterrupts.has(key);
60639
+ if (cleared) {} else if (!guardPreexisting) {
60640
+ this.removing.delete(key);
60486
60641
  }
60487
60642
  return { cleared };
60488
60643
  }
@@ -60491,6 +60646,14 @@ class TurnQueue {
60491
60646
  }
60492
60647
  cancelQueuedItem(chatKey, sessionAlias, itemId, concurrencyKey) {
60493
60648
  const key = this.resolveKey(chatKey, sessionAlias, concurrencyKey);
60649
+ const pendingInterrupt = this.pendingInterrupts.get(key);
60650
+ if (pendingInterrupt?.id === itemId) {
60651
+ this.pendingInterrupts.delete(key);
60652
+ this.emitQueueUpdated(chatKey, sessionAlias, key);
60653
+ this.emitPeerInterrupt("cancelled_before_start", pendingInterrupt.executionContext.chatKey, pendingInterrupt.executionContext.sessionAlias, pendingInterrupt);
60654
+ this.notifyQueuedPeerCancelled([pendingInterrupt]);
60655
+ return { cancelled: true };
60656
+ }
60494
60657
  const q = this.queues.get(key);
60495
60658
  if (!q)
60496
60659
  return { cancelled: false };
@@ -60561,6 +60724,7 @@ class ControlService {
60561
60724
  ...this.deps.onTurnIdleTimeout ? { onIdleTimeout: this.deps.onTurnIdleTimeout } : {},
60562
60725
  ...this.deps.cancelDrainTimeoutMs !== undefined ? { cancelDrainTimeoutMs: this.deps.cancelDrainTimeoutMs } : {},
60563
60726
  ...this.deps.onQueuedPeerCancelled ? { onQueuedPeerCancelled: this.deps.onQueuedPeerCancelled } : {},
60727
+ ...this.deps.onPeerInterruptEvent ? { onPeerInterruptEvent: this.deps.onPeerInterruptEvent } : {},
60564
60728
  emitQueueUpdated: (chatKey, sessionAlias, items) => this.deps.events.emit({
60565
60729
  type: "queue-updated",
60566
60730
  chatKey,
@@ -60848,6 +61012,7 @@ class ControlService {
60848
61012
  return {
60849
61013
  alias: toDisplaySessionAlias(session3.alias),
60850
61014
  agent: session3.agent,
61015
+ driver: session3.driver,
60851
61016
  workspace: session3.workspace,
60852
61017
  transportSession: session3.transportSession,
60853
61018
  running,
@@ -61084,7 +61249,7 @@ class ControlService {
61084
61249
  if (!session3 || session3.archived === true) {
61085
61250
  throw new AgentMessagingError("TARGET_UNAVAILABLE", "The target Agent session is not currently available or is archived.");
61086
61251
  }
61087
- const admission = this.turnQueue.submitPeerTurn({
61252
+ const peerParams = {
61088
61253
  chatKey: input.chatKey,
61089
61254
  sessionAlias: input.sessionAlias,
61090
61255
  boundSessionAlias: internalAlias,
@@ -61096,17 +61261,28 @@ class ControlService {
61096
61261
  allowRestoreArchived: false,
61097
61262
  preserveCoordinatorRoute: true,
61098
61263
  peerOrigin: input.peerOrigin
61099
- });
61100
- if (admission.status === "rejected") {
61101
- if (admission.reason === "queue-full") {
61102
- throw new AgentMessagingError("MESSAGE_QUEUE_FULL", "The target Agent's message queue is full.");
61103
- }
61104
- if (admission.reason === "target-unavailable") {
61105
- throw new AgentMessagingError("TARGET_UNAVAILABLE", "The target Agent session is currently being removed or archived.");
61264
+ };
61265
+ if (input.requestedMode === "interrupt") {
61266
+ const admission2 = this.turnQueue.submitPeerInterrupt(peerParams);
61267
+ if (admission2.status === "rejected") {
61268
+ this.throwPeerAdmissionError(admission2.reason, "interrupt");
61106
61269
  }
61107
- throw new AgentMessagingError("DELIVERY_FAILED", `Peer turn admission rejected: ${admission.reason}`);
61270
+ return admission2.status === "injected" ? { status: "injected", modeUsed: "prompt", targetState: "idle" } : { status: "queued", modeUsed: "interrupt", targetState: "running" };
61271
+ }
61272
+ const admission = this.turnQueue.submitPeerTurn(peerParams);
61273
+ if (admission.status === "rejected") {
61274
+ this.throwPeerAdmissionError(admission.reason, "peer");
61108
61275
  }
61109
- return { status: admission.status };
61276
+ return { status: admission.status, modeUsed: "queue" };
61277
+ }
61278
+ throwPeerAdmissionError(reason, mode) {
61279
+ if (reason === "queue-full") {
61280
+ 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.");
61281
+ }
61282
+ if (reason === "target-unavailable") {
61283
+ throw new AgentMessagingError("TARGET_UNAVAILABLE", "The target Agent session is currently being removed or archived.");
61284
+ }
61285
+ throw new AgentMessagingError("DELIVERY_FAILED", `Peer turn admission rejected: ${reason}`);
61110
61286
  }
61111
61287
  async submitCompletionTurn(input) {
61112
61288
  const session3 = await this.deps.sessions.getSession(input.sourceAlias);
@@ -61217,9 +61393,9 @@ var init_control_service = __esm(() => {
61217
61393
  init_native_session_history();
61218
61394
  init_workspace_fs();
61219
61395
  init_workspace_git();
61396
+ init_turn_support();
61220
61397
  init_session_turn_runner();
61221
61398
  init_turn_queue();
61222
- init_turn_support();
61223
61399
  init_command_timeouts();
61224
61400
  MODEL_SET_SETTLE_BUDGET_MS = 2 * (DEFAULT_MANAGEMENT_COMMAND_TIMEOUT_MS + BRIDGE_REQUEST_TIMEOUT_GRACE_MS);
61225
61401
  });
@@ -62875,7 +63051,7 @@ async function buildApp(paths, deps = {}) {
62875
63051
  return null;
62876
63052
  }
62877
63053
  },
62878
- deliverLogicalTurn: async (alias, renderedText, messageId, peerOrigin) => {
63054
+ deliverLogicalTurn: async (alias, renderedText, messageId, peerOrigin, requestedMode) => {
62879
63055
  if (controlRef) {
62880
63056
  const chatKey = `relay:agent-message:${alias}`;
62881
63057
  return await controlRef.submitPeerTurn({
@@ -62885,10 +63061,11 @@ async function buildApp(paths, deps = {}) {
62885
63061
  text: renderedText,
62886
63062
  senderId: "agent-messaging",
62887
63063
  messageId,
63064
+ requestedMode,
62888
63065
  peerOrigin
62889
63066
  });
62890
63067
  }
62891
- return { status: "queued" };
63068
+ return { status: "queued", modeUsed: "queue" };
62892
63069
  },
62893
63070
  deliverCompletionTurn: async (alias, completion, requestMessageId) => {
62894
63071
  if (controlRef) {
@@ -63073,6 +63250,16 @@ async function buildApp(paths, deps = {}) {
63073
63250
  idleMs
63074
63251
  });
63075
63252
  },
63253
+ onPeerInterruptEvent: (event) => {
63254
+ logger.info(`agent_messaging.peer_interrupt_${event.kind}`, "peer interrupt lane event", {
63255
+ chatKey: event.chatKey,
63256
+ sessionAlias: event.sessionAlias,
63257
+ ...event.requestMessageId !== undefined ? { requestMessageId: event.requestMessageId } : {},
63258
+ ...event.promptRequestId !== undefined ? { promptRequestId: event.promptRequestId } : {},
63259
+ ...event.pendingRequestMessageId !== undefined ? { pendingRequestMessageId: event.pendingRequestMessageId } : {},
63260
+ ...event.predecessorWasAlreadyAborted !== undefined ? { predecessorWasAlreadyAborted: event.predecessorWasAlreadyAborted } : {}
63261
+ });
63262
+ },
63076
63263
  agentMessaging: {
63077
63264
  deliverInbound: async (input) => await agentMessaging.deliverInbound(input),
63078
63265
  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.4",
3
+ "version": "0.23.0-beta.6",
4
4
  "description": "随时随地通过聊天频道(微信 / 飞书 / 元宝等)远程控制 `acpx` 上的 Claude Code、Codex 等 Agents。",
5
5
  "keywords": [
6
6
  "acpx",