@ganglion/xacpx 0.12.1 → 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
@@ -940,6 +1011,7 @@ ${detail}`,
940
1011
  sessionBlockedByTasks: (alias, count) => `Session "${alias}" has ${count} unfinished task(s). Cancel or wait for them to complete first.`,
941
1012
  sessionBlockedByTasksHint: "Use /tasks to list tasks, or /task cancel <id> to cancel one.",
942
1013
  sessionRemoved: (alias) => `Session "${alias}" removed.`,
1014
+ sessionArchived: (alias) => `Archived session "${alias}". Send a message to restore it.`,
943
1015
  sessionRemovedWasActive: "This was the active session. Its chat context has been cleared.",
944
1016
  sessionRemovedWasActivePromoted: (alias) => `This was the active session. Switched back to the previous session "${alias}".`,
945
1017
  sessionTransportShared: (transportSession, count) => `Note: backend session "${transportSession}" is still referenced by ${count} other session(s) and was not closed.`,
@@ -2035,6 +2107,7 @@ ${detail}`,
2035
2107
  sessionBlockedByTasks: (alias, count) => `会话「${alias}」下还有 ${count} 个未结束的任务,请先取消或等待完成。`,
2036
2108
  sessionBlockedByTasksHint: "使用 /tasks 查看任务列表,或 /task cancel <id> 取消任务。",
2037
2109
  sessionRemoved: (alias) => `已删除会话「${alias}」。`,
2110
+ sessionArchived: (alias) => `已归档会话「${alias}」。发送消息即可恢复。`,
2038
2111
  sessionRemovedWasActive: "该会话是当前活跃会话,已自动清除相关聊天上下文。",
2039
2112
  sessionRemovedWasActivePromoted: (alias) => `该会话是当前活跃会话,已切换回上一个会话「${alias}」。`,
2040
2113
  sessionTransportShared: (transportSession, count) => `提示:后端会话「${transportSession}」仍被其他 ${count} 个会话引用,未关闭。`,
@@ -3424,6 +3497,31 @@ var init_agent_session_list = __esm(() => {
3424
3497
  init_path();
3425
3498
  });
3426
3499
 
3500
+ // src/transport/acpx-session-files.ts
3501
+ import { readdir, unlink as unlink2 } from "node:fs/promises";
3502
+ import { homedir as homedir4 } from "node:os";
3503
+ import { join as join3 } from "node:path";
3504
+ async function deleteAcpxSessionFiles(options) {
3505
+ const dir = options.sessionsDir ?? join3(homedir4(), ".acpx", "sessions");
3506
+ const safeId = encodeURIComponent(options.acpxRecordId);
3507
+ await unlink2(join3(dir, `${safeId}.json`)).catch(() => {
3508
+ return;
3509
+ });
3510
+ let entries;
3511
+ try {
3512
+ entries = await readdir(dir);
3513
+ } catch {
3514
+ return;
3515
+ }
3516
+ const streamFiles = entries.filter((name) => name.startsWith(`${safeId}.stream.`));
3517
+ for (const name of streamFiles) {
3518
+ await unlink2(join3(dir, name)).catch(() => {
3519
+ return;
3520
+ });
3521
+ }
3522
+ }
3523
+ var init_acpx_session_files = () => {};
3524
+
3427
3525
  // src/bridge/bridge-main.ts
3428
3526
  import { createInterface } from "node:readline";
3429
3527
 
@@ -3496,9 +3594,9 @@ init_terminate_process_tree();
3496
3594
  init_prompt_output();
3497
3595
  init_prompt_media();
3498
3596
  init_streaming_prompt();
3499
- import { copyFile, readdir } from "node:fs/promises";
3500
- import { homedir as homedir4 } from "node:os";
3501
- import { dirname as dirname2, join as join3, win32 } from "node:path";
3597
+ import { copyFile, readdir as readdir2 } from "node:fs/promises";
3598
+ import { homedir as homedir5 } from "node:os";
3599
+ import { dirname as dirname2, join as join4, win32 } from "node:path";
3502
3600
  import { spawn as spawn4 } from "node:child_process";
3503
3601
 
3504
3602
  // src/bridge/parse-missing-optional-dep.ts
