@ganglion/xacpx 0.10.0 → 0.11.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
@@ -258,6 +258,7 @@ xacpx doctor
258
258
  xacpx doctor --verbose
259
259
  xacpx doctor --smoke
260
260
  xacpx doctor --smoke --agent codex --workspace backend
261
+ xacpx doctor --fix
261
262
  ```
262
263
 
263
264
  Notes:
@@ -266,6 +267,7 @@ Notes:
266
267
  - `--smoke` additionally runs a minimal real transport-level prompt check
267
268
  - `--agent` / `--workspace` only affect `--smoke`
268
269
  - Without `--smoke`, the related checks show as `SKIP`
270
+ - `--fix` applies safe local repairs (runtime dir permissions, stale locks, invalid state records) and re-checks; state-mutating repairs are withheld while the daemon runs — see [docs/doctor-command.md](docs/doctor-command.md)
269
271
 
270
272
  ### How to use `update`
271
273
 
@@ -329,6 +331,7 @@ These templates only write `driver`; the actual launch command is resolved by ac
329
331
  | `/use <alias>` | Switch the current session |
330
332
  | `/status` | Show the current session status |
331
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`) |
332
335
  | `/replymode` | Show the current reply mode |
333
336
  | `/replymode stream` | Streaming replies |
334
337
  | `/replymode verbose` | Streaming + tool-call summaries |
@@ -512,6 +515,25 @@ take a look at today's API timeout issue
512
515
 
513
516
  For more filtering, aliases, and troubleshooting, see [docs/native-sessions.md](./docs/native-sessions.md).
514
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
+
515
537
  ## Config and runtime files
516
538
 
517
539
  Default file locations:
@@ -64,6 +64,10 @@ function encodeBridgePromptThoughtEvent(event) {
64
64
  return `${JSON.stringify(event)}
65
65
  `;
66
66
  }
