@ganglion/xacpx 0.10.1 → 0.12.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
@@ -331,6 +331,7 @@ These templates only write `driver`; the actual launch command is resolved by ac
331
331
  | `/use <alias>` | Switch the current session |
332
332
  | `/status` | Show the current session status |
333
333
  | `/mode` / `/mode <id>` | View or set the underlying `acpx` mode |
334
+ | `/model` / `/model <id>` | View or switch the session's LLM model (also `/session new --model`, `/agent add --model`) |
334
335
  | `/replymode` | Show the current reply mode |
335
336
  | `/replymode stream` | Streaming replies |
336
337
  | `/replymode verbose` | Streaming + tool-call summaries |
@@ -514,6 +515,25 @@ take a look at today's API timeout issue
514
515
 
515
516
  For more filtering, aliases, and troubleshooting, see [docs/native-sessions.md](./docs/native-sessions.md).
516
517
 
518
+ ## Self-hosted relay hub (optional)
519
+
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
+
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.
523
+
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
528
+
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
533
+ ```
534
+
535
+ 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
+
517
537
  ## Config and runtime files
518
538
 
519
539
  Default file locations:
@@ -64,6 +64,14 @@ function encodeBridgePromptThoughtEvent(event) {
64
64
  return `${JSON.stringify(event)}
65
65
  `;
66
66
  }
67
+ function encodeBridgePromptPlanEvent(event) {
68
+ return `${JSON.stringify(event)}
69
+ `;
70
+ }
71
+ function encodeBridgePromptUsageEvent(event) {
72
+ return `${JSON.stringify(event)}
73
+ `;
74
+ }
67
75
  function encodeBridgeSessionProgressEvent(event) {
68
76
  return `${JSON.stringify(event)}
69
77
  `;
@@ -419,6 +427,9 @@ function createStreamingPromptState(formatToolCalls = false, options) {
419
427
  let toolEventMode;
420
428
  let onToolEvent;
421
429
  let onThought;
430
+ let onPlan;
431
+ let onUsage;
432
+ let rawStream = false;
422
433
  if (options === undefined) {
423
434
  toolEventMode = "text";
424
435
  onToolEvent = undefined;
@@ -428,6 +439,9 @@ function createStreamingPromptState(formatToolCalls = false, options) {
428
439
  } else {
429
440
  onToolEvent = options.onToolEvent;
430
441
  onThought = options.onThought;
442
+ onPlan = options.onPlan;
443
+ onUsage = options.onUsage;
444
+ rawStream = options.rawStream ?? false;
431
445
  toolEventMode = resolveToolEventMode({
432
446
  toolEventMode: options.mode,
433
447
  onToolEvent
@@ -441,13 +455,16 @@ function createStreamingPromptState(formatToolCalls = false, options) {
441
455
  formatToolCalls,
442
456
  emittedToolCallIds: new Set,
443
457
  toolEventMode,
458
+ rawStream,
444
459
  onToolEvent,
445
460
  onThought,
461
+ onPlan,
462
+ onUsage,
446
463
  finalize() {
447
464
  if (this.pendingLine.trim().length > 0) {
448
465
  parseStreamingChunks(this, this.pendingLine);
449
466
  }
450
- const remaining = this.buffer.trim();
467
+ const remaining = this.rawStream ? this.buffer : this.buffer.trim();
451
468
  this.buffer = "";
452
469
  this.pendingLine = "";
453
470
  return remaining;
@@ -479,9 +496,9 @@ function parseStreamingChunks(state, line) {
479
496
  const update = event.params?.update;
480
497
  if (!update)
481
498
  return;
482
- if (state.formatToolCalls && (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update")) {
499
+ if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
483
500
  const wantsStructured = state.toolEventMode === "structured" || state.toolEventMode === "both";
484
- const wantsText = state.toolEventMode === "text" || state.toolEventMode === "both";
501
+ const wantsText = (state.toolEventMode === "text" || state.toolEventMode === "both") && state.formatToolCalls;
485
502
  if (wantsStructured && state.onToolEvent) {
486
503
  const toolEvent = buildToolUseEvent(update);
487
504
  if (toolEvent)
@@ -501,6 +518,19 @@ function parseStreamingChunks(state, line) {
501
518
  }
502
519
  return;
503
520
  }
521
+ if (update.sessionUpdate === "plan") {
522
+ const entries = Array.isArray(update.entries) ? update.entries.filter((x) => !!x && typeof x === "object" && typeof x.content === "string" && typeof x.status === "string") : [];
523
+ if (entries.length > 0)
524
+ state.onPlan?.(entries);
525
+ return;
526
+ }
527
+ if (update.sessionUpdate === "usage_update") {
528
+ const used = typeof update.used === "number" && Number.isFinite(update.used) ? update.used : undefined;
529
+ 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 });
532
+ return;
533
+ }
504
534
  const isThoughtChunk = update.sessionUpdate === "agent_thought_chunk" && update.content?.type === "text" && typeof update.content.text === "string";
505
535
  if (isThoughtChunk) {
506
536
  const chunk2 = update.content.text;
@@ -517,6 +547,8 @@ function parseStreamingChunks(state, line) {
517
547
  if (chunk.length === 0)
518
548
  return;
519
549
  state.buffer += chunk;
550
+ if (state.rawStream)
551
+ return;
520
552
  let boundary;
521
553
  while ((boundary = state.buffer.indexOf(`
