@ganglion/xacpx-channel-relay 0.1.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 ADDED
@@ -0,0 +1,8 @@
1
+ # @ganglion/xacpx-channel-relay
2
+
3
+ Connector channel plugin: dials out from a local xacpx instance to a
4
+ self-hosted @ganglion/xacpx-relay hub over WebSocket.
5
+
6
+ Pairing: `xacpx channel add relay --url ws://<relay-host>:8788 --token <pairing-token>`.
7
+ On first connect the pairing token is exchanged for a long-lived instance
8
+ credential stored at `<xacpx-home>/relay/credential.json` (never in config.json).
@@ -0,0 +1,40 @@
1
+ import type { ChannelStartInput, CoordinatorMessageInput, MessageChannelRuntime, ScheduledChannelMessageInput } from "xacpx/plugin-api";
2
+ import { type RelayCredential } from "./credential-store.js";
3
+ import { type RelayClientOptions } from "./relay-client.js";
4
+ type OrchestrationTaskRecord = Parameters<MessageChannelRuntime["notifyTaskCompletion"]>[0];
5
+ interface CredentialStoreLike {
6
+ load(): RelayCredential | null;
7
+ save(credential: RelayCredential): void;
8
+ clear(): void;
9
+ }
10
+ interface RelayClientLike {
11
+ start(abortSignal: AbortSignal): void;
12
+ stop(): void;
13
+ sendEvent(type: string, payload: unknown): void;
14
+ }
15
+ export interface RelayChannelDeps {
16
+ credentialStore?: CredentialStoreLike;
17
+ createClient?: (options: RelayClientOptions) => RelayClientLike;
18
+ }
19
+ export declare class RelayChannel implements MessageChannelRuntime {
20
+ private readonly deps;
21
+ readonly id = "relay";
22
+ readonly nativeSessionListFormat: "table";
23
+ private readonly config;
24
+ private readonly credentials;
25
+ private client;
26
+ private unsubscribe;
27
+ private control;
28
+ constructor(options: Record<string, unknown> | undefined, deps?: RelayChannelDeps);
29
+ isLoggedIn(): boolean;
30
+ login(): Promise<string>;
31
+ logout(): void;
32
+ start(input: ChannelStartInput): Promise<void>;
33
+ stop(): void;
34
+ notifyTaskCompletion(task: OrchestrationTaskRecord): Promise<void>;
35
+ notifyTaskProgress(task: OrchestrationTaskRecord, text: string): Promise<void>;
36
+ sendCoordinatorMessage(input: CoordinatorMessageInput): Promise<void>;
37
+ sendScheduledMessage(input: ScheduledChannelMessageInput): Promise<void>;
38
+ private sendNotice;
39
+ }
40
+ export {};
@@ -0,0 +1,21 @@
1
+ export interface RelayChannelConfig {
2
+ url: string;
3
+ pairingToken?: string;
4
+ name?: string;
5
+ }
6
+ /**
7
+ * Normalize a relay URL input into a full ws(s):// URL.
8
+ *
9
+ * Rules:
10
+ * - Trim. Empty → "".
11
+ * - Already ws:// or wss:// (case-insensitive) → return as-is.
12
+ * - http:// → ws://, https:// → wss:// (map scheme, keep the rest).
13
+ * - No scheme → parse host[:port]:
14
+ * - Explicit numeric port → ws://<host>:<port>.
15
+ * - IPv4 literal, "localhost", or bracketed IPv6 → ws://<host>:8788.
16
+ * - Domain name → wss://<host> (production TLS, no port appended).
17
+ *
18
+ * Note: bare unbracketed IPv6 (e.g. "::1") is not supported; use "[::1]" instead.
19
+ */
20
+ export declare function normalizeRelayUrl(input: string): string;
21
+ export declare function parseRelayChannelConfig(options: Record<string, unknown> | undefined): RelayChannelConfig;
@@ -0,0 +1,7 @@
1
+ import { type OrchestrationTaskDto, type RelayEnvelope, type ScheduledTaskDto } from "@ganglion/xacpx-relay-protocol";
2
+ import type { ControlService } from "xacpx/plugin-api";
3
+ export declare function scheduledTaskToDto(record: ReturnType<ControlService["listScheduledTasks"]>[number]): ScheduledTaskDto;
4
+ export declare function orchestrationTaskToDto(record: Awaited<ReturnType<ControlService["listOrchestrationTasks"]>>[number]): OrchestrationTaskDto;
5
+ export type ControlBridge = (envelope: RelayEnvelope, respond: (payload: unknown) => void) => void;
6
+ export declare function createControlBridge(control: ControlService): ControlBridge;
7
+ export declare function subscribeControlEvents(control: ControlService, sendEvent: (type: string, payload: unknown) => void): () => void;
@@ -0,0 +1,13 @@
1
+ export interface RelayCredential {
2
+ instanceId: string;
3
+ credential: string;
4
+ relayUrl: string;
5
+ }
6
+ export declare function defaultCredentialPath(): string;
7
+ export declare class CredentialStore {
8
+ private readonly filePath;
9
+ constructor(filePath: string);
10
+ load(): RelayCredential | null;
11
+ save(credential: RelayCredential): void;
12
+ clear(): void;
13
+ }
@@ -0,0 +1,5 @@
1
+ import type { XacpxPlugin } from "xacpx/plugin-api";
2
+ export { RelayChannel } from "./channel.js";
3
+ export { relayCliProvider } from "./relay-provider.js";
4
+ declare const plugin: XacpxPlugin;
5
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,799 @@
1
+ // packages/channel-relay/src/channel.ts
2
+ import {
3
+ MSG as MSG3
4
+ } from "@ganglion/xacpx-relay-protocol";
5
+
6
+ // packages/channel-relay/src/config.ts
7
+ function normalizeRelayUrl(input) {
8
+ const trimmed = input.trim();
9
+ if (!trimmed)
10
+ return "";
11
+ if (/^wss?:\/\//i.test(trimmed))
12
+ return trimmed;
13
+ if (/^http:\/\//i.test(trimmed))
14
+ return "ws://" + trimmed.slice("http://".length);
15
+ if (/^https:\/\//i.test(trimmed))
16
+ return "wss://" + trimmed.slice("https://".length);
17
+ const s = trimmed;
18
+ const bracketMatch = s.match(/^\[([^\]]+)\](?::(\d+))?$/);
19
+ if (bracketMatch) {
20
+ const ipv6 = bracketMatch[1];
21
+ const port2 = bracketMatch[2];
22
+ if (port2)
23
+ return `ws://[${ipv6}]:${port2}`;
24
+ return `ws://[${ipv6}]:8788`;
25
+ }
26
+ let host = s;
27
+ let port;
28
+ const idx = s.lastIndexOf(":");
29
+ if (idx > 0 && /^\d+$/.test(s.slice(idx + 1))) {
30
+ host = s.slice(0, idx);
31
+ port = s.slice(idx + 1);
32
+ }
33
+ if (port) {
34
+ return `ws://${host}:${port}`;
35
+ }
36
+ const isIPv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
37
+ if (isIPv4 || host === "localhost") {
38
+ return `ws://${host}:8788`;
39
+ }
40
+ return `wss://${host}`;
41
+ }
42
+ function parseRelayChannelConfig(options) {
43
+ const raw = typeof options?.url === "string" ? options.url : "";
44
+ const url = normalizeRelayUrl(raw);
45
+ if (!url) {
46
+ throw new Error("relay channel requires options.url (a domain, IP, or IP:port / ws(s):// address)");
47
+ }
48
+ if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
49
+ throw new Error(`relay channel options.url must resolve to ws:// or wss://, got: ${raw}`);
50
+ }
51
+ const config = { url };
52
+ if (typeof options?.pairingToken === "string" && options.pairingToken.trim()) {
53
+ config.pairingToken = options.pairingToken.trim();
54
+ }
55
+ if (typeof options?.name === "string" && options.name.trim()) {
56
+ config.name = options.name.trim();
57
+ }
58
+ return config;
59
+ }
60
+
61
+ // packages/channel-relay/src/credential-store.ts
62
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
63
+ import { homedir } from "node:os";
64
+ import { dirname, join } from "node:path";
65
+ import { coreHomeDir } from "xacpx/plugin-api";
66
+ function defaultCredentialPath() {
67
+ return join(coreHomeDir(process.env.HOME ?? homedir()), "relay", "credential.json");
68
+ }
69
+
70
+ class CredentialStore {
71
+ filePath;
72
+ constructor(filePath) {
73
+ this.filePath = filePath;
74
+ }
75
+ load() {
76
+ try {
77
+ const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
78
+ if (typeof parsed.instanceId === "string" && typeof parsed.credential === "string" && typeof parsed.relayUrl === "string") {
79
+ return { instanceId: parsed.instanceId, credential: parsed.credential, relayUrl: parsed.relayUrl };
80
+ }
81
+ return null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ save(credential) {
87
+ mkdirSync(dirname(this.filePath), { recursive: true });
88
+ const tmp = `${this.filePath}.tmp`;
89
+ writeFileSync(tmp, JSON.stringify(credential, null, 2), { encoding: "utf8", mode: 384 });
90
+ chmodSync(tmp, 384);
91
+ renameSync(tmp, this.filePath);
92
+ }
93
+ clear() {
94
+ rmSync(this.filePath, { force: true });
95
+ }
96
+ }
97
+
98
+ // packages/channel-relay/src/control-bridge.ts
99
+ import {
100
+ MSG,
101
+ errorPayload
102
+ } from "@ganglion/xacpx-relay-protocol";
103
+
104
+ // packages/channel-relay/src/tool-presentation.ts
105
+ var TEXT_CAP = 8000;
106
+ var DIFF_CAP = 4000;
107
+ function cap(s, n = TEXT_CAP) {
108
+ return s.length > n ? s.slice(0, n) + `
109
+ …(truncated)` : s;
110
+ }
111
+ function asString(v) {
112
+ if (typeof v === "string")
113
+ return v;
114
+ if (typeof v === "number" || typeof v === "boolean")
115
+ return String(v);
116
+ return;
117
+ }
118
+ function rec(v) {
119
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
120
+ }
121
+ function blocksOf(content) {
122
+ if (Array.isArray(content))
123
+ return content.filter((b) => b && typeof b === "object");
124
+ if (content && typeof content === "object")
125
+ return [content];
126
+ return [];
127
+ }
128
+ function textFromContentBlock(cb) {
129
+ switch (cb.type) {
130
+ case "text":
131
+ return asString(cb.text);
132
+ case "resource_link":
133
+ return asString(cb.title) ?? asString(cb.name) ?? asString(cb.uri);
134
+ case "resource": {
135
+ const r = rec(cb.resource);
136
+ const text = asString(r.text);
137
+ if (text)
138
+ return text;
139
+ const uri = asString(r.uri);
140
+ return uri ? `[resource] ${uri}` : undefined;
141
+ }
142
+ default:
143
+ return;
144
+ }
145
+ }
146
+ function textFromBlocks(blocks) {
147
+ const parts = [];
148
+ for (const b of blocks) {
149
+ const t = b.type === "content" ? textFromContentBlock(rec(b.content)) : textFromContentBlock(b);
150
+ if (t)
151
+ parts.push(t);
152
+ }
153
+ return parts.length ? parts.join(`
154
+ `) : undefined;
155
+ }
156
+ function diffBlock(blocks) {
157
+ return blocks.find((b) => b.type === "diff");
158
+ }
159
+ function parsedCmd0(input) {
160
+ const pc = input.parsed_cmd;
161
+ if (Array.isArray(pc) && pc[0] && typeof pc[0] === "object")
162
+ return pc[0];
163
+ return;
164
+ }
165
+ function locationPath(event) {
166
+ const locs = event.locations;
167
+ if (Array.isArray(locs) && locs[0] && typeof locs[0] === "object") {
168
+ const l = locs[0];
169
+ return asString(l.path) ?? asString(l.file);
170
+ }
171
+ return;
172
+ }
173
+ function readLines(input) {
174
+ const { offset, limit } = input;
175
+ if (typeof offset === "number" && typeof limit === "number")
176
+ return `${offset}–${offset + limit}`;
177
+ if (typeof limit === "number")
178
+ return `first ${limit}`;
179
+ return;
180
+ }
181
+ function primitiveFields(input) {
182
+ const out = [];
183
+ for (const [label, v] of Object.entries(input)) {
184
+ const value = asString(v);
185
+ if (value !== undefined)
186
+ out.push({ label, value: cap(value) });
187
+ }
188
+ return out;
189
+ }
190
+ function toolUseEventToStepDto(event) {
191
+ const input = rec(event.rawInput);
192
+ const blocks = blocksOf(event.content);
193
+ const output = rec(event.rawOutput);
194
+ const rawOutputText = asString(event.rawOutput);
195
+ const pc = parsedCmd0(input);
196
+ const fallbackTitle = event.summary ?? event.toolName;
197
+ const errMsg = event.status === "error" ? asString(output.error) ?? asString(output.message) ?? textFromBlocks(blocks) ?? asString(output.output) ?? asString(output.text) ?? rawOutputText : undefined;
198
+ const base = {
199
+ toolCallId: event.toolCallId,
200
+ toolName: event.toolName,
201
+ kind: event.kind,
202
+ status: event.status,
203
+ ...event.durationMs !== undefined ? { durationMs: event.durationMs } : {},
204
+ ...errMsg ? { error: cap(errMsg, 2000) } : {}
205
+ };
206
+ if (event.kind === "edit") {
207
+ const diff = diffBlock(blocks);
208
+ if (diff) {
209
+ const path2 = asString(diff.path) ?? locationPath(event) ?? asString(input.file_path) ?? asString(input.path) ?? fallbackTitle;
210
+ const detail = { type: "diff", path: path2, oldText: cap(asString(diff.oldText) ?? "", DIFF_CAP), newText: cap(asString(diff.newText) ?? "", DIFF_CAP) };
211
+ return { ...base, title: path2, detail };
212
+ }
213
+ const path = locationPath(event) ?? asString(input.file_path) ?? asString(input.path) ?? fallbackTitle;
214
+ return { ...base, title: path, detail: { type: "fields", fields: primitiveFields(input) } };
215
+ }
216
+ if (event.kind === "read") {
217
+ const path = asString(input.file_path) ?? asString(input.path) ?? asString(pc?.name) ?? locationPath(event) ?? fallbackTitle;
218
+ const lines = readLines(input);
219
+ const preview = textFromBlocks(blocks) ?? asString(output.text) ?? rawOutputText;
220
+ const detail = { type: "read", path, ...lines ? { lines } : {}, ...preview ? { preview: cap(preview) } : {} };
221
+ return { ...base, title: path, detail };
222
+ }
223
+ if (event.kind === "execute") {
224
+ const command = asString(input.command) ?? asString(input.cmd) ?? asString(pc?.cmd) ?? fallbackTitle;
225
+ const out2 = asString(output.stdout) ?? textFromBlocks(blocks) ?? asString(output.text) ?? rawOutputText;
226
+ const exitCode = typeof output.exitCode === "number" ? output.exitCode : undefined;
227
+ const detail = { type: "command", command, ...out2 ? { output: cap(out2) } : {}, ...exitCode !== undefined ? { exitCode } : {} };
228
+ return { ...base, title: command, detail };
229
+ }
230
+ if (event.kind === "search") {
231
+ const query = asString(input.query) ?? asString(input.pattern) ?? asString(input.search) ?? asString(input.command) ?? asString(pc?.cmd) ?? fallbackTitle;
232
+ const out2 = textFromBlocks(blocks) ?? asString(output.stdout) ?? asString(output.text) ?? rawOutputText;
233
+ const detail = { type: "search", query, ...out2 ? { output: cap(out2) } : {} };
234
+ return { ...base, title: query, detail };
235
+ }
236
+ if (event.kind === "think") {
237
+ const text = asString(input.description) ?? asString(input.prompt) ?? textFromBlocks(blocks) ?? "";
238
+ return { ...base, title: fallbackTitle, detail: { type: "text", text: cap(text) } };
239
+ }
240
+ const out = textFromBlocks(blocks) ?? asString(output.stdout) ?? asString(output.text) ?? rawOutputText;
241
+ return { ...base, title: fallbackTitle, detail: { type: "fields", fields: primitiveFields(input), ...out ? { output: cap(out) } : {} } };
242
+ }
243
+
244
+ // packages/channel-relay/src/control-bridge.ts
245
+ function scheduledTaskToDto(record) {
246
+ return {
247
+ id: record.id,
248
+ sessionAlias: record.session_alias,
249
+ executeAt: record.execute_at,
250
+ message: record.message,
251
+ status: record.status,
252
+ createdAt: record.created_at,
253
+ ...record.executed_at ? { executedAt: record.executed_at } : {},
254
+ ...record.failed_at ? { failedAt: record.failed_at } : {},
255
+ ...record.last_error ? { lastError: record.last_error } : {}
256
+ };
257
+ }
258
+ function orchestrationTaskToDto(record) {
259
+ return {
260
+ taskId: record.taskId,
261
+ status: record.status,
262
+ targetAgent: record.targetAgent,
263
+ workspace: record.workspace,
264
+ task: record.task,
265
+ summary: record.summary,
266
+ createdAt: record.createdAt,
267
+ updatedAt: record.updatedAt
268
+ };
269
+ }
270
+ function createControlBridge(control) {
271
+ return (envelope, respond) => {
272
+ dispatchControlRequest(control, envelope).then(respond).catch((error) => {
273
+ respond(errorPayload("internal", error instanceof Error ? error.message : String(error)));
274
+ });
275
+ };
276
+ }
277
+ async function dispatchControlRequest(control, envelope) {
278
+ const payload = envelope.payload;
279
+ switch (envelope.type) {
280
+ case MSG.sessionsList: {
281
+ const input = payload;
282
+ return { sessions: control.listSessions(input.chatKey) };
283
+ }
284
+ case MSG.sessionsCreate: {
285
+ const input = payload;
286
+ return await control.createSession(input.chatKey, input.alias, input.agent, input.workspace, input.agentSessionId, input.model);
287
+ }
288
+ case MSG.sessionsNativeList: {
289
+ const input = payload;
290
+ return { sessions: await control.listNativeSessions(input.chatKey, input.agent, input.workspace) };
291
+ }
292
+ case MSG.sessionsRemove: {
293
+ const input = payload;
294
+ return await control.removeSession(input.chatKey, input.alias);
295
+ }
296
+ case MSG.agentsList:
297
+ return { agents: control.listAgents() };
298
+ case MSG.workspacesList:
299
+ return { workspaces: control.listWorkspaces() };
300
+ case MSG.workspacesCreate: {
301
+ const input = payload;
302
+ const name = typeof input.name === "string" ? input.name.trim() : "";
303
+ const cwd = typeof input.cwd === "string" ? input.cwd.trim() : "";
304
+ if (!name || !cwd)
305
+ return errorPayload("bad-request", "workspace name and cwd are required");
306
+ return { workspace: await control.createWorkspace(name, cwd, input.description) };
307
+ }
308
+ case MSG.agentsCatalog:
309
+ return { agents: control.listAgentCatalog() };
310
+ case MSG.agentsCreate: {
311
+ const input = payload;
312
+ const name = typeof input.name === "string" ? input.name.trim() : "";
313
+ const driver = typeof input.driver === "string" ? input.driver.trim() : "";
314
+ if (!name || !driver)
315
+ return errorPayload("bad-request", "agent name and driver are required");
316
+ return { agent: await control.createAgent(name, driver) };
317
+ }
318
+ case MSG.agentsRemove: {
319
+ const input = payload;
320
+ const name = typeof input.name === "string" ? input.name.trim() : "";
321
+ if (!name)
322
+ return errorPayload("bad-request", "agent name is required");
323
+ await control.removeAgent(name);
324
+ return { ok: true };
325
+ }
326
+ case MSG.workspacesRemove: {
327
+ const input = payload;
328
+ const name = typeof input.name === "string" ? input.name.trim() : "";
329
+ if (!name)
330
+ return errorPayload("bad-request", "workspace name is required");
331
+ await control.removeWorkspace(name);
332
+ return { ok: true };
333
+ }
334
+ case MSG.prompt:
335
+ return await control.prompt(payload);
336
+ case MSG.promptCancel: {
337
+ const input = payload;
338
+ return { cancelled: control.cancelTurn(input.chatKey, input.sessionAlias) };
339
+ }
340
+ case MSG.commandExecute: {
341
+ const input = payload;
342
+ return { output: await control.executeCommand(input) };
343
+ }
344
+ case MSG.scheduledList: {
345
+ const input = payload;
346
+ return { tasks: control.listScheduledTasks(input.chatKey).map(scheduledTaskToDto) };
347
+ }
348
+ case MSG.scheduledCreate: {
349
+ const input = payload;
350
+ const ms = Date.parse(input.executeAt);
351
+ if (Number.isNaN(ms))
352
+ return errorPayload("bad-request", "executeAt is not a valid ISO timestamp");
353
+ const task = await control.createScheduledTask({
354
+ chatKey: input.chatKey,
355
+ sessionAlias: input.sessionAlias,
356
+ executeAt: new Date(ms),
357
+ message: input.message
358
+ });
359
+ return scheduledTaskToDto(task);
360
+ }
361
+ case MSG.scheduledCancel: {
362
+ const input = payload;
363
+ return { cancelled: await control.cancelScheduledTask(input.id, input.chatKey) };
364
+ }
365
+ case MSG.orchestrationList:
366
+ return { tasks: (await control.listOrchestrationTasks()).map(orchestrationTaskToDto) };
367
+ case MSG.orchestrationGet: {
368
+ const input = payload;
369
+ const task = await control.getOrchestrationTask(input.taskId);
370
+ return { task: task ? orchestrationTaskToDto(task) : null };
371
+ }
372
+ case MSG.orchestrationCancel: {
373
+ const input = payload;
374
+ return orchestrationTaskToDto(await control.cancelOrchestrationTask({ taskId: input.taskId }));
375
+ }
376
+ case MSG.fsList: {
377
+ const input = payload;
378
+ if (!input.workspace)
379
+ return errorPayload("bad-request", "workspace is required");
380
+ return await control.listDirectory(input.workspace, input.path);
381
+ }
382
+ case MSG.fsRead: {
383
+ const input = payload;
384
+ if (!input.workspace || !input.path)
385
+ return errorPayload("bad-request", "workspace and path are required");
386
+ return await control.readWorkspaceFile(input.workspace, input.path);
387
+ }
388
+ case MSG.fsDiff: {
389
+ const input = payload;
390
+ if (!input.workspace)
391
+ return errorPayload("bad-request", "workspace is required");
392
+ return await control.workspaceGitDiff(input.workspace, input.path);
393
+ }
394
+ case MSG.fsSearch: {
395
+ const input = payload;
396
+ if (!input.workspace)
397
+ return errorPayload("bad-request", "workspace is required");
398
+ return await control.searchWorkspace(input.workspace, input.query ?? "");
399
+ }
400
+ case MSG.sessionModelGet: {
401
+ const input = payload;
402
+ if (!input.sessionAlias)
403
+ return errorPayload("bad-request", "sessionAlias is required");
404
+ return await control.getSessionModel(input.chatKey, input.sessionAlias);
405
+ }
406
+ case MSG.sessionModelSet: {
407
+ const input = payload;
408
+ if (!input.sessionAlias || !input.modelId)
409
+ return errorPayload("bad-request", "sessionAlias and modelId are required");
410
+ await control.setSessionModel(input.chatKey, input.sessionAlias, input.modelId);
411
+ return { ok: true };
412
+ }
413
+ default:
414
+ return errorPayload("unknown-type", `unsupported rpc type: ${envelope.type}`);
415
+ }
416
+ }
417
+ function historyMessagesToRows(messages) {
418
+ return messages.map((m) => {
419
+ if (m.role === "user")
420
+ return { direction: "in", text: m.text };
421
+ const parts = [];
422
+ const toolSteps = [];
423
+ const reasoningChunks = [];
424
+ for (const p of m.parts ?? []) {
425
+ if (p.kind === "text")
426
+ parts.push({ type: "text", text: p.text });
427
+ else if (p.kind === "reasoning") {
428
+ parts.push({ type: "reasoning", text: p.text });
429
+ reasoningChunks.push(p.text);
430
+ } else if (p.kind === "tool") {
431
+ const step = toolUseEventToStepDto(p.tool);
432
+ parts.push({ type: "tool", step });
433
+ toolSteps.push(step);
434
+ }
435
+ }
436
+ const hasStructured = toolSteps.length > 0 || reasoningChunks.length > 0 || parts.length > 0;
437
+ const structured = hasStructured ? {
438
+ ...toolSteps.length ? { toolSteps } : {},
439
+ ...reasoningChunks.length ? { reasoning: reasoningChunks.join(`
440
+ `) } : {},
441
+ ...parts.length ? { parts } : {}
442
+ } : undefined;
443
+ return { direction: "out", text: m.text, ...structured ? { structured } : {} };
444
+ });
445
+ }
446
+ function subscribeControlEvents(control, sendEvent) {
447
+ return control.events.subscribe((event) => {
448
+ if (event.type === "tool-event") {
449
+ sendEvent(MSG.instanceEvent, {
450
+ event: { type: "tool-event", chatKey: event.chatKey, sessionAlias: event.sessionAlias, step: toolUseEventToStepDto(event.event) }
451
+ });
452
+ return;
453
+ }
454
+ if (event.type === "session-history") {
455
+ sendEvent(MSG.instanceEvent, {
456
+ event: { type: "session-history", chatKey: event.chatKey, sessionAlias: event.sessionAlias, messages: historyMessagesToRows(event.messages) }
457
+ });
458
+ return;
459
+ }
460
+ sendEvent(MSG.instanceEvent, { event });
461
+ });
462
+ }
463
+
464
+ // packages/channel-relay/src/relay-client.ts
465
+ import WebSocket from "ws";
466
+ import {
467
+ MSG as MSG2,
468
+ RELAY_PROTOCOL_VERSION,
469
+ decodeEnvelope,
470
+ encodeEnvelope,
471
+ isErrorPayload
472
+ } from "@ganglion/xacpx-relay-protocol";
473
+ var DEFAULT_DELAYS = [1000, 2000, 5000, 1e4, 30000];
474
+ var HANDSHAKE_ID = "handshake-1";
475
+
476
+ class RelayClient {
477
+ options;
478
+ socket = null;
479
+ attempts = 0;
480
+ stopped = false;
481
+ ready = false;
482
+ constructor(options) {
483
+ this.options = options;
484
+ }
485
+ start(abortSignal) {
486
+ abortSignal.addEventListener("abort", () => this.stop(), { once: true });
487
+ if (!abortSignal.aborted)
488
+ this.connect();
489
+ }
490
+ stop() {
491
+ this.stopped = true;
492
+ this.socket?.close();
493
+ this.socket = null;
494
+ }
495
+ sendEvent(type, payload) {
496
+ if (!this.ready || !this.socket || this.socket.readyState !== WebSocket.OPEN) {
497
+ return;
498
+ }
499
+ this.socket.send(encodeEnvelope({ protocolVersion: RELAY_PROTOCOL_VERSION, kind: "event", type, payload }));
500
+ }
501
+ connect() {
502
+ if (this.stopped)
503
+ return;
504
+ const socket = (this.options.createSocket ?? ((url) => new WebSocket(url)))(this.options.url);
505
+ this.socket = socket;
506
+ this.ready = false;
507
+ socket.on("open", () => this.sendHandshake(socket));
508
+ socket.on("message", (data) => this.handleMessage(socket, String(data)));
509
+ socket.on("error", () => {});
510
+ socket.on("close", () => {
511
+ this.ready = false;
512
+ if (this.stopped)
513
+ return;
514
+ const delays = this.options.reconnectDelaysMs ?? DEFAULT_DELAYS;
515
+ const delay = delays[Math.min(this.attempts, delays.length - 1)] ?? 30000;
516
+ this.attempts += 1;
517
+ setTimeout(() => this.connect(), delay);
518
+ });
519
+ }
520
+ sendHandshake(socket) {
521
+ const credential = this.options.credentialStore.load();
522
+ if (credential) {
523
+ socket.send(encodeEnvelope({
524
+ protocolVersion: RELAY_PROTOCOL_VERSION,
525
+ kind: "req",
526
+ id: HANDSHAKE_ID,
527
+ type: MSG2.instanceAuth,
528
+ payload: {
529
+ instanceId: credential.instanceId,
530
+ credential: credential.credential,
531
+ coreVersion: this.options.coreVersion
532
+ }
533
+ }));
534
+ return;
535
+ }
536
+ if (this.options.pairingToken) {
537
+ socket.send(encodeEnvelope({
538
+ protocolVersion: RELAY_PROTOCOL_VERSION,
539
+ kind: "req",
540
+ id: HANDSHAKE_ID,
541
+ type: MSG2.instanceRegister,
542
+ payload: {
543
+ pairingToken: this.options.pairingToken,
544
+ name: this.options.instanceName,
545
+ coreVersion: this.options.coreVersion
546
+ }
547
+ }));
548
+ return;
549
+ }
550
+ this.options.logger?.error("relay.no_credentials", "relay channel has neither credential nor pairing token", {});
551
+ this.stopped = true;
552
+ socket.close();
553
+ }
554
+ handleMessage(socket, raw) {
555
+ const decoded = decodeEnvelope(raw);
556
+ if (!decoded.ok) {
557
+ this.options.logger?.error("relay.decode_failed", `relay sent an undecodable message: ${decoded.error}`, { error: decoded.error, detail: decoded.detail ?? "" });
558
+ if (decoded.error === "version-mismatch") {
559
+ this.stopped = true;
560
+ socket.close();
561
+ }
562
+ return;
563
+ }
564
+ const envelope = decoded.envelope;
565
+ if (envelope.kind === "event" && envelope.type === "relay.protocol-error") {
566
+ const p = envelope.payload;
567
+ const detail = isErrorPayload(p) ? `${p.error.code}: ${p.error.message}` : "protocol error";
568
+ this.options.logger?.error("relay.protocol_error", `relay reported a protocol error: ${detail}`, {});
569
+ this.stopped = true;
570
+ socket.close();
571
+ return;
572
+ }
573
+ if (envelope.kind === "res" && envelope.id === HANDSHAKE_ID) {
574
+ if (isErrorPayload(envelope.payload)) {
575
+ this.options.logger?.error("relay.handshake_rejected", "relay rejected the handshake; not retrying", {
576
+ code: envelope.payload.error.code,
577
+ message: envelope.payload.error.message
578
+ });
579
+ this.stopped = true;
580
+ socket.close();
581
+ return;
582
+ }
583
+ if (envelope.type === MSG2.instanceRegister) {
584
+ const result = envelope.payload;
585
+ const credential = {
586
+ instanceId: result.instanceId,
587
+ credential: result.credential,
588
+ relayUrl: this.options.url
589
+ };
590
+ this.options.credentialStore.save(credential);
591
+ }
592
+ this.ready = true;
593
+ this.attempts = 0;
594
+ this.options.onReady?.();
595
+ return;
596
+ }
597
+ if (envelope.kind === "req") {
598
+ const respond = (payload) => {
599
+ socket.send(encodeEnvelope({
600
+ protocolVersion: RELAY_PROTOCOL_VERSION,
601
+ kind: "res",
602
+ id: envelope.id,
603
+ type: envelope.type,
604
+ payload
605
+ }));
606
+ };
607
+ this.options.onRequest(envelope, respond);
608
+ }
609
+ }
610
+ }
611
+
612
+ // packages/channel-relay/src/channel.ts
613
+ class RelayChannel {
614
+ deps;
615
+ id = "relay";
616
+ nativeSessionListFormat = "table";
617
+ config;
618
+ credentials;
619
+ client = null;
620
+ unsubscribe = null;
621
+ control = null;
622
+ constructor(options, deps = {}) {
623
+ this.deps = deps;
624
+ this.config = parseRelayChannelConfig(options);
625
+ this.credentials = deps.credentialStore ?? new CredentialStore(defaultCredentialPath());
626
+ }
627
+ isLoggedIn() {
628
+ return this.credentials.load() !== null || this.config.pairingToken !== undefined;
629
+ }
630
+ async login() {
631
+ return "relay channel pairs automatically on start; configure it via: xacpx channel add relay --url <ws-url> --token <pairing-token>";
632
+ }
633
+ logout() {
634
+ this.credentials.clear();
635
+ }
636
+ async start(input) {
637
+ if (!input.control) {
638
+ throw new Error("relay channel requires ChannelStartInput.control (xacpx >= 0.11)");
639
+ }
640
+ this.control = input.control;
641
+ const bridge = createControlBridge(input.control);
642
+ const client = (this.deps.createClient ?? ((options) => new RelayClient(options)))({
643
+ url: this.config.url,
644
+ credentialStore: this.credentials,
645
+ pairingToken: this.config.pairingToken,
646
+ instanceName: this.config.name,
647
+ coreVersion: input.coreVersion,
648
+ onRequest: bridge,
649
+ logger: input.logger
650
+ });
651
+ this.client = client;
652
+ this.unsubscribe = subscribeControlEvents(input.control, (type, payload) => client.sendEvent(type, payload));
653
+ client.start(input.abortSignal);
654
+ await new Promise((resolve) => {
655
+ if (input.abortSignal.aborted) {
656
+ resolve();
657
+ return;
658
+ }
659
+ input.abortSignal.addEventListener("abort", () => resolve(), { once: true });
660
+ });
661
+ this.stop();
662
+ }
663
+ stop() {
664
+ this.unsubscribe?.();
665
+ this.unsubscribe = null;
666
+ this.client?.stop();
667
+ this.client = null;
668
+ this.control = null;
669
+ }
670
+ async notifyTaskCompletion(task) {
671
+ this.sendNotice({ kind: "task-completion", taskId: task.taskId, text: task.summary || task.resultText || task.taskId });
672
+ }
673
+ async notifyTaskProgress(task, text) {
674
+ this.sendNotice({ kind: "task-progress", taskId: task.taskId, text });
675
+ }
676
+ async sendCoordinatorMessage(input) {
677
+ this.sendNotice({ kind: "coordinator-message", chatKey: input.chatKey, text: input.text });
678
+ }
679
+ async sendScheduledMessage(input) {
680
+ if (!this.control) {
681
+ throw new Error("relay channel cannot dispatch scheduled task before start()");
682
+ }
683
+ const result = await this.control.runScheduledTurn({
684
+ chatKey: input.chatKey,
685
+ sessionAlias: input.sessionAlias,
686
+ promptText: input.promptText,
687
+ taskId: input.taskId ?? "",
688
+ executeAt: input.executeAt ?? new Date(0).toISOString(),
689
+ ...input.abortSignal ? { abortSignal: input.abortSignal } : {}
690
+ });
691
+ if (!result.ok) {
692
+ throw new Error(result.errorMessage ?? "scheduled turn failed");
693
+ }
694
+ }
695
+ sendNotice(payload) {
696
+ this.client?.sendEvent(MSG3.instanceNotice, payload);
697
+ }
698
+ }
699
+
700
+ // packages/channel-relay/src/relay-provider.ts
701
+ function stringField(input, key) {
702
+ const value = input[key];
703
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
704
+ }
705
+ var relayCliProvider = {
706
+ type: "relay",
707
+ displayName: "Relay Hub",
708
+ supportsLogin: false,
709
+ parseAddArgs(args) {
710
+ const input = {};
711
+ for (let i = 0;i < args.length; i++) {
712
+ const arg = args[i];
713
+ const next = args[i + 1];
714
+ if (arg === "--url" || arg === "--token" || arg === "--name") {
715
+ if (!next || next.startsWith("--")) {
716
+ return { ok: false, message: `${arg} requires a value` };
717
+ }
718
+ input[arg.slice(2)] = next;
719
+ i += 1;
720
+ } else {
721
+ return { ok: false, message: `unknown flag: ${arg}` };
722
+ }
723
+ }
724
+ return { ok: true, input };
725
+ },
726
+ buildDefaultConfig(input) {
727
+ const url = stringField(input, "url");
728
+ const pairingToken = stringField(input, "token");
729
+ const name = stringField(input, "name");
730
+ const options = {};
731
+ if (url !== undefined)
732
+ options.url = normalizeRelayUrl(url);
733
+ if (pairingToken !== undefined)
734
+ options.pairingToken = pairingToken;
735
+ if (name !== undefined)
736
+ options.name = name;
737
+ return {
738
+ id: "relay",
739
+ type: "relay",
740
+ enabled: true,
741
+ options
742
+ };
743
+ },
744
+ validateConfig(config) {
745
+ const issues = [];
746
+ const options = config.options ?? {};
747
+ const url = normalizeRelayUrl(typeof options.url === "string" ? options.url : "");
748
+ if (!url) {
749
+ issues.push({ kind: "missing-required-field", flag: "--url", message: "relay url — a domain (wss), or IP[:port] (ws, default :8788) is required" });
750
+ } else if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
751
+ issues.push({ kind: "invalid-config", message: `url must be a domain, IP[:port], or ws(s):// address, got: ${String(options.url ?? "")}` });
752
+ }
753
+ if (typeof options.pairingToken !== "string" || !options.pairingToken) {
754
+ issues.push({ kind: "missing-required-field", flag: "--token", message: "access token is required (generate one with: xacpx-relay add token)" });
755
+ }
756
+ return issues;
757
+ },
758
+ renderSummary(config) {
759
+ const options = config.options ?? {};
760
+ const lines = [`relay url: ${String(options.url ?? "")}`, "pairing token: ***"];
761
+ if (typeof options.name === "string")
762
+ lines.push(`instance name: ${options.name}`);
763
+ return lines;
764
+ },
765
+ async promptForMissingFields(input, io) {
766
+ const completed = { ...input };
767
+ if (!stringField(completed, "url")) {
768
+ const value = (await io.promptText("Relay url (domain, or IP[:port]; e.g. relay.example.com or 1.2.3.4): ")).trim();
769
+ if (value)
770
+ completed.url = value;
771
+ }
772
+ if (!stringField(completed, "token")) {
773
+ const value = (await io.promptSecret("Pairing token: ")).trim();
774
+ if (value)
775
+ completed.token = value;
776
+ }
777
+ return completed;
778
+ }
779
+ };
780
+
781
+ // packages/channel-relay/src/index.ts
782
+ var plugin = {
783
+ apiVersion: 1,
784
+ name: "@ganglion/xacpx-channel-relay",
785
+ minXacpxVersion: "0.11.0",
786
+ channels: [
787
+ {
788
+ type: "relay",
789
+ factory: (options, deps) => new RelayChannel(options, deps),
790
+ cliProvider: relayCliProvider
791
+ }
792
+ ]
793
+ };
794
+ var src_default = plugin;
795
+ export {
796
+ relayCliProvider,
797
+ src_default as default,
798
+ RelayChannel
799
+ };
@@ -0,0 +1,30 @@
1
+ import WebSocket from "ws";
2
+ import { type RelayEnvelope } from "@ganglion/xacpx-relay-protocol";
3
+ import type { AppLogger } from "xacpx/plugin-api";
4
+ import type { CredentialStore } from "./credential-store.js";
5
+ export interface RelayClientOptions {
6
+ url: string;
7
+ credentialStore: Pick<CredentialStore, "load" | "save" | "clear">;
8
+ pairingToken?: string;
9
+ instanceName?: string;
10
+ coreVersion?: string;
11
+ onRequest: (envelope: RelayEnvelope, respond: (payload: unknown) => void) => void;
12
+ onReady?: () => void;
13
+ reconnectDelaysMs?: number[];
14
+ createSocket?: (url: string) => WebSocket;
15
+ logger?: AppLogger;
16
+ }
17
+ export declare class RelayClient {
18
+ private readonly options;
19
+ private socket;
20
+ private attempts;
21
+ private stopped;
22
+ private ready;
23
+ constructor(options: RelayClientOptions);
24
+ start(abortSignal: AbortSignal): void;
25
+ stop(): void;
26
+ sendEvent(type: string, payload: unknown): void;
27
+ private connect;
28
+ private sendHandshake;
29
+ private handleMessage;
30
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChannelCliProvider } from "xacpx/plugin-api";
2
+ export declare const relayCliProvider: ChannelCliProvider;
@@ -0,0 +1,4 @@
1
+ import type { ToolUseEvent } from "xacpx/plugin-api";
2
+ import type { ToolStepDto } from "@ganglion/xacpx-relay-protocol";
3
+ /** Normalize a raw core ToolUseEvent into a friendly, capped, presentation-ready step. */
4
+ export declare function toolUseEventToStepDto(event: ToolUseEvent): ToolStepDto;
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@ganglion/xacpx-channel-relay",
3
+ "version": "0.1.0",
4
+ "description": "Relay hub connector channel plugin for xacpx.",
5
+ "license": "MIT",
6
+ "keywords": ["xacpx", "relay", "channel", "plugin"],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/gadzan/xacpx.git",
10
+ "directory": "packages/channel-relay"
11
+ },
12
+ "type": "module",
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": ["dist", "README.md"],
22
+ "peerDependencies": {
23
+ "xacpx": ">=0.11.0-0"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "xacpx": { "optional": true }
27
+ },
28
+ "publishConfig": { "access": "public" },
29
+ "dependencies": {
30
+ "@ganglion/xacpx-relay-protocol": "^0.1.0",
31
+ "ws": "^8.20.0"
32
+ }
33
+ }