67
+ function encodeBridgePromptPlanEvent(event) {
68
+ return `${JSON.stringify(event)}
69
+ `;
70
+ }
67
71
  function encodeBridgeSessionProgressEvent(event) {
68
72
  return `${JSON.stringify(event)}
69
73
  `;
@@ -419,6 +423,8 @@ function createStreamingPromptState(formatToolCalls = false, options) {
419
423
  let toolEventMode;
420
424
  let onToolEvent;
421
425
  let onThought;
426
+ let onPlan;
427
+ let rawStream = false;
422
428
  if (options === undefined) {
423
429
  toolEventMode = "text";
424
430
  onToolEvent = undefined;
@@ -428,6 +434,8 @@ function createStreamingPromptState(formatToolCalls = false, options) {
428
434
  } else {
429
435
  onToolEvent = options.onToolEvent;
430
436
  onThought = options.onThought;
437
+ onPlan = options.onPlan;
438
+ rawStream = options.rawStream ?? false;
431
439
  toolEventMode = resolveToolEventMode({
432
440
  toolEventMode: options.mode,
433
441
  onToolEvent
@@ -441,13 +449,15 @@ function createStreamingPromptState(formatToolCalls = false, options) {
441
449
  formatToolCalls,
442
450
  emittedToolCallIds: new Set,
443
451
  toolEventMode,
452
+ rawStream,
444
453
  onToolEvent,
445
454
  onThought,
455
+ onPlan,
446
456
  finalize() {
447
457
  if (this.pendingLine.trim().length > 0) {
448
458
  parseStreamingChunks(this, this.pendingLine);
449
459
  }
450
- const remaining = this.buffer.trim();
460
+ const remaining = this.rawStream ? this.buffer : this.buffer.trim();
451
461
  this.buffer = "";
452
462
  this.pendingLine = "";
453
463
  return remaining;
@@ -479,9 +489,9 @@ function parseStreamingChunks(state, line) {
479
489
  const update = event.params?.update;
480
490
  if (!update)
481
491
  return;
482
- if (state.formatToolCalls && (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update")) {
492
+ if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
483
493
  const wantsStructured = state.toolEventMode === "structured" || state.toolEventMode === "both";
484
- const wantsText = state.toolEventMode === "text" || state.toolEventMode === "both";
494
+ const wantsText = (state.toolEventMode === "text" || state.toolEventMode === "both") && state.formatToolCalls;
485
495
  if (wantsStructured && state.onToolEvent) {
486
496
  const toolEvent = buildToolUseEvent(update);
487
497
  if (toolEvent)
@@ -501,6 +511,12 @@ function parseStreamingChunks(state, line) {
501
511
  }
502
512
  return;
503
513
  }
514
+ if (update.sessionUpdate === "plan") {
515
+ const entries = Array.isArray(update.entries) ? update.entries.filter((x) => !!x && typeof x === "object" && typeof x.content === "string" && typeof x.status === "string") : [];
516
+ if (entries.length > 0)
517
+ state.onPlan?.(entries);
518
+ return;
519
+ }
504
520
  const isThoughtChunk = update.sessionUpdate === "agent_thought_chunk" && update.content?.type === "text" && typeof update.content.text === "string";
505
521
  if (isThoughtChunk) {
506
522
  const chunk2 = update.content.text;
@@ -517,6 +533,8 @@ function parseStreamingChunks(state, line) {
517
533
  if (chunk.length === 0)
518
534
  return;
519
535
  state.buffer += chunk;
536
+ if (state.rawStream)
537
+ return;
520
538
  let boundary;
521
539
  while ((boundary = state.buffer.indexOf(`
522
540
 
@@ -882,6 +900,14 @@ var init_session = __esm(() => {
882
900
  modeModeLabel: (modeId) => `- mode: ${modeId}`,
883
901
  modeNotSet: "not set",
884
902
  modeSet: (modeId) => `Current session mode set to: ${modeId}`,
903
+ modelHeader: "Current model:",
904
+ modelSessionLabel: (alias) => `- Session: ${alias}`,
905
+ modelModelLabel: (modelId) => `- model: ${modelId}`,
906
+ modelNotSet: "not set (using agent default)",
907
+ modelAvailableLabel: (models) => `- available: ${models}`,
908
+ modelSet: (modelId) => `Current session model switched to: ${modelId}`,
909
+ modelSetFailed: (modelId, detail) => `Failed to switch model: ${modelId}
910
+ ${detail}`,
885
911
  replyModeHeader: "Current reply mode:",
886
912
  replyModeSessionLabel: (alias) => `- Session: ${alias}`,
887
913
  replyModeGlobalDefault: (value) => `- Global default: ${value}`,
@@ -958,6 +984,11 @@ var init_session = __esm(() => {
958
984
  modeHelpCmdShowDesc: "Show the saved mode of the current session",
959
985
  modeHelpCmdSet: "/mode <id>",
960
986
  modeHelpCmdSetDesc: "Set the current session mode",
987
+ modelHelpSummary: "View or switch the LLM model for the current session.",
988
+ modelHelpCmdShow: "/model",
989
+ modelHelpCmdShowDesc: "Show the current session model and the available ones",
990
+ modelHelpCmdSet: "/model <id>",
991
+ modelHelpCmdSetDesc: "Switch the current session model (e.g. gpt-5.2[high])",
961
992
  replyModeHelpSummary: "View or set the reply output mode for the current logical session.",
962
993
  replyModeHelpCmdShow: "/replymode",
963
994
  replyModeHelpCmdShowDesc: "Show global default, current override, and effective value",
@@ -1961,6 +1992,14 @@ var init_session2 = __esm(() => {
1961
1992
  modeModeLabel: (modeId) => `- mode:${modeId}`,
1962
1993
  modeNotSet: "未设置",
1963
1994
  modeSet: (modeId) => `已设置当前会话 mode:${modeId}`,
1995
+ modelHeader: "当前 model:",
1996
+ modelSessionLabel: (alias) => `- 会话:${alias}`,
1997
+ modelModelLabel: (modelId) => `- model:${modelId}`,
1998
+ modelNotSet: "未设置(使用 agent 默认)",
1999
+ modelAvailableLabel: (models) => `- 可选:${models}`,
2000
+ modelSet: (modelId) => `已切换当前会话 model:${modelId}`,
2001
+ modelSetFailed: (modelId, detail) => `切换 model 失败:${modelId}
2002
+ ${detail}`,
1964
2003
  replyModeHeader: "当前 reply mode:",
1965
2004
  replyModeSessionLabel: (alias) => `- 会话:${alias}`,
1966
2005
  replyModeGlobalDefault: (value) => `- 全局默认:${value}`,
@@ -2037,6 +2076,11 @@ var init_session2 = __esm(() => {
2037
2076
  modeHelpCmdShowDesc: "查看当前会话已保存的 mode",
2038
2077
  modeHelpCmdSet: "/mode <id>",
2039
2078
  modeHelpCmdSetDesc: "设置当前会话 mode",
2079
+ modelHelpSummary: "查看或切换当前会话的 LLM model。",
2080
+ modelHelpCmdShow: "/model",
2081
+ modelHelpCmdShowDesc: "查看当前会话 model 及可选项",
2082
+ modelHelpCmdSet: "/model <id>",
2083
+ modelHelpCmdSetDesc: "切换当前会话 model(如 gpt-5.2[high])",
2040
2084
  replyModeHelpSummary: "查看或设置当前逻辑会话的回复输出模式。",
2041
2085
  replyModeHelpCmdShow: "/replymode",
2042
2086
  replyModeHelpCmdShowDesc: "查看全局默认、当前覆盖和实际生效值",
@@ -3123,6 +3167,7 @@ class AcpxQueueOwnerLauncher {
3123
3167
  nonInteractivePermissions: input.nonInteractivePermissions,
3124
3168
  ttlMs: this.ttlMs,
3125
3169
  maxQueueDepth: this.maxQueueDepth,
3170
+ ...input.sessionOptions ? { sessionOptions: input.sessionOptions } : {},
3126
3171
  mcpServers: [buildXacpxMcpServerSpec({
3127
3172
  xacpxCommand: this.xacpxCommand,
3128
3173
  coordinatorSession: input.coordinatorSession,
@@ -3473,6 +3518,10 @@ class CommandTimeoutError extends Error {
3473
3518
  this.name = "CommandTimeoutError";
3474
3519
  }
3475
3520
  }
3521
+ function modelArgs(model) {
3522
+ const trimmed = model?.trim();
3523
+ return trimmed ? ["--model", trimmed] : [];
3524
+ }
3476
3525
 
3477
3526
  class BridgeRuntime {
3478
3527
  command;
@@ -3682,11 +3731,13 @@ class BridgeRuntime {
3682
3731
  ...structuredPrompt ? ["--file", structuredPrompt.filePath] : [input.text]
3683
3732
  ]));
3684
3733
  const formatToolCalls = (input.replyMode ?? "verbose") === "verbose";
3734
+ const rawStream = input.replyMode === "stream";
3685
3735
  const toolEventMode = input.toolEventMode ?? (input.toolEvents === true ? "structured" : "text");
3686
3736
  try {
3687
3737
  const result = onEvent ? await this.runPromptCommand(spawnSpec.command, spawnSpec.args, onEvent, {
3688
3738
  formatToolCalls,
3689
- toolEventMode
3739
+ toolEventMode,
3740
+ rawStream
3690
3741
  }) : await this.run(spawnSpec.command, spawnSpec.args);
3691
3742
  return { text: getPromptText(result) };
3692
3743
  } finally {
@@ -3755,6 +3806,40 @@ class BridgeRuntime {
3755
3806
  }
3756
3807
  return {};
3757
3808
  }
3809
+ async setModel(input) {
3810
+ const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs({ ...input, model: input.modelId }, [
3811
+ "set",
3812
+ "-s",
3813
+ input.name,
3814
+ "model",
3815
+ input.modelId
3816
+ ]));
3817
+ const result = await this.run(spawnSpec.command, spawnSpec.args);
3818
+ if (result.code !== 0) {
3819
+ throw new Error(result.stderr || result.stdout || "set-model failed");
3820
+ }
3821
+ return {};
3822
+ }
3823
+ async getSessionModel(input) {
3824
+ const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, [
3825
+ "status",
3826
+ "-s",
3827
+ input.name
3828
+ ], { format: "json" }));
3829
+ const result = await this.run(spawnSpec.command, spawnSpec.args);
3830
+ if (result.code !== 0) {
3831
+ throw new Error(result.stderr || result.stdout || "status failed");
3832
+ }
3833
+ try {
3834
+ const json = JSON.parse(result.stdout);
3835
+ return {
3836
+ current: typeof json.model === "string" ? json.model : undefined,
3837
+ available: Array.isArray(json.availableModels) ? json.availableModels.filter((m) => typeof m === "string") : []
3838
+ };
3839
+ } catch {
3840
+ return { available: [] };
3841
+ }
3842
+ }
3758
3843
  async cancel(input) {
3759
3844
  const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, [
3760
3845
  "cancel",
@@ -3794,7 +3879,8 @@ class BridgeRuntime {
3794
3879
  options.format ?? "quiet",
3795
3880
  "--cwd",
3796
3881
  input.cwd,
3797
- ...this.buildPermissionArgs()
3882
+ ...this.buildPermissionArgs(),
3883
+ ...modelArgs(input.model)
3798
3884
  ];
3799
3885
  if (options.verbose) {
3800
3886
  prefix.push("--verbose");
@@ -3812,6 +3898,7 @@ class BridgeRuntime {
3812
3898
  "--cwd",
3813
3899
  input.cwd,
3814
3900
  ...this.buildPermissionArgs(),
3901
+ ...modelArgs(input.model),
3815
3902
  ...this.buildQueueOwnerTtlArgs()
3816
3903
  ];
3817
3904
  if (input.agentCommand) {
@@ -3888,8 +3975,9 @@ async function runStreamingPrompt(command, args, onEvent, options = {}) {
3888
3975
  const spawnPrompt = options.spawnPrompt ?? ((spawnCommand, spawnArgs) => spawn4(spawnCommand, spawnArgs, { stdio: ["ignore", "pipe", "pipe"] }));
3889
3976
  const setIntervalFn = options.setIntervalFn ?? ((fn, delay) => setInterval(fn, delay));
3890
3977
  const clearIntervalFn = options.clearIntervalFn ?? ((timer) => clearInterval(timer));
3891
- const maxSegmentWaitMs = options.maxSegmentWaitMs ?? 30000;
3892
- const flushCheckIntervalMs = options.flushCheckIntervalMs ?? 5000;
3978
+ const rawStream = options.rawStream ?? false;
3979
+ const maxSegmentWaitMs = options.maxSegmentWaitMs ?? (rawStream ? 200 : 30000);
3980
+ const flushCheckIntervalMs = options.flushCheckIntervalMs ?? (rawStream ? 80 : 5000);
3893
3981
  const now = options.now ?? (() => Date.now());
3894
3982
  return await new Promise((resolve, reject) => {
3895
3983
  const child = spawnPrompt(command, args);
@@ -3898,12 +3986,14 @@ async function runStreamingPrompt(command, args, onEvent, options = {}) {
3898
3986
  const toolEventMode = options.toolEventMode ?? "text";
3899
3987
  const state = createStreamingPromptState(options.formatToolCalls ?? false, {
3900
3988
  mode: toolEventMode,
3989
+ rawStream,
3901
3990
  ...onEvent && (toolEventMode === "structured" || toolEventMode === "both") ? { onToolEvent: (toolEvent) => onEvent({ type: "prompt.tool_event", event: toolEvent }) } : {},
3902
- ...onEvent ? { onThought: (chunk) => onEvent({ type: "prompt.thought", text: chunk }) } : {}
3991
+ ...onEvent ? { onThought: (chunk) => onEvent({ type: "prompt.thought", text: chunk }) } : {},
3992
+ ...onEvent ? { onPlan: (entries) => onEvent({ type: "prompt.plan", entries }) } : {}
3903
3993
  });
3904
3994
  let lastReplyAt = now();
3905
3995
  const flushBuffer = () => {
3906
- const remaining = state.buffer.trim();
3996
+ const remaining = rawStream ? state.buffer : state.buffer.trim();
3907
3997
  if (remaining.length > 0) {
3908
3998
  state.buffer = "";
3909
3999
  onEvent?.({ type: "prompt.segment", text: remaining });
@@ -4020,6 +4110,8 @@ var BRIDGE_METHODS = new Set([
4020
4110
  "resumeAgentSession",
4021
4111
  "prompt",
4022
4112
  "setMode",
4113
+ "setModel",
4114
+ "getSessionModel",
4023
4115
  "cancel",
4024
4116
  "removeSession",
4025
4117
  "getAgentSessionId"
@@ -4031,6 +4123,8 @@ var SESSION_SCOPED_METHODS = new Set([
4031
4123
  "resumeAgentSession",
4032
4124
  "prompt",
4033
4125
  "setMode",
4126
+ "setModel",
4127
+ "getSessionModel",
4034
4128
  "cancel",
4035
4129
  "removeSession",
4036
4130
  "getAgentSessionId"
@@ -4127,6 +4221,7 @@ class BridgeServer {
4127
4221
  agentCommand: asOptionalString(params.agentCommand),
4128
4222
  cwd: requireString(params, "cwd"),
4129
4223
  name: requireString(params, "name"),
4224
+ model: asOptionalString(params.model),
4130
4225
  mcpCoordinatorSession: asOptionalString(params.mcpCoordinatorSession),
4131
4226
  mcpSourceHandle: asOptionalString(params.mcpSourceHandle)
4132
4227
  }, (progress) => {
@@ -4152,6 +4247,7 @@ class BridgeServer {
4152
4247
  agentCommand: asOptionalString(params.agentCommand),
4153
4248
  cwd: requireString(params, "cwd"),
4154
4249
  name: requireString(params, "name"),
4250
+ model: asOptionalString(params.model),
4155
4251
  mcpCoordinatorSession: asOptionalString(params.mcpCoordinatorSession),
4156
4252
  mcpSourceHandle: asOptionalString(params.mcpSourceHandle),
4157
4253
  text: requirePromptText(params, media),
@@ -4178,6 +4274,12 @@ class BridgeServer {
4178
4274
  event: "prompt.thought",
4179
4275
  text: event.text
4180
4276
  }));
4277
+ } else if (event.type === "prompt.plan") {
4278
+ writeLine?.(encodeBridgePromptPlanEvent({
4279
+ id: requestId,
4280
+ event: "prompt.plan",
4281
+ entries: event.entries
4282
+ }));
4181
4283
  }
4182
4284
  });
4183
4285
  case "resumeAgentSession":
@@ -4196,6 +4298,21 @@ class BridgeServer {
4196
4298
  name: requireString(params, "name"),
4197
4299
  modeId: requireString(params, "modeId")
4198
4300
  });
4301
+ case "setModel":
4302
+ return await this.runtime.setModel({
4303
+ agent: requireString(params, "agent"),
4304
+ agentCommand: asOptionalString(params.agentCommand),
4305
+ cwd: requireString(params, "cwd"),
4306
+ name: requireString(params, "name"),
4307
+ modelId: requireString(params, "modelId")
4308
+ });
4309
+ case "getSessionModel":
4310
+ return await this.runtime.getSessionModel({
4311
+ agent: requireString(params, "agent"),
4312
+ agentCommand: asOptionalString(params.agentCommand),
4313
+ cwd: requireString(params, "cwd"),
4314
+ name: requireString(params, "name")
4315
+ });
4199
4316
  case "cancel":
4200
4317
  return await this.runtime.cancel({
4201
4318
  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
+ }