@@ -3518,6 +3616,7 @@ function parseMissingOptionalDep(text) {
3518
3616
  init_discover_parent_package_paths();
3519
3617
  init_acpx_queue_owner_launcher();
3520
3618
  init_agent_session_list();
3619
+ init_acpx_session_files();
3521
3620
  class EnsureSessionFailedError extends Error {
3522
3621
  kind;
3523
3622
  data;
@@ -3798,7 +3897,7 @@ class BridgeRuntime {
3798
3897
  acpxRecordId = parsed.id;
3799
3898
  }
3800
3899
  const agentSessionId = typeof parsed.agentSessionId === "string" ? parsed.agentSessionId : undefined;
3801
- if (acpxRecordId) {
3900
+ if (acpxRecordId && /^[\w.:-]+$/.test(acpxRecordId) && acpxRecordId.length >= 8) {
3802
3901
  return { acpxRecordId, agentSessionId };
3803
3902
  }
3804
3903
  } catch {
@@ -3890,6 +3989,17 @@ class BridgeRuntime {
3890
3989
  }
3891
3990
  throw new Error(result.stderr || result.stdout || "sessions close failed");
3892
3991
  }
3992
+ async deleteSession(input) {
3993
+ let acpxRecordId;
3994
+ try {
3995
+ ({ acpxRecordId } = await this.readSessionRecord(input));
3996
+ } catch {
3997
+ return {};
3998
+ }
3999
+ await this.removeSession(input);
4000
+ await deleteAcpxSessionFiles({ acpxRecordId });
4001
+ return {};
4002
+ }
3893
4003
  async shutdown() {
3894
4004
  return {};
3895
4005
  }
@@ -4010,7 +4120,8 @@ async function runStreamingPrompt(command, args, onEvent, options = {}) {
4010
4120
  ...onEvent && (toolEventMode === "structured" || toolEventMode === "both") ? { onToolEvent: (toolEvent) => onEvent({ type: "prompt.tool_event", event: toolEvent }) } : {},
4011
4121
  ...onEvent ? { onThought: (chunk) => onEvent({ type: "prompt.thought", text: chunk }) } : {},
4012
4122
  ...onEvent ? { onPlan: (entries) => onEvent({ type: "prompt.plan", entries }) } : {},
4013
- ...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 }) } : {}
4014
4125
  });
4015
4126
  let lastReplyAt = now();
4016
4127
  const flushBuffer = () => {
@@ -4080,14 +4191,14 @@ async function tryRepairAcpxSessionIndex(deps = {}) {
4080
4191
  if (platform !== "win32") {
4081
4192
  return false;
4082
4193
  }
4083
- const home = deps.home ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir4();
4194
+ const home = deps.home ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir5();
4084
4195
  if (!home) {
4085
4196
  return false;
4086
4197
  }
4087
- const pathJoin = platform === "win32" ? win32.join : join3;
4198
+ const pathJoin = platform === "win32" ? win32.join : join4;
4088
4199
  const sessionsDir = pathJoin(home, ".acpx", "sessions");
4089
4200
  const indexPath = pathJoin(sessionsDir, "index.json");
4090
- const readdirFn = deps.readdirFn ?? readdir;
4201
+ const readdirFn = deps.readdirFn ?? readdir2;
4091
4202
  const copyFileFn = deps.copyFileFn ?? copyFile;
4092
4203
  let files;
4093
4204
  try {
@@ -4135,6 +4246,7 @@ var BRIDGE_METHODS = new Set([
4135
4246
  "getSessionModel",
4136
4247
  "cancel",
4137
4248
  "removeSession",
4249
+ "deleteSession",
4138
4250
  "getAgentSessionId"
4139
4251
  ]);
4140
4252
  var SESSION_SCOPED_METHODS = new Set([
@@ -4148,6 +4260,7 @@ var SESSION_SCOPED_METHODS = new Set([
4148
4260
  "getSessionModel",
4149
4261
  "cancel",
4150
4262
  "removeSession",
4263
+ "deleteSession",
4151
4264
  "getAgentSessionId"
4152
4265
  ]);
4153
4266
 
@@ -4306,7 +4419,15 @@ class BridgeServer {
4306
4419
  id: requestId,
4307
4420
  event: "prompt.usage",
4308
4421
  used: event.used,
4309
- 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
4310
4431
  }));
4311
4432
  }
4312
4433
  });
@@ -4355,6 +4476,13 @@ class BridgeServer {
4355
4476
  cwd: requireString(params, "cwd"),
4356
4477
  name: requireString(params, "name")
4357
4478
  });
4479
+ case "deleteSession":
4480
+ return await this.runtime.deleteSession({
4481
+ agent: requireString(params, "agent"),
4482
+ agentCommand: asOptionalString(params.agentCommand),
4483
+ cwd: requireString(params, "cwd"),
4484
+ name: requireString(params, "name")
4485
+ });
4358
4486
  case "getAgentSessionId":
4359
4487
  return await this.runtime.getAgentSessionId({
4360
4488
  agent: requireString(params, "agent"),