522
554
 
@@ -882,6 +914,14 @@ var init_session = __esm(() => {
882
914
  modeModeLabel: (modeId) => `- mode: ${modeId}`,
883
915
  modeNotSet: "not set",
884
916
  modeSet: (modeId) => `Current session mode set to: ${modeId}`,
917
+ modelHeader: "Current model:",
918
+ modelSessionLabel: (alias) => `- Session: ${alias}`,
919
+ modelModelLabel: (modelId) => `- model: ${modelId}`,
920
+ modelNotSet: "not set (using agent default)",
921
+ modelAvailableLabel: (models) => `- available: ${models}`,
922
+ modelSet: (modelId) => `Current session model switched to: ${modelId}`,
923
+ modelSetFailed: (modelId, detail) => `Failed to switch model: ${modelId}
924
+ ${detail}`,
885
925
  replyModeHeader: "Current reply mode:",
886
926
  replyModeSessionLabel: (alias) => `- Session: ${alias}`,
887
927
  replyModeGlobalDefault: (value) => `- Global default: ${value}`,
@@ -958,6 +998,11 @@ var init_session = __esm(() => {
958
998
  modeHelpCmdShowDesc: "Show the saved mode of the current session",
959
999
  modeHelpCmdSet: "/mode <id>",
960
1000
  modeHelpCmdSetDesc: "Set the current session mode",
1001
+ modelHelpSummary: "View or switch the LLM model for the current session.",
1002
+ modelHelpCmdShow: "/model",
1003
+ modelHelpCmdShowDesc: "Show the current session model and the available ones",
1004
+ modelHelpCmdSet: "/model <id>",
1005
+ modelHelpCmdSetDesc: "Switch the current session model (e.g. gpt-5.2[high])",
961
1006
  replyModeHelpSummary: "View or set the reply output mode for the current logical session.",
962
1007
  replyModeHelpCmdShow: "/replymode",
963
1008
  replyModeHelpCmdShowDesc: "Show global default, current override, and effective value",
@@ -1961,6 +2006,14 @@ var init_session2 = __esm(() => {
1961
2006
  modeModeLabel: (modeId) => `- mode:${modeId}`,
1962
2007
  modeNotSet: "未设置",
1963
2008
  modeSet: (modeId) => `已设置当前会话 mode:${modeId}`,
2009
+ modelHeader: "当前 model:",
2010
+ modelSessionLabel: (alias) => `- 会话:${alias}`,
2011
+ modelModelLabel: (modelId) => `- model:${modelId}`,
2012
+ modelNotSet: "未设置(使用 agent 默认)",
2013
+ modelAvailableLabel: (models) => `- 可选:${models}`,
2014
+ modelSet: (modelId) => `已切换当前会话 model:${modelId}`,
2015
+ modelSetFailed: (modelId, detail) => `切换 model 失败:${modelId}
2016
+ ${detail}`,
1964
2017
  replyModeHeader: "当前 reply mode:",
1965
2018
  replyModeSessionLabel: (alias) => `- 会话:${alias}`,
1966
2019
  replyModeGlobalDefault: (value) => `- 全局默认:${value}`,
@@ -2037,6 +2090,11 @@ var init_session2 = __esm(() => {
2037
2090
  modeHelpCmdShowDesc: "查看当前会话已保存的 mode",
2038
2091
  modeHelpCmdSet: "/mode <id>",
2039
2092
  modeHelpCmdSetDesc: "设置当前会话 mode",
2093
+ modelHelpSummary: "查看或切换当前会话的 LLM model。",
2094
+ modelHelpCmdShow: "/model",
2095
+ modelHelpCmdShowDesc: "查看当前会话 model 及可选项",
2096
+ modelHelpCmdSet: "/model <id>",
2097
+ modelHelpCmdSetDesc: "切换当前会话 model(如 gpt-5.2[high])",
2040
2098
  replyModeHelpSummary: "查看或设置当前逻辑会话的回复输出模式。",
2041
2099
  replyModeHelpCmdShow: "/replymode",
2042
2100
  replyModeHelpCmdShowDesc: "查看全局默认、当前覆盖和实际生效值",
@@ -3123,6 +3181,7 @@ class AcpxQueueOwnerLauncher {
3123
3181
  nonInteractivePermissions: input.nonInteractivePermissions,
3124
3182
  ttlMs: this.ttlMs,
3125
3183
  maxQueueDepth: this.maxQueueDepth,
3184
+ ...input.sessionOptions ? { sessionOptions: input.sessionOptions } : {},
3126
3185
  mcpServers: [buildXacpxMcpServerSpec({
3127
3186
  xacpxCommand: this.xacpxCommand,
3128
3187
  coordinatorSession: input.coordinatorSession,
@@ -3473,6 +3532,10 @@ class CommandTimeoutError extends Error {
3473
3532
  this.name = "CommandTimeoutError";
3474
3533
  }
3475
3534
  }
3535
+ function modelArgs(model) {
3536
+ const trimmed = model?.trim();
3537
+ return trimmed ? ["--model", trimmed] : [];
3538
+ }
3476
3539
 
3477
3540
  class BridgeRuntime {
3478
3541
  command;
@@ -3682,11 +3745,13 @@ class BridgeRuntime {
3682
3745
  ...structuredPrompt ? ["--file", structuredPrompt.filePath] : [input.text]
3683
3746
  ]));
3684
3747
  const formatToolCalls = (input.replyMode ?? "verbose") === "verbose";
3748
+ const rawStream = input.replyMode === "stream";
3685
3749
  const toolEventMode = input.toolEventMode ?? (input.toolEvents === true ? "structured" : "text");
3686
3750
  try {
3687
3751
  const result = onEvent ? await this.runPromptCommand(spawnSpec.command, spawnSpec.args, onEvent, {
3688
3752
  formatToolCalls,
3689
- toolEventMode
3753
+ toolEventMode,
3754
+ rawStream
3690
3755
  }) : await this.run(spawnSpec.command, spawnSpec.args);
3691
3756
  return { text: getPromptText(result) };
3692
3757
  } finally {
@@ -3755,6 +3820,40 @@ class BridgeRuntime {
3755
3820
  }
3756
3821
  return {};
3757
3822
  }
3823
+ async setModel(input) {
3824
+ const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs({ ...input, model: input.modelId }, [
3825
+ "set",
3826
+ "-s",
3827
+ input.name,
3828
+ "model",
3829
+ input.modelId
3830
+ ]));
3831
+ const result = await this.run(spawnSpec.command, spawnSpec.args);
3832
+ if (result.code !== 0) {
3833
+ throw new Error(result.stderr || result.stdout || "set-model failed");
3834
+ }
3835
+ return {};
3836
+ }
3837
+ async getSessionModel(input) {
3838
+ const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, [
3839
+ "status",
3840
+ "-s",
3841
+ input.name
3842
+ ], { format: "json" }));
3843
+ const result = await this.run(spawnSpec.command, spawnSpec.args);
3844
+ if (result.code !== 0) {
3845
+ throw new Error(result.stderr || result.stdout || "status failed");
3846
+ }
3847
+ try {
3848
+ const json = JSON.parse(result.stdout);
3849
+ return {
3850
+ current: typeof json.model === "string" ? json.model : undefined,
3851
+ available: Array.isArray(json.availableModels) ? json.availableModels.filter((m) => typeof m === "string") : []
3852
+ };
3853
+ } catch {
3854
+ return { available: [] };
3855
+ }
3856
+ }
3758
3857
  async cancel(input) {
3759
3858
  const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, [
3760
3859
  "cancel",
@@ -3794,7 +3893,8 @@ class BridgeRuntime {
3794
3893
  options.format ?? "quiet",
3795
3894
  "--cwd",
3796
3895
  input.cwd,
3797
- ...this.buildPermissionArgs()
3896
+ ...this.buildPermissionArgs(),
3897
+ ...modelArgs(input.model)
3798
3898
  ];
3799
3899
  if (options.verbose) {
3800
3900
  prefix.push("--verbose");
@@ -3812,6 +3912,7 @@ class BridgeRuntime {
3812
3912
  "--cwd",
3813
3913
  input.cwd,
3814
3914
  ...this.buildPermissionArgs(),
3915
+ ...modelArgs(input.model),
3815
3916
  ...this.buildQueueOwnerTtlArgs()
3816
3917
  ];
3817
3918
  if (input.agentCommand) {
@@ -3888,8 +3989,9 @@ async function runStreamingPrompt(command, args, onEvent, options = {}) {
3888
3989
  const spawnPrompt = options.spawnPrompt ?? ((spawnCommand, spawnArgs) => spawn4(spawnCommand, spawnArgs, { stdio: ["ignore", "pipe", "pipe"] }));
3889
3990
  const setIntervalFn = options.setIntervalFn ?? ((fn, delay) => setInterval(fn, delay));
3890
3991
  const clearIntervalFn = options.clearIntervalFn ?? ((timer) => clearInterval(timer));
3891
- const maxSegmentWaitMs = options.maxSegmentWaitMs ?? 30000;
3892
- const flushCheckIntervalMs = options.flushCheckIntervalMs ?? 5000;
3992
+ const rawStream = options.rawStream ?? false;
3993
+ const maxSegmentWaitMs = options.maxSegmentWaitMs ?? (rawStream ? 200 : 30000);
3994
+ const flushCheckIntervalMs = options.flushCheckIntervalMs ?? (rawStream ? 80 : 5000);
3893
3995
  const now = options.now ?? (() => Date.now());
3894
3996
  return await new Promise((resolve, reject) => {
3895
3997
  const child = spawnPrompt(command, args);
@@ -3898,12 +4000,15 @@ async function runStreamingPrompt(command, args, onEvent, options = {}) {
3898
4000
  const toolEventMode = options.toolEventMode ?? "text";
3899
4001
  const state = createStreamingPromptState(options.formatToolCalls ?? false, {
3900
4002
  mode: toolEventMode,
4003
+ rawStream,
3901
4004
  ...onEvent && (toolEventMode === "structured" || toolEventMode === "both") ? { onToolEvent: (toolEvent) => onEvent({ type: "prompt.tool_event", event: toolEvent }) } : {},
3902
- ...onEvent ? { onThought: (chunk) => onEvent({ type: "prompt.thought", text: chunk }) } : {}
4005
+ ...onEvent ? { onThought: (chunk) => onEvent({ type: "prompt.thought", text: chunk }) } : {},
4006
+ ...onEvent ? { onPlan: (entries) => onEvent({ type: "prompt.plan", entries }) } : {},
4007
+ ...onEvent ? { onUsage: (usage) => onEvent({ type: "prompt.usage", used: usage.used, size: usage.size }) } : {}
3903
4008
  });
3904
4009
  let lastReplyAt = now();
3905
4010
  const flushBuffer = () => {
3906
- const remaining = state.buffer.trim();
4011
+ const remaining = rawStream ? state.buffer : state.buffer.trim();
3907
4012
  if (remaining.length > 0) {
3908
4013
  state.buffer = "";
3909
4014
  onEvent?.({ type: "prompt.segment", text: remaining });
@@ -4020,6 +4125,8 @@ var BRIDGE_METHODS = new Set([
4020
4125
  "resumeAgentSession",
4021
4126
  "prompt",
4022
4127
  "setMode",
4128
+ "setModel",
4129
+ "getSessionModel",
4023
4130
  "cancel",
4024
4131
  "removeSession",
4025
4132
  "getAgentSessionId"
@@ -4031,6 +4138,8 @@ var SESSION_SCOPED_METHODS = new Set([
4031
4138
  "resumeAgentSession",
4032
4139
  "prompt",
4033
4140
  "setMode",
4141
+ "setModel",
4142
+ "getSessionModel",
4034
4143
  "cancel",
4035
4144
  "removeSession",
4036
4145
  "getAgentSessionId"
@@ -4127,6 +4236,7 @@ class BridgeServer {
4127
4236
  agentCommand: asOptionalString(params.agentCommand),
4128
4237
  cwd: requireString(params, "cwd"),
4129
4238
  name: requireString(params, "name"),
4239
+ model: asOptionalString(params.model),
4130
4240
  mcpCoordinatorSession: asOptionalString(params.mcpCoordinatorSession),
4131
4241
  mcpSourceHandle: asOptionalString(params.mcpSourceHandle)
4132
4242
  }, (progress) => {
@@ -4152,6 +4262,7 @@ class BridgeServer {
4152
4262
  agentCommand: asOptionalString(params.agentCommand),
4153
4263
  cwd: requireString(params, "cwd"),
4154
4264
  name: requireString(params, "name"),
4265
+ model: asOptionalString(params.model),
4155
4266
  mcpCoordinatorSession: asOptionalString(params.mcpCoordinatorSession),
4156
4267
  mcpSourceHandle: asOptionalString(params.mcpSourceHandle),
4157
4268
  text: requirePromptText(params, media),
@@ -4178,6 +4289,19 @@ class BridgeServer {
4178
4289
  event: "prompt.thought",
4179
4290
  text: event.text
4180
4291
  }));
4292
+ } else if (event.type === "prompt.plan") {
4293
+ writeLine?.(encodeBridgePromptPlanEvent({
4294
+ id: requestId,
4295
+ event: "prompt.plan",
4296
+ entries: event.entries
4297
+ }));
4298
+ } else if (event.type === "prompt.usage") {
4299
+ writeLine?.(encodeBridgePromptUsageEvent({
4300
+ id: requestId,
4301
+ event: "prompt.usage",
4302
+ used: event.used,
4303
+ size: event.size
4304
+ }));
4181
4305
  }
4182
4306
  });
4183
4307
  case "resumeAgentSession":
@@ -4196,6 +4320,21 @@ class BridgeServer {
4196
4320
  name: requireString(params, "name"),
4197
4321
  modeId: requireString(params, "modeId")
4198
4322
  });
4323
+ case "setModel":
4324
+ return await this.runtime.setModel({
4325
+ agent: requireString(params, "agent"),
4326
+ agentCommand: asOptionalString(params.agentCommand),
4327
+ cwd: requireString(params, "cwd"),
4328
+ name: requireString(params, "name"),
4329
+ modelId: requireString(params, "modelId")
4330
+ });
4331
+ case "getSessionModel":
4332
+ return await this.runtime.getSessionModel({
4333
+ agent: requireString(params, "agent"),
4334
+ agentCommand: asOptionalString(params.agentCommand),
4335
+ cwd: requireString(params, "cwd"),
4336
+ name: requireString(params, "name")
4337
+ });
4199
4338
  case "cancel":
4200
4339
  return await this.runtime.cancel({
4201
4340
  agent: requireString(params, "agent"),
@@ -7,6 +7,7 @@ import type { PerfTracer } from "../perf/perf-tracer.js";
7
7
  import type { SessionService } from "../sessions/session-service.js";
8
8
  import type { ActiveTurnRegistry } from "../sessions/active-turn-registry.js";
9
9
  import type { Locale } from "../i18n/index.js";
10
+ import type { ControlService } from "../control/control-service.js";
10
11
  export type { ChatAgent };
11
12
  export interface OutboundQuota {
12
13
  onInbound(chatKey: string): void;
@@ -44,6 +45,7 @@ export interface ScheduledChannelMessageInput {
44
45
  sessionDescriptor?: ScheduledSessionDescriptor;
45
46
  accountId?: string;
46
47
  replyContextToken?: string;
48
+ executeAt?: string;
47
49
  noticeText: string;
48
50
  promptText: string;
49
51
  abortSignal?: AbortSignal;
@@ -71,6 +73,12 @@ export interface ChannelStartInput {
71
73
  * the active locale via `getLocale()` from `xacpx/plugin-api`.
72
74
  */
73
75
  locale?: Locale;
76
+ /**
77
+ * Structured control facade (sessions / prompt / scheduler / orchestration)
78
+ * for structured consumers such as the relay connector. Optional: text-only
79
+ * channels ignore it.
80
+ */
81
+ control?: ControlService;
74
82
  }
75
83
  export interface OrchestrationDeliveryCallbacks {
76
84
  markTaskNoticeDelivered: (taskId: string, accountId: string) => Promise<void>;
@@ -140,3 +148,11 @@ export interface ToolUseEvent {
140
148
  /** Set when status transitions out of "running". */
141
149
  durationMs?: number;
142
150
  }
151
+ export type PlanEntryStatus = "pending" | "in_progress" | "completed";
152
+ /** One entry of the agent's ACP `plan` (its live todo list). The agent re-sends the
153
+ * WHOLE list on each update, so consumers REPLACE rather than append. */
154
+ export interface PlanEntry {
155
+ content: string;
156
+ status: PlanEntryStatus;
157
+ priority?: "high" | "medium" | "low";
158
+ }