@ganglion/xacpx 0.13.0 → 0.14.0

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/README.md CHANGED
@@ -519,19 +519,27 @@ For more filtering, aliases, and troubleshooting, see [docs/native-sessions.md](
519
519
 
520
520
  If you run several xacpx instances and want to drive them all from one browser dashboard, you can self-host the **relay hub**. Each instance dials out to the hub over WebSocket and registers; you log in to a multi-tenant web dashboard and manage every instance's sessions — chat, scheduled tasks, and orchestration — from one place. Streaming agent replies render as markdown, and the layout works on mobile.
521
521
 
522
- > Status: the relay packages are built and audited but **not yet published to npm**, so today you deploy from a source checkout. See the full guide for the exact steps.
522
+ The hub ships as an npm package (`@ganglion/xacpx-relay`) with the dashboard **bundled in** no separate build. It serves everything on a single port (HTTP API + dashboard + the instance WebSocket gateway), and authentication is a single **access token** used for both web login and connector pairing.
523
523
 
524
524
  ```bash
525
- # Build the hub server + dashboard from a repo checkout
526
- git clone https://github.com/gadzan/xacpx && cd xacpx && bun install
527
- bun run build:relay && bun run build:relay-web
525
+ # 1. On the hub host: install (dashboard is bundled nothing else to build)
526
+ npm i -g @ganglion/xacpx-relay
528
527
 
529
- # Create the first admin, then start (point --web-root at the built dashboard)
530
- node packages/relay/dist/cli.js init-admin --username admin --db /var/lib/xacpx-relay/relay.db
531
- node packages/relay/dist/cli.js start --db /var/lib/xacpx-relay/relay.db \
532
- --web-root packages/relay-web/dist --host 0.0.0.0
528
+ # 2. Mint an access token (DB auto-created at ~/.xacpx-relay/relay.db)
529
+ xacpx-relay add token
530
+ # → prints the token once; use it to log into the dashboard AND to pair connectors
531
+
532
+ # 3. Start the hub (defaults: --host 0.0.0.0 --http-port 8787, dashboard auto-detected)
533
+ xacpx-relay start
534
+
535
+ # 4. On each instance host: add the connector channel and point it at the hub
536
+ xacpx plugin add @ganglion/xacpx-channel-relay # requires xacpx >= 0.11.0
537
+ xacpx channel add relay --url wss://relay.example.com --token <access-token> --name my-box
538
+ xacpx restart
533
539
  ```
534
540
 
541
+ In production, terminate TLS at a reverse proxy in front of the single port and have instances dial `wss://`. There's no `stop`/`status` subcommand — manage the process with systemd/pm2/Docker (`Ctrl-C`/`SIGTERM` to stop); update with `xacpx-relay update`.
542
+
535
543
  Full walkthrough — pairing instances, TLS/reverse-proxy, systemd, backups, troubleshooting: **[Self-Hosting the Relay Hub](https://gadzan.github.io/xacpx/guide/relay-self-hosting)** (or [docs/relay-deployment.md](./docs/relay-deployment.md) for the terse runbook).
536
544
 
537
545
  ## Config and runtime files
@@ -72,6 +72,10 @@ function encodeBridgePromptUsageEvent(event) {
72
72
  return `${JSON.stringify(event)}
73
73
  `;
74
74
  }
75
+ function encodeBridgePromptCommandsEvent(event) {
76
+ return `${JSON.stringify(event)}
77
+ `;
78
+ }
75
79
  function encodeBridgeSessionProgressEvent(event) {
76
80
  return `${JSON.stringify(event)}
77
81
  `;
@@ -429,6 +433,7 @@ function createStreamingPromptState(formatToolCalls = false, options) {
429
433
  let onThought;
430
434
  let onPlan;
431
435
  let onUsage;
436
+ let onCommands;
432
437
  let rawStream = false;
433
438
  if (options === undefined) {
434
439
  toolEventMode = "text";
@@ -441,6 +446,7 @@ function createStreamingPromptState(formatToolCalls = false, options) {
441
446
  onThought = options.onThought;
442
447
  onPlan = options.onPlan;
443
448
  onUsage = options.onUsage;
449
+ onCommands = options.onCommands;
444
450
  rawStream = options.rawStream ?? false;
445
451
  toolEventMode = resolveToolEventMode({
446
452
  toolEventMode: options.mode,
@@ -460,6 +466,7 @@ function createStreamingPromptState(formatToolCalls = false, options) {
460
466
  onThought,
461
467
  onPlan,
462
468
  onUsage,
469
+ onCommands,
463
470
  finalize() {
464
471
  if (this.pendingLine.trim().length > 0) {
465
472
  parseStreamingChunks(this, this.pendingLine);
@@ -527,8 +534,17 @@ function parseStreamingChunks(state, line) {
527
534
  if (update.sessionUpdate === "usage_update") {
528
535
  const used = typeof update.used === "number" && Number.isFinite(update.used) ? update.used : undefined;
529
536
  const size = typeof update.size === "number" && Number.isFinite(update.size) ? update.size : undefined;
530
- if (used !== undefined && size !== undefined && size > 0)
531
- state.onUsage?.({ used, size });
537
+ if (used !== undefined && size !== undefined && size > 0) {
538
+ const cost = normalizeUsageCost(update.cost);
539
+ const breakdown = normalizeUsageBreakdown(update._meta?.usage);
540
+ state.onUsage?.({ used, size, ...cost ? { cost } : {}, ...breakdown ? { breakdown } : {} });
541
+ }
542
+ return;
543
+ }
544
+ if (update.sessionUpdate === "available_commands_update") {
545
+ if (Array.isArray(update.availableCommands)) {
546
+ state.onCommands?.(normalizeAgentCommands(update.availableCommands));
547
+ }
532
548
  return;
533
549
  }
534
550
  const isThoughtChunk = update.sessionUpdate === "agent_thought_chunk" && update.content?.type === "text" && typeof update.content.text === "string";
@@ -695,6 +711,52 @@ function readFirstStringArray(record, keys) {
695
711
  }
696
712
  return;
697
713
  }
714
+ function asFiniteNumber(value) {
715
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
716
+ }
717
+ function firstFiniteNumber(record, keys) {
718
+ for (const key of keys) {
719
+ const n = asFiniteNumber(record[key]);
720
+ if (n !== undefined)
721
+ return n;
722
+ }
723
+ return;
724
+ }
725
+ function normalizeUsageBreakdown(value) {
726
+ if (!isRecord(value))
727
+ return;
728
+ const out = {};
729
+ for (const [key, aliases] of USAGE_BREAKDOWN_FIELDS) {
730
+ const n = firstFiniteNumber(value, aliases);
731
+ if (n !== undefined)
732
+ out[key] = n;
733
+ }
734
+ return Object.keys(out).length > 0 ? out : undefined;
735
+ }
736
+ function normalizeUsageCost(value) {
737
+ if (!isRecord(value))
738
+ return;
739
+ const amount = asFiniteNumber(value.amount);
740
+ const currency = readString(value, "currency");
741
+ if (amount === undefined && !currency)
742
+ return;
743
+ return { ...amount !== undefined ? { amount } : {}, ...currency ? { currency } : {} };
744
+ }
745
+ function normalizeAgentCommands(value) {
746
+ if (!Array.isArray(value))
747
+ return [];
748
+ const out = [];
749
+ for (const entry of value) {
750
+ if (!isRecord(entry))
751
+ continue;
752
+ const name = readString(entry, "name");
753
+ if (!name)
754
+ continue;
755
+ const description = readString(entry, "description");
756
+ out.push({ name, ...description ? { description } : {}, hasInput: entry.input != null });
757
+ }
758
+ return out;
759
+ }
698
760
  function isRecord(value) {
699
761
  return typeof value === "object" && value !== null && !Array.isArray(value);
700
762
  }
@@ -720,8 +782,17 @@ function isGenericToolTitle(kind, title) {
720
782
  }
721
783
  return false;
722
784
  }
785
+ var USAGE_BREAKDOWN_FIELDS;
723
786
  var init_streaming_prompt = __esm(() => {
724
787
  init_tool_kind_emoji();
788
+ USAGE_BREAKDOWN_FIELDS = [
789
+ ["inputTokens", ["inputTokens", "input_tokens"]],
790
+ ["outputTokens", ["outputTokens", "output_tokens"]],
791
+ ["cachedReadTokens", ["cachedReadTokens", "cacheReadInputTokens", "cache_read_input_tokens"]],
792
+ ["cachedWriteTokens", ["cachedWriteTokens", "cacheCreationInputTokens", "cache_creation_input_tokens"]],
793
+ ["thoughtTokens", ["thoughtTokens", "thought_tokens"]],
794
+ ["totalTokens", ["totalTokens", "total_tokens"]]
795
+ ];
725
796
  });
726
797
 
727
798
  // src/recovery/discover-parent-package-paths.ts
@@ -4049,7 +4120,8 @@ async function runStreamingPrompt(command, args, onEvent, options = {}) {
4049
4120
  ...onEvent && (toolEventMode === "structured" || toolEventMode === "both") ? { onToolEvent: (toolEvent) => onEvent({ type: "prompt.tool_event", event: toolEvent }) } : {},
4050
4121
  ...onEvent ? { onThought: (chunk) => onEvent({ type: "prompt.thought", text: chunk }) } : {},
4051
4122
  ...onEvent ? { onPlan: (entries) => onEvent({ type: "prompt.plan", entries }) } : {},
4052
- ...onEvent ? { onUsage: (usage) => onEvent({ type: "prompt.usage", used: usage.used, size: usage.size }) } : {}
4123
+ ...onEvent ? { onUsage: (usage) => onEvent({ type: "prompt.usage", used: usage.used, size: usage.size, ...usage.cost ? { cost: usage.cost } : {}, ...usage.breakdown ? { breakdown: usage.breakdown } : {} }) } : {},
4124
+ ...onEvent ? { onCommands: (commands) => onEvent({ type: "prompt.commands", commands }) } : {}
4053
4125
  });
4054
4126
  let lastReplyAt = now();
4055
4127
  const flushBuffer = () => {
@@ -4347,7 +4419,15 @@ class BridgeServer {
4347
4419
  id: requestId,
4348
4420
  event: "prompt.usage",
4349
4421
  used: event.used,
4350
- size: event.size
4422
+ size: event.size,
4423
+ ...event.cost ? { cost: event.cost } : {},
4424
+ ...event.breakdown ? { breakdown: event.breakdown } : {}
4425
+ }));
4426
+ } else if (event.type === "prompt.commands") {
4427
+ writeLine?.(encodeBridgePromptCommandsEvent({
4428
+ id: requestId,
4429
+ event: "prompt.commands",
4430
+ commands: event.commands
4351
4431
  }));
4352
4432
  }
4353
4433
  });
package/dist/cli.js CHANGED
@@ -22377,7 +22377,7 @@ async function handleSessionArchive(context, chatKey, alias, archive) {
22377
22377
  await archive(internalAlias);
22378
22378
  return { text: t().session.sessionArchived(alias) };
22379
22379
  }
22380
- async function promptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage) {
22380
+ async function promptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands) {
22381
22381
  if (session3.archived) {
22382
22382
  await context.sessions.setArchived(session3.alias, false);
22383
22383
  }
@@ -22412,7 +22412,7 @@ async function promptWithSession(context, session3, chatKey, text, reply, replyC
22412
22412
  const { promptText, taskIds, groupIds, claimHumanReply } = await preparePromptWithFallback(context, session3, chatKey, text, replyContextToken, accountId);
22413
22413
  try {
22414
22414
  const replyContext = transportReply && context.quota && getChannelIdFromChatKey(chatKey) === "weixin" ? { chatKey, quota: context.quota } : undefined;
22415
- const result = await context.interaction.promptTransportSession(session3, promptText, transportReply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpan, onPlan, onUsage);
22415
+ const result = await context.interaction.promptTransportSession(session3, promptText, transportReply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpan, onPlan, onUsage, onCommands);
22416
22416
  if (claimHumanReply) {
22417
22417
  try {
22418
22418
  await context.orchestration?.claimActiveHumanReply?.(claimHumanReply);
@@ -22432,23 +22432,23 @@ async function promptWithSession(context, session3, chatKey, text, reply, replyC
22432
22432
  throw error2;
22433
22433
  }
22434
22434
  }
22435
- async function handlePromptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage) {
22435
+ async function handlePromptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands) {
22436
22436
  try {
22437
- return await promptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage);
22437
+ return await promptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands);
22438
22438
  } catch (error2) {
22439
22439
  const recovered = await context.recovery.tryRecoverMissingSession(session3, error2);
22440
22440
  if (recovered) {
22441
- return await promptWithSession(context, recovered, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage);
22441
+ return await promptWithSession(context, recovered, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands);
22442
22442
  }
22443
22443
  return context.recovery.renderTransportError(session3, error2);
22444
22444
  }
22445
22445
  }
22446
- async function handlePrompt(context, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage) {
22446
+ async function handlePrompt(context, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands) {
22447
22447
  const session3 = metadata?.boundSessionAlias ? context.sessions.getResolvedSessionByInternalAlias(metadata.boundSessionAlias) : await context.sessions.getCurrentSession(chatKey);
22448
22448
  if (!session3) {
22449
22449
  return { text: t().session.noCurrent };
22450
22450
  }
22451
- return await handlePromptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage);
22451
+ return await handlePromptWithSession(context, session3, chatKey, text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands);
22452
22452
  }
22453
22453
  function toCoordinatorRouteChatMetadata(metadata) {
22454
22454
  if (!metadata) {
@@ -24707,7 +24707,7 @@ class CommandRouter {
24707
24707
  this.logger = logger2 ?? createNoopAppLogger();
24708
24708
  this.activeTurns = activeTurns;
24709
24709
  }
24710
- async handle(chatKey, input, reply, replyContextToken, accountId, media, metadata, abortSignal, onToolEvent, onThought, perfSpan, onPlan, onUsage) {
24710
+ async handle(chatKey, input, reply, replyContextToken, accountId, media, metadata, abortSignal, onToolEvent, onThought, perfSpan, onPlan, onUsage, onCommands) {
24711
24711
  const startedAt = Date.now();
24712
24712
  let command = parseCommand(input);
24713
24713
  if (metadata?.channel === "control" && command.kind !== "prompt") {
@@ -24869,16 +24869,16 @@ class CommandRouter {
24869
24869
  ...this.sessions.resolveSession(descriptor.alias, descriptor.agent, descriptor.workspace, descriptor.transportSession),
24870
24870
  transient: true
24871
24871
  };
24872
- return await handlePromptWithSession(sessionContext, transientSession, chatKey, command.text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage);
24872
+ return await handlePromptWithSession(sessionContext, transientSession, chatKey, command.text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands);
24873
24873
  }
24874
24874
  if (metadata?.scheduledSessionAlias) {
24875
24875
  const scheduledSession = await this.sessions.getSession(metadata.scheduledSessionAlias);
24876
24876
  if (!scheduledSession) {
24877
24877
  throw new Error(`session "${metadata.scheduledSessionAlias}" not found for scheduled prompt`);
24878
24878
  }
24879
- return await handlePromptWithSession(sessionContext, scheduledSession, chatKey, command.text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage);
24879
+ return await handlePromptWithSession(sessionContext, scheduledSession, chatKey, command.text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands);
24880
24880
  }
24881
- return await handlePrompt(sessionContext, chatKey, command.text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage);
24881
+ return await handlePrompt(sessionContext, chatKey, command.text, reply, replyContextToken, accountId, media, abortSignal, onToolEvent, onThought, perfSpan, metadata, onPlan, onUsage, onCommands);
24882
24882
  }
24883
24883
  }
24884
24884
  });
@@ -25102,7 +25102,7 @@ class CommandRouter {
25102
25102
  setModelTransportSession: (session3, modelId) => this.setModelTransportSession(session3, modelId),
25103
25103
  getModelTransportSession: (session3) => this.getModelTransportSession(session3),
25104
25104
  cancelTransportSession: (session3) => this.cancelTransportSession(session3),
25105
- promptTransportSession: (session3, text, reply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpanOverride, onPlan, onUsage) => this.promptTransportSession(session3, text, reply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpanOverride ?? perfSpan, onPlan, onUsage)
25105
+ promptTransportSession: (session3, text, reply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpanOverride, onPlan, onUsage, onCommands) => this.promptTransportSession(session3, text, reply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpanOverride ?? perfSpan, onPlan, onUsage, onCommands)
25106
25106
  };
25107
25107
  }
25108
25108
  createSessionRenderRecoveryOps() {
@@ -25281,7 +25281,7 @@ class CommandRouter {
25281
25281
  async checkTransportSession(session3) {
25282
25282
  return await this.measureTransportCall("has_session", session3, () => this.transport.hasSession(session3));
25283
25283
  }
25284
- async promptTransportSession(session3, text, reply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpan, onPlan, onUsage) {
25284
+ async promptTransportSession(session3, text, reply, replyContext, media, abortSignal, onToolEvent, onThought, perfSpan, onPlan, onUsage, onCommands) {
25285
25285
  session3.mcpCoordinatorSession ??= stableCoordinatorSession(session3.transportSession);
25286
25286
  let done = false;
25287
25287
  let abortRequested = false;
@@ -25340,7 +25340,8 @@ class CommandRouter {
25340
25340
  ...onToolEvent ? { onToolEvent } : {},
25341
25341
  ...onThought ? { onThought } : {},
25342
25342
  ...onPlan ? { onPlan } : {},
25343
- ...onUsage ? { onUsage } : {}
25343
+ ...onUsage ? { onUsage } : {},
25344
+ ...onCommands ? { onCommands } : {}
25344
25345
  }));
25345
25346
  } catch (error2) {
25346
25347
  localOutcome = isAbortError2(error2) || abortRequested ? "aborted" : "error";
@@ -25526,7 +25527,7 @@ class ConsoleAgent {
25526
25527
  ...m.fileName ? { fileName: m.fileName } : {}
25527
25528
  })) : undefined;
25528
25529
  request.perfSpan?.mark("agent.dispatched");
25529
- return await this.router.handle(request.conversationId, request.text, request.reply, request.replyContextToken, request.accountId, promptMedia, request.metadata, request.abortSignal, request.onToolEvent, request.onThought, request.perfSpan, request.onPlan, request.onUsage);
25530
+ return await this.router.handle(request.conversationId, request.text, request.reply, request.replyContextToken, request.accountId, promptMedia, request.metadata, request.abortSignal, request.onToolEvent, request.onThought, request.perfSpan, request.onPlan, request.onUsage, request.onCommands);
25530
25531
  }
25531
25532
  isKnownCommand(text) {
25532
25533
  return isKnownXacpxCommandText(text);
@@ -30607,6 +30608,10 @@ function encodeBridgePromptUsageEvent(event) {
30607
30608
  return `${JSON.stringify(event)}
30608
30609
  `;
30609
30610
  }
30611
+ function encodeBridgePromptCommandsEvent(event) {
30612
+ return `${JSON.stringify(event)}
30613
+ `;
30614
+ }
30610
30615
  function encodeBridgeSessionProgressEvent(event) {
30611
30616
  return `${JSON.stringify(event)}
30612
30617
  `;
@@ -30913,7 +30918,14 @@ class AcpxBridgeClient {
30913
30918
  pending.onEvent?.({
30914
30919
  type: "prompt.usage",
30915
30920
  used: message.used,
30916
- size: message.size
30921
+ size: message.size,
30922
+ ...message.cost ? { cost: message.cost } : {},
30923
+ ...message.breakdown ? { breakdown: message.breakdown } : {}
30924
+ });
30925
+ } else if (message.event === "prompt.commands") {
30926
+ pending.onEvent?.({
30927
+ type: "prompt.commands",
30928
+ commands: message.commands
30917
30929
  });
30918
30930
  } else if (message.event === "session.progress") {
30919
30931
  pending.onEvent?.({
@@ -31376,6 +31388,8 @@ class AcpxBridgeTransport {
31376
31388
  let planChain = Promise.resolve();
31377
31389
  let usageError;
31378
31390
  let usageChain = Promise.resolve();
31391
+ let commandsError;
31392
+ let commandsChain = Promise.resolve();
31379
31393
  let toolEventMode = resolveToolEventMode(options);
31380
31394
  if ((toolEventMode === "structured" || toolEventMode === "both") && !options?.onToolEvent) {
31381
31395
  toolEventMode = "text";
@@ -31431,19 +31445,30 @@ class AcpxBridgeTransport {
31431
31445
  if (event.type === "prompt.usage") {
31432
31446
  const onUsage = options?.onUsage;
31433
31447
  if (onUsage) {
31434
- const usage = { used: event.used, size: event.size };
31448
+ const usage = { used: event.used, size: event.size, ...event.cost ? { cost: event.cost } : {}, ...event.breakdown ? { breakdown: event.breakdown } : {} };
31435
31449
  usageChain = usageChain.then(() => onUsage(usage)).catch((error2) => {
31436
31450
  usageError ??= error2;
31437
31451
  });
31438
31452
  }
31439
31453
  return;
31440
31454
  }
31455
+ if (event.type === "prompt.commands") {
31456
+ const onCommands = options?.onCommands;
31457
+ if (onCommands) {
31458
+ const commands = event.commands;
31459
+ commandsChain = commandsChain.then(() => onCommands(commands)).catch((error2) => {
31460
+ commandsError ??= error2;
31461
+ });
31462
+ }
31463
+ return;
31464
+ }
31441
31465
  });
31442
31466
  await segmentChain;
31443
31467
  await toolEventChain;
31444
31468
  await thoughtChain;
31445
31469
  await planChain;
31446
31470
  await usageChain;
31471
+ await commandsChain;
31447
31472
  if (sink) {
31448
31473
  const { overflowCount } = sink.finalize();
31449
31474
  await sink.drain({ timeoutMs: 30000 });
@@ -31467,6 +31492,9 @@ class AcpxBridgeTransport {
31467
31492
  if (usageError) {
31468
31493
  throw usageError;
31469
31494
  }
31495
+ if (commandsError) {
31496
+ throw commandsError;
31497
+ }
31470
31498
  return { text: summary ? `${summary}
31471
31499
 
31472
31500
  ${result.text}` : "" };
@@ -31486,6 +31514,9 @@ ${result.text}` : "" };
31486
31514
  if (usageError) {
31487
31515
  throw usageError;
31488
31516
  }
31517
+ if (commandsError) {
31518
+ throw commandsError;
31519
+ }
31489
31520
  return result;
31490
31521
  }
31491
31522
  async setMode(session3, modeId) {
@@ -31695,6 +31726,7 @@ function createStreamingPromptState(formatToolCalls = false, options) {
31695
31726
  let onThought;
31696
31727
  let onPlan;
31697
31728
  let onUsage;
31729
+ let onCommands;
31698
31730
  let rawStream = false;
31699
31731
  if (options === undefined) {
31700
31732
  toolEventMode = "text";
@@ -31707,6 +31739,7 @@ function createStreamingPromptState(formatToolCalls = false, options) {
31707
31739
  onThought = options.onThought;
31708
31740
  onPlan = options.onPlan;
31709
31741
  onUsage = options.onUsage;
31742
+ onCommands = options.onCommands;
31710
31743
  rawStream = options.rawStream ?? false;
31711
31744
  toolEventMode = resolveToolEventMode({
31712
31745
  toolEventMode: options.mode,
@@ -31726,6 +31759,7 @@ function createStreamingPromptState(formatToolCalls = false, options) {
31726
31759
  onThought,
31727
31760
  onPlan,
31728
31761
  onUsage,
31762
+ onCommands,
31729
31763
  finalize() {
31730
31764
  if (this.pendingLine.trim().length > 0) {
31731
31765
  parseStreamingChunks(this, this.pendingLine);
@@ -31793,8 +31827,17 @@ function parseStreamingChunks(state, line) {
31793
31827
  if (update.sessionUpdate === "usage_update") {
31794
31828
  const used = typeof update.used === "number" && Number.isFinite(update.used) ? update.used : undefined;
31795
31829
  const size = typeof update.size === "number" && Number.isFinite(update.size) ? update.size : undefined;
31796
- if (used !== undefined && size !== undefined && size > 0)
31797
- state.onUsage?.({ used, size });
31830
+ if (used !== undefined && size !== undefined && size > 0) {
31831
+ const cost = normalizeUsageCost(update.cost);
31832
+ const breakdown = normalizeUsageBreakdown(update._meta?.usage);
31833
+ state.onUsage?.({ used, size, ...cost ? { cost } : {}, ...breakdown ? { breakdown } : {} });
31834
+ }
31835
+ return;
31836
+ }
31837
+ if (update.sessionUpdate === "available_commands_update") {
31838
+ if (Array.isArray(update.availableCommands)) {
31839
+ state.onCommands?.(normalizeAgentCommands(update.availableCommands));
31840
+ }
31798
31841
  return;
31799
31842
  }
31800
31843
  const isThoughtChunk = update.sessionUpdate === "agent_thought_chunk" && update.content?.type === "text" && typeof update.content.text === "string";
@@ -31961,6 +32004,52 @@ function readFirstStringArray(record3, keys) {
31961
32004
  }
31962
32005
  return;
31963
32006
  }
32007
+ function asFiniteNumber(value) {
32008
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
32009
+ }
32010
+ function firstFiniteNumber(record3, keys) {
32011
+ for (const key of keys) {
32012
+ const n = asFiniteNumber(record3[key]);
32013
+ if (n !== undefined)
32014
+ return n;
32015
+ }
32016
+ return;
32017
+ }
32018
+ function normalizeUsageBreakdown(value) {
32019
+ if (!isRecord3(value))
32020
+ return;
32021
+ const out = {};
32022
+ for (const [key, aliases] of USAGE_BREAKDOWN_FIELDS) {
32023
+ const n = firstFiniteNumber(value, aliases);
32024
+ if (n !== undefined)
32025
+ out[key] = n;
32026
+ }
32027
+ return Object.keys(out).length > 0 ? out : undefined;
32028
+ }
32029
+ function normalizeUsageCost(value) {
32030
+ if (!isRecord3(value))
32031
+ return;
32032
+ const amount = asFiniteNumber(value.amount);
32033
+ const currency = readString(value, "currency");
32034
+ if (amount === undefined && !currency)
32035
+ return;
32036
+ return { ...amount !== undefined ? { amount } : {}, ...currency ? { currency } : {} };
32037
+ }
32038
+ function normalizeAgentCommands(value) {
32039
+ if (!Array.isArray(value))
32040
+ return [];
32041
+ const out = [];
32042
+ for (const entry of value) {
32043
+ if (!isRecord3(entry))
32044
+ continue;
32045
+ const name = readString(entry, "name");
32046
+ if (!name)
32047
+ continue;
32048
+ const description = readString(entry, "description");
32049
+ out.push({ name, ...description ? { description } : {}, hasInput: entry.input != null });
32050
+ }
32051
+ return out;
32052
+ }
31964
32053
  function isRecord3(value) {
31965
32054
  return typeof value === "object" && value !== null && !Array.isArray(value);
31966
32055
  }
@@ -31986,8 +32075,17 @@ function isGenericToolTitle(kind, title) {
31986
32075
  }
31987
32076
  return false;
31988
32077
  }
32078
+ var USAGE_BREAKDOWN_FIELDS;
31989
32079
  var init_streaming_prompt = __esm(() => {
31990
32080
  init_tool_kind_emoji();
32081
+ USAGE_BREAKDOWN_FIELDS = [
32082
+ ["inputTokens", ["inputTokens", "input_tokens"]],
32083
+ ["outputTokens", ["outputTokens", "output_tokens"]],
32084
+ ["cachedReadTokens", ["cachedReadTokens", "cacheReadInputTokens", "cache_read_input_tokens"]],
32085
+ ["cachedWriteTokens", ["cachedWriteTokens", "cacheCreationInputTokens", "cache_creation_input_tokens"]],
32086
+ ["thoughtTokens", ["thoughtTokens", "thought_tokens"]],
32087
+ ["totalTokens", ["totalTokens", "total_tokens"]]
32088
+ ];
31991
32089
  });
31992
32090
 
31993
32091
  // src/transport/acpx-cli/node-pty-helper.ts
@@ -33497,24 +33595,29 @@ var init_workspace_fs = __esm(() => {
33497
33595
  });
33498
33596
 
33499
33597
  // src/control/control-service.ts
33598
+ import path15 from "node:path";
33599
+
33500
33600
  class ControlService {
33501
33601
  deps;
33502
33602
  constructor(deps) {
33503
33603
  this.deps = deps;
33504
33604
  }
33505
33605
  workspaceFs = new WorkspaceFs(() => this.deps.workspaces.list().map((w) => ({ name: w.name, cwd: w.cwd })));
33506
- listDirectory(workspace3, path15) {
33507
- return this.workspaceFs.listDirectory(workspace3, path15);
33606
+ listDirectory(workspace3, path16) {
33607
+ return this.workspaceFs.listDirectory(workspace3, path16);
33508
33608
  }
33509
- readWorkspaceFile(workspace3, path15) {
33510
- return this.workspaceFs.readFile(workspace3, path15);
33609
+ readWorkspaceFile(workspace3, path16) {
33610
+ return this.workspaceFs.readFile(workspace3, path16);
33511
33611
  }
33512
- workspaceGitDiff(workspace3, path15) {
33513
- return this.workspaceFs.gitDiff(workspace3, path15);
33612
+ workspaceGitDiff(workspace3, path16) {
33613
+ return this.workspaceFs.gitDiff(workspace3, path16);
33514
33614
  }
33515
33615
  searchWorkspace(workspace3, query) {
33516
33616
  return this.workspaceFs.search(workspace3, query);
33517
33617
  }
33618
+ async uploadFile(input) {
33619
+ return this.deps.uploadStore.save(input.filename, input.content, input.mimeType);
33620
+ }
33518
33621
  async getSessionModel(chatKey, alias) {
33519
33622
  const session3 = await this.resolveControlSession(chatKey, alias);
33520
33623
  if (!session3)
@@ -33658,7 +33761,8 @@ class ControlService {
33658
33761
  text: input.text,
33659
33762
  senderId: input.senderId,
33660
33763
  ...input.isOwner !== undefined ? { isOwner: input.isOwner } : {},
33661
- ...input.accountId !== undefined ? { accountId: input.accountId } : {}
33764
+ ...input.accountId !== undefined ? { accountId: input.accountId } : {},
33765
+ ...input.media !== undefined ? { media: input.media } : {}
33662
33766
  });
33663
33767
  }
33664
33768
  async runScheduledTurn(input) {
@@ -33730,6 +33834,32 @@ ${chunk}` : chunk
33730
33834
  });
33731
33835
  emittedChunk = true;
33732
33836
  };
33837
+ const incomingMedia = params.media ?? [];
33838
+ const sandboxedMedia = incomingMedia.length ? (() => {
33839
+ const uploadRoot = path15.resolve(this.deps.uploadStore.root);
33840
+ const kept = incomingMedia.filter((ref) => {
33841
+ const resolved = path15.resolve(ref.filePath);
33842
+ return resolved === uploadRoot || resolved.startsWith(uploadRoot + path15.sep);
33843
+ });
33844
+ const dropped = incomingMedia.length - kept.length;
33845
+ if (dropped > 0) {
33846
+ console.warn(`[control] dropped ${dropped} media ref(s) with filePath outside the upload sandbox`);
33847
+ }
33848
+ return kept;
33849
+ })() : incomingMedia;
33850
+ const chatMedia = sandboxedMedia.map((ref) => ({
33851
+ kind: ref.kind,
33852
+ filePath: ref.filePath,
33853
+ mimeType: ref.mimeType,
33854
+ ...ref.fileName ? { fileName: ref.fileName } : {},
33855
+ sizeBytes: ref.size,
33856
+ source: {
33857
+ channelId: "relay",
33858
+ accountId: params.accountId ?? "control",
33859
+ chatKey: params.chatKey,
33860
+ messageId: ref.id
33861
+ }
33862
+ }));
33733
33863
  try {
33734
33864
  const response = await this.deps.agent.chat({
33735
33865
  accountId: params.accountId ?? "control",
@@ -33737,6 +33867,7 @@ ${chunk}` : chunk
33737
33867
  text: params.text,
33738
33868
  metadata: buildControlMetadata(params.senderId, params.isOwner),
33739
33869
  abortSignal: controller.signal,
33870
+ ...chatMedia.length > 0 ? { media: chatMedia } : {},
33740
33871
  reply: async (chunk) => {
33741
33872
  emitChunk(chunk);
33742
33873
  },
@@ -33770,7 +33901,17 @@ ${chunk}` : chunk
33770
33901
  chatKey: params.chatKey,
33771
33902
  sessionAlias: params.sessionAlias,
33772
33903
  used: usage.used,
33773
- size: usage.size
33904
+ size: usage.size,
33905
+ ...usage.cost ? { cost: usage.cost } : {},
33906
+ ...usage.breakdown ? { breakdown: usage.breakdown } : {}
33907
+ });
33908
+ },
33909
+ onCommands: (commands) => {
33910
+ this.deps.events.emit({
33911
+ type: "agent-commands",
33912
+ chatKey: params.chatKey,
33913
+ sessionAlias: params.sessionAlias,
33914
+ commands
33774
33915
  });
33775
33916
  }
33776
33917
  });
@@ -33859,13 +34000,92 @@ var init_control_service = __esm(() => {
33859
34000
  init_workspace_fs();
33860
34001
  });
33861
34002
 
34003
+ // src/control/upload-store.ts
34004
+ import { mkdtemp as mkdtemp2, readdir as readdir5, rm as rm10, stat as stat4, writeFile as writeFile8 } from "node:fs/promises";
34005
+ import { homedir as homedir12 } from "node:os";
34006
+ import path16 from "node:path";
34007
+ function defaultRootDir() {
34008
+ const home = process.env.HOME ?? homedir12();
34009
+ return path16.join(coreHomeDir(home), "runtime", "uploads");
34010
+ }
34011
+ function sanitizeUploadFilename(raw) {
34012
+ const base = path16.basename(raw).replace(/[/\\]/g, "").replace(/^\.+/, "");
34013
+ const cleaned = base.trim();
34014
+ return cleaned.length > 0 ? cleaned : "file";
34015
+ }
34016
+
34017
+ class UploadStore {
34018
+ rootDir;
34019
+ maxBytes;
34020
+ ttlMs;
34021
+ now;
34022
+ constructor(opts = {}) {
34023
+ this.rootDir = opts.rootDir ?? defaultRootDir();
34024
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
34025
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
34026
+ this.now = opts.now ?? (() => new Date);
34027
+ }
34028
+ get root() {
34029
+ return this.rootDir;
34030
+ }
34031
+ async save(filename, base642, mimeType) {
34032
+ if (base642.length > Math.ceil(this.maxBytes * 4 / 3) + 4)
34033
+ throw new Error("file-too-large");
34034
+ const bytes = Buffer.from(base642, "base64");
34035
+ if (bytes.byteLength === 0)
34036
+ throw new Error("empty-file");
34037
+ if (bytes.byteLength > this.maxBytes)
34038
+ throw new Error("file-too-large");
34039
+ const safeName = sanitizeUploadFilename(filename);
34040
+ const { mkdir: mkdir9 } = await import("node:fs/promises");
34041
+ await mkdir9(this.rootDir, { recursive: true });
34042
+ const dir = await mkdtemp2(path16.join(this.rootDir, "u-"));
34043
+ const filePath = path16.join(dir, safeName);
34044
+ await writeFile8(filePath, bytes);
34045
+ return {
34046
+ id: path16.basename(dir),
34047
+ path: filePath,
34048
+ filename: safeName,
34049
+ mimeType,
34050
+ size: bytes.byteLength
34051
+ };
34052
+ }
34053
+ async cleanup() {
34054
+ let entries;
34055
+ try {
34056
+ entries = await readdir5(this.rootDir);
34057
+ } catch {
34058
+ return 0;
34059
+ }
34060
+ const cutoff = this.now().getTime() - this.ttlMs;
34061
+ let removed = 0;
34062
+ for (const name of entries) {
34063
+ const dir = path16.join(this.rootDir, name);
34064
+ try {
34065
+ const info = await stat4(dir);
34066
+ if (info.mtimeMs < cutoff) {
34067
+ await rm10(dir, { recursive: true, force: true });
34068
+ removed += 1;
34069
+ }
34070
+ } catch {}
34071
+ }
34072
+ return removed;
34073
+ }
34074
+ }
34075
+ var DEFAULT_MAX_BYTES, DEFAULT_TTL_MS;
34076
+ var init_upload_store = __esm(() => {
34077
+ init_core_home();
34078
+ DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
34079
+ DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
34080
+ });
34081
+
33862
34082
  // src/config/agent-catalog.ts
33863
34083
  import { existsSync as existsSync3 } from "node:fs";
33864
34084
  import { delimiter as delimiter2, join as join21 } from "node:path";
33865
34085
  function isBinaryOnPath(binary) {
33866
- const path15 = process.env.PATH ?? "";
34086
+ const path17 = process.env.PATH ?? "";
33867
34087
  const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
33868
- for (const dir of path15.split(delimiter2)) {
34088
+ for (const dir of path17.split(delimiter2)) {
33869
34089
  if (!dir)
33870
34090
  continue;
33871
34091
  for (const ext of exts) {
@@ -33910,7 +34130,7 @@ __export(exports_main, {
33910
34130
  buildApp: () => buildApp
33911
34131
  });
33912
34132
  import { randomUUID as randomUUID3 } from "node:crypto";
33913
- import { homedir as homedir12 } from "node:os";
34133
+ import { homedir as homedir13 } from "node:os";
33914
34134
  import { dirname as dirname12, join as join22 } from "node:path";
33915
34135
  import { fileURLToPath as fileURLToPath5 } from "node:url";
33916
34136
  function startProgressHeartbeat(orchestration3, config4, logger2, channel) {
@@ -34402,6 +34622,9 @@ async function buildApp(paths, deps = {}) {
34402
34622
  const router3 = new CommandRouter(sessions, transport, config4, configStore, logger2, undefined, orchestration3, quota, scheduledService, deps.channel?.supportsScheduledMessages ? { supportsScheduledMessages: deps.channel.supportsScheduledMessages.bind(deps.channel) } : undefined, deps.channel?.nativeSessionListFormat ? deps.channel.nativeSessionListFormat.bind(deps.channel) : undefined, activeTurns);
34403
34623
  const agent3 = new ConsoleAgent(router3, logger2);
34404
34624
  const controlEvents = createControlEventBus(logger2);
34625
+ const uploadStore = new UploadStore;
34626
+ uploadStore.cleanup();
34627
+ const uploadCleanupInterval = setInterval(() => void uploadStore.cleanup().catch(() => {}), 60 * 60 * 1000);
34405
34628
  const control = new ControlService({
34406
34629
  agent: agent3,
34407
34630
  sessions,
@@ -34444,7 +34667,8 @@ async function buildApp(paths, deps = {}) {
34444
34667
  const updated = await configStore.removeWorkspace(name);
34445
34668
  replaceRuntimeConfig(config4, updated);
34446
34669
  }
34447
- }
34670
+ },
34671
+ uploadStore
34448
34672
  });
34449
34673
  const scheduledScheduler = new ScheduledTaskScheduler(scheduledService, {
34450
34674
  dispatchTask: buildScheduledDispatchTask({
@@ -34514,6 +34738,7 @@ async function buildApp(paths, deps = {}) {
34514
34738
  reapStaleQueueOwners: () => reapWarmQueueOwners("startup"),
34515
34739
  dispose: async () => {
34516
34740
  scheduledScheduler.stop();
34741
+ clearInterval(uploadCleanupInterval);
34517
34742
  if (progressHeartbeatInterval !== undefined) {
34518
34743
  clearInterval(progressHeartbeatInterval);
34519
34744
  }
@@ -34584,7 +34809,7 @@ async function prepareChannelMedia(configPath, config4) {
34584
34809
  return { mediaStore, channelDeps: { mediaStore, allowedMediaRoots } };
34585
34810
  }
34586
34811
  function resolveRuntimePaths() {
34587
- const home = process.env.HOME ?? homedir12();
34812
+ const home = process.env.HOME ?? homedir13();
34588
34813
  if (!home) {
34589
34814
  throw new Error("Unable to resolve the current user home directory");
34590
34815
  }
@@ -34656,6 +34881,7 @@ var init_main = __esm(async () => {
34656
34881
  init_render_text();
34657
34882
  init_quota_manager();
34658
34883
  init_control_service();
34884
+ init_upload_store();
34659
34885
  init_agent_catalog();
34660
34886
  init_perf_tracer();
34661
34887
  init_bootstrap();
@@ -34849,12 +35075,12 @@ var init_config_check = __esm(async () => {
34849
35075
  });
34850
35076
 
34851
35077
  // src/doctor/checks/daemon-check.ts
34852
- import { readdir as readdir5, readFile as readFile15, rm as rm10 } from "node:fs/promises";
35078
+ import { readdir as readdir6, readFile as readFile15, rm as rm11 } from "node:fs/promises";
34853
35079
  import { fileURLToPath as fileURLToPath6 } from "node:url";
34854
- import { homedir as homedir13 } from "node:os";
35080
+ import { homedir as homedir14 } from "node:os";
34855
35081
  import { join as join23 } from "node:path";
34856
35082
  async function checkDaemon(options = {}) {
34857
- const home = options.home ?? process.env.HOME ?? homedir13();
35083
+ const home = options.home ?? process.env.HOME ?? homedir14();
34858
35084
  const runtimeDir = options.configPath ? resolveRuntimeDirFromConfigPath(options.configPath) : undefined;
34859
35085
  const paths = (options.resolveDaemonPaths ?? resolveDaemonPaths)({
34860
35086
  home,
@@ -34988,22 +35214,22 @@ async function detectStaleConsumerLockFix(runtimeDir, deps) {
34988
35214
  }
34989
35215
  async function defaultListConsumerLocks(runtimeDir) {
34990
35216
  try {
34991
- return await readdir5(runtimeDir);
35217
+ return await readdir6(runtimeDir);
34992
35218
  } catch {
34993
35219
  return [];
34994
35220
  }
34995
35221
  }
34996
- async function defaultReadConsumerLock(path15) {
35222
+ async function defaultReadConsumerLock(path17) {
34997
35223
  try {
34998
- const raw = await readFile15(path15, "utf8");
35224
+ const raw = await readFile15(path17, "utf8");
34999
35225
  const parsed = JSON.parse(raw);
35000
35226
  return typeof parsed.pid === "number" ? { pid: parsed.pid } : null;
35001
35227
  } catch {
35002
35228
  return null;
35003
35229
  }
35004
35230
  }
35005
- async function defaultRemoveConsumerLock(path15) {
35006
- await rm10(path15, { force: true });
35231
+ async function defaultRemoveConsumerLock(path17) {
35232
+ await rm11(path17, { force: true });
35007
35233
  }
35008
35234
  function resolveCliEntryPath() {
35009
35235
  return process.argv[1] ?? fileURLToPath6(import.meta.url);
@@ -35018,11 +35244,11 @@ var init_daemon_check = __esm(() => {
35018
35244
  });
35019
35245
 
35020
35246
  // src/doctor/checks/logs-check.ts
35021
- import { stat as stat4, readdir as readdir6 } from "node:fs/promises";
35247
+ import { stat as stat5, readdir as readdir7 } from "node:fs/promises";
35022
35248
  import { basename as basename3, join as join24 } from "node:path";
35023
- import { homedir as homedir14 } from "node:os";
35249
+ import { homedir as homedir15 } from "node:os";
35024
35250
  async function checkLogs(options = {}) {
35025
- const home = options.home ?? process.env.HOME ?? homedir14();
35251
+ const home = options.home ?? process.env.HOME ?? homedir15();
35026
35252
  const runtimeDir = options.configPath ? resolveRuntimeDirFromConfigPath(options.configPath) : undefined;
35027
35253
  const paths = (options.resolveDaemonPaths ?? resolveDaemonPaths)({
35028
35254
  home,
@@ -35054,13 +35280,13 @@ async function checkLogs(options = {}) {
35054
35280
  const matched = entries.filter((entry) => isTrackedLogName(entry, tracked));
35055
35281
  const files = [];
35056
35282
  for (const name of matched) {
35057
- const path15 = join24(paths.runtimeDir, name);
35283
+ const path17 = join24(paths.runtimeDir, name);
35058
35284
  try {
35059
- const fileStat = await probe.stat(path15);
35285
+ const fileStat = await probe.stat(path17);
35060
35286
  if (fileStat.isDirectory()) {
35061
35287
  continue;
35062
35288
  }
35063
- files.push({ name, path: path15, size: fileStat.size });
35289
+ files.push({ name, path: path17, size: fileStat.size });
35064
35290
  } catch {
35065
35291
  continue;
35066
35292
  }
@@ -35134,8 +35360,8 @@ function formatBytes(bytes) {
35134
35360
  }
35135
35361
  function createLogsFsProbe() {
35136
35362
  return {
35137
- stat: async (path15) => await stat4(path15),
35138
- readdir: async (path15) => await readdir6(path15)
35363
+ stat: async (path17) => await stat5(path17),
35364
+ readdir: async (path17) => await readdir7(path17)
35139
35365
  };
35140
35366
  }
35141
35367
  function formatError6(error2) {
@@ -35204,9 +35430,9 @@ var init_orchestration_health = __esm(() => {
35204
35430
  });
35205
35431
 
35206
35432
  // src/doctor/checks/orchestration-socket-check.ts
35207
- import { homedir as homedir15 } from "node:os";
35433
+ import { homedir as homedir16 } from "node:os";
35208
35434
  async function checkOrchestrationSocket(options = {}) {
35209
- const home = options.home ?? process.env.HOME ?? homedir15();
35435
+ const home = options.home ?? process.env.HOME ?? homedir16();
35210
35436
  const runtimeDir = options.configPath ? resolveRuntimeDirFromConfigPath(options.configPath) : undefined;
35211
35437
  const paths = (options.resolveDaemonPaths ?? resolveDaemonPaths)({
35212
35438
  home,
@@ -35379,11 +35605,11 @@ var init_plugin_check = __esm(async () => {
35379
35605
 
35380
35606
  // src/doctor/checks/runtime-check.ts
35381
35607
  import { constants } from "node:fs";
35382
- import { access as access4, stat as stat5 } from "node:fs/promises";
35608
+ import { access as access4, stat as stat6 } from "node:fs/promises";
35383
35609
  import { dirname as dirname13 } from "node:path";
35384
- import { homedir as homedir16 } from "node:os";
35610
+ import { homedir as homedir17 } from "node:os";
35385
35611
  async function checkRuntime(options = {}) {
35386
- const home = options.home ?? process.env.HOME ?? homedir16();
35612
+ const home = options.home ?? process.env.HOME ?? homedir17();
35387
35613
  const runtimeDir = options.configPath ? resolveRuntimeDirFromConfigPath(options.configPath) : undefined;
35388
35614
  const paths = (options.resolveDaemonPaths ?? resolveDaemonPaths)({
35389
35615
  home,
@@ -35477,107 +35703,107 @@ function formatMode(mode) {
35477
35703
  }
35478
35704
  function createRuntimeFsProbe() {
35479
35705
  return {
35480
- stat: async (path15) => await stat5(path15),
35481
- access: async (path15, mode) => await access4(path15, mode)
35706
+ stat: async (path17) => await stat6(path17),
35707
+ access: async (path17, mode) => await access4(path17, mode)
35482
35708
  };
35483
35709
  }
35484
- async function checkDirectoryCreatable(label, path15, probe, platform) {
35710
+ async function checkDirectoryCreatable(label, path17, probe, platform) {
35485
35711
  try {
35486
- const stats = await probe.stat(path15);
35712
+ const stats = await probe.stat(path17);
35487
35713
  if (!stats.isDirectory()) {
35488
35714
  return {
35489
35715
  ok: false,
35490
- detail: `${label}: ${path15} (exists but is not a directory)`
35716
+ detail: `${label}: ${path17} (exists but is not a directory)`
35491
35717
  };
35492
35718
  }
35493
- await probe.access(path15, directoryAccessMode(platform));
35719
+ await probe.access(path17, directoryAccessMode(platform));
35494
35720
  return {
35495
35721
  ok: true,
35496
- detail: `${label}: ${path15} (writable)`
35722
+ detail: `${label}: ${path17} (writable)`
35497
35723
  };
35498
35724
  } catch (error2) {
35499
35725
  if (!isMissingPathError2(error2)) {
35500
35726
  return {
35501
35727
  ok: false,
35502
- detail: `${label}: ${path15} (unusable: ${formatError9(error2)})`
35728
+ detail: `${label}: ${path17} (unusable: ${formatError9(error2)})`
35503
35729
  };
35504
35730
  }
35505
- const parentCheck = await checkCreatableAncestorDirectory(path15, probe, platform);
35731
+ const parentCheck = await checkCreatableAncestorDirectory(path17, probe, platform);
35506
35732
  if (!parentCheck.ok) {
35507
35733
  return {
35508
35734
  ok: false,
35509
- detail: `${label}: ${path15} (parent not writable: ${parentCheck.blockingPath})`
35735
+ detail: `${label}: ${path17} (parent not writable: ${parentCheck.blockingPath})`
35510
35736
  };
35511
35737
  }
35512
35738
  return {
35513
35739
  ok: true,
35514
- detail: `${label}: ${path15} (creatable via ${parentCheck.creatableFrom})`
35740
+ detail: `${label}: ${path17} (creatable via ${parentCheck.creatableFrom})`
35515
35741
  };
35516
35742
  }
35517
35743
  }
35518
- async function checkFileCreatable(label, path15, probe, platform) {
35744
+ async function checkFileCreatable(label, path17, probe, platform) {
35519
35745
  try {
35520
- const stats = await probe.stat(path15);
35746
+ const stats = await probe.stat(path17);
35521
35747
  if (stats.isDirectory()) {
35522
35748
  return {
35523
35749
  ok: false,
35524
- detail: `${label}: ${path15} (exists but is a directory)`
35750
+ detail: `${label}: ${path17} (exists but is a directory)`
35525
35751
  };
35526
35752
  }
35527
- await probe.access(path15, constants.W_OK);
35753
+ await probe.access(path17, constants.W_OK);
35528
35754
  return {
35529
35755
  ok: true,
35530
- detail: `${label}: ${path15} (writable)`
35756
+ detail: `${label}: ${path17} (writable)`
35531
35757
  };
35532
35758
  } catch (error2) {
35533
35759
  if (!isMissingPathError2(error2)) {
35534
35760
  return {
35535
35761
  ok: false,
35536
- detail: `${label}: ${path15} (unusable: ${formatError9(error2)})`
35762
+ detail: `${label}: ${path17} (unusable: ${formatError9(error2)})`
35537
35763
  };
35538
35764
  }
35539
- const parentCheck = await checkCreatableAncestorDirectory(dirname13(path15), probe, platform);
35765
+ const parentCheck = await checkCreatableAncestorDirectory(dirname13(path17), probe, platform);
35540
35766
  if (!parentCheck.ok) {
35541
35767
  return {
35542
35768
  ok: false,
35543
- detail: `${label}: ${path15} (parent not writable: ${parentCheck.blockingPath})`
35769
+ detail: `${label}: ${path17} (parent not writable: ${parentCheck.blockingPath})`
35544
35770
  };
35545
35771
  }
35546
35772
  return {
35547
35773
  ok: true,
35548
- detail: `${label}: ${path15} (creatable via ${parentCheck.creatableFrom})`
35774
+ detail: `${label}: ${path17} (creatable via ${parentCheck.creatableFrom})`
35549
35775
  };
35550
35776
  }
35551
35777
  }
35552
- async function checkCreatableAncestorDirectory(path15, probe, platform) {
35778
+ async function checkCreatableAncestorDirectory(path17, probe, platform) {
35553
35779
  try {
35554
- const stats = await probe.stat(path15);
35780
+ const stats = await probe.stat(path17);
35555
35781
  if (!stats.isDirectory()) {
35556
35782
  return {
35557
35783
  ok: false,
35558
- creatableFrom: path15,
35559
- blockingPath: path15
35784
+ creatableFrom: path17,
35785
+ blockingPath: path17
35560
35786
  };
35561
35787
  }
35562
- await probe.access(path15, directoryAccessMode(platform));
35788
+ await probe.access(path17, directoryAccessMode(platform));
35563
35789
  return {
35564
35790
  ok: true,
35565
- creatableFrom: path15
35791
+ creatableFrom: path17
35566
35792
  };
35567
35793
  } catch (error2) {
35568
35794
  if (!isMissingPathError2(error2)) {
35569
35795
  return {
35570
35796
  ok: false,
35571
- creatableFrom: path15,
35572
- blockingPath: path15
35797
+ creatableFrom: path17,
35798
+ blockingPath: path17
35573
35799
  };
35574
35800
  }
35575
- const parent = dirname13(path15);
35576
- if (parent === path15) {
35801
+ const parent = dirname13(path17);
35802
+ if (parent === path17) {
35577
35803
  return {
35578
35804
  ok: false,
35579
- creatableFrom: path15,
35580
- blockingPath: path15
35805
+ creatableFrom: path17,
35806
+ blockingPath: path17
35581
35807
  };
35582
35808
  }
35583
35809
  const parentCheck = await checkCreatableAncestorDirectory(parent, probe, platform);
@@ -36016,10 +36242,10 @@ var init_render_doctor = __esm(() => {
36016
36242
  });
36017
36243
 
36018
36244
  // src/doctor/doctor.ts
36019
- import { homedir as homedir17 } from "node:os";
36245
+ import { homedir as homedir18 } from "node:os";
36020
36246
  import { join as join25 } from "node:path";
36021
36247
  async function runDoctor(options = {}, deps = {}) {
36022
- const home = deps.home ?? process.env.HOME ?? homedir17();
36248
+ const home = deps.home ?? process.env.HOME ?? homedir18();
36023
36249
  const runtimePaths = resolveDoctorRuntimePaths(home, deps.resolveRuntimePaths);
36024
36250
  const sharedLoadConfig = createSharedLoadConfig(runtimePaths, deps.loadConfig ?? loadConfig);
36025
36251
  const runners = [
@@ -36356,7 +36582,7 @@ var init_doctor2 = __esm(async () => {
36356
36582
  // src/cli.ts
36357
36583
  init_core_home();
36358
36584
  import { randomUUID as randomUUID4 } from "node:crypto";
36359
- import { homedir as homedir18 } from "node:os";
36585
+ import { homedir as homedir19 } from "node:os";
36360
36586
  import { dirname as dirname14, join as join26, sep as sep2 } from "node:path";
36361
36587
  import { fileURLToPath as fileURLToPath7 } from "node:url";
36362
36588
 
@@ -52962,7 +53188,7 @@ function decodeFirstRunOnboarding(raw) {
52962
53188
  return null;
52963
53189
  }
52964
53190
  function requireHome2() {
52965
- const home = process.env.HOME ?? homedir18();
53191
+ const home = process.env.HOME ?? homedir19();
52966
53192
  if (!home) {
52967
53193
  throw new Error("Unable to resolve the current user home directory");
52968
53194
  }
@@ -1,5 +1,5 @@
1
1
  import type { CommandRouterContext, RouterResponse, SessionInteractionOps, SessionLifecycleOps, SessionRenderRecoveryOps } from "../router-types";
2
- import type { PromptMediaInput, ResolvedSession } from "../../transport/types";
2
+ import type { AgentCommand, PromptMediaInput, PromptUsage, ResolvedSession } from "../../transport/types";
3
3
  import type { PlanEntry, ToolUseEvent } from "../../channels/types.js";
4
4
  import type { PerfSpan } from "../../perf/perf-tracer";
5
5
  import type { HelpTopicMetadata } from "../help/help-types";
@@ -39,11 +39,5 @@ export declare function handleSessionReset(context: SessionHandlerContext, chatK
39
39
  export declare function handleSessionTail(context: SessionHandlerContext, chatKey: string, lines?: number): Promise<RouterResponse>;
40
40
  export declare function handleSessionRemove(context: SessionHandlerContext, chatKey: string, alias: string): Promise<RouterResponse>;
41
41
  export declare function handleSessionArchive(context: SessionHandlerContext, chatKey: string, alias: string, archive: (internalAlias: string) => Promise<void>): Promise<RouterResponse>;
42
- export declare function handlePromptWithSession(context: SessionHandlerContext, session: ResolvedSession, chatKey: string, text: string, reply?: (text: string) => Promise<void>, replyContextToken?: string, accountId?: string, media?: PromptMediaInput, abortSignal?: AbortSignal, onToolEvent?: (event: ToolUseEvent) => void | Promise<void>, onThought?: (chunk: string) => void | Promise<void>, perfSpan?: PerfSpan, metadata?: ChatRequestMetadata, onPlan?: (entries: PlanEntry[]) => void | Promise<void>, onUsage?: (usage: {
43
- used: number;
44
- size: number;
45
- }) => void | Promise<void>): Promise<RouterResponse>;
46
- export declare function handlePrompt(context: SessionHandlerContext, chatKey: string, text: string, reply?: (text: string) => Promise<void>, replyContextToken?: string, accountId?: string, media?: PromptMediaInput, abortSignal?: AbortSignal, onToolEvent?: (event: ToolUseEvent) => void | Promise<void>, onThought?: (chunk: string) => void | Promise<void>, perfSpan?: PerfSpan, metadata?: ChatRequestMetadata, onPlan?: (entries: PlanEntry[]) => void | Promise<void>, onUsage?: (usage: {
47
- used: number;
48
- size: number;
49
- }) => void | Promise<void>): Promise<RouterResponse>;
42
+ export declare function handlePromptWithSession(context: SessionHandlerContext, session: ResolvedSession, chatKey: string, text: string, reply?: (text: string) => Promise<void>, replyContextToken?: string, accountId?: string, media?: PromptMediaInput, abortSignal?: AbortSignal, onToolEvent?: (event: ToolUseEvent) => void | Promise<void>, onThought?: (chunk: string) => void | Promise<void>, perfSpan?: PerfSpan, metadata?: ChatRequestMetadata, onPlan?: (entries: PlanEntry[]) => void | Promise<void>, onUsage?: (usage: PromptUsage) => void | Promise<void>, onCommands?: (commands: AgentCommand[]) => void | Promise<void>): Promise<RouterResponse>;
43
+ export declare function handlePrompt(context: SessionHandlerContext, chatKey: string, text: string, reply?: (text: string) => Promise<void>, replyContextToken?: string, accountId?: string, media?: PromptMediaInput, abortSignal?: AbortSignal, onToolEvent?: (event: ToolUseEvent) => void | Promise<void>, onThought?: (chunk: string) => void | Promise<void>, perfSpan?: PerfSpan, metadata?: ChatRequestMetadata, onPlan?: (entries: PlanEntry[]) => void | Promise<void>, onUsage?: (usage: PromptUsage) => void | Promise<void>, onCommands?: (commands: AgentCommand[]) => void | Promise<void>): Promise<RouterResponse>;
@@ -3,7 +3,7 @@ import type { AppConfig } from "../config/types";
3
3
  import type { AppLogger } from "../logging/app-logger";
4
4
  import type { OrchestrationService } from "../orchestration/orchestration-service";
5
5
  import type { SessionService } from "../sessions/session-service";
6
- import type { PromptMediaInput, ReplyQuotaContext, SessionTransport } from "../transport/types";
6
+ import type { AgentCommand, PromptMediaInput, PromptUsage, ReplyQuotaContext, SessionTransport } from "../transport/types";
7
7
  import type { QuotaManager } from "../weixin/messaging/quota-manager.js";
8
8
  import type { PlanEntry, ToolUseEvent } from "../channels/types.js";
9
9
  import type { PerfSpan } from "../perf/perf-tracer";
@@ -113,10 +113,7 @@ export interface SessionInteractionOps {
113
113
  cancelled: boolean;
114
114
  message: string;
115
115
  }>;
116
- promptTransportSession: (session: import("../transport/types").ResolvedSession, text: string, reply?: (text: string) => Promise<void>, replyContext?: ReplyQuotaContext, media?: PromptMediaInput, abortSignal?: AbortSignal, onToolEvent?: (event: ToolUseEvent) => void | Promise<void>, onThought?: (chunk: string) => void | Promise<void>, perfSpan?: PerfSpan, onPlan?: (entries: PlanEntry[]) => void | Promise<void>, onUsage?: (usage: {
117
- used: number;
118
- size: number;
119
- }) => void | Promise<void>) => Promise<{
116
+ promptTransportSession: (session: import("../transport/types").ResolvedSession, text: string, reply?: (text: string) => Promise<void>, replyContext?: ReplyQuotaContext, media?: PromptMediaInput, abortSignal?: AbortSignal, onToolEvent?: (event: ToolUseEvent) => void | Promise<void>, onThought?: (chunk: string) => void | Promise<void>, perfSpan?: PerfSpan, onPlan?: (entries: PlanEntry[]) => void | Promise<void>, onUsage?: (usage: PromptUsage) => void | Promise<void>, onCommands?: (commands: AgentCommand[]) => void | Promise<void>) => Promise<{
120
117
  text: string;
121
118
  }>;
122
119
  }
@@ -1,5 +1,6 @@
1
1
  import type { AppLogger } from "../logging/app-logger";
2
2
  import type { ToolUseEvent, PlanEntry } from "../channels/types";
3
+ import type { AgentCommand, UsageBreakdown, UsageCost } from "../transport/types";
3
4
  import type { NativeHistoryMessage } from "../transport/native-session-history";
4
5
  export interface ScheduledOrigin {
5
6
  taskId: string;
@@ -37,6 +38,13 @@ export type ControlEvent = {
37
38
  sessionAlias: string;
38
39
  used: number;
39
40
  size: number;
41
+ cost?: UsageCost;
42
+ breakdown?: UsageBreakdown;
43
+ } | {
44
+ type: "agent-commands";
45
+ chatKey: string;
46
+ sessionAlias: string;
47
+ commands: AgentCommand[];
40
48
  } | {
41
49
  type: "turn-finished";
42
50
  chatKey: string;
@@ -9,6 +9,8 @@ import type { OrchestrationTaskRecord } from "../orchestration/orchestration-typ
9
9
  import type { ControlEventBus } from "./control-event-bus";
10
10
  import type { AgentCatalogEntry } from "../config/agent-catalog";
11
11
  import { type DirListing, type FileContent, type SearchResult, type WorkspaceDiff } from "./workspace-fs";
12
+ import type { PromptAttachmentRef } from "@ganglion/xacpx-relay-protocol";
13
+ import type { UploadStore } from "./upload-store.js";
12
14
  export interface ControlSessionInfo {
13
15
  alias: string;
14
16
  agent: string;
@@ -63,6 +65,7 @@ export interface ControlServiceDeps {
63
65
  create(name: string, cwd: string, description?: string): Promise<ControlWorkspaceInfo>;
64
66
  remove(name: string): Promise<void>;
65
67
  };
68
+ uploadStore: UploadStore;
66
69
  }
67
70
  export interface ControlPromptInput {
68
71
  chatKey: string;
@@ -71,6 +74,7 @@ export interface ControlPromptInput {
71
74
  accountId?: string;
72
75
  senderId: string;
73
76
  isOwner?: boolean;
77
+ media?: PromptAttachmentRef[];
74
78
  }
75
79
  export interface ControlPromptResult {
76
80
  ok: boolean;
@@ -105,6 +109,17 @@ export declare class ControlService {
105
109
  readWorkspaceFile(workspace: string, path: string): Promise<FileContent>;
106
110
  workspaceGitDiff(workspace: string, path?: string): Promise<WorkspaceDiff>;
107
111
  searchWorkspace(workspace: string, query: string): Promise<SearchResult>;
112
+ uploadFile(input: {
113
+ filename: string;
114
+ content: string;
115
+ mimeType: string;
116
+ }): Promise<{
117
+ id: string;
118
+ path: string;
119
+ filename: string;
120
+ mimeType: string;
121
+ size: number;
122
+ }>;
108
123
  /** Read a session's current model and the agent-advertised available ids. */
109
124
  getSessionModel(chatKey: string, alias: string): Promise<{
110
125
  current?: string;
@@ -0,0 +1,28 @@
1
+ export interface UploadStoreOptions {
2
+ rootDir?: string;
3
+ maxBytes?: number;
4
+ ttlMs?: number;
5
+ now?: () => Date;
6
+ }
7
+ export interface SavedUpload {
8
+ id: string;
9
+ path: string;
10
+ filename: string;
11
+ mimeType: string;
12
+ size: number;
13
+ }
14
+ /** Strip directory components and traversal segments, leaving a safe basename. */
15
+ export declare function sanitizeUploadFilename(raw: string): string;
16
+ export declare class UploadStore {
17
+ private readonly rootDir;
18
+ private readonly maxBytes;
19
+ private readonly ttlMs;
20
+ private readonly now;
21
+ constructor(opts?: UploadStoreOptions);
22
+ /** Sandbox root all uploads are written under. Callers may use this to verify a
23
+ * media filePath actually originated from a control.upload (defense-in-depth). */
24
+ get root(): string;
25
+ save(filename: string, base64: string, mimeType: string): Promise<SavedUpload>;
26
+ /** Remove upload dirs whose mtime is older than the TTL. Returns count removed. */
27
+ cleanup(): Promise<number>;
28
+ }
@@ -3,6 +3,37 @@ import type { QuotaManager } from "../weixin/messaging/quota-manager.js";
3
3
  import type { PlanEntry, ToolUseEvent } from "../channels/types.js";
4
4
  import type { ToolEventMode } from "./tool-event-mode.js";
5
5
  export type { ToolEventMode } from "./tool-event-mode.js";
6
+ /** Cumulative session cost the agent reported (ACP `usage_update.cost`). Both optional. */
7
+ export interface UsageCost {
8
+ amount?: number;
9
+ currency?: string;
10
+ }
11
+ /**
12
+ * Per-turn token breakdown from ACP `usage_update._meta.usage` (Claude reports it;
13
+ * codex may omit). All fields optional — treat missing as "unknown", not zero.
14
+ */
15
+ export interface UsageBreakdown {
16
+ inputTokens?: number;
17
+ outputTokens?: number;
18
+ cachedReadTokens?: number;
19
+ cachedWriteTokens?: number;
20
+ thoughtTokens?: number;
21
+ totalTokens?: number;
22
+ }
23
+ /** Context-usage side-channel payload: window fill plus optional cost & token breakdown. */
24
+ export interface PromptUsage {
25
+ used: number;
26
+ size: number;
27
+ cost?: UsageCost;
28
+ breakdown?: UsageBreakdown;
29
+ }
30
+ /** An agent-advertised slash command (ACP `available_commands_update`). */
31
+ export interface AgentCommand {
32
+ name: string;
33
+ description?: string;
34
+ /** Whether the command accepts an argument (ACP advertised a non-null `input`). */
35
+ hasInput?: boolean;
36
+ }
6
37
  export interface ReplyQuotaContext {
7
38
  chatKey: string;
8
39
  quota: QuotaManager;
@@ -125,10 +156,12 @@ export interface PromptOptions {
125
156
  * scalar (re-sent during a turn). Optional — only agents that report it fire this
126
157
  * (e.g. claude does, codex does not), and text channels omit the handler.
127
158
  */
128
- onUsage?: (usage: {
129
- used: number;
130
- size: number;
131
- }) => void | Promise<void>;
159
+ onUsage?: (usage: PromptUsage) => void | Promise<void>;
160
+ /**
161
+ * Agent-advertised slash commands (ACP `available_commands_update`). Replace-latest
162
+ * list, re-sent when the agent updates it. Optional — not every adapter advertises.
163
+ */
164
+ onCommands?: (commands: AgentCommand[]) => void | Promise<void>;
132
165
  /**
133
166
  * How tool_call / tool_call_update events are surfaced for this prompt.
134
167
  *
@@ -1,5 +1,6 @@
1
1
  import type { ChannelMediaAttachment, OutboundChannelMedia } from "../../channels/media-types.js";
2
2
  import type { PlanEntry, ScheduledSessionDescriptor, ToolUseEvent } from "../../channels/types.js";
3
+ import type { AgentCommand, PromptUsage } from "../../transport/types.js";
3
4
  import type { PerfSpan } from "../../perf/perf-tracer.js";
4
5
  /**
5
6
  * Agent interface — any AI backend that can handle a chat message.
@@ -51,10 +52,9 @@ export interface ChatRequest {
51
52
  /** Structured plan/todo side-channel; see PromptOptions.onPlan. */
52
53
  onPlan?: (entries: PlanEntry[]) => void | Promise<void>;
53
54
  /** Context-usage side-channel; see PromptOptions.onUsage. */
54
- onUsage?: (usage: {
55
- used: number;
56
- size: number;
57
- }) => void | Promise<void>;
55
+ onUsage?: (usage: PromptUsage) => void | Promise<void>;
56
+ /** Agent-advertised slash commands; see PromptOptions.onCommands. */
57
+ onCommands?: (commands: AgentCommand[]) => void | Promise<void>;
58
58
  /**
59
59
  * Optional per-turn performance tracing span. When `logging.perf.enabled` is
60
60
  * true, the channel handler attaches a `PerfSpan` so downstream layers can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganglion/xacpx",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "随时随地通过聊天频道(微信 / 飞书 / 元宝等)远程控制 `acpx` 上的 Claude Code、Codex 等 Agents。",
5
5
  "keywords": [
6
6
  "acpx",
@@ -85,7 +85,7 @@
85
85
  },
86
86
  "dependencies": {
87
87
  "@modelcontextprotocol/sdk": "^1.29.0",
88
- "acpx": "^0.9.0",
88
+ "acpx": "^0.11.0",
89
89
  "node-pty": "^1.1.0",
90
90
  "proper-lockfile": "^4.1.2",
91
91
  "protobufjs": "^7.5